From 533c069e433278b83f4fa965ecdbede6076d9fc0 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Wed, 1 Apr 2026 23:12:23 +0900 Subject: [PATCH] feat: add WSL editor launch modes and update checking functionality - Introduced WSL editor launch modes in SettingsPage with options for internal, remote, and UNC path launches. - Enhanced settings saving to include the new WSL launch mode. - Added an update section to check for application updates, download, and install them with progress tracking. - Updated Toast component to handle error messages with a copy button for convenience. - Expanded commands to include project tags and templates management, along with WSL proxy synchronization features. --- .github/workflows/release.yml | 131 +++ .gitignore | 3 + package.json | 2 + pnpm-lock.yaml | 20 + src-tauri/Cargo.lock | 265 +++++- src-tauri/Cargo.toml | 4 + src-tauri/src/commands/cicd.rs | 259 ++++++ src-tauri/src/commands/git_ops.rs | 6 - src-tauri/src/commands/git_repos.rs | 2 +- src-tauri/src/commands/groups.rs | 18 +- src-tauri/src/commands/mod.rs | 3 + src-tauri/src/commands/network.rs | 328 +++++++- src-tauri/src/commands/projects.rs | 24 +- src-tauri/src/commands/proxy.rs | 171 +++- src-tauri/src/commands/publish.rs | 2 +- src-tauri/src/commands/settings.rs | 12 +- src-tauri/src/commands/shell.rs | 83 +- src-tauri/src/commands/tags.rs | 127 +++ src-tauri/src/commands/templates.rs | 479 +++++++++++ src-tauri/src/db.rs | 87 ++ src-tauri/src/lib.rs | 36 +- src-tauri/src/models.rs | 46 ++ src-tauri/tauri.conf.json | 14 +- src/components/dashboard/CicdModal.tsx | 470 +++++++++++ src/components/dashboard/Dashboard.tsx | 486 +++++++++-- src/components/dashboard/ProjectCard.tsx | 70 +- src/components/dashboard/ProjectModal.tsx | 47 +- src/components/dashboard/PublishModal.tsx | 5 +- src/components/manage/ManagePage.tsx | 198 ++++- src/components/manage/NewProjectModal.tsx | 300 +++++++ src/components/manage/TagsManager.tsx | 163 ++++ src/components/manage/TemplateEditor.tsx | 271 ++++++ src/components/proxy/ProxyPage.tsx | 962 ++++++++++++++++------ src/components/settings/SettingsPage.tsx | 152 +++- src/components/ui/Toast.tsx | 17 +- src/lib/commands.ts | 222 ++++- 36 files changed, 5042 insertions(+), 443 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 src-tauri/src/commands/cicd.rs create mode 100644 src-tauri/src/commands/tags.rs create mode 100644 src-tauri/src/commands/templates.rs create mode 100644 src/components/dashboard/CicdModal.tsx create mode 100644 src/components/manage/NewProjectModal.tsx create mode 100644 src/components/manage/TagsManager.tsx create mode 100644 src/components/manage/TemplateEditor.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cd721d2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,131 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + + - run: pnpm install --frozen-lockfile + + # 构建 Tauri 应用,tauri-action 自动签名并创建 GitHub Release + - name: Build & Release + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + tagName: ${{ github.ref_name }} + releaseName: Dev Manager ${{ github.ref_name }} + releaseBody: | + ## 更新内容 + 请查看提交记录了解详情。 + releaseDraft: false + prerelease: false + + # 找到生成的安装包和签名文件 + - name: Collect artifacts + id: artifacts + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + $version = $tag.TrimStart('v') + + $nsisDir = "src-tauri/target/release/bundle/nsis" + + # 安装包(用于首次下载) + $installer = Get-ChildItem $nsisDir -Filter "*_x64-setup.exe" | Select-Object -First 1 + # 更新包(updater 用) + $updateZip = Get-ChildItem $nsisDir -Filter "*.nsis.zip" | Select-Object -First 1 + $sigFile = Get-ChildItem $nsisDir -Filter "*.nsis.zip.sig" | Select-Object -First 1 + + echo "INSTALLER=$($installer.FullName)" >> $env:GITHUB_OUTPUT + echo "UPDATE_ZIP=$($updateZip.FullName)" >> $env:GITHUB_OUTPUT + echo "SIG_FILE=$($sigFile.FullName)" >> $env:GITHUB_OUTPUT + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + echo "INSTALLER_NAME=$($installer.Name)" >> $env:GITHUB_OUTPUT + echo "UPDATE_ZIP_NAME=$($updateZip.Name)" >> $env:GITHUB_OUTPUT + + # 上传到阿里云 OSS + - name: Install ossutil + shell: pwsh + run: | + Invoke-WebRequest -Uri "https://gosspublic.alicdn.com/ossutil/1.7.17/ossutil64.zip" -OutFile ossutil.zip + Expand-Archive ossutil.zip -DestinationPath ossutil + echo "$PWD/ossutil" >> $env:GITHUB_PATH + + - name: Upload to OSS + shell: pwsh + env: + OSS_KEY_ID: ${{ secrets.OSS_KEY_ID }} + OSS_KEY_SECRET: ${{ secrets.OSS_KEY_SECRET }} + OSS_BUCKET: ${{ secrets.OSS_BUCKET }} + OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }} + run: | + $tag = "${{ github.ref_name }}" + $bucket = "oss://$env:OSS_BUCKET" + $endpoint = "https://$env:OSS_ENDPOINT" + + # 配置 ossutil + ossutil64/ossutil64.exe config -e $endpoint -i $env:OSS_KEY_ID -k $env:OSS_KEY_SECRET + + # 上传安装包和更新包 + ossutil64/ossutil64.exe cp "${{ steps.artifacts.outputs.INSTALLER }}" "$bucket/releases/$tag/${{ steps.artifacts.outputs.INSTALLER_NAME }}" -f + ossutil64/ossutil64.exe cp "${{ steps.artifacts.outputs.UPDATE_ZIP }}" "$bucket/releases/$tag/${{ steps.artifacts.outputs.UPDATE_ZIP_NAME }}" -f + ossutil64/ossutil64.exe cp "${{ steps.artifacts.outputs.SIG_FILE }}" "$bucket/releases/$tag/${{ steps.artifacts.outputs.UPDATE_ZIP_NAME }}.sig" -f + + # 生成并上传 latest.json(国内客户端更新检测入口) + - name: Generate latest.json + shell: pwsh + env: + OSS_KEY_ID: ${{ secrets.OSS_KEY_ID }} + OSS_KEY_SECRET: ${{ secrets.OSS_KEY_SECRET }} + OSS_BUCKET: ${{ secrets.OSS_BUCKET }} + OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }} + run: | + $tag = "${{ github.ref_name }}" + $version = "${{ steps.artifacts.outputs.VERSION }}" + $date = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') + $baseUrl = "https://$env:OSS_BUCKET.$env:OSS_ENDPOINT/releases/$tag" + $zipName = "${{ steps.artifacts.outputs.UPDATE_ZIP_NAME }}" + $sig = (Get-Content "${{ steps.artifacts.outputs.SIG_FILE }}" -Raw).Trim() + + $json = [ordered]@{ + version = $version + pub_date = $date + notes = "版本 $tag 已发布" + platforms = [ordered]@{ + 'windows-x86_64' = [ordered]@{ + signature = $sig + url = "$baseUrl/$zipName" + } + } + } | ConvertTo-Json -Depth 5 + + $json | Out-File -FilePath latest.json -Encoding utf8NoBOM + + ossutil64/ossutil64.exe cp latest.json "oss://$env:OSS_BUCKET/latest.json" -f diff --git a/.gitignore b/.gitignore index fdbaae6..8514853 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ src-tauri/target/ # 系统 .DS_Store Thumbs.db + +# 防止签名密钥被误提交(Windows ~ 路径展开问题) +~/ diff --git a/package.json b/package.json index 2f5e80e..60d36ab 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-shell": "^2.3.5", + "@tauri-apps/plugin-updater": "^2.10.0", "react": "^19.1.0", "react-dom": "^19.1.0", "zustand": "^5.0.12" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7003708..d925720 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,15 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.3 + '@tauri-apps/plugin-process': + specifier: ^2.3.1 + version: 2.3.1 '@tauri-apps/plugin-shell': specifier: ^2.3.5 version: 2.3.5 + '@tauri-apps/plugin-updater': + specifier: ^2.10.0 + version: 2.10.0 react: specifier: ^19.1.0 version: 19.2.4 @@ -627,9 +633,15 @@ packages: '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + '@tauri-apps/plugin-shell@2.3.5': resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} + '@tauri-apps/plugin-updater@2.10.0': + resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1379,10 +1391,18 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-shell@2.3.5': dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-updater@2.10.0': + dependencies: + '@tauri-apps/api': 2.10.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 78507a6..8b71d68 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -59,6 +59,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -769,6 +778,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -807,6 +827,7 @@ dependencies = [ name = "dev-manager-tauri" version = "0.1.0" dependencies = [ + "base64 0.22.1", "chrono", "git2", "once_cell", @@ -821,8 +842,11 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-http", "tauri-plugin-opener", + "tauri-plugin-process", "tauri-plugin-shell", + "tauri-plugin-updater", "ureq", + "urlencoding", "uuid", ] @@ -1104,6 +1128,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1480,7 +1515,7 @@ dependencies = [ "libc", "libgit2-sys", "log", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "url", ] @@ -2240,7 +2275,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -2388,6 +2426,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2589,6 +2633,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2604,6 +2649,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2666,6 +2723,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-src" version = "300.5.5+3.5.5" @@ -2714,6 +2777,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -2763,7 +2840,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2996,6 +3073,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -3428,6 +3511,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3545,15 +3637,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3660,6 +3757,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -3670,6 +3779,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.10" @@ -3702,6 +3838,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scheduled-thread-pool" version = "0.2.7" @@ -3768,6 +3913,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -4108,7 +4276,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -4344,6 +4512,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4568,6 +4747,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" @@ -4589,6 +4778,39 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.2", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -5196,6 +5418,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -5502,6 +5730,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -6197,6 +6434,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" @@ -6361,6 +6608,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a56ba30..1ae155b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,10 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } git2 = { version = "0.19", features = ["vendored-openssl"] } tauri-plugin-http = "2" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" ureq = { version = "2", features = ["json"] } open = "5" +base64 = "0.22" +urlencoding = "2" diff --git a/src-tauri/src/commands/cicd.rs b/src-tauri/src/commands/cicd.rs new file mode 100644 index 0000000..56fe352 --- /dev/null +++ b/src-tauri/src/commands/cicd.rs @@ -0,0 +1,259 @@ +use crate::db; +use crate::models::{CicdConfig, WorkflowResult}; +use rusqlite::params; +use std::path::Path; +use uuid::Uuid; + +#[tauri::command] +pub fn get_cicd_config(project_id: String) -> Result, String> { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let result = conn.query_row( + "SELECT id, project_id, config_type, trigger_branch, build_command, node_version, + server_host, server_user, deploy_path, start_command, oss_endpoint, created_at + FROM cicd_configs WHERE project_id = ?1", + params![project_id], + CicdConfig::from_row, + ); + match result { + Ok(cfg) => Ok(Some(cfg)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.to_string()), + } +} + +#[tauri::command] +pub fn save_cicd_config(input: CicdConfig) -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let id = if input.id.is_empty() { + Uuid::new_v4().to_string() + } else { + input.id.clone() + }; + conn.execute( + "INSERT INTO cicd_configs + (id, project_id, config_type, trigger_branch, build_command, node_version, + server_host, server_user, deploy_path, start_command, oss_endpoint) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11) + ON CONFLICT(project_id) DO UPDATE SET + config_type = excluded.config_type, + trigger_branch = excluded.trigger_branch, + build_command = excluded.build_command, + node_version = excluded.node_version, + server_host = excluded.server_host, + server_user = excluded.server_user, + deploy_path = excluded.deploy_path, + start_command = excluded.start_command, + oss_endpoint = excluded.oss_endpoint", + params![ + id, + input.project_id, + input.config_type, + input.trigger_branch, + input.build_command, + input.node_version, + input.server_host, + input.server_user, + input.deploy_path, + input.start_command, + input.oss_endpoint, + ], + ) + .map_err(|e| e.to_string())?; + + get_cicd_config(input.project_id)?.ok_or_else(|| "save failed".to_string()) +} + +/// 根据项目的 cicd_configs 生成 workflow 文件,写入 {project_path}/.github/workflows/ +/// project_path 必须是 Windows 可访问的路径(WSL 项目请传 UNC 路径 \\wsl.localhost\...) +#[tauri::command] +pub fn generate_workflow(project_id: String, project_path: String) -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let config = conn + .query_row( + "SELECT id, project_id, config_type, trigger_branch, build_command, node_version, + server_host, server_user, deploy_path, start_command, oss_endpoint, created_at + FROM cicd_configs WHERE project_id = ?1", + params![project_id], + CicdConfig::from_row, + ) + .map_err(|e| e.to_string())?; + + let (content, filename, secrets) = match config.config_type.as_str() { + "tauri_oss" => build_tauri_oss_workflow(&config), + _ => build_web_ssh_workflow(&config), + }; + + let workflows_dir = Path::new(&project_path).join(".github").join("workflows"); + std::fs::create_dir_all(&workflows_dir).map_err(|e| e.to_string())?; + let file_path = workflows_dir.join(&filename); + std::fs::write(&file_path, &content).map_err(|e| e.to_string())?; + + Ok(WorkflowResult { + content, + path: file_path.to_string_lossy().to_string(), + secrets, + }) +} + +// ── workflow 模板生成 ────────────────────────────────────────────────────────── + +fn build_web_ssh_workflow(cfg: &CicdConfig) -> (String, String, Vec) { + let branch = &cfg.trigger_branch; + let node = cfg.node_version.as_deref().unwrap_or("20"); + let build = cfg.build_command.as_deref().unwrap_or("pnpm build"); + let path = cfg.deploy_path.as_deref().unwrap_or("/app/project"); + let start = cfg.start_command.as_deref().unwrap_or("pm2 restart app"); + + let content = format!( + r#"name: Deploy + +on: + push: + branches: [{branch}] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '{node}' + cache: 'pnpm' + + - run: pnpm install --frozen-lockfile + - run: {build} + + - name: Deploy to server + uses: appleboy/ssh-action@v1 + with: + host: ${{{{ secrets.SERVER_HOST }}}} + username: ${{{{ secrets.SERVER_USER }}}} + key: ${{{{ secrets.SSH_KEY }}}} + script: | + cd {path} + git pull + pnpm install --frozen-lockfile + {build} + {start} +"# + ); + + let secrets = vec![ + "SERVER_HOST".to_string(), + "SERVER_USER".to_string(), + "SSH_KEY".to_string(), + ]; + (content, "deploy.yml".to_string(), secrets) +} + +fn build_tauri_oss_workflow(cfg: &CicdConfig) -> (String, String, Vec) { + let node = cfg.node_version.as_deref().unwrap_or("20"); + let endpoint = cfg + .oss_endpoint + .as_deref() + .unwrap_or("oss-cn-hangzhou.aliyuncs.com"); + + let content = format!( + r#"name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '{node}' + cache: 'pnpm' + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - run: pnpm install --frozen-lockfile + + - name: Build Tauri App + uses: tauri-apps/tauri-action@v0 + env: + TAURI_SIGNING_PRIVATE_KEY: ${{{{ secrets.TAURI_SIGNING_PRIVATE_KEY }}}} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{{{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}}} + with: + tagName: ${{{{ github.ref_name }}}} + releaseName: ${{{{ github.ref_name }}}} + releaseBody: | + ## 更新内容 + 请查看 CHANGELOG 了解详情。 + releaseDraft: false + prerelease: false + + - name: Upload installer to 阿里云 OSS + uses: fangbinwei/aliyun-oss-website-action@v1 + with: + accessKeyId: ${{{{ secrets.OSS_KEY_ID }}}} + accessKeySecret: ${{{{ secrets.OSS_KEY_SECRET }}}} + bucket: ${{{{ secrets.OSS_BUCKET }}}} + endpoint: {endpoint} + folder: releases/${{{{ github.ref_name }}}}/ + include: | + src-tauri/target/release/bundle/**/*.exe + src-tauri/target/release/bundle/**/*.msi + + - name: Generate and upload latest.json + shell: pwsh + env: + OSS_BUCKET: ${{{{ secrets.OSS_BUCKET }}}} + OSS_KEY_ID: ${{{{ secrets.OSS_KEY_ID }}}} + OSS_KEY_SECRET: ${{{{ secrets.OSS_KEY_SECRET }}}} + run: | + $tag = "${{{{ github.ref_name }}}}" + $date = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') + $url = "https://$env:OSS_BUCKET.{endpoint}/releases/$tag/setup.exe" + $sig = Get-Content -Path (Get-ChildItem 'src-tauri/target/release/bundle' -Filter '*.exe.sig' -Recurse | Select-Object -First 1 -ExpandProperty FullName) -Raw + $json = [ordered]@{{ + version = $tag.TrimStart('v') + pub_date = $date + notes = "版本 $tag" + platforms = [ordered]@{{ + 'windows-x86_64' = [ordered]@{{ url = $url; signature = $sig.Trim() }} + }} + }} | ConvertTo-Json -Depth 5 + $json | Out-File -FilePath latest.json -Encoding utf8NoBOM + + - name: Upload latest.json to OSS + uses: fangbinwei/aliyun-oss-website-action@v1 + with: + accessKeyId: ${{{{ secrets.OSS_KEY_ID }}}} + accessKeySecret: ${{{{ secrets.OSS_KEY_SECRET }}}} + bucket: ${{{{ secrets.OSS_BUCKET }}}} + endpoint: {endpoint} + folder: '' + include: latest.json +"# + ); + + let secrets = vec![ + "TAURI_SIGNING_PRIVATE_KEY".to_string(), + "TAURI_SIGNING_PRIVATE_KEY_PASSWORD".to_string(), + "OSS_KEY_ID".to_string(), + "OSS_KEY_SECRET".to_string(), + "OSS_BUCKET".to_string(), + ]; + (content, "release.yml".to_string(), secrets) +} diff --git a/src-tauri/src/commands/git_ops.rs b/src-tauri/src/commands/git_ops.rs index 31da0f1..6b2c606 100644 --- a/src-tauri/src/commands/git_ops.rs +++ b/src-tauri/src/commands/git_ops.rs @@ -12,12 +12,6 @@ pub struct GitStatus { pub last_commit_time: Option, } -#[derive(Debug, Serialize, Deserialize)] -pub struct CloneProgress { - pub received_objects: usize, - pub total_objects: usize, - pub done: bool, -} fn get_token() -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/git_repos.rs b/src-tauri/src/commands/git_repos.rs index 7f86ee7..2dd093c 100644 --- a/src-tauri/src/commands/git_repos.rs +++ b/src-tauri/src/commands/git_repos.rs @@ -1,7 +1,7 @@ use crate::db::pool; use crate::models::GitRepo; use rusqlite::params; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; // ── 输入结构体 ───────────────────────────────────────────────────────────────── diff --git a/src-tauri/src/commands/groups.rs b/src-tauri/src/commands/groups.rs index 78295e8..b55bafe 100644 --- a/src-tauri/src/commands/groups.rs +++ b/src-tauri/src/commands/groups.rs @@ -17,7 +17,7 @@ pub fn get_groups(space_id: Option) -> Result, String> let result = match &space_id { Some(sid) => { let mut stmt = conn.prepare( - "SELECT * FROM product_groups WHERE space_id = ?1 ORDER BY id" + "SELECT * FROM product_groups WHERE space_id = ?1 ORDER BY sort_order ASC, id" ).map_err(|e| e.to_string())?; let x = stmt.query_map(params![sid], ProductGroup::from_row) .map_err(|e| e.to_string())? @@ -25,7 +25,7 @@ pub fn get_groups(space_id: Option) -> Result, String> .map_err(|e| e.to_string())?; x } None => { - let mut stmt = conn.prepare("SELECT * FROM product_groups ORDER BY id").map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT * FROM product_groups ORDER BY sort_order ASC, id").map_err(|e| e.to_string())?; let x = stmt.query_map([], ProductGroup::from_row) .map_err(|e| e.to_string())? .collect::, _>>() @@ -69,6 +69,20 @@ pub fn delete_group(id: String) -> Result<(), String> { Ok(()) } +/// 按传入的 id 顺序批量更新 sort_order(index 即为新顺序) +#[tauri::command] +pub fn reorder_groups(ids: Vec) -> Result<(), String> { + let conn = pool().get().map_err(|e| e.to_string())?; + for (i, id) in ids.iter().enumerate() { + conn.execute( + "UPDATE product_groups SET sort_order = ?1 WHERE id = ?2", + params![i as i32, id], + ) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + #[tauri::command] pub fn get_group_maps() -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d71726d..9dae5f8 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -13,3 +13,6 @@ pub mod git_ops; pub mod proxy; pub mod git_repos; pub mod publish; +pub mod templates; +pub mod tags; +pub mod cicd; diff --git a/src-tauri/src/commands/network.rs b/src-tauri/src/commands/network.rs index 082a6d7..01f89aa 100644 --- a/src-tauri/src/commands/network.rs +++ b/src-tauri/src/commands/network.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use crate::db::pool; use rusqlite::params; use serde::{Deserialize, Serialize}; @@ -303,48 +305,326 @@ $results -join "`n" // ── WSL v2rayA 代理控制 ──────────────────────────────────────────────────────── +fn get_wsl_distro() -> String { + use crate::db::pool; + use rusqlite::params; + pool().get().ok() + .and_then(|conn| { + conn.query_row( + "SELECT value FROM settings WHERE key = 'wsl_distro'", + params![], + |row| row.get::<_, String>(0), + ).ok() + }) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "Ubuntu".into()) +} + fn run_wsl_root(cmd: &str) -> Result { + let distro = get_wsl_distro(); std::process::Command::new("wsl") - .args(["-u", "root", "-e", "bash", "-c", cmd]) + .args(["-d", &distro, "-u", "root", "-e", "bash", "-c", cmd]) .output() .map_err(|e| format!("WSL 不可用: {e}")) } -/// 获取 v2rayA 运行状态:"running" | "stopped" | "wsl_unavailable" -#[tauri::command] -pub fn get_v2raya_status() -> Result { - let output = run_wsl_root("pgrep -x v2raya > /dev/null 2>&1 && echo running || echo stopped") - .map_err(|_| "wsl_unavailable".to_string())?; - let out = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(if out == "running" { - "running".to_string() - } else { - "stopped".to_string() - }) +/// run_wsl_root + 检查 exit code,用于关键写操作 +fn run_wsl_root_ok(cmd: &str) -> Result { + let output = run_wsl_root(cmd)?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("WSL 命令失败 (exit {})", output.status.code().unwrap_or(-1)) + } else { + let first_line = stderr.lines().next().unwrap_or(&stderr); + format!("WSL 命令失败: {}", &first_line[..first_line.len().min(120)]) + }); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } -/// 启动 v2rayA(后台运行,不阻塞 WSL 会话) +/// 验证字符串是合法 IPv4/IPv6 地址 +fn is_valid_ip(s: &str) -> bool { + s.parse::().is_ok() +} + +/// 获取 WSL 对外 IP(用于从 Windows 侧访问 WSL 服务) +/// WSL2 NAT 模式下 localhost 不通,需用 WSL 实际 IP #[tauri::command] -pub fn start_v2raya() -> Result<(), String> { +pub fn get_wsl_ip() -> Result { let output = run_wsl_root( - "nohup v2raya --log-disable-color > /tmp/v2raya.log 2>&1 & sleep 1; \ - pgrep -x v2raya > /dev/null && echo ok || echo fail", - )?; - let out = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if out.contains("ok") { - Ok(()) + "hostname -I | awk '{print $1}'" + ).map_err(|_| "WSL 不可用".to_string())?; + let ip = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if ip.is_empty() { + Err("无法获取 WSL IP".to_string()) } else { - Err("v2rayA 启动失败,请确认已在 WSL 中安装(apt install v2raya)".to_string()) + Ok(ip) } } -/// 停止 v2rayA +/// 将 v2rayN 代理端口同步写入 WSL /etc/profile.d/v2rayn-proxy.sh #[tauri::command] -pub fn stop_v2raya() -> Result<(), String> { - run_wsl_root("pkill v2raya; exit 0")?; +pub fn sync_wsl_proxy(db_path: Option) -> Result { + // 1. 定位 guiNConfig.json + let db_file = match db_path.filter(|s| !s.is_empty()) { + Some(p) => std::path::PathBuf::from(p), + None => { + let saved = crate::db::pool().get().ok() + .and_then(|conn| conn.query_row( + "SELECT value FROM settings WHERE key = 'v2rayn_db_path'", + rusqlite::params![], + |row| row.get::<_, String>(0), + ).ok()) + .filter(|s| !s.is_empty()); + match saved { + Some(p) => std::path::PathBuf::from(p), + None => return Err("未配置 v2rayN 数据库路径,请先在「Win 代理配置」中设置".into()), + } + } + }; + + let config_file = db_file.parent() + .ok_or("无法获取 v2rayN 配置目录")? + .join("guiNConfig.json"); + + // 2. 读取代理端口(Inbound[0].LocalPort,默认 10808) + let config_str = std::fs::read_to_string(&config_file) + .map_err(|e| format!("读取 guiNConfig.json 失败: {e}"))?; + + #[derive(serde::Deserialize)] + struct Inbound { #[serde(rename = "LocalPort")] local_port: u16 } + #[derive(serde::Deserialize)] + struct GuiConfig { #[serde(rename = "Inbound")] inbound: Option> } + + let cfg: GuiConfig = serde_json::from_str(&config_str) + .map_err(|e| format!("解析 guiNConfig.json 失败: {e}"))?; + let port = cfg.inbound.as_ref() + .and_then(|v| v.first()) + .map(|i| i.local_port) + .unwrap_or(10808); + + // 3. 生成脚本内容并用 base64 写入 WSL,避免 shell 引号转义问题 + let script = format!( + "# v2rayN proxy - managed by dev-manager (port {port})\n\ + _WIN_HOST=$(ip route 2>/dev/null | grep default | awk '{{print $3}}' | head -1)\n\ + if [ -n \"$_WIN_HOST\" ]; then\n\ + export http_proxy=\"http://$_WIN_HOST:{port}\"\n\ + export https_proxy=\"http://$_WIN_HOST:{port}\"\n\ + export HTTP_PROXY=\"http://$_WIN_HOST:{port}\"\n\ + export HTTPS_PROXY=\"http://$_WIN_HOST:{port}\"\n\ + export ALL_PROXY=\"socks5://$_WIN_HOST:{port}\"\n\ + export no_proxy=\"localhost,127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16\"\n\ + export NO_PROXY=\"localhost,127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16\"\n\ + fi\n" + ); + let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &script); + // rm -f 先删除旧文件(包括符号链接本身,不跟随),再创建新普通文件,防止链接劫持 + let cmd = format!( + "rm -f /etc/profile.d/v2rayn-proxy.sh && echo '{encoded}' | base64 -d > /etc/profile.d/v2rayn-proxy.sh && chmod 644 /etc/profile.d/v2rayn-proxy.sh" + ); + run_wsl_root_ok(&cmd)?; + + // 让非登录 shell(如 VSCode 终端)也能加载代理变量 + run_wsl_root_ok( + r#"grep -qF 'v2rayn-proxy.sh' /etc/bash.bashrc 2>/dev/null || \ + echo '[ -f /etc/profile.d/v2rayn-proxy.sh ] && source /etc/profile.d/v2rayn-proxy.sh' >> /etc/bash.bashrc"# + )?; + + // 为 WSL 默认登录用户写入 ~/.bashrc(root 的 ~ 是 /root,不是用户目录) + // 优先读 /etc/wsl.conf [user] default=xxx,否则 fallback 到 UID=1000 + run_wsl_root_ok( + r#"_DEFAULT_USER=$(grep -oP '(?<=default=)\S+' /etc/wsl.conf 2>/dev/null | head -1); \ + [ -z "$_DEFAULT_USER" ] && _DEFAULT_USER=$(getent passwd 1000 | cut -d: -f1); \ + _USER_HOME=$(getent passwd "$_DEFAULT_USER" 2>/dev/null | cut -d: -f6); \ + [ -n "$_USER_HOME" ] && { \ + grep -qF 'v2rayn-proxy.sh' "$_USER_HOME/.bashrc" 2>/dev/null || \ + echo '[ -f /etc/profile.d/v2rayn-proxy.sh ] && source /etc/profile.d/v2rayn-proxy.sh' >> "$_USER_HOME/.bashrc"; \ + }"# + )?; + + // 将当前 Windows host IP 写入 /etc/environment(PAM 全局,覆盖所有 shell 类型) + // /etc/environment 不支持 shell 展开,必须用当前实际 IP + let win_host = { + let out = run_wsl_root("ip route 2>/dev/null | grep default | awk '{print $3}' | head -1")?; + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + if win_host.is_empty() { + // WSL 网络异常时 L4(/etc/environment)无法写入,仅 L1 动态脚本生效 + // PAM 类会话(GUI 程序)代理将不可用,给用户明确提示 + return Ok(format!( + "WSL 代理脚本已写入(端口 {port}),但无法获取 Windows 网关 IP,\ + /etc/environment 未更新(重启 WSL 后可重新同步)" + )); + } + + if !win_host.is_empty() { + let no_proxy_val = "localhost,127.0.0.1,::1"; + let env_block = format!( + "http_proxy=http://{win_host}:{port}\n\ + https_proxy=http://{win_host}:{port}\n\ + HTTP_PROXY=http://{win_host}:{port}\n\ + HTTPS_PROXY=http://{win_host}:{port}\n\ + ALL_PROXY=socks5://{win_host}:{port}\n\ + no_proxy={no_proxy_val}\n\ + NO_PROXY={no_proxy_val}" + ); + let env_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &env_block); + // 先删除旧的 proxy 条目,再追加新的 + let update_env_cmd = format!( + r#"sed -i -E '/^https?_proxy|^HTTP_PROXY|^HTTPS_PROXY|^ALL_PROXY|^no_proxy|^NO_PROXY/Id' /etc/environment 2>/dev/null; \ + echo '{env_b64}' | base64 -d >> /etc/environment"# + ); + run_wsl_root_ok(&update_env_cmd)?; + } + + Ok(format!("WSL 代理已配置(端口 {port} → {win_host}),新开终端自动生效")) +} + +/// 读取 WSL 当前代理配置状态,并检测 /etc/environment 中的 IP 是否已过时 +/// 4 项检测合并为 1 次 WSL 调用,降低延迟约 75% +#[tauri::command] +pub fn get_wsl_proxy_status() -> Result { + // 单次调用输出 4 段,以 __SEP1__ ~ __SEP3__ 分隔: + // 段0: profile.d 脚本内容(缺失则输出 __MISSING__) + // 段1: /etc/environment 中的 http_proxy 主机 IP + // 段2: 当前网关 IP + // 段3: bash.bashrc 是否包含 source 行(yes/no) + let output = run_wsl_root( + r#"if [ -f /etc/profile.d/v2rayn-proxy.sh ]; then cat /etc/profile.d/v2rayn-proxy.sh; else echo __MISSING__; fi; \ + echo __SEP1__; \ + grep -i '^http_proxy=' /etc/environment 2>/dev/null | head -1 | sed 's|.*://||;s|:.*||'; \ + echo __SEP2__; \ + ip route 2>/dev/null | grep default | awk '{print $3}' | head -1; \ + echo __SEP3__; \ + grep -qF 'v2rayn-proxy.sh' /etc/bash.bashrc 2>/dev/null && echo yes || echo no"# + ).map_err(|_| "WSL 不可用".to_string())?; + + let raw = String::from_utf8_lossy(&output.stdout).to_string(); + // 手动按分隔符切割 + let seg: Vec<&str> = { + let mut v = Vec::new(); + let mut rest = raw.as_str(); + // 兼容 Windows 管道可能插入的 \r\n + for sep in &["__SEP1__\r\n", "__SEP1__\n", "__SEP2__\r\n", "__SEP2__\n", "__SEP3__\r\n", "__SEP3__\n"] + .chunks(2) + .map(|pair| if raw.contains(pair[0]) { pair[0] } else { pair[1] }) + .collect::>() + { + if let Some(pos) = rest.find(sep) { + v.push(rest[..pos].trim()); + rest = &rest[pos + sep.len()..]; + } else { + v.push(rest.trim()); + rest = ""; + } + } + v.push(rest.trim()); + v + }; + + let script_content = seg.first().copied().unwrap_or(""); + if script_content.is_empty() || script_content == "__MISSING__" { + return Ok(serde_json::json!({ "configured": false })); + } + + // 从注释行提取端口 "# ... (port NNNN)" + let port: Option = script_content.lines() + .find(|l| l.contains("(port ")) + .and_then(|l| l.split("(port ").nth(1)) + .and_then(|s| s.trim_end_matches(')').trim().parse().ok()); + + let configured_ip = seg.get(1).copied().unwrap_or("").to_string(); + let configured_ip = if is_valid_ip(&configured_ip) { Some(configured_ip) } else { None }; + + let current_ip = seg.get(2).copied().unwrap_or("").to_string(); + let current_ip = if is_valid_ip(¤t_ip) { Some(current_ip) } else { None }; + + // 若无法提取 configured_ip(如 /etc/environment 被手动修改),保守地标为 stale + let stale = match (&configured_ip, ¤t_ip) { + (Some(a), Some(b)) => a != b, + (None, Some(_)) => true, // 无法确认,提示重新同步 + _ => false, + }; + + let bashrc_ok = seg.get(3).copied().unwrap_or("no") == "yes"; + + Ok(serde_json::json!({ + "configured": true, + "port": port, + "stale": stale, + "configured_ip": configured_ip, + "current_ip": current_ip, + "bashrc_ok": bashrc_ok, + })) +} + +/// 清除 WSL 代理配置 +#[tauri::command] +pub fn clear_wsl_proxy() -> Result<(), String> { + run_wsl_root_ok("rm -f /etc/profile.d/v2rayn-proxy.sh")?; + run_wsl_root_ok(r#"sed -i '/v2rayn-proxy\.sh/d' /etc/bash.bashrc 2>/dev/null; true"#)?; + // 同时清理 WSL 默认登录用户的 ~/.bashrc + run_wsl_root_ok( + r#"_DEFAULT_USER=$(grep -oP '(?<=default=)\S+' /etc/wsl.conf 2>/dev/null | head -1); \ + [ -z "$_DEFAULT_USER" ] && _DEFAULT_USER=$(getent passwd 1000 | cut -d: -f1); \ + _USER_HOME=$(getent passwd "$_DEFAULT_USER" 2>/dev/null | cut -d: -f6); \ + [ -n "$_USER_HOME" ] && sed -i '/v2rayn-proxy\.sh/d' "$_USER_HOME/.bashrc" 2>/dev/null; true"# + )?; + run_wsl_root_ok(r#"sed -i -E '/^https?_proxy|^HTTP_PROXY|^HTTPS_PROXY|^ALL_PROXY|^no_proxy|^NO_PROXY/Id' /etc/environment 2>/dev/null; true"#)?; Ok(()) } +/// 测试 WSL 侧出口 IP(通过 v2rayn-proxy.sh 代理) +#[tauri::command] +pub fn test_wsl_proxy_ip() -> Result { + let output = run_wsl_root( + "source /etc/profile.d/v2rayn-proxy.sh 2>/dev/null && \ + curl -s --max-time 10 --proxy \"$http_proxy\" https://api.ipify.org 2>&1" + ).map_err(|_| "WSL 不可用".to_string())?; + let ip = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !is_valid_ip(&ip) { + let hint = if ip.is_empty() { "无输出".to_string() } + else { ip.lines().next().unwrap_or(&ip).chars().take(80).collect() }; + Err(format!("获取失败({hint}),请确认代理已配置且节点可用")) + } else { + Ok(ip) + } +} + +/// 测试 Windows 侧出口 IP(通过系统代理) +#[tauri::command] +pub fn test_win_proxy_ip() -> Result { + // 1. 读取系统代理地址 + let reg_out = std::process::Command::new("powershell") + .args(["-NonInteractive", "-NoProfile", "-Command", + r#"(Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue).ProxyServer"#]) + .output() + .map_err(|e| format!("读取系统代理失败: {e}"))?; + let proxy_server = String::from_utf8_lossy(®_out.stdout).trim().to_string(); + if proxy_server.is_empty() { + return Err("Windows 未配置系统代理(v2rayN 可能未启动)".to_string()); + } + + // 2. 用 curl.exe 通过代理获取 IP + let output = std::process::Command::new("curl.exe") + .args([ + "--proxy", &format!("http://{}", proxy_server), + "-s", "--max-time", "10", + "https://api.ipify.org", + ]) + .output() + .map_err(|e| format!("curl.exe 执行失败: {e}"))?; + let ip = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !is_valid_ip(&ip) { + let hint = if ip.is_empty() { "无输出".to_string() } + else { ip.lines().next().unwrap_or(&ip).chars().take(80).collect() }; + Err(format!("获取失败(代理 {proxy_server},{hint})")) + } else { + Ok(ip) + } +} + /// 获取模式切换历史 #[tauri::command] pub fn get_mode_log(limit: Option) -> Result, String> { diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index d7ac678..a837a59 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -21,6 +21,7 @@ const PROJECT_VIEW_SQL: &str = " COALESCE(gr.status, pp.status) AS status, COALESCE(gr.priority, pp.priority) AS priority, COALESCE(gr.tech_stack, pp.tech_stack) AS tech_stack, + pp.tags AS tags, COALESCE(gr.repo_url, pp.repo_url) AS repo_url, COALESCE(gr.upstream_url, pp.upstream_url) AS upstream_url, COALESCE(gr.default_branch, pp.default_branch) AS default_branch, @@ -78,6 +79,7 @@ pub struct UpdateProjectInput { pub status: Option, pub priority: Option, pub tech_stack: Option, + pub tags: Option, pub staging_url: Option, pub prod_url: Option, pub server_note: Option, @@ -216,14 +218,15 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin status = COALESCE(?4, status), priority = COALESCE(?5, priority), tech_stack = COALESCE(?6, tech_stack), - staging_url = ?7, - prod_url = ?8, - server_note = ?9, + tags = COALESCE(?7, tags), + staging_url = ?8, + prod_url = ?9, + server_note = ?10, updated_at = datetime('now') - WHERE id = (SELECT profile_id FROM project_workspaces WHERE id = ?10)", + WHERE id = (SELECT profile_id FROM project_workspaces WHERE id = ?11)", params![ input.name, input.description, input.type_, input.status, input.priority, - input.tech_stack, + input.tech_stack, input.tags, input.staging_url, input.prod_url, input.server_note, id ], @@ -249,6 +252,17 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin Ok(()) } +#[tauri::command] +pub fn update_project_tags(id: String, tags: String) -> Result<(), String> { + let conn = pool().get().map_err(|e| e.to_string())?; + conn.execute( + "UPDATE project_profiles SET tags = ?1, updated_at = datetime('now') + WHERE id = (SELECT profile_id FROM project_workspaces WHERE id = ?2)", + params![if tags.is_empty() { None } else { Some(tags) }, id], + ).map_err(|e| e.to_string())?; + Ok(()) +} + #[tauri::command] pub fn delete_project(id: String, delete_local: Option) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 6bd62e8..07d0933 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -1,5 +1,5 @@ use rusqlite::{Connection, OpenFlags}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Debug, Serialize, Clone)] @@ -35,15 +35,27 @@ fn find_v2rayn_db() -> Option { } } - // 2. Scoop 安装:扫描常见盘符 - for drive in ["C", "D", "E", "F"] { - let p = PathBuf::from(format!( - "{}:\\Scoop\\persist\\v2rayn\\guiConfigs\\guiNDB.db", - drive - )); - if p.exists() { - return Some(p); + // 2. Scoop 安装:扫描常见盘符 + 用户目录下的 Scoop + let scoop_roots: Vec = { + let mut roots = vec![]; + // 用户目录下的 Scoop(默认安装位置) + if let Ok(home) = std::env::var("USERPROFILE") { + roots.push(PathBuf::from(&home).join("scoop")); + roots.push(PathBuf::from(&home).join("Scoop")); } + // 各盘符根目录 + for drive in ["C", "D", "E", "F", "G"] { + roots.push(PathBuf::from(format!("{drive}:\\scoop"))); + roots.push(PathBuf::from(format!("{drive}:\\Scoop"))); + } + roots + }; + for root in &scoop_roots { + let p = root.join("persist").join("v2rayn").join("guiConfigs").join("guiNDB.db"); + if p.exists() { return Some(p); } + // 有些 scoop bucket 用 v2rayn 全小写或带版本 + let p2 = root.join("persist").join("v2rayN").join("guiConfigs").join("guiNDB.db"); + if p2.exists() { return Some(p2); } } None @@ -122,3 +134,144 @@ pub fn scan_v2rayn_nodes(db_path: Option) -> Result, Str pub fn detect_v2rayn_db_path() -> Option { find_v2rayn_db().map(|p| p.to_string_lossy().to_string()) } + +#[derive(Debug, Serialize, Deserialize)] +pub struct ActiveV2raynNode { + pub index_id: String, + pub remarks: String, + pub address: String, + pub port: i64, + pub config_type: i64, + pub share_link: String, +} + +/// 读取 v2rayN 当前激活节点,返回节点信息和 share link +#[tauri::command] +pub fn get_active_v2rayn_node(db_path: Option) -> Result { + // 1. 找到 DB 路径 + let db_file = match db_path { + Some(ref p) => PathBuf::from(p), + None => find_v2rayn_db().ok_or("未找到 v2rayN 数据库,请先配置路径")?, + }; + + // 2. 找同目录的 guiNConfig.json,读取当前激活 IndexId + let config_file = db_file.parent() + .ok_or("无法获取 DB 目录")? + .join("guiNConfig.json"); + + let config_str = std::fs::read_to_string(&config_file) + .map_err(|e| format!("读取 guiNConfig.json 失败: {e}"))?; + + #[derive(Deserialize)] + struct GuiConfig { #[serde(rename = "IndexId")] index_id: String } + let gui_cfg: GuiConfig = serde_json::from_str(&config_str) + .map_err(|e| format!("解析 guiNConfig.json 失败: {e}"))?; + + let active_id = gui_cfg.index_id; + + // 3. 从 DB 查该节点详情 + let tmp = copy_to_temp(&db_file)?; + let conn = Connection::open_with_flags(&tmp, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| format!("打开 DB 失败: {e}"))?; + + let node = conn.query_row( + "SELECT IndexId, ConfigType, Remarks, Address, Port, Id, Security, Network, + StreamSecurity, Flow, Sni, Fingerprint, PublicKey, ShortId, SpiderX, + RequestHost, Path, AlterId + FROM ProfileItem WHERE IndexId = ?1", + [&active_id], + |row| Ok(V2raynNode { + index_id: row.get(0)?, + config_type: row.get(1)?, + remarks: row.get::<_, Option>(2)?.unwrap_or_default(), + address: row.get::<_, Option>(3)?.unwrap_or_default(), + port: row.get::<_, Option>(4)?.unwrap_or(0), + id: row.get(5)?, + security: row.get(6)?, + network: row.get(7)?, + stream_security: row.get(8)?, + flow: row.get(9)?, + sni: row.get(10)?, + fingerprint: row.get(11)?, + public_key: row.get(12)?, + short_id: row.get(13)?, + spider_x: row.get(14)?, + request_host: row.get(15)?, + path: row.get(16)?, + alter_id: row.get(17)?, + alter_id_i: None, + }), + ).map_err(|_| format!("未在 DB 中找到激活节点 {active_id}"))?; + + let _ = std::fs::remove_file(&tmp); + + // 4. 生成 share link + let share_link = build_share_link_rust(&node); + + Ok(ActiveV2raynNode { + index_id: node.index_id, + remarks: node.remarks, + address: node.address, + port: node.port, + config_type: node.config_type, + share_link, + }) +} + +/// 在 Rust 侧生成 share link(与前端 buildShareLink 逻辑一致) +fn build_share_link_rust(n: &V2raynNode) -> String { + let remarks_enc = urlencoding::encode(&n.remarks).into_owned(); + match n.config_type { + // VMess + 1 => { + let obj = serde_json::json!({ + "v": "2", "ps": n.remarks, "add": n.address, "port": n.port.to_string(), + "id": n.id.as_deref().unwrap_or(""), + "aid": n.alter_id.unwrap_or(0).to_string(), + "net": n.network.as_deref().unwrap_or("tcp"), + "tls": n.stream_security.as_deref().unwrap_or(""), + "sni": n.sni.as_deref().unwrap_or(""), + "host": n.request_host.as_deref().unwrap_or(""), + "path": n.path.as_deref().unwrap_or(""), + "type": "none", + }); + let b64 = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, obj.to_string() + ); + format!("vmess://{b64}") + } + // VLESS + 5 => { + let mut params = format!( + "security={}&encryption=none", + n.stream_security.as_deref().unwrap_or("none") + ); + if let Some(f) = &n.flow { if !f.is_empty() { params += &format!("&flow={f}"); } } + if let Some(s) = &n.sni { if !s.is_empty() { params += &format!("&sni={s}"); } } + if let Some(fp) = &n.fingerprint { if !fp.is_empty() { params += &format!("&fp={fp}"); } } + if let Some(pk) = &n.public_key { if !pk.is_empty() { params += &format!("&pbk={pk}"); } } + if let Some(sid) = &n.short_id { if !sid.is_empty() { params += &format!("&sid={sid}"); } } + if let Some(net) = &n.network { if !net.is_empty() { params += &format!("&type={net}"); } } + format!("vless://{}@{}:{}?{params}#{remarks_enc}", + n.id.as_deref().unwrap_or(""), n.address, n.port) + } + // Trojan + 3 => { + let mut params = String::new(); + if let Some(s) = &n.sni { if !s.is_empty() { params = format!("sni={s}"); } } + let q = if params.is_empty() { String::new() } else { format!("?{params}") }; + format!("trojan://{}@{}:{}{}#{remarks_enc}", + n.id.as_deref().unwrap_or(""), n.address, n.port, q) + } + // Shadowsocks + 6 => { + let method = n.security.as_deref().unwrap_or("chacha20-poly1305"); + let userinfo = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + format!("{method}:{}", n.id.as_deref().unwrap_or("")) + ); + format!("ss://{userinfo}@{}:{}#{remarks_enc}", n.address, n.port) + } + _ => format!("unsupported://{}:{}", n.address, n.port), + } +} diff --git a/src-tauri/src/commands/publish.rs b/src-tauri/src/commands/publish.rs index e889822..5405acf 100644 --- a/src-tauri/src/commands/publish.rs +++ b/src-tauri/src/commands/publish.rs @@ -218,7 +218,7 @@ fn detect_templates(root: &Path) -> Vec { /// 从原始路径(可能是 Linux 路径或 UNC 路径)提取 Linux 绝对路径 /// - 原始路径以 `/` 开头 → 直接返回 /// - win_path 是 `\\wsl.localhost\Distro\...` → 提取 `/...` 部分 -fn extract_linux_path(original: &str, win_path: &str) -> Option { +pub fn extract_linux_path(original: &str, win_path: &str) -> Option { if original.starts_with('/') { return Some(original.to_string()); } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 3165762..d957734 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -3,8 +3,16 @@ use rusqlite::params; use serde_json::{Map, Value}; use std::collections::HashMap; -/// 全局 key(不按空间隔离,属于整个应用级别配置) -const GLOBAL_KEYS: &[&str] = &["github_client_id", "github_client_secret"]; +/// 全局 key(不按空间隔离,属于整个应用/机器级别配置) +const GLOBAL_KEYS: &[&str] = &[ + "github_client_id", "github_client_secret", + "wsl_distro", // 机器级别,与空间无关 + "wsl_open_mode", // WSL 编辑器启动方式 + "editors", "active_editors", "active_editor", + "v2rayn_db_path", // v2rayN 数据库路径 + "v2raya_username", // v2rayA 登录用户名 + "v2raya_password", // v2rayA 登录密码 +]; /// 获取 settings: /// - 有 space_id:返回该空间的 space_settings + 全局 key(github_client_id/secret) diff --git a/src-tauri/src/commands/shell.rs b/src-tauri/src/commands/shell.rs index 849308f..0f03b13 100644 --- a/src-tauri/src/commands/shell.rs +++ b/src-tauri/src/commands/shell.rs @@ -3,12 +3,41 @@ use rusqlite::params; use std::path::Path; use std::process::Command; +/// 将 UNC WSL 路径转换为 Linux 路径 +/// \\wsl.localhost\Ubuntu\home\user\proj → /home/user/proj +/// \\wsl$\Ubuntu\home\user\proj → /home/user/proj +/// /home/user/proj → /home/user/proj(直接返回) +fn unc_to_linux(path: &str) -> String { + for prefix in &["\\\\wsl.localhost\\", "\\\\wsl$\\"] { + if path.to_lowercase().starts_with(&prefix.to_lowercase()) { + let rest = &path[prefix.len()..]; + // 去掉 distro 名(第一段),剩余部分转 / + let after_distro = rest.splitn(2, '\\').nth(1).unwrap_or(""); + return format!("/{}", after_distro.replace('\\', "/")); + } + } + // 已经是 Linux 路径,直接返回 + path.to_string() +} + fn get_setting(key: &str) -> Option { let conn = pool().get().ok()?; - conn.query_row("SELECT value FROM settings WHERE key = ?1", params![key], |row| { - row.get::<_, String>(0) - }) - .ok() + // 先查全局 settings 表 + if let Ok(val) = conn.query_row( + "SELECT value FROM settings WHERE key = ?1", + params![key], + |row| row.get::<_, String>(0), + ) { + if !val.is_empty() { + return Some(val); + } + } + // 兼容旧数据:回退查 space_settings(任意空间取第一条) + conn.query_row( + "SELECT value FROM space_settings WHERE key = ?1 LIMIT 1", + params![key], + |row| row.get::<_, String>(0), + ).ok() } #[tauri::command] @@ -24,11 +53,49 @@ pub fn open_editor(project_id: String, env: String, editor_path: String) -> Resu let wsl_distro = get_setting("wsl_distro").unwrap_or_else(|| "Ubuntu".into()); - let args: Vec = match env.as_str() { - "wsl" => { - let path = wsl_path.ok_or("WSL path not configured")?; - vec!["--remote".into(), format!("wsl+{wsl_distro}"), path] + // WSL 环境:按 wsl_open_mode 配置自适应启动 + // wsl_internal (默认): wsl -d -- + // remote_flag : --remote wsl+ + // unc_path : (直接用 UNC 路径,不走 Remote) + if env == "wsl" { + let path = wsl_path.ok_or("WSL path not configured")?; + let open_mode = get_setting("wsl_open_mode") + .unwrap_or_else(|| "wsl_internal".into()); + + if open_mode == "wsl_internal" { + // 直接调用 wsl 进程,用参数数组传路径,避免 cmd /c 的引号转义问题 + let linux_path = unc_to_linux(&path); + let cmd_name = std::path::Path::new(&editor_path) + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| editor_path.clone()); + Command::new("wsl") + .args(["-d", &wsl_distro, "--", &cmd_name, &linux_path]) + .spawn() + .map_err(|e| format!("无法启动编辑器: {e}"))?; + return Ok(format!("wsl -d {} -- {} {}", wsl_distro, cmd_name, linux_path)); } + + let cmd_str = match open_mode.as_str() { + "remote_flag" => { + let linux_path = unc_to_linux(&path); + format!("\"{}\" --remote wsl+{} \"{}\"", editor_path, wsl_distro, linux_path) + } + _ => { + // unc_path:直接用原始路径,不走 Remote + format!("\"{}\" \"{}\"", editor_path, path) + } + }; + + Command::new("cmd") + .args(["/c", &cmd_str]) + .spawn() + .map_err(|e| format!("无法启动编辑器: {e}"))?; + return Ok(cmd_str); + } + + let args: Vec = match env.as_str() { "win" => { let path = win_path.ok_or("Windows path not configured")?; vec![path] diff --git a/src-tauri/src/commands/tags.rs b/src-tauri/src/commands/tags.rs new file mode 100644 index 0000000..e5b5719 --- /dev/null +++ b/src-tauri/src/commands/tags.rs @@ -0,0 +1,127 @@ +use crate::db; +use rusqlite::params; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Serialize, Deserialize, Clone)] +pub struct ProjectTag { + pub id: String, + pub name: String, + pub color: String, +} + +#[derive(Serialize)] +pub struct ProfileTagMap { + pub profile_id: String, + pub tag_id: String, +} + +#[tauri::command] +pub fn get_tags() -> Result, String> { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare( + "SELECT id, name, color FROM project_tags ORDER BY name" + ).map_err(|e| e.to_string())?; + let tags = stmt.query_map([], |row| Ok(ProjectTag { + id: row.get(0)?, + name: row.get(1)?, + color: row.get(2)?, + })) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + Ok(tags) +} + +#[tauri::command] +pub fn create_tag(name: String, color: String) -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let id = Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO project_tags (id, name, color) VALUES (?1, ?2, ?3)", + params![id, name.trim(), color], + ).map_err(|e| e.to_string())?; + Ok(id) +} + +#[tauri::command] +pub fn update_tag(id: String, name: String, color: String) -> Result<(), String> { + let conn = db::pool().get().map_err(|e| e.to_string())?; + conn.execute( + "UPDATE project_tags SET name = ?1, color = ?2 WHERE id = ?3", + params![name.trim(), color, id], + ).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn delete_tag(id: String) -> Result<(), String> { + let conn = db::pool().get().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM project_tag_map WHERE tag_id = ?1", params![id]) + .map_err(|e| e.to_string())?; + conn.execute("DELETE FROM project_tags WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// 获取某个 workspace 对应项目的标签 +#[tauri::command] +pub fn get_project_tags(workspace_id: String) -> Result, String> { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare( + "SELECT t.id, t.name, t.color + FROM project_tags t + JOIN project_tag_map m ON m.tag_id = t.id + JOIN project_workspaces pw ON pw.profile_id = m.profile_id + WHERE pw.id = ?1 + ORDER BY t.name" + ).map_err(|e| e.to_string())?; + let tags = stmt.query_map(params![workspace_id], |row| Ok(ProjectTag { + id: row.get(0)?, + name: row.get(1)?, + color: row.get(2)?, + })) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + Ok(tags) +} + +/// 全量替换某个 workspace 对应项目的标签(tag_ids 为空则清空) +#[tauri::command] +pub fn set_project_tags(workspace_id: String, tag_ids: Vec) -> Result<(), String> { + let conn = db::pool().get().map_err(|e| e.to_string())?; + // 找到 profile_id + let profile_id: String = conn.query_row( + "SELECT profile_id FROM project_workspaces WHERE id = ?1", + params![workspace_id], + |row| row.get(0), + ).map_err(|e| format!("workspace not found: {e}"))?; + + conn.execute("DELETE FROM project_tag_map WHERE profile_id = ?1", params![profile_id]) + .map_err(|e| e.to_string())?; + for tag_id in &tag_ids { + conn.execute( + "INSERT OR IGNORE INTO project_tag_map (profile_id, tag_id) VALUES (?1, ?2)", + params![profile_id, tag_id], + ).map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 获取全量 project_tag_map(用于前端 join) +#[tauri::command] +pub fn get_all_project_tag_maps() -> Result, String> { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare( + "SELECT profile_id, tag_id FROM project_tag_map" + ).map_err(|e| e.to_string())?; + let maps = stmt.query_map([], |row| Ok(ProfileTagMap { + profile_id: row.get(0)?, + tag_id: row.get(1)?, + })) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + Ok(maps) +} diff --git a/src-tauri/src/commands/templates.rs b/src-tauri/src/commands/templates.rs new file mode 100644 index 0000000..0518234 --- /dev/null +++ b/src-tauri/src/commands/templates.rs @@ -0,0 +1,479 @@ +use crate::db; +use rusqlite::params; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use uuid::Uuid; + +// ── 数据结构 ────────────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Clone)] +pub struct TemplateFile { + pub id: String, + pub template_id: String, + pub path: String, + pub content: String, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ProjectTemplate { + pub id: String, + pub name: String, + pub description: Option, + pub platform: String, // "win" | "wsl" | "both" + pub tech_stack: Option, + pub init_commands: Option, + pub is_builtin: bool, + pub created_at: Option, + #[serde(default)] + pub files: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplateFileInput { + pub path: String, + pub content: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveTemplateInput { + pub id: Option, + pub name: String, + pub description: Option, + pub platform: String, + pub tech_stack: Option, + pub init_commands: Option, + pub files: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateProjectResult { + pub project_id: String, + pub logs: Vec, +} + +// ── Tauri 命令 ──────────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn list_templates() -> Result, String> { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare( + "SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at + FROM project_templates ORDER BY is_builtin DESC, name", + ) + .map_err(|e| e.to_string())?; + + let templates = stmt + .query_map([], |row| { + Ok(ProjectTemplate { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + platform: row.get(3)?, + tech_stack: row.get(4)?, + init_commands: row.get(5)?, + is_builtin: row.get::<_, i64>(6)? == 1, + created_at: row.get(7)?, + files: vec![], + }) + }) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + + Ok(templates) +} + +#[tauri::command] +pub fn get_template(id: String) -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + + let mut tmpl = conn + .query_row( + "SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at + FROM project_templates WHERE id = ?1", + params![id], + |row| { + Ok(ProjectTemplate { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + platform: row.get(3)?, + tech_stack: row.get(4)?, + init_commands: row.get(5)?, + is_builtin: row.get::<_, i64>(6)? == 1, + created_at: row.get(7)?, + files: vec![], + }) + }, + ) + .map_err(|e| format!("模板不存在: {e}"))?; + + let mut file_stmt = conn + .prepare( + "SELECT id, template_id, path, content + FROM project_template_files WHERE template_id = ?1 ORDER BY path", + ) + .map_err(|e| e.to_string())?; + + tmpl.files = file_stmt + .query_map(params![id], |row| { + Ok(TemplateFile { + id: row.get(0)?, + template_id: row.get(1)?, + path: row.get(2)?, + content: row.get(3)?, + }) + }) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + + Ok(tmpl) +} + +#[tauri::command] +pub fn save_template(input: SaveTemplateInput) -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let id = input.id.unwrap_or_else(|| Uuid::new_v4().to_string()); + + conn.execute( + "INSERT INTO project_templates (id, name, description, platform, tech_stack, init_commands, is_builtin) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + platform = excluded.platform, + tech_stack = excluded.tech_stack, + init_commands = excluded.init_commands", + params![id, input.name, input.description, input.platform, input.tech_stack, input.init_commands], + ) + .map_err(|e| e.to_string())?; + + // 全量替换文件列表 + conn.execute( + "DELETE FROM project_template_files WHERE template_id = ?1", + params![id], + ) + .map_err(|e| e.to_string())?; + + for file in &input.files { + let file_id = Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO project_template_files (id, template_id, path, content) VALUES (?1, ?2, ?3, ?4)", + params![file_id, id, file.path, file.content], + ) + .map_err(|e| e.to_string())?; + } + + Ok(id) +} + +#[tauri::command] +pub fn delete_template(id: String) -> Result<(), String> { + let conn = db::pool().get().map_err(|e| e.to_string())?; + conn.execute( + "DELETE FROM project_templates WHERE id = ?1 AND is_builtin = 0", + params![id], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn create_project_from_template( + template_id: String, + parent_path: String, + project_name: String, + platform: String, + space_id: Option, +) -> Result { + let tmpl = get_template(template_id)?; + let mut logs: Vec = Vec::new(); + + // ── 1. 计算项目路径 ─────────────────────────────────────────────────────── + let win_parent = crate::commands::publish::wsl_to_win(&parent_path); + let project_win_path = format!( + "{}\\{}", + win_parent.trim_end_matches('\\'), + project_name + ); + let project_root = Path::new(&project_win_path); + + if project_root.exists() { + return Err(format!("目录已存在: {}", project_win_path)); + } + + // ── 2. 创建根目录 ───────────────────────────────────────────────────────── + std::fs::create_dir_all(project_root) + .map_err(|e| format!("创建目录失败: {e}"))?; + logs.push(format!("📁 已创建目录: {}", project_win_path)); + + // ── 3. 写入模板文件 ─────────────────────────────────────────────────────── + for file in &tmpl.files { + if file.path.trim().is_empty() { + continue; + } + let content = apply_vars(&file.content, &project_name); + let file_path = project_root.join(&file.path); + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("创建子目录失败 {}: {e}", file.path))?; + } + std::fs::write(&file_path, content) + .map_err(|e| format!("写入 {} 失败: {e}", file.path))?; + logs.push(format!("📄 {}", file.path)); + } + + // ── 4. 执行初始化命令 ───────────────────────────────────────────────────── + if let Some(cmds) = &tmpl.init_commands { + let linux_project_path = if platform == "wsl" { + crate::commands::publish::extract_linux_path(&parent_path, &win_parent) + .map(|p| format!("{}/{}", p.trim_end_matches('/'), project_name)) + } else { + None + }; + + for line in cmds.lines() { + let cmd = line.trim(); + if cmd.is_empty() || cmd.starts_with('#') { + continue; + } + + logs.push(format!("⚙ 执行: {}", cmd)); + + let output = if platform == "wsl" { + let lp = linux_project_path + .as_deref() + .unwrap_or(&project_win_path); + std::process::Command::new("wsl") + .args(["bash", "-c", &format!("cd '{}' && {}", lp, cmd)]) + .output() + } else { + std::process::Command::new("cmd") + .args(["/c", &format!("chcp 65001 > nul && {}", cmd)]) + .current_dir(&project_win_path) + .output() + }; + + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + if !stdout.is_empty() { + logs.push(stdout); + } + if !stderr.is_empty() { + logs.push(format!("⚠ {}", stderr)); + } + if out.status.success() { + logs.push(" ✓".to_string()); + } else { + logs.push(format!(" ✗ 退出码: {}", out.status)); + } + } + Err(e) => { + logs.push(format!(" ✗ 启动失败: {e}")); + } + } + } + } + + // ── 5. 注册到数据库 ─────────────────────────────────────────────────────── + let conn = db::pool().get().map_err(|e| e.to_string())?; + let profile_id = Uuid::new_v4().to_string(); + let workspace_id = Uuid::new_v4().to_string(); + + conn.execute( + "INSERT INTO project_profiles (id, name, description, type, status, priority, tech_stack) + VALUES (?1, ?2, ?3, 'app', 'active', 'mid', ?4)", + params![profile_id, project_name, tmpl.description, tmpl.tech_stack], + ) + .map_err(|e| e.to_string())?; + + let is_wsl = platform == "wsl"; + let (wsl_path, win_path_col): (Option<&str>, Option<&str>) = if is_wsl { + (Some(&project_win_path), None) + } else { + (None, Some(&project_win_path)) + }; + // 存储时统一用 "windows"(与其他创建路径保持一致) + let stored_platform = if is_wsl { "wsl" } else { "windows" }; + + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, space_id, wsl_path, win_path, platform) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![workspace_id, profile_id, space_id, wsl_path, win_path_col, stored_platform], + ) + .map_err(|e| e.to_string())?; + + logs.push(format!("✅ 项目已注册,ID: {}", workspace_id)); + + Ok(CreateProjectResult { + project_id: workspace_id, + logs, + }) +} + +// ── 变量替换 ────────────────────────────────────────────────────────────────── + +fn apply_vars(content: &str, project_name: &str) -> String { + let kebab = project_name + .to_lowercase() + .replace(' ', "-") + .replace('_', "-"); + let snake = project_name + .to_lowercase() + .replace(' ', "_") + .replace('-', "_"); + content + .replace("{{project_name}}", project_name) + .replace("{{project_name_kebab}}", &kebab) + .replace("{{project_name_snake}}", &snake) +} + +// ── 内置模板种子数据 ────────────────────────────────────────────────────────── + +pub fn seed_builtin_templates() { + let conn = match db::pool().get() { + Ok(c) => c, + Err(_) => return, + }; + + // 已有内置模板则跳过 + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM project_templates WHERE is_builtin = 1", + [], + |row| row.get(0), + ) + .unwrap_or(0); + if count > 0 { + return; + } + + struct Tpl { + id: &'static str, + name: &'static str, + description: &'static str, + platform: &'static str, + tech_stack: &'static str, + init_commands: &'static str, + files: Vec<(&'static str, &'static str)>, + } + + let templates = vec![ + Tpl { + id: "builtin-pnpm-monorepo", + name: "pnpm Monorepo", + description: "多包 monorepo,含 apps/ 与 packages/ 目录", + platform: "both", + tech_stack: "Node,pnpm", + init_commands: "pnpm install", + files: vec![ + ("package.json", r#"{ + "name": "{{project_name_kebab}}", + "private": true, + "scripts": { + "dev": "pnpm -r --filter './apps/**' dev", + "build": "pnpm -r --filter './apps/**' build", + "lint": "pnpm -r lint" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8" + } +} +"#), + ("pnpm-workspace.yaml", r#"packages: + - 'apps/*' + - 'packages/*' +"#), + (".npmrc", "strict-peer-dependencies=false\nauto-install-peers=true\n"), + ("apps/.gitkeep", ""), + ("packages/.gitkeep", ""), + (".gitignore", "node_modules/\ndist/\n.next/\n*.log\n.env\n.env.*\n!.env.example\n"), + ], + }, + Tpl { + id: "builtin-pnpm-app", + name: "pnpm App", + description: "单包 Node.js 应用", + platform: "both", + tech_stack: "Node,pnpm", + init_commands: "pnpm install", + files: vec![ + ("package.json", r#"{ + "name": "{{project_name_kebab}}", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + } +} +"#), + ("src/index.js", "console.log('Hello from {{project_name}}!')\n"), + (".gitignore", "node_modules/\ndist/\n*.log\n.env\n"), + ], + }, + Tpl { + id: "builtin-rust", + name: "Rust", + description: "Rust 二进制项目", + platform: "both", + tech_stack: "Rust", + init_commands: "cargo build", + files: vec![ + ("Cargo.toml", r#"[package] +name = "{{project_name_kebab}}" +version = "0.1.0" +edition = "2021" + +[dependencies] +"#), + ("src/main.rs", "fn main() {\n println!(\"Hello from {{project_name}}!\");\n}\n"), + (".gitignore", "target/\n"), + ], + }, + Tpl { + id: "builtin-empty", + name: "空文件夹", + description: "只创建目录,不添加任何文件", + platform: "both", + tech_stack: "", + init_commands: "", + files: vec![], + }, + ]; + + for t in &templates { + let _ = conn.execute( + "INSERT OR IGNORE INTO project_templates + (id, name, description, platform, tech_stack, init_commands, is_builtin) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)", + params![ + t.id, + t.name, + if t.description.is_empty() { None } else { Some(t.description) }, + t.platform, + if t.tech_stack.is_empty() { None } else { Some(t.tech_stack) }, + if t.init_commands.is_empty() { None } else { Some(t.init_commands) }, + ], + ); + + for (path, content) in &t.files { + let file_id = format!("{}-{}", t.id, path.replace('/', "-")); + let _ = conn.execute( + "INSERT OR IGNORE INTO project_template_files (id, template_id, path, content) + VALUES (?1, ?2, ?3, ?4)", + params![file_id, t.id, path, content], + ); + } + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 354b23e..a3fb3ac 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -269,6 +269,52 @@ fn migrate(conn: &rusqlite::Connection) { [], ); + // 增量迁移:项目模板系统 + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS project_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + platform TEXT NOT NULL DEFAULT 'both', + tech_stack TEXT, + init_commands TEXT, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS project_template_files ( + id TEXT PRIMARY KEY, + template_id TEXT NOT NULL REFERENCES project_templates(id) ON DELETE CASCADE, + path TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + UNIQUE(template_id, path) + ); + ").expect("migrate project templates"); + + // 迁移 project_group_map:将外键从 projects(id) 改为 project_workspaces(id) + let pgm_sql: Option = conn.query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='project_group_map'", + [], + |row| row.get(0), + ).ok(); + if pgm_sql.map(|s| s.contains("REFERENCES projects")).unwrap_or(false) { + conn.execute_batch(" + PRAGMA foreign_keys=OFF; + CREATE TABLE project_group_map_new ( + project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE, + group_id TEXT NOT NULL REFERENCES product_groups(id) ON DELETE CASCADE, + role TEXT, + PRIMARY KEY (project_id, group_id) + ); + INSERT OR IGNORE INTO project_group_map_new (project_id, group_id, role) + SELECT m.project_id, m.group_id, m.role + FROM project_group_map m + INNER JOIN project_workspaces pw ON pw.id = m.project_id; + DROP TABLE project_group_map; + ALTER TABLE project_group_map_new RENAME TO project_group_map; + PRAGMA foreign_keys=ON; + ").expect("migrate project_group_map FK to project_workspaces"); + } + // 从旧 projects 表迁移数据(INSERT OR IGNORE 保证幂等) let _ = conn.execute_batch(" INSERT OR IGNORE INTO project_profiles @@ -292,4 +338,45 @@ fn migrate(conn: &rusqlite::Connection) { recent_focus, startup_commands, last_push_at, updated_at FROM projects; "); + + // 增量迁移:project_profiles 加 tags 字段 + let _ = conn.execute("ALTER TABLE project_profiles ADD COLUMN tags TEXT", []); + + // 标签管理表 + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS project_tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#8b5cf6' + ); + CREATE TABLE IF NOT EXISTS project_tag_map ( + profile_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (profile_id, tag_id) + ); + ").expect("create tag tables"); + + // 增量迁移:product_groups 加 sort_order 字段 + let _ = conn.execute( + "ALTER TABLE product_groups ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0", + [], + ); + + // CI/CD 配置表(替代 deploy_commands) + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS cicd_configs ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL UNIQUE, + config_type TEXT NOT NULL DEFAULT 'web_ssh', + trigger_branch TEXT NOT NULL DEFAULT 'main', + build_command TEXT DEFAULT 'pnpm build', + node_version TEXT DEFAULT '20', + server_host TEXT, + server_user TEXT DEFAULT 'ubuntu', + deploy_path TEXT, + start_command TEXT DEFAULT 'pm2 restart app', + oss_endpoint TEXT DEFAULT 'oss-cn-hangzhou.aliyuncs.com', + created_at TEXT DEFAULT (datetime('now')) + ); + ").expect("create cicd_configs table"); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a9255a7..c07074e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,13 +3,15 @@ mod db; mod models; use git2; -use commands::{activity::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, network::{get_v2raya_status, start_v2raya, stop_v2raya}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project}, proxy::*, publish::*, settings::{detect_editors, get_settings, search_editor_path, update_settings}, shell::*, spaces::*}; +use commands::{activity::*, cicd::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{detect_editors, get_settings, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .setup(|app| { @@ -20,6 +22,8 @@ pub fn run() { unsafe { let _ = git2::opts::set_verify_owner_validation(false); } + // 种植内置模板(首次启动时执行) + commands::templates::seed_builtin_templates(); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -28,6 +32,7 @@ pub fn run() { get_project, create_project, update_project, + update_project_tags, delete_project, scan_subfolders, batch_create_projects, @@ -39,6 +44,7 @@ pub fn run() { create_group, update_group, delete_group, + reorder_groups, get_group_maps, add_group_member, remove_group_member, @@ -61,12 +67,16 @@ pub fn run() { delete_dev_tool, upsert_dev_tool, // wsl proxy - get_v2raya_status, - start_v2raya, - stop_v2raya, + get_wsl_ip, + sync_wsl_proxy, + get_wsl_proxy_status, + clear_wsl_proxy, + test_wsl_proxy_ip, + test_win_proxy_ip, // v2rayn import scan_v2rayn_nodes, detect_v2rayn_db_path, + get_active_v2rayn_node, // project env scan scan_project_env, get_project_env_tools, @@ -120,6 +130,24 @@ pub fn run() { write_gitignore, resolve_wsl_path, get_wsl_distros, + // project templates + list_templates, + get_template, + save_template, + delete_template, + create_project_from_template, + // tags + get_tags, + create_tag, + update_tag, + delete_tag, + get_project_tags, + set_project_tags, + get_all_project_tag_maps, + // cicd + get_cicd_config, + save_cicd_config, + generate_workflow, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index b9a88db..06a1ac3 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -19,6 +19,7 @@ pub struct Project { pub status: Option, pub priority: Option, pub tech_stack: Option, + pub tags: Option, pub repo_url: Option, pub upstream_url: Option, pub default_branch: Option, @@ -50,6 +51,7 @@ impl Project { status: row.get("status").ok(), priority: row.get("priority").ok(), tech_stack: row.get("tech_stack").ok(), + tags: row.get("tags").ok(), repo_url: row.get("repo_url").ok(), upstream_url: row.get("upstream_url").ok(), default_branch: row.get("default_branch").ok(), @@ -93,6 +95,7 @@ pub struct ProductGroup { pub name: String, pub description: Option, pub space_id: Option, + pub sort_order: i32, } impl ProductGroup { @@ -102,6 +105,7 @@ impl ProductGroup { name: row.get("name")?, description: row.get("description")?, space_id: row.get("space_id").ok(), + sort_order: row.get("sort_order").unwrap_or(0), }) } } @@ -184,6 +188,48 @@ impl DeployCommand { } } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CicdConfig { + pub id: String, + pub project_id: String, + pub config_type: String, + pub trigger_branch: String, + pub build_command: Option, + pub node_version: Option, + pub server_host: Option, + pub server_user: Option, + pub deploy_path: Option, + pub start_command: Option, + pub oss_endpoint: Option, + pub created_at: Option, +} + +impl CicdConfig { + pub fn from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + project_id: row.get("project_id")?, + config_type: row.get("config_type")?, + trigger_branch: row.get("trigger_branch")?, + build_command: row.get("build_command").ok(), + node_version: row.get("node_version").ok(), + server_host: row.get("server_host").ok(), + server_user: row.get("server_user").ok(), + deploy_path: row.get("deploy_path").ok(), + start_command: row.get("start_command").ok(), + oss_endpoint: row.get("oss_endpoint").ok(), + created_at: row.get("created_at").ok(), + }) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WorkflowResult { + pub content: String, + pub path: String, + pub secrets: Vec, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GitRepo { pub id: String, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ad139ad..2d98a32 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,6 +32,18 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "windows": { + "nsis": {} + } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUzQzJDNkVFNkFDQkVDQTgKUldTbzdNdHE3c2JDNCtuTExFRDM3M3luSkdPNS9UUFBISWVGN1poeEo3NHM3Q0dTNnpkRTZseXAK", + "endpoints": [ + "https://appforwin.oss-cn-hangzhou.aliyuncs.com/latest.json" + ], + "dialog": false + } } } diff --git a/src/components/dashboard/CicdModal.tsx b/src/components/dashboard/CicdModal.tsx new file mode 100644 index 0000000..9e1340d --- /dev/null +++ b/src/components/dashboard/CicdModal.tsx @@ -0,0 +1,470 @@ +import { useState, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getCicdConfig, + saveCicdConfig, + generateWorkflow, + fetchActionsRuns, + triggerWorkflow, + parseRepoPath, + githubGetToken, + resolveWslPath, + type CicdConfig, + type ActionsRun, +} from "../../lib/commands"; +import { useUIStore } from "../../store/ui"; + +interface Props { + projectId: string; + projectName: string; + repoUrl?: string; + winPath?: string; + wslPath?: string; + platform?: string; + onClose: () => void; +} + +type Tab = "config" | "runs"; + +const DEFAULT_CONFIG: Omit = { + config_type: "web_ssh", + trigger_branch: "main", + build_command: "pnpm build", + node_version: "20", + server_host: "", + server_user: "ubuntu", + deploy_path: "/app/project", + start_command: "pm2 restart app", + oss_endpoint: "oss-cn-hangzhou.aliyuncs.com", +}; + +function runStatusBadge(run: ActionsRun) { + if (run.status === "in_progress" || run.status === "queued") { + return 🔄 运行中; + } + if (run.conclusion === "success") { + return ✅ 成功; + } + if (run.conclusion === "failure") { + return ❌ 失败; + } + if (run.conclusion === "cancelled") { + return ⊘ 已取消; + } + return {run.status}; +} + +function relativeTime(iso: string) { + const diff = Date.now() - new Date(iso).getTime(); + const m = Math.floor(diff / 60000); + if (m < 1) return "刚刚"; + if (m < 60) return `${m}分钟前`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}小时前`; + return `${Math.floor(h / 24)}天前`; +} + +export function CicdModal({ + projectId, + projectName, + repoUrl, + winPath, + wslPath, + platform, + onClose, +}: Props) { + const queryClient = useQueryClient(); + const showToast = useUIStore((s) => s.showToast); + const [tab, setTab] = useState("config"); + const [form, setForm] = useState>({ + ...DEFAULT_CONFIG, + config_type: platform === "windows" ? "tauri_oss" : "web_ssh", + }); + const [generatedSecrets, setGeneratedSecrets] = useState(null); + const [generating, setGenerating] = useState(false); + const [triggering, setTriggering] = useState(false); + + const repoPath = repoUrl ? parseRepoPath(repoUrl) : null; + + // 加载已保存配置 + const { data: saved } = useQuery({ + queryKey: ["cicdConfig", projectId], + queryFn: () => getCicdConfig(projectId), + }); + + useEffect(() => { + if (saved) { + setForm({ + config_type: saved.config_type, + trigger_branch: saved.trigger_branch, + build_command: saved.build_command ?? DEFAULT_CONFIG.build_command, + node_version: saved.node_version ?? DEFAULT_CONFIG.node_version, + server_host: saved.server_host ?? "", + server_user: saved.server_user ?? DEFAULT_CONFIG.server_user, + deploy_path: saved.deploy_path ?? DEFAULT_CONFIG.deploy_path, + start_command: saved.start_command ?? DEFAULT_CONFIG.start_command, + oss_endpoint: saved.oss_endpoint ?? DEFAULT_CONFIG.oss_endpoint, + }); + } + }, [saved]); + + // GitHub Actions runs + const { data: runs = [], refetch: refetchRuns, isFetching: fetchingRuns } = useQuery({ + queryKey: ["actionsRuns", projectId], + queryFn: async () => { + if (!repoPath) return []; + const token = await githubGetToken(); + if (!token) return []; + return fetchActionsRuns(token, repoPath, 8); + }, + enabled: tab === "runs" && !!repoPath, + staleTime: 30000, + retry: false, + }); + + const saveMutation = useMutation({ + mutationFn: () => + saveCicdConfig({ + id: saved?.id ?? "", + project_id: projectId, + ...form, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["cicdConfig", projectId] }); + showToast("配置已保存"); + }, + onError: (e) => showToast(`保存失败: ${e}`), + }); + + async function handleGenerate() { + setGenerating(true); + setGeneratedSecrets(null); + try { + // 优先 win_path,WSL 项目则转换 wsl_path → UNC 路径 + let projectPath = winPath; + if (!projectPath && wslPath) { + projectPath = await resolveWslPath(wslPath); + } + if (!projectPath) { + showToast("项目路径未配置,无法生成 workflow 文件"); + return; + } + const result = await generateWorkflow(projectId, projectPath); + setGeneratedSecrets(result.secrets); + showToast(`✅ 已生成 ${result.path}`); + } catch (e) { + showToast(`生成失败: ${e}`); + } finally { + setGenerating(false); + } + } + + async function handleTrigger() { + if (!repoPath) return; + setTriggering(true); + try { + const token = await githubGetToken(); + if (!token) { showToast("未登录 GitHub"); return; } + const workflowFile = form.config_type === "tauri_oss" ? "release.yml" : "deploy.yml"; + await triggerWorkflow(token, repoPath, workflowFile, form.trigger_branch); + showToast("已触发 workflow,稍后刷新查看状态"); + setTimeout(() => refetchRuns(), 3000); + } catch (e) { + showToast(`触发失败: ${e}`); + } finally { + setTriggering(false); + } + } + + const isWebSsh = form.config_type === "web_ssh"; + const hasSavedConfig = !!saved?.id; + const hasPath = !!(winPath || wslPath); + + return ( +
+
+ {/* 标题 */} +
+
+

CI/CD 控制台

+

{projectName}

+
+ +
+ + {/* Tab 切换 */} +
+ {(["config", "runs"] as Tab[]).map((t) => ( + + ))} +
+ +
+ {/* ── 配置 Tab ── */} + {tab === "config" && ( +
+ {/* 类型选择 */} +
+ +
+ {[ + { value: "web_ssh", label: "Web 项目(SSH 部署)", desc: "WSL / Linux 服务器" }, + { value: "tauri_oss", label: "桌面客户端(OSS 分发)", desc: "Tauri / Windows 安装包" }, + ].map((opt) => ( + + ))} +
+
+ + {/* 公共字段 */} +
+ setForm((f) => ({ ...f, trigger_branch: v }))} + placeholder="main" + /> + setForm((f) => ({ ...f, node_version: v }))} + placeholder="20" + /> +
+ setForm((f) => ({ ...f, build_command: v }))} + placeholder="pnpm build" + mono + /> + + {/* web_ssh 专属字段 */} + {isWebSsh && ( + <> +
+ setForm((f) => ({ ...f, server_host: v }))} + placeholder="your-server.com" + hint="仅用于生成 workflow,实际通过 Secrets 注入" + /> + setForm((f) => ({ ...f, server_user: v }))} + placeholder="ubuntu" + /> +
+ setForm((f) => ({ ...f, deploy_path: v }))} + placeholder="/app/my-project" + mono + /> + setForm((f) => ({ ...f, start_command: v }))} + placeholder="pm2 restart app" + mono + /> + + )} + + {/* tauri_oss 专属字段 */} + {!isWebSsh && ( + setForm((f) => ({ ...f, oss_endpoint: v }))} + placeholder="oss-cn-hangzhou.aliyuncs.com" + hint="触发方式:push tag(v1.0.0)" + mono + /> + )} + + {/* 操作按钮 */} +
+ + +
+ + {/* Secrets 清单 */} + {generatedSecrets && ( +
+

+ ✅ Workflow 文件已生成,请在 GitHub 仓库设置以下 Secrets: +

+

+ Settings → Secrets and variables → Actions → New repository secret +

+
+ {generatedSecrets.map((s) => ( + + {s} + + ))} +
+ {isWebSsh && ( +

+ SSH_KEY:服务器私钥内容(cat ~/.ssh/id_rsa) +

+ )} +
+ )} +
+ )} + + {/* ── 运行历史 Tab ── */} + {tab === "runs" && ( +
+ {!repoPath ? ( +

+ 项目未关联 GitHub 仓库,无法查看 CI 状态 +

+ ) : ( + <> +
+

{repoPath}

+
+ + +
+
+ + {runs.length === 0 && !fetchingRuns && ( +

暂无运行记录

+ )} + + {runs.map((run) => ( +
+
{runStatusBadge(run)}
+
+

+ #{run.run_number} · {run.head_branch} +

+

{relativeTime(run.updated_at)}

+
+ { + e.preventDefault(); + import("@tauri-apps/plugin-opener").then(({ openUrl }) => + openUrl(run.html_url) + ); + }} + > + 查看日志 → + +
+ ))} + + )} +
+ )} +
+
+
+ ); +} + +// ── 通用输入框组件 ───────────────────────────────────────────────────────────── + +function Field({ + label, + value, + onChange, + placeholder, + hint, + mono, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + hint?: string; + mono?: boolean; +}) { + return ( +
+ + {hint &&

{hint}

} + onChange(e.target.value)} + placeholder={placeholder} + className={`w-full bg-zinc-800 text-white text-sm rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-zinc-600 ${ + mono ? "font-mono" : "" + }`} + /> +
+ ); +} diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index ff057cd..2a0aa93 100644 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -1,15 +1,19 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { getGroups, getGroupMaps, getProjects, getSpaces } from "../../lib/commands"; +import { getGroups, getGroupMaps, getProjects, getSpaces, getTags, getAllProjectTagMaps, type ProductGroup, type Project } from "../../lib/commands"; import { ProjectCard } from "./ProjectCard"; +import { DeployModal } from "./DeployModal"; import { ManagePage } from "../manage/ManagePage"; import { BatchAddModal } from "./BatchAddModal"; import { MountRepoModal } from "./MountRepoModal"; import { DiscoveryPanel } from "./DiscoveryPanel"; +import { StatusBadge } from "../ui/Badge"; import { getDiscoverableProjects, type SpacesJson } from "../../lib/spacesSync"; +import { openUrl } from "@tauri-apps/plugin-opener"; type DashView = "board" | "manage"; +type BoardTab = "groups" | "standalone"; interface Props { spacesJson: SpacesJson | null; @@ -17,6 +21,7 @@ interface Props { export function Dashboard({ spacesJson }: Props) { const [view, setView] = useState("board"); + const [boardTab, setBoardTab] = useState("groups"); const [showBatch, setShowBatch] = useState(false); const [showMount, setShowMount] = useState(false); const qc = useQueryClient(); @@ -99,100 +104,401 @@ export function Dashboard({ spacesJson }: Props) { ) : ( -
-
- {/* 跨空间发现面板 */} - {discoverableProjects.length > 0 && currentSpace && ( - - )} - - {/* 产品线 */} - {groups.map((g) => { - const members = maps - .filter((m) => m.group_id === g.id) - .map((m) => ({ project: projects.find((p) => p.id === m.project_id), role: m.role })) - .filter((x): x is { project: NonNullable; role: typeof x.role } => !!x.project); - - if (members.length === 0) return null; - - const winMembers = members.filter(({ project: p }) => !p.platform || p.platform === "windows"); - const wslMembers = members.filter(({ project: p }) => p.platform === "wsl"); - - return ( -
- {/* 产品线标题 */} -
-

{g.name}

- {g.description && ( - {g.description} - )} - - {members.length} 个项目 - -
- {/* 双列 */} -
-
-

Windows

- {winMembers.length > 0 - ? winMembers.map(({ project, role }) => ( - - )) - :

暂无

- } -
-
-

WSL / Linux

- {wslMembers.length > 0 - ? wslMembers.map(({ project, role }) => ( - - )) - :

暂无

- } -
-
-
- ); - })} - - {/* 独立项目(未分配产品线) */} - {standalone.length > 0 && ( -
-
-

独立项目

- - {standalone.length} 个 +
+ {/* 看板子标签 */} +
+
-
-
-

Windows

- {standalone.filter((p) => !p.platform || p.platform === "windows").map((p) => ( - - ))} -
-
-

WSL / Linux

- {standalone.filter((p) => p.platform === "wsl").map((p) => ( - - ))} -
-
-
- )} + )} + + +
+ +
+
+ + {/* 跨空间发现面板(始终显示) */} + {discoverableProjects.length > 0 && currentSpace && ( + + )} + + {/* ── 产品组标签 ── */} + {boardTab === "groups" && ( + <> + {groups.map((g) => { + const members = maps + .filter((m) => m.group_id === g.id) + .map((m) => ({ project: projects.find((p) => p.id === m.project_id), role: m.role })) + .filter((x): x is { project: NonNullable; role: typeof x.role } => !!x.project); + + if (members.length === 0) return null; + + return ( + + ); + })} + {groups.every((g) => !maps.some((m) => m.group_id === g.id)) && ( +
+

🗂

+

还没有产品组,在「管理」中创建产品组并添加项目

+
+ )} + + )} + + {/* ── 独立项目标签 ── */} + {boardTab === "standalone" && ( + <> + {standalone.length > 0 ? ( + + ) : ( +
+

📂

+

没有独立项目,所有项目已归属产品组

+
+ )} + + )} - {projects.length === 0 && ( -
-

📂

-

还没有项目,点击右上角「+ 添加项目」开始

- )} -
+
)} ); } + +/* ── GroupBoardSection ─────────────────────────────────────────────────────── */ + +interface GroupMember { + project: Project; + role: string | null | undefined; +} + +interface GroupBoardSectionProps { + group: ProductGroup; + members: GroupMember[]; + spacesJson: SpacesJson | null; +} + +function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionProps) { + const [deployTarget, setDeployTarget] = useState(null); + + const winMembers = members.filter(({ project: p }) => p.platform !== "wsl"); + const wslMembers = members.filter(({ project: p }) => p.platform === "wsl"); + + return ( +
+ {/* 产品组标题栏 */} +
+

{g.name}

+ {g.description && ( + {g.description} + )} + + {members.length} 个项目 + +
+ + {/* 列头 */} +
+
本地项目
+
部署信息
+
+ + {/* 主体:每个项目一行,左=卡片 右=部署信息,平台分组作行标题 */} +
+ {/* Windows 分组 */} +
+

Windows

+ {winMembers.length} 个 +
+ {winMembers.length > 0 + ? winMembers.map(({ project, role }) => ( + + )) + :

暂无

+ } + + {/* WSL 分组 */} +
+

WSL / Linux

+ {wslMembers.length} 个 +
+ {wslMembers.length > 0 + ? wslMembers.map(({ project, role }) => ( + + )) + :

暂无

+ } +
+ + {deployTarget && ( + setDeployTarget(null)} + /> + )} +
+ ); +} + +/* ── ProjectRow ────────────────────────────────────────────────────────────── */ + +function ProjectRow({ + project: p, role, spacesJson, onDeploy, +}: { + project: Project; + role?: string; + spacesJson: SpacesJson | null; + onDeploy: (p: Project) => void; +}) { + return ( +
+ {/* 左:项目卡片 */} +
+ +
+ + {/* 右:部署信息(与卡片同行对齐) */} +
+ {/* 状态 + 角色 */} +
+ {p.status && } + {role && ( + + {role} + + )} + {p.updated_at && ( + {p.updated_at.slice(0, 10)} + )} +
+ + {/* recent_focus */} + {p.recent_focus && ( +

+ 📌 {p.recent_focus} +

+ )} + + {/* URL */} +
+ {p.prod_url && ( + + )} + {p.staging_url && ( + + )} +
+ + {/* 服务器备注 */} + {p.server_note && ( +

🖥 {p.server_note}

+ )} + + {/* 无信息提示 */} + {!p.prod_url && !p.staging_url && !p.server_note && !p.recent_focus && ( +

未配置部署信息

+ )} + + {/* 部署按钮 */} +
+ +
+
+
+ ); +} + +/* ── StandaloneBoardSection ────────────────────────────────────────────────── */ + +function StandaloneBoardSection({ + standalone, spacesJson, +}: { + standalone: Project[]; + spacesJson: SpacesJson | null; +}) { + const [deployTarget, setDeployTarget] = useState(null); + const [selectedTagIds, setSelectedTagIds] = useState>(new Set()); + const [search, setSearch] = useState(""); + + const { data: allTags = [] } = useQuery({ queryKey: ["tags"], queryFn: getTags }); + const { data: tagMaps = [] } = useQuery({ queryKey: ["tagMaps"], queryFn: getAllProjectTagMaps }); + + const toggleTag = (tagId: string) => { + setSelectedTagIds((prev) => { + const next = new Set(prev); + next.has(tagId) ? next.delete(tagId) : next.add(tagId); + return next; + }); + }; + + const filtered = standalone.filter((p) => { + if (search.trim() && !p.name.toLowerCase().includes(search.trim().toLowerCase())) return false; + if (selectedTagIds.size === 0) return true; + const projectTagIds = tagMaps + .filter((m) => m.profile_id === p.profile_id) + .map((m) => m.tag_id); + return [...selectedTagIds].some((id) => projectTagIds.includes(id)); + }); + + const winProjects = filtered.filter((p) => p.platform !== "wsl"); + const wslProjects = filtered.filter((p) => p.platform === "wsl"); + + return ( +
+ {/* 搜索 + 标签筛选栏(始终显示) */} +
+ {/* 搜索框 */} +
+ setSearch(e.target.value)} + placeholder="搜索项目名称…" + className="flex-1 border border-gray-200 rounded-lg px-3 py-1.5 text-xs outline-none focus:ring-2 focus:ring-violet-200 bg-white" + /> + + {filtered.length} / {standalone.length} 个 + + {(search || selectedTagIds.size > 0) && ( + + )} +
+ {/* 标签筛选 chips */} + {allTags.length > 0 && ( +
+ 标签: + {allTags.map((tag) => ( + + ))} +
+ )} + {allTags.length === 0 && ( +

暂无标签,在「管理-标签」里创建后可在此筛选

+ )} +
+ + {/* 列头 */} +
+
本地项目
+
部署信息
+
+ +
+ {/* Windows */} +
+

Windows

+ {winProjects.length} 个 +
+ {winProjects.length > 0 + ? winProjects.map((p) => ( + + )) + :

暂无

+ } + + {/* WSL */} +
+

WSL / Linux

+ {wslProjects.length} 个 +
+ {wslProjects.length > 0 + ? wslProjects.map((p) => ( + + )) + :

暂无

+ } +
+ + {deployTarget && ( + setDeployTarget(null)} + /> + )} +
+ ); +} diff --git a/src/components/dashboard/ProjectCard.tsx b/src/components/dashboard/ProjectCard.tsx index 8575670..09dfbcb 100644 --- a/src/components/dashboard/ProjectCard.tsx +++ b/src/components/dashboard/ProjectCard.tsx @@ -1,11 +1,11 @@ import { useState, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, githubGetToken, githubGetAccount, gitCreateBranch, gitListBranches, gitCheckoutBranch, type Project, type Space } from "../../lib/commands"; +import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, githubGetToken, githubGetAccount, gitCreateBranch, gitListBranches, gitCheckoutBranch, fetchActionsRuns, parseRepoPath, type Project, type Space, type ActionsRun } from "../../lib/commands"; import { useUIStore } from "../../store/ui"; import { PriorityBadge, StatusBadge } from "../ui/Badge"; import { ProjectToolsModal } from "./ProjectToolsModal"; import { ProjectEnvModal } from "./ProjectEnvModal"; -import { DeployModal } from "./DeployModal"; +import { CicdModal } from "./CicdModal"; import { PublishModal } from "./PublishModal"; import { getOtherSpacesInfo, recordPushToRemote, formatRelativeTime, type SpacesJson } from "../../lib/spacesSync"; import { openUrl } from "@tauri-apps/plugin-opener"; @@ -20,7 +20,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) { const [expanded, setExpanded] = useState(false); const [showToolsMgr, setShowToolsMgr] = useState(false); const [showEnvScan, setShowEnvScan] = useState(false); - const [showDeploy, setShowDeploy] = useState(false); + const [showCicd, setShowCicd] = useState(false); const [showPublish, setShowPublish] = useState(false); const [showBranchMenu, setShowBranchMenu] = useState(false); const [newBranchName, setNewBranchName] = useState(""); @@ -127,6 +127,22 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) { retry: false, }); + // CI 最新 run 状态(有 repo_url 时才查) + const ciRepoPath = p.repo_url ? parseRepoPath(p.repo_url) : null; + const { data: latestRun } = useQuery({ + queryKey: ["ciLatest", p.id], + queryFn: async () => { + if (!ciRepoPath) return null; + const token = await githubGetToken(); + if (!token) return null; + const runs = await fetchActionsRuns(token, ciRepoPath, 1); + return runs[0] ?? null; + }, + enabled: !!ciRepoPath, + staleTime: 60000, + retry: false, + }); + // PR 链接:从 repo_url 解析出 GitHub 路径 const prUrl = (() => { if (!p.repo_url || !gitStat) return null; @@ -237,6 +253,8 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) { const [resuming, setResuming] = useState(null); // spaceId 正在接续 + const customTags = (p.tags ?? "").split(",").map((t) => t.trim()).filter(Boolean); + const handleResume = async (branch: string, spaceId: string) => { if (!hasGit) { showToast("❌ 当前项目没有配置 git 路径"); return; } if (gitStat?.has_changes) { @@ -309,7 +327,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {

)} - {/* Tags */} + {/* Tech stack tags */} {tags.length > 0 && (
{tags.map((t) => ( @@ -320,6 +338,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
)} + {/* Paths */} {(p.wsl_path || p.win_path) && (
@@ -478,6 +497,21 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) { PR )} + {latestRun && ( + + )}
) : gitError ? ( @@ -524,6 +558,18 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) { )} + {/* ── 标签行(纯展示,编辑请点「编辑」) */} + {customTags.length > 0 && ( +
+ 标签 + {customTags.map((t) => ( + + {t} + + ))} +
+ )} + {/* ── 快捷操作区 ── */} {(p.wsl_path || p.win_path || projectTools.length > 0) && (
@@ -576,8 +622,8 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
{(p.updated_at ?? "").slice(0, 10)}
-
+
+ +
+ {allTags.length === 0 && ( +

请先在「管理-标签」里创建标签

+ )} + {allTags.map((tag) => { + const selected = selectedTagIds.includes(tag.id); + return ( + + ); + })} +
+
diff --git a/src/components/dashboard/PublishModal.tsx b/src/components/dashboard/PublishModal.tsx index 4fc049f..16d181c 100644 --- a/src/components/dashboard/PublishModal.tsx +++ b/src/components/dashboard/PublishModal.tsx @@ -87,10 +87,6 @@ export function PublishModal({ project, onClose }: Props) { const steps: ExecLog[] = []; const needInit = !scan.has_git || !scan.has_commits; const needGitignore = !scan.has_gitignore && selectedTemplates.length > 0; - const hasRemoteConflict = scan.remotes.some( - (r) => r.name === "origin" && !r.url.includes(repoName) - ); - if (needGitignore) steps.push({ label: "生成 .gitignore", status: "pending" }); steps.push({ label: "在 GitHub 创建远端仓库", status: "pending" }); if (needInit) { @@ -432,6 +428,7 @@ export function PublishModal({ project, onClose }: Props) { onClick={handleExecute} disabled={ !repoName.trim() || + !scan || scan.has_changes || (scan.suspicious_files.length > 0 && !confirmedSensitive) } diff --git a/src/components/manage/ManagePage.tsx b/src/components/manage/ManagePage.tsx index 3dcabf8..3761c37 100644 --- a/src/components/manage/ManagePage.tsx +++ b/src/components/manage/ManagePage.tsx @@ -3,24 +3,28 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { deleteProject, getGroups, getGroupMaps, getProjects, getSpaces, deleteGroup, addGroupMember, removeGroupMember, - createGroup, updateGroup, - type ProductGroup, type Project, + createGroup, updateGroup, reorderGroups, listTemplates, deleteTemplate, + type ProductGroup, type Project, type ProjectTemplate, } from "../../lib/commands"; import { useUIStore } from "../../store/ui"; import { PriorityBadge, StatusBadge } from "../ui/Badge"; import { Dialog } from "../ui/Dialog"; import { ImportRepoModal } from "../dashboard/ImportRepoModal"; +import { TemplateEditor } from "./TemplateEditor"; +import { NewProjectModal } from "./NewProjectModal"; +import { TagsManager } from "./TagsManager"; export function ManagePage() { - const [tab, setTab] = useState<"projects" | "groups">("projects"); + const [tab, setTab] = useState<"projects" | "groups" | "templates" | "tags">("projects"); const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces }); const currentSpaceId = spaces.find((s) => s.is_current)?.id; + const TAB_LABELS = { projects: "项目", groups: "产品组", templates: "项目模板", tags: "标签" }; + return (
- {/* tab 导航栏:固定全宽,不随内容宽度变化 */}
- {(["projects", "groups"] as const).map((t) => ( + {(["projects", "groups", "templates", "tags"] as const).map((t) => ( ))}
- {/* 内容区:独立滚动,宽度始终撑满 */}
- {tab === "projects" ? : } + {tab === "projects" && } + {tab === "groups" && } + {tab === "templates" && } + {tab === "tags" && }
@@ -52,6 +58,7 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) { const [delTarget, setDelTarget] = useState(null); const [deleteLocal, setDeleteLocal] = useState(false); const [showImport, setShowImport] = useState(false); + const [showNewProject, setShowNewProject] = useState(false); const { data: projects = [] } = useQuery({ queryKey: ["projects", spaceId], @@ -78,6 +85,12 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) { > 导入仓库 +