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.
This commit is contained in:
parent
93617565bf
commit
533c069e43
131
.github/workflows/release.yml
vendored
Normal file
131
.github/workflows/release.yml
vendored
Normal file
@ -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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -23,3 +23,6 @@ src-tauri/target/
|
||||
# 系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 防止签名密钥被误提交(Windows ~ 路径展开问题)
|
||||
~/
|
||||
|
||||
@ -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"
|
||||
|
||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
265
src-tauri/Cargo.lock
generated
265
src-tauri/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
259
src-tauri/src/commands/cicd.rs
Normal file
259
src-tauri/src/commands/cicd.rs
Normal file
@ -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<Option<CicdConfig>, 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<CicdConfig, String> {
|
||||
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<WorkflowResult, String> {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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)
|
||||
}
|
||||
@ -12,12 +12,6 @@ pub struct GitStatus {
|
||||
pub last_commit_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CloneProgress {
|
||||
pub received_objects: usize,
|
||||
pub total_objects: usize,
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
fn get_token() -> Result<String, String> {
|
||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::db::pool;
|
||||
use crate::models::GitRepo;
|
||||
use rusqlite::params;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
|
||||
// ── 输入结构体 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ pub fn get_groups(space_id: Option<String>) -> Result<Vec<ProductGroup>, 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<String>) -> Result<Vec<ProductGroup>, 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::<Result<Vec<_>, _>>()
|
||||
@ -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<String>) -> 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<Vec<ProjectGroupMap>, String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<std::process::Output, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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::<std::net::IpAddr>().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<String, String> {
|
||||
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<String>) -> Result<String, String> {
|
||||
// 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<Vec<Inbound>> }
|
||||
|
||||
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<serde_json::Value, String> {
|
||||
// 单次调用输出 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::<Vec<_>>()
|
||||
{
|
||||
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<u16> = 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<String, String> {
|
||||
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<String, String> {
|
||||
// 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<i64>) -> Result<Vec<ModeLogEntry>, String> {
|
||||
|
||||
@ -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<String>,
|
||||
pub priority: Option<String>,
|
||||
pub tech_stack: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub staging_url: Option<String>,
|
||||
pub prod_url: Option<String>,
|
||||
pub server_note: Option<String>,
|
||||
@ -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<bool>) -> Result<(), String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
|
||||
@ -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<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<PathBuf> = {
|
||||
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<String>) -> Result<Vec<V2raynNode>, Str
|
||||
pub fn detect_v2rayn_db_path() -> Option<String> {
|
||||
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<String>) -> Result<ActiveV2raynNode, String> {
|
||||
// 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<String>>(2)?.unwrap_or_default(),
|
||||
address: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
|
||||
port: row.get::<_, Option<i64>>(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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -218,7 +218,7 @@ fn detect_templates(root: &Path) -> Vec<String> {
|
||||
/// 从原始路径(可能是 Linux 路径或 UNC 路径)提取 Linux 绝对路径
|
||||
/// - 原始路径以 `/` 开头 → 直接返回
|
||||
/// - win_path 是 `\\wsl.localhost\Distro\...` → 提取 `/...` 部分
|
||||
fn extract_linux_path(original: &str, win_path: &str) -> Option<String> {
|
||||
pub fn extract_linux_path(original: &str, win_path: &str) -> Option<String> {
|
||||
if original.starts_with('/') {
|
||||
return Some(original.to_string());
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<String> {
|
||||
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<String> = 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 <distro> -- <editor> <linux_path>
|
||||
// remote_flag : <editor> --remote wsl+<distro> <linux_path>
|
||||
// unc_path : <editor> <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<String> = match env.as_str() {
|
||||
"win" => {
|
||||
let path = win_path.ok_or("Windows path not configured")?;
|
||||
vec![path]
|
||||
|
||||
127
src-tauri/src/commands/tags.rs
Normal file
127
src-tauri/src/commands/tags.rs
Normal file
@ -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<Vec<ProjectTag>, 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::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_tag(name: String, color: String) -> Result<String, String> {
|
||||
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<Vec<ProjectTag>, 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::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// 全量替换某个 workspace 对应项目的标签(tag_ids 为空则清空)
|
||||
#[tauri::command]
|
||||
pub fn set_project_tags(workspace_id: String, tag_ids: Vec<String>) -> 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<Vec<ProfileTagMap>, 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::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(maps)
|
||||
}
|
||||
479
src-tauri/src/commands/templates.rs
Normal file
479
src-tauri/src/commands/templates.rs
Normal file
@ -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<String>,
|
||||
pub platform: String, // "win" | "wsl" | "both"
|
||||
pub tech_stack: Option<String>,
|
||||
pub init_commands: Option<String>,
|
||||
pub is_builtin: bool,
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub files: Vec<TemplateFile>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub platform: String,
|
||||
pub tech_stack: Option<String>,
|
||||
pub init_commands: Option<String>,
|
||||
pub files: Vec<TemplateFileInput>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateProjectResult {
|
||||
pub project_id: String,
|
||||
pub logs: Vec<String>,
|
||||
}
|
||||
|
||||
// ── Tauri 命令 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_templates() -> Result<Vec<ProjectTemplate>, 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::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_template(id: String) -> Result<ProjectTemplate, String> {
|
||||
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::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(tmpl)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_template(input: SaveTemplateInput) -> Result<String, String> {
|
||||
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<String>,
|
||||
) -> Result<CreateProjectResult, String> {
|
||||
let tmpl = get_template(template_id)?;
|
||||
let mut logs: Vec<String> = 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String> = 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");
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -19,6 +19,7 @@ pub struct Project {
|
||||
pub status: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub tech_stack: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub repo_url: Option<String>,
|
||||
pub upstream_url: Option<String>,
|
||||
pub default_branch: Option<String>,
|
||||
@ -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<String>,
|
||||
pub space_id: Option<String>,
|
||||
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<String>,
|
||||
pub node_version: Option<String>,
|
||||
pub server_host: Option<String>,
|
||||
pub server_user: Option<String>,
|
||||
pub deploy_path: Option<String>,
|
||||
pub start_command: Option<String>,
|
||||
pub oss_endpoint: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
impl CicdConfig {
|
||||
pub fn from_row(row: &Row<'_>) -> rusqlite::Result<Self> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct GitRepo {
|
||||
pub id: String,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
470
src/components/dashboard/CicdModal.tsx
Normal file
470
src/components/dashboard/CicdModal.tsx
Normal file
@ -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<CicdConfig, "id" | "project_id" | "created_at"> = {
|
||||
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 <span className="text-yellow-400 text-xs">🔄 运行中</span>;
|
||||
}
|
||||
if (run.conclusion === "success") {
|
||||
return <span className="text-green-400 text-xs">✅ 成功</span>;
|
||||
}
|
||||
if (run.conclusion === "failure") {
|
||||
return <span className="text-red-400 text-xs">❌ 失败</span>;
|
||||
}
|
||||
if (run.conclusion === "cancelled") {
|
||||
return <span className="text-zinc-400 text-xs">⊘ 已取消</span>;
|
||||
}
|
||||
return <span className="text-zinc-400 text-xs">{run.status}</span>;
|
||||
}
|
||||
|
||||
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<Tab>("config");
|
||||
const [form, setForm] = useState<Omit<CicdConfig, "id" | "project_id" | "created_at">>({
|
||||
...DEFAULT_CONFIG,
|
||||
config_type: platform === "windows" ? "tauri_oss" : "web_ssh",
|
||||
});
|
||||
const [generatedSecrets, setGeneratedSecrets] = useState<string[] | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [triggering, setTriggering] = useState(false);
|
||||
|
||||
const repoPath = repoUrl ? parseRepoPath(repoUrl) : null;
|
||||
|
||||
// 加载已保存配置
|
||||
const { data: saved } = useQuery<CicdConfig | null>({
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-zinc-900 rounded-xl shadow-2xl w-[600px] max-h-[85vh] flex flex-col border border-zinc-700">
|
||||
{/* 标题 */}
|
||||
<div className="px-5 py-4 border-b border-zinc-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">CI/CD 控制台</h2>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">{projectName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-white text-xl leading-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex border-b border-zinc-700 px-5">
|
||||
{(["config", "runs"] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`py-2.5 mr-4 text-sm border-b-2 transition-colors ${
|
||||
tab === t
|
||||
? "border-blue-500 text-white"
|
||||
: "border-transparent text-zinc-400 hover:text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{t === "config" ? "配置" : "运行历史"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
||||
{/* ── 配置 Tab ── */}
|
||||
{tab === "config" && (
|
||||
<div className="space-y-4">
|
||||
{/* 类型选择 */}
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400 mb-1 block">部署类型</label>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: "web_ssh", label: "Web 项目(SSH 部署)", desc: "WSL / Linux 服务器" },
|
||||
{ value: "tauri_oss", label: "桌面客户端(OSS 分发)", desc: "Tauri / Windows 安装包" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setForm((f) => ({ ...f, config_type: opt.value }))}
|
||||
className={`flex-1 rounded-lg px-3 py-2.5 text-left border transition-colors ${
|
||||
form.config_type === opt.value
|
||||
? "border-blue-500 bg-blue-500/10"
|
||||
: "border-zinc-700 bg-zinc-800 hover:border-zinc-500"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm text-white font-medium">{opt.label}</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 公共字段 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field
|
||||
label="触发分支"
|
||||
value={form.trigger_branch}
|
||||
onChange={(v) => setForm((f) => ({ ...f, trigger_branch: v }))}
|
||||
placeholder="main"
|
||||
/>
|
||||
<Field
|
||||
label="Node.js 版本"
|
||||
value={form.node_version ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, node_version: v }))}
|
||||
placeholder="20"
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="构建命令"
|
||||
value={form.build_command ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, build_command: v }))}
|
||||
placeholder="pnpm build"
|
||||
mono
|
||||
/>
|
||||
|
||||
{/* web_ssh 专属字段 */}
|
||||
{isWebSsh && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field
|
||||
label="服务器地址"
|
||||
value={form.server_host ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, server_host: v }))}
|
||||
placeholder="your-server.com"
|
||||
hint="仅用于生成 workflow,实际通过 Secrets 注入"
|
||||
/>
|
||||
<Field
|
||||
label="SSH 用户名"
|
||||
value={form.server_user ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, server_user: v }))}
|
||||
placeholder="ubuntu"
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="部署路径(服务器上)"
|
||||
value={form.deploy_path ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, deploy_path: v }))}
|
||||
placeholder="/app/my-project"
|
||||
mono
|
||||
/>
|
||||
<Field
|
||||
label="启动命令(服务器上)"
|
||||
value={form.start_command ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, start_command: v }))}
|
||||
placeholder="pm2 restart app"
|
||||
mono
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* tauri_oss 专属字段 */}
|
||||
{!isWebSsh && (
|
||||
<Field
|
||||
label="阿里云 OSS Endpoint"
|
||||
value={form.oss_endpoint ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, oss_endpoint: v }))}
|
||||
placeholder="oss-cn-hangzhou.aliyuncs.com"
|
||||
hint="触发方式:push tag(v1.0.0)"
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
{saveMutation.isPending ? "保存中…" : "保存配置"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!hasSavedConfig || !hasPath || generating}
|
||||
title={
|
||||
!hasSavedConfig
|
||||
? "请先保存配置"
|
||||
: !hasPath
|
||||
? "项目未配置本地路径"
|
||||
: ""
|
||||
}
|
||||
className="px-4 py-2 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
{generating ? "生成中…" : "生成 Workflow 文件"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Secrets 清单 */}
|
||||
{generatedSecrets && (
|
||||
<div className="bg-zinc-800 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-zinc-300">
|
||||
✅ Workflow 文件已生成,请在 GitHub 仓库设置以下 Secrets:
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Settings → Secrets and variables → Actions → New repository secret
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{generatedSecrets.map((s) => (
|
||||
<code
|
||||
key={s}
|
||||
className="bg-zinc-900 text-amber-300 text-xs px-2 py-0.5 rounded font-mono"
|
||||
>
|
||||
{s}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
{isWebSsh && (
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
SSH_KEY:服务器私钥内容(cat ~/.ssh/id_rsa)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 运行历史 Tab ── */}
|
||||
{tab === "runs" && (
|
||||
<div className="space-y-3">
|
||||
{!repoPath ? (
|
||||
<p className="text-zinc-500 text-sm text-center py-8">
|
||||
项目未关联 GitHub 仓库,无法查看 CI 状态
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-400">{repoPath}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => refetchRuns()}
|
||||
disabled={fetchingRuns}
|
||||
className="text-xs text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
{fetchingRuns ? "刷新中…" : "↻ 刷新"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTrigger}
|
||||
disabled={triggering || !hasSavedConfig}
|
||||
title={!hasSavedConfig ? "请先保存配置" : ""}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-xs rounded-lg transition-colors"
|
||||
>
|
||||
{triggering ? "触发中…" : "手动触发"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runs.length === 0 && !fetchingRuns && (
|
||||
<p className="text-zinc-500 text-sm text-center py-8">暂无运行记录</p>
|
||||
)}
|
||||
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="bg-zinc-800 rounded-lg px-3 py-2.5 flex items-center gap-3"
|
||||
>
|
||||
<div className="w-20 shrink-0">{runStatusBadge(run)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">
|
||||
#{run.run_number} · {run.head_branch}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-400">{relativeTime(run.updated_at)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={run.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-blue-400 hover:text-blue-300 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
import("@tauri-apps/plugin-opener").then(({ openUrl }) =>
|
||||
openUrl(run.html_url)
|
||||
);
|
||||
}}
|
||||
>
|
||||
查看日志 →
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 通用输入框组件 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
hint,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400 mb-1 block">{label}</label>
|
||||
{hint && <p className="text-xs text-zinc-500 mb-1">{hint}</p>}
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => 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" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<DashView>("board");
|
||||
const [boardTab, setBoardTab] = useState<BoardTab>("groups");
|
||||
const [showBatch, setShowBatch] = useState(false);
|
||||
const [showMount, setShowMount] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
@ -99,100 +104,401 @@ export function Dashboard({ spacesJson }: Props) {
|
||||
<ManagePage />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="w-full px-8 py-8 space-y-8 pb-16">
|
||||
{/* 跨空间发现面板 */}
|
||||
{discoverableProjects.length > 0 && currentSpace && (
|
||||
<DiscoveryPanel
|
||||
items={discoverableProjects}
|
||||
currentSpaceId={currentSpace.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 产品线 */}
|
||||
{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<typeof x.project>; 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 (
|
||||
<section key={g.id} className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* 产品线标题 */}
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-gray-100 bg-gray-50/80">
|
||||
<h2 className="text-sm font-bold text-gray-800 tracking-tight">{g.name}</h2>
|
||||
{g.description && (
|
||||
<span className="text-xs text-gray-400 border-l border-gray-200 pl-3">{g.description}</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-gray-400 bg-white border border-gray-200 rounded-full px-2.5 py-0.5">
|
||||
{members.length} 个项目
|
||||
</span>
|
||||
</div>
|
||||
{/* 双列 */}
|
||||
<div className="grid grid-cols-2 divide-x divide-gray-100">
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-xs font-semibold text-blue-500 uppercase tracking-widest">Windows</p>
|
||||
{winMembers.length > 0
|
||||
? winMembers.map(({ project, role }) => (
|
||||
<ProjectCard key={project.id} project={project} role={role ?? undefined} spacesJson={spacesJson} />
|
||||
))
|
||||
: <p className="text-xs text-gray-300 py-6 text-center">暂无</p>
|
||||
}
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-xs font-semibold text-green-600 uppercase tracking-widest">WSL / Linux</p>
|
||||
{wslMembers.length > 0
|
||||
? wslMembers.map(({ project, role }) => (
|
||||
<ProjectCard key={project.id} project={project} role={role ?? undefined} spacesJson={spacesJson} />
|
||||
))
|
||||
: <p className="text-xs text-gray-300 py-6 text-center">暂无</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 独立项目(未分配产品线) */}
|
||||
{standalone.length > 0 && (
|
||||
<section className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-gray-100 bg-gray-50/80">
|
||||
<h2 className="text-sm font-bold text-gray-800 tracking-tight">独立项目</h2>
|
||||
<span className="ml-auto text-xs text-gray-400 bg-white border border-gray-200 rounded-full px-2.5 py-0.5">
|
||||
{standalone.length} 个
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* 看板子标签 */}
|
||||
<div className="shrink-0 border-b border-gray-100 bg-white px-8 flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setBoardTab("groups")}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
boardTab === "groups"
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
>
|
||||
产品组
|
||||
{groups.filter((g) => maps.some((m) => m.group_id === g.id)).length > 0 && (
|
||||
<span className="ml-1.5 text-xs bg-gray-100 text-gray-500 rounded-full px-1.5 py-0.5">
|
||||
{groups.filter((g) => maps.some((m) => m.group_id === g.id)).length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 divide-x divide-gray-100">
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-xs font-semibold text-blue-500 uppercase tracking-widest">Windows</p>
|
||||
{standalone.filter((p) => !p.platform || p.platform === "windows").map((p) => (
|
||||
<ProjectCard key={p.id} project={p} spacesJson={spacesJson} />
|
||||
))}
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-xs font-semibold text-green-600 uppercase tracking-widest">WSL / Linux</p>
|
||||
{standalone.filter((p) => p.platform === "wsl").map((p) => (
|
||||
<ProjectCard key={p.id} project={p} spacesJson={spacesJson} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBoardTab("standalone")}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
boardTab === "standalone"
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
>
|
||||
独立项目
|
||||
{standalone.length > 0 && (
|
||||
<span className="ml-1.5 text-xs bg-gray-100 text-gray-500 rounded-full px-1.5 py-0.5">
|
||||
{standalone.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="w-full px-8 py-8 space-y-8 pb-16">
|
||||
|
||||
{/* 跨空间发现面板(始终显示) */}
|
||||
{discoverableProjects.length > 0 && currentSpace && (
|
||||
<DiscoveryPanel
|
||||
items={discoverableProjects}
|
||||
currentSpaceId={currentSpace.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 产品组标签 ── */}
|
||||
{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<typeof x.project>; role: typeof x.role } => !!x.project);
|
||||
|
||||
if (members.length === 0) return null;
|
||||
|
||||
return (
|
||||
<GroupBoardSection
|
||||
key={g.id}
|
||||
group={g}
|
||||
members={members}
|
||||
spacesJson={spacesJson}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{groups.every((g) => !maps.some((m) => m.group_id === g.id)) && (
|
||||
<div className="text-center py-24 text-gray-400">
|
||||
<p className="text-4xl mb-4">🗂</p>
|
||||
<p className="text-sm">还没有产品组,在「管理」中创建产品组并添加项目</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 独立项目标签 ── */}
|
||||
{boardTab === "standalone" && (
|
||||
<>
|
||||
{standalone.length > 0 ? (
|
||||
<StandaloneBoardSection standalone={standalone} spacesJson={spacesJson} />
|
||||
) : (
|
||||
<div className="text-center py-24 text-gray-400">
|
||||
<p className="text-4xl mb-4">📂</p>
|
||||
<p className="text-sm">没有独立项目,所有项目已归属产品组</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{projects.length === 0 && (
|
||||
<div className="text-center py-24 text-gray-400">
|
||||
<p className="text-4xl mb-4">📂</p>
|
||||
<p className="text-sm">还没有项目,点击右上角「+ 添加项目」开始</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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<Project | null>(null);
|
||||
|
||||
const winMembers = members.filter(({ project: p }) => p.platform !== "wsl");
|
||||
const wslMembers = members.filter(({ project: p }) => p.platform === "wsl");
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* 产品组标题栏 */}
|
||||
<div className="flex items-center gap-3 px-6 py-3.5 border-b border-gray-100 bg-gray-50/80">
|
||||
<h2 className="text-sm font-bold text-gray-800 tracking-tight">{g.name}</h2>
|
||||
{g.description && (
|
||||
<span className="text-xs text-gray-400 border-l border-gray-200 pl-3">{g.description}</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-gray-400 bg-white border border-gray-200 rounded-full px-2.5 py-0.5">
|
||||
{members.length} 个项目
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 列头 */}
|
||||
<div className="flex border-b border-gray-100 bg-gray-50/60">
|
||||
<div className="flex-1 px-5 py-2 text-xs font-semibold text-gray-400">本地项目</div>
|
||||
<div className="w-72 shrink-0 px-4 py-2 text-xs font-semibold text-gray-400 border-l border-gray-100">部署信息</div>
|
||||
</div>
|
||||
|
||||
{/* 主体:每个项目一行,左=卡片 右=部署信息,平台分组作行标题 */}
|
||||
<div className="flex flex-col">
|
||||
{/* Windows 分组 */}
|
||||
<div className="flex items-center gap-2 px-5 py-2 bg-blue-50/40 border-b border-gray-100">
|
||||
<p className="text-xs font-semibold text-blue-500 uppercase tracking-widest">Windows</p>
|
||||
<span className="text-[10px] text-blue-400">{winMembers.length} 个</span>
|
||||
</div>
|
||||
{winMembers.length > 0
|
||||
? winMembers.map(({ project, role }) => (
|
||||
<ProjectRow
|
||||
key={project.id}
|
||||
project={project}
|
||||
role={role ?? undefined}
|
||||
spacesJson={spacesJson}
|
||||
onDeploy={setDeployTarget}
|
||||
/>
|
||||
))
|
||||
: <p className="text-xs text-gray-300 py-5 text-center border-b border-gray-50">暂无</p>
|
||||
}
|
||||
|
||||
{/* WSL 分组 */}
|
||||
<div className="flex items-center gap-2 px-5 py-2 bg-green-50/40 border-b border-gray-100">
|
||||
<p className="text-xs font-semibold text-green-600 uppercase tracking-widest">WSL / Linux</p>
|
||||
<span className="text-[10px] text-green-500">{wslMembers.length} 个</span>
|
||||
</div>
|
||||
{wslMembers.length > 0
|
||||
? wslMembers.map(({ project, role }) => (
|
||||
<ProjectRow
|
||||
key={project.id}
|
||||
project={project}
|
||||
role={role ?? undefined}
|
||||
spacesJson={spacesJson}
|
||||
onDeploy={setDeployTarget}
|
||||
/>
|
||||
))
|
||||
: <p className="text-xs text-gray-300 py-5 text-center">暂无</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
{deployTarget && (
|
||||
<DeployModal
|
||||
projectId={deployTarget.id}
|
||||
projectName={deployTarget.name}
|
||||
projectPath={deployTarget.win_path ?? undefined}
|
||||
projectWslPath={deployTarget.wsl_path ?? undefined}
|
||||
onClose={() => setDeployTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── ProjectRow ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function ProjectRow({
|
||||
project: p, role, spacesJson, onDeploy,
|
||||
}: {
|
||||
project: Project;
|
||||
role?: string;
|
||||
spacesJson: SpacesJson | null;
|
||||
onDeploy: (p: Project) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex divide-x divide-gray-100 border-b border-gray-50 last:border-b-0">
|
||||
{/* 左:项目卡片 */}
|
||||
<div className="flex-1 min-w-0 p-4">
|
||||
<ProjectCard project={p} role={role} spacesJson={spacesJson} />
|
||||
</div>
|
||||
|
||||
{/* 右:部署信息(与卡片同行对齐) */}
|
||||
<div className="w-72 shrink-0 p-4 flex flex-col gap-2">
|
||||
{/* 状态 + 角色 */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{p.status && <StatusBadge status={p.status} />}
|
||||
{role && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-600 border border-purple-100">
|
||||
{role}
|
||||
</span>
|
||||
)}
|
||||
{p.updated_at && (
|
||||
<span className="text-[10px] text-gray-300 ml-auto">{p.updated_at.slice(0, 10)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* recent_focus */}
|
||||
{p.recent_focus && (
|
||||
<p className="text-[11px] text-indigo-500 leading-relaxed line-clamp-2">
|
||||
📌 {p.recent_focus}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* URL */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{p.prod_url && (
|
||||
<button
|
||||
onClick={() => openUrl(p.prod_url!).catch(() => {})}
|
||||
className="flex items-center gap-1 text-[11px] px-2 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 transition-colors"
|
||||
title={p.prod_url}
|
||||
>
|
||||
🌐 生产
|
||||
</button>
|
||||
)}
|
||||
{p.staging_url && (
|
||||
<button
|
||||
onClick={() => openUrl(p.staging_url!).catch(() => {})}
|
||||
className="flex items-center gap-1 text-[11px] px-2 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 transition-colors"
|
||||
title={p.staging_url}
|
||||
>
|
||||
🧪 测试
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 服务器备注 */}
|
||||
{p.server_note && (
|
||||
<p className="text-[11px] text-gray-400" title={p.server_note}>🖥 {p.server_note}</p>
|
||||
)}
|
||||
|
||||
{/* 无信息提示 */}
|
||||
{!p.prod_url && !p.staging_url && !p.server_note && !p.recent_focus && (
|
||||
<p className="text-[11px] text-gray-300 italic">未配置部署信息</p>
|
||||
)}
|
||||
|
||||
{/* 部署按钮 */}
|
||||
<div className="mt-auto pt-1">
|
||||
<button
|
||||
onClick={() => onDeploy(p)}
|
||||
className="px-2.5 py-1 rounded text-[11px] border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
🚀 部署
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── StandaloneBoardSection ────────────────────────────────────────────────── */
|
||||
|
||||
function StandaloneBoardSection({
|
||||
standalone, spacesJson,
|
||||
}: {
|
||||
standalone: Project[];
|
||||
spacesJson: SpacesJson | null;
|
||||
}) {
|
||||
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(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 (
|
||||
<section className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* 搜索 + 标签筛选栏(始终显示) */}
|
||||
<div className="px-5 py-3 border-b border-gray-100 flex flex-col gap-2 bg-gray-50/40">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-300 shrink-0">
|
||||
{filtered.length} / {standalone.length} 个
|
||||
</span>
|
||||
{(search || selectedTagIds.size > 0) && (
|
||||
<button
|
||||
onClick={() => { setSearch(""); setSelectedTagIds(new Set()); }}
|
||||
className="text-[11px] text-gray-400 hover:text-gray-600 shrink-0"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 标签筛选 chips */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] text-gray-300 shrink-0">标签:</span>
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium border-2 transition-colors ${
|
||||
selectedTagIds.has(tag.id) ? "text-white border-transparent" : "bg-white border-gray-200 text-gray-500"
|
||||
}`}
|
||||
style={selectedTagIds.has(tag.id) ? { backgroundColor: tag.color, borderColor: tag.color } : {}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{allTags.length === 0 && (
|
||||
<p className="text-[11px] text-gray-300">暂无标签,在「管理-标签」里创建后可在此筛选</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 列头 */}
|
||||
<div className="flex border-b border-gray-100 bg-gray-50/60">
|
||||
<div className="flex-1 px-5 py-2 text-xs font-semibold text-gray-400">本地项目</div>
|
||||
<div className="w-72 shrink-0 px-4 py-2 text-xs font-semibold text-gray-400 border-l border-gray-100">部署信息</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{/* Windows */}
|
||||
<div className="flex items-center gap-2 px-5 py-2 bg-blue-50/40 border-b border-gray-100">
|
||||
<p className="text-xs font-semibold text-blue-500 uppercase tracking-widest">Windows</p>
|
||||
<span className="text-[10px] text-blue-400">{winProjects.length} 个</span>
|
||||
</div>
|
||||
{winProjects.length > 0
|
||||
? winProjects.map((p) => (
|
||||
<ProjectRow key={p.id} project={p} spacesJson={spacesJson} onDeploy={setDeployTarget} />
|
||||
))
|
||||
: <p className="text-xs text-gray-300 py-5 text-center border-b border-gray-50">暂无</p>
|
||||
}
|
||||
|
||||
{/* WSL */}
|
||||
<div className="flex items-center gap-2 px-5 py-2 bg-green-50/40 border-b border-gray-100">
|
||||
<p className="text-xs font-semibold text-green-600 uppercase tracking-widest">WSL / Linux</p>
|
||||
<span className="text-[10px] text-green-500">{wslProjects.length} 个</span>
|
||||
</div>
|
||||
{wslProjects.length > 0
|
||||
? wslProjects.map((p) => (
|
||||
<ProjectRow key={p.id} project={p} spacesJson={spacesJson} onDeploy={setDeployTarget} />
|
||||
))
|
||||
: <p className="text-xs text-gray-300 py-5 text-center">暂无</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
{deployTarget && (
|
||||
<DeployModal
|
||||
projectId={deployTarget.id}
|
||||
projectName={deployTarget.name}
|
||||
projectPath={deployTarget.win_path ?? undefined}
|
||||
projectWslPath={deployTarget.wsl_path ?? undefined}
|
||||
onClose={() => setDeployTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<ActionsRun | null>({
|
||||
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<string | null>(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) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{/* Tech stack tags */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((t) => (
|
||||
@ -320,6 +338,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Paths */}
|
||||
{(p.wsl_path || p.win_path) && (
|
||||
<div className="flex flex-col gap-1">
|
||||
@ -478,6 +497,21 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
PR
|
||||
</button>
|
||||
)}
|
||||
{latestRun && (
|
||||
<button
|
||||
onClick={() => setShowCicd(true)}
|
||||
title={`CI: ${latestRun.conclusion ?? latestRun.status}`}
|
||||
className="px-1.5 py-0.5 rounded border border-gray-200 bg-white hover:bg-gray-50 transition-colors text-[11px]"
|
||||
>
|
||||
{latestRun.status === "in_progress" || latestRun.status === "queued"
|
||||
? "🔄"
|
||||
: latestRun.conclusion === "success"
|
||||
? "✅"
|
||||
: latestRun.conclusion === "failure"
|
||||
? "❌"
|
||||
: "⊘"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : gitError ? (
|
||||
@ -524,6 +558,18 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 标签行(纯展示,编辑请点「编辑」) */}
|
||||
{customTags.length > 0 && (
|
||||
<div className="border-t border-gray-100 px-5 py-2.5 flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] font-semibold text-violet-500 shrink-0">标签</span>
|
||||
{customTags.map((t) => (
|
||||
<span key={t} className="px-2 py-0.5 rounded-full text-xs bg-violet-50 text-violet-700 border border-violet-200 font-medium">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 快捷操作区 ── */}
|
||||
{(p.wsl_path || p.win_path || projectTools.length > 0) && (
|
||||
<div className="border-t border-gray-100 px-5 py-3 flex flex-col gap-2">
|
||||
@ -576,8 +622,8 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
<div className="border-t border-gray-100 px-5 py-2.5 flex items-center justify-between">
|
||||
<span className="text-[11px] text-gray-300">{(p.updated_at ?? "").slice(0, 10)}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => setShowDeploy(true)}
|
||||
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="部署命令">
|
||||
<button onClick={() => setShowCicd(true)}
|
||||
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="CI/CD 控制台">
|
||||
🚀
|
||||
</button>
|
||||
<button onClick={() => setShowEnvScan(true)}
|
||||
@ -629,13 +675,15 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
|
||||
<PublishModal project={p} onClose={() => setShowPublish(false)} />
|
||||
)}
|
||||
|
||||
{showDeploy && (
|
||||
<DeployModal
|
||||
{showCicd && (
|
||||
<CicdModal
|
||||
projectId={p.id}
|
||||
projectName={p.name}
|
||||
projectPath={p.win_path ?? undefined}
|
||||
projectWslPath={p.wsl_path ?? undefined}
|
||||
onClose={() => setShowDeploy(false)}
|
||||
repoUrl={p.repo_url ?? undefined}
|
||||
winPath={p.win_path ?? undefined}
|
||||
wslPath={p.wsl_path ?? undefined}
|
||||
platform={p.platform ?? undefined}
|
||||
onClose={() => setShowCicd(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { createProject, updateProject, getSpaces, githubGetToken, githubGetAccount, type Project } from "../../lib/commands";
|
||||
import { createProject, updateProject, getSpaces, githubGetToken, githubGetAccount, getTags, getProjectTags, setProjectTags, type Project } from "../../lib/commands";
|
||||
import { syncProjectCatalog } from "../../lib/spacesSync";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import { Dialog } from "../ui/Dialog";
|
||||
|
||||
const EMPTY: Partial<Project> = {
|
||||
id: "", name: "", description: "", type: "", status: "active", priority: "mid",
|
||||
wsl_path: "", win_path: "", tech_stack: "", recent_focus: "", startup_commands: "",
|
||||
wsl_path: "", win_path: "", tech_stack: "", tags: "", recent_focus: "", startup_commands: "",
|
||||
platform: "windows",
|
||||
};
|
||||
|
||||
@ -30,8 +30,19 @@ export function ProjectModal() {
|
||||
const [tab, setTab] = useState<"info" | "deploy">("info");
|
||||
const isNew = editTarget === null;
|
||||
|
||||
// 标签多选
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const { data: allTags = [] } = useQuery({ queryKey: ["tags"], queryFn: getTags });
|
||||
|
||||
useEffect(() => {
|
||||
setForm(editTarget ?? EMPTY);
|
||||
if (editTarget && editTarget.id) {
|
||||
getProjectTags(editTarget.id).then((tags) => {
|
||||
setSelectedTagIds(tags.map((t) => t.id));
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
setSelectedTagIds([]);
|
||||
}
|
||||
}, [editTarget]);
|
||||
|
||||
if (editTarget === undefined) return null;
|
||||
@ -70,12 +81,18 @@ const pickFolder = async (field: "wsl_path" | "win_path") => {
|
||||
try {
|
||||
if (isNew) {
|
||||
await createProject({ ...form, space_id: currentSpace?.id } as Project);
|
||||
// 新建时用前端生成的 workspace id(即 form.id)设置标签
|
||||
if (form.id && selectedTagIds.length > 0) {
|
||||
await setProjectTags(form.id, selectedTagIds).catch(() => {});
|
||||
}
|
||||
showToast("项目已添加 ✓");
|
||||
} else {
|
||||
await updateProject(editTarget!.id, form);
|
||||
await setProjectTags(editTarget!.id, selectedTagIds);
|
||||
showToast("已保存 ✓");
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
qc.invalidateQueries({ queryKey: ["tagMaps"] });
|
||||
// 若项目有 repo_url,静默同步到 .dev-spaces catalog
|
||||
const savedRepoUrl = isNew ? form.repo_url : (form.repo_url ?? editTarget?.repo_url);
|
||||
if (savedRepoUrl && currentSpace) {
|
||||
@ -259,6 +276,32 @@ const pickFolder = async (field: "wsl_path" | "win_path") => {
|
||||
onChange={(e) => set("tech_stack", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<label className={labelCls}>分类标签</label>
|
||||
<div className="flex flex-wrap gap-1.5 min-h-[36px]">
|
||||
{allTags.length === 0 && (
|
||||
<p className="text-xs text-gray-300">请先在「管理-标签」里创建标签</p>
|
||||
)}
|
||||
{allTags.map((tag) => {
|
||||
const selected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTagIds((prev) =>
|
||||
selected ? prev.filter((id) => id !== tag.id) : [...prev, tag.id]
|
||||
)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium border-2 transition-all ${
|
||||
selected ? "text-white border-transparent" : "bg-white border-gray-200 text-gray-500"
|
||||
}`}
|
||||
style={selected ? { backgroundColor: tag.color, borderColor: tag.color } : {}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* tab 导航栏:固定全宽,不随内容宽度变化 */}
|
||||
<div className="shrink-0 border-b border-gray-200 bg-white px-8 flex gap-1">
|
||||
{(["projects", "groups"] as const).map((t) => (
|
||||
{(["projects", "groups", "templates", "tags"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
@ -28,15 +32,17 @@ export function ManagePage() {
|
||||
tab === t ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{t === "projects" ? "项目" : "产品组"}
|
||||
{TAB_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容区:独立滚动,宽度始终撑满 */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="w-full px-8 py-8 pb-16">
|
||||
{tab === "projects" ? <ProjectsTab spaceId={currentSpaceId} /> : <GroupsTab spaceId={currentSpaceId} />}
|
||||
{tab === "projects" && <ProjectsTab spaceId={currentSpaceId} />}
|
||||
{tab === "groups" && <GroupsTab spaceId={currentSpaceId} />}
|
||||
{tab === "templates" && <TemplatesTab spaceId={currentSpaceId} />}
|
||||
{tab === "tags" && <TagsManager />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -52,6 +58,7 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
|
||||
const [delTarget, setDelTarget] = useState<string | null>(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 }) {
|
||||
>
|
||||
导入仓库
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewProject(true)}
|
||||
className="px-3 py-1.5 rounded-lg border border-indigo-200 text-indigo-600 bg-indigo-50 text-sm hover:bg-indigo-100"
|
||||
>
|
||||
🔨 新建项目
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(null)}
|
||||
className="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700"
|
||||
@ -91,7 +104,7 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
{["名称 / ID", "类型", "状态", "优先级", "技术栈", "本地路径", "操作"].map((h) => (
|
||||
{["名称 / 描述", "类型", "状态", "优先级", "技术栈", "本地路径", "操作"].map((h) => (
|
||||
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
@ -103,7 +116,6 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
|
||||
<tr key={p.id} className="bg-white hover:bg-gray-50/60 transition-colors group">
|
||||
<td className="px-5 py-4">
|
||||
<div className="font-semibold text-gray-900">{p.name}</div>
|
||||
<div className="text-xs text-gray-400 font-mono mt-0.5">{p.id}</div>
|
||||
{p.description && (
|
||||
<div className="text-xs text-gray-400 mt-0.5 max-w-[200px] truncate">{p.description}</div>
|
||||
)}
|
||||
@ -184,6 +196,9 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
|
||||
{showImport && (
|
||||
<ImportRepoModal onClose={() => { setShowImport(false); qc.invalidateQueries({ queryKey: ["projects"] }); }} />
|
||||
)}
|
||||
{showNewProject && (
|
||||
<NewProjectModal spaceId={spaceId} onClose={() => setShowNewProject(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -216,6 +231,15 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
|
||||
showToast("已删除");
|
||||
};
|
||||
|
||||
const move = async (index: number, dir: -1 | 1) => {
|
||||
const next = index + dir;
|
||||
if (next < 0 || next >= groups.length) return;
|
||||
const ids = groups.map((g) => g.id);
|
||||
[ids[index], ids[next]] = [ids[next], ids[index]];
|
||||
await reorderGroups(ids);
|
||||
qc.invalidateQueries({ queryKey: ["groups", spaceId] });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@ -229,12 +253,27 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{groups.map((g) => {
|
||||
{groups.map((g, idx) => {
|
||||
const members = maps.filter((m) => m.group_id === g.id);
|
||||
return (
|
||||
<div key={g.id} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 bg-gray-50/60">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* 排序按钮 */}
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
onClick={() => move(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
className="text-gray-300 hover:text-gray-600 disabled:opacity-20 leading-none text-[10px]"
|
||||
title="上移"
|
||||
>▲</button>
|
||||
<button
|
||||
onClick={() => move(idx, 1)}
|
||||
disabled={idx === groups.length - 1}
|
||||
className="text-gray-300 hover:text-gray-600 disabled:opacity-20 leading-none text-[10px]"
|
||||
title="下移"
|
||||
>▼</button>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-800">{g.name}</span>
|
||||
<span className="text-xs font-mono text-gray-400 bg-white border border-gray-200 rounded px-1.5 py-0.5">{g.id}</span>
|
||||
{g.description && (
|
||||
@ -380,14 +419,19 @@ function AddMemberModal({ group, projects, existingIds, onClose, onSave }: {
|
||||
group: ProductGroup; projects: Project[]; existingIds: Set<string>;
|
||||
onClose: () => void; onSave: () => void;
|
||||
}) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const available = projects.filter((p) => !existingIds.has(p.id));
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return;
|
||||
await addGroupMember(selected, group.id, role || undefined);
|
||||
onSave();
|
||||
try {
|
||||
await addGroupMember(selected, group.id, role || undefined);
|
||||
onSave();
|
||||
} catch (e) {
|
||||
showToast(`❌ 添加失败: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -397,7 +441,7 @@ function AddMemberModal({ group, projects, existingIds, onClose, onSave }: {
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">选择项目</label>
|
||||
<select className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm" value={selected} onChange={(e) => setSelected(e.target.value)}>
|
||||
<option value="">— 请选择 —</option>
|
||||
{available.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.id})</option>)}
|
||||
{available.map((p) => <option key={p.id} value={p.id}>{p.name}{p.description ? ` — ${p.description}` : ""}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@ -412,3 +456,129 @@ function AddMemberModal({ group, projects, existingIds, onClose, onSave }: {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ── Templates Tab ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function TemplatesTab({ spaceId: _spaceId }: { spaceId?: string }) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const qc = useQueryClient();
|
||||
const [editorTarget, setEditorTarget] = useState<string | null | undefined>(undefined);
|
||||
const [delTarget, setDelTarget] = useState<ProjectTemplate | null>(null);
|
||||
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ["templates"],
|
||||
queryFn: listTemplates,
|
||||
});
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!delTarget) return;
|
||||
try {
|
||||
await deleteTemplate(delTarget.id);
|
||||
qc.invalidateQueries({ queryKey: ["templates"] });
|
||||
showToast("已删除");
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
} finally {
|
||||
setDelTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const PLATFORM_COLOR: Record<string, string> = {
|
||||
both: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
win: "bg-blue-50 text-blue-600 border-blue-200",
|
||||
wsl: "bg-green-50 text-green-600 border-green-200",
|
||||
};
|
||||
const PLATFORM_LABEL: Record<string, string> = { both: "通用", win: "Windows", wsl: "WSL" };
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700">共 {templates.length} 个模板</h2>
|
||||
<button
|
||||
onClick={() => setEditorTarget(null)}
|
||||
className="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700"
|
||||
>
|
||||
+ 新建模板
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{templates.map((t) => (
|
||||
<div key={t.id} className="bg-white rounded-xl border border-gray-200 shadow-sm px-5 py-4 flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-gray-800">{t.name}</span>
|
||||
{t.isBuiltin && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 border border-gray-200">内置</span>
|
||||
)}
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${PLATFORM_COLOR[t.platform] ?? PLATFORM_COLOR.both}`}>
|
||||
{PLATFORM_LABEL[t.platform] ?? t.platform}
|
||||
</span>
|
||||
</div>
|
||||
{t.description && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">{t.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => setEditorTarget(t.id)}
|
||||
className="px-2.5 py-1 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
{t.isBuiltin ? "查看" : "编辑"}
|
||||
</button>
|
||||
{!t.isBuiltin && (
|
||||
<button
|
||||
onClick={() => setDelTarget(t)}
|
||||
className="px-2.5 py-1 rounded-lg border border-red-200 text-xs text-red-500 hover:bg-red-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{t.techStack && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.techStack.split(",").map((tag) => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded-full bg-indigo-50 text-indigo-500">
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{t.initCommands && (
|
||||
<code className="text-[11px] text-gray-400 font-mono">
|
||||
$ {t.initCommands.split("\n")[0]}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{templates.length === 0 && (
|
||||
<div className="col-span-2 text-center py-16 text-gray-400 text-sm">暂无模板</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editorTarget !== undefined && (
|
||||
<TemplateEditor
|
||||
templateId={editorTarget}
|
||||
onClose={() => setEditorTarget(undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{delTarget && (
|
||||
<Dialog title="确认删除模板" onClose={() => setDelTarget(null)} size="sm">
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
删除「{delTarget.name}」后不可恢复。
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setDelTarget(null)} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50">取消</button>
|
||||
<button onClick={handleDelete} className="px-4 py-2 rounded-lg text-sm bg-red-600 text-white hover:bg-red-700">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
300
src/components/manage/NewProjectModal.tsx
Normal file
300
src/components/manage/NewProjectModal.tsx
Normal file
@ -0,0 +1,300 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
listTemplates, createProjectFromTemplate,
|
||||
} from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
interface Props {
|
||||
spaceId?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Step = "config" | "template" | "executing" | "done";
|
||||
|
||||
const PLATFORM_LABEL: Record<string, string> = {
|
||||
both: "通用",
|
||||
win: "仅 Win",
|
||||
wsl: "仅 WSL",
|
||||
};
|
||||
|
||||
export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// ── Step 1 ────────────────────────────────────────────────────────────────
|
||||
const [platform, setPlatform] = useState<"win" | "wsl">("win");
|
||||
const [parentPath, setParentPath] = useState("");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
|
||||
// ── Step 2 ────────────────────────────────────────────────────────────────
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||
|
||||
// ── Step 3/4 ──────────────────────────────────────────────────────────────
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [step, setStep] = useState<Step>("config");
|
||||
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ["templates"],
|
||||
queryFn: listTemplates,
|
||||
});
|
||||
|
||||
const filteredTemplates = templates.filter(
|
||||
(t) => t.platform === "both" || t.platform === platform
|
||||
);
|
||||
|
||||
const projectFullPath = parentPath && projectName
|
||||
? `${parentPath.replace(/[/\\]+$/, "")}\\${projectName}`
|
||||
: "";
|
||||
|
||||
// ── 选择文件夹 ─────────────────────────────────────────────────────────────
|
||||
const pickFolder = async () => {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (typeof selected === "string") setParentPath(selected);
|
||||
} catch (e) {
|
||||
showToast(`❌ 选择文件夹失败: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 执行创建 ──────────────────────────────────────────────────────────────
|
||||
const handleCreate = async () => {
|
||||
if (!selectedTemplateId || !parentPath || !projectName.trim()) return;
|
||||
setStep("executing");
|
||||
setLogs(["⏳ 正在创建项目…"]);
|
||||
try {
|
||||
const result = await createProjectFromTemplate(
|
||||
selectedTemplateId,
|
||||
parentPath,
|
||||
projectName.trim(),
|
||||
platform,
|
||||
spaceId,
|
||||
);
|
||||
setLogs(result.logs);
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
setStep("done");
|
||||
} catch (e) {
|
||||
setLogs((prev) => [...prev, `❌ 创建失败: ${e}`]);
|
||||
setStep("done");
|
||||
}
|
||||
};
|
||||
|
||||
// ── 渲染 ──────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[600px] max-h-[85vh] flex flex-col border border-gray-200">
|
||||
|
||||
{/* 标题栏 */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-800">从模板新建项目</h2>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{(["config", "template", "executing", "done"] as Step[]).map((s, i) => (
|
||||
<div key={s} className="flex items-center gap-1.5">
|
||||
{i > 0 && <span className="text-gray-200 text-xs">›</span>}
|
||||
<span className={`text-xs ${step === s ? "text-blue-600 font-semibold" : "text-gray-300"}`}>
|
||||
{s === "config" ? "基本配置" : s === "template" ? "选择模板" : s === "executing" ? "执行中" : "完成"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 px-6 py-5 space-y-4">
|
||||
|
||||
{/* ── Step 1:基本配置 ── */}
|
||||
{step === "config" && (
|
||||
<>
|
||||
{/* 平台 */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-2">运行平台</label>
|
||||
<div className="flex gap-2">
|
||||
{(["win", "wsl"] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPlatform(p)}
|
||||
className={`flex-1 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
|
||||
platform === p
|
||||
? p === "win"
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-green-500 bg-green-50 text-green-700"
|
||||
: "border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{p === "win" ? "🪟 Windows" : "🐧 WSL / Linux"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 父目录 */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-2">父目录</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200"
|
||||
value={parentPath}
|
||||
onChange={(e) => setParentPath(e.target.value)}
|
||||
placeholder={platform === "wsl"
|
||||
? "\\\\wsl.localhost\\Ubuntu-22.04\\home\\user\\projects"
|
||||
: "C:\\Users\\user\\projects"}
|
||||
/>
|
||||
<button
|
||||
onClick={pickFolder}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 shrink-0"
|
||||
>
|
||||
选择
|
||||
</button>
|
||||
</div>
|
||||
{platform === "wsl" && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
WSL 路径请在文件管理器中导航到 <code className="bg-gray-100 px-1 rounded">\\wsl.localhost\发行版名\...</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 项目名称 */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-2">项目名称</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="my-awesome-project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 路径预览 */}
|
||||
{projectFullPath && (
|
||||
<div className="bg-gray-50 rounded-lg px-4 py-3 text-xs">
|
||||
<span className="text-gray-400">将创建:</span>
|
||||
<span className="font-mono text-gray-700 ml-1 break-all">{projectFullPath}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Step 2:选择模板 ── */}
|
||||
{step === "template" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-400">选择一个模板来初始化项目结构</p>
|
||||
{filteredTemplates.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">没有适用于当前平台的模板</p>
|
||||
)}
|
||||
{filteredTemplates.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setSelectedTemplateId(t.id)}
|
||||
className={`w-full text-left px-4 py-3 rounded-xl border transition-all ${
|
||||
selectedTemplateId === t.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-blue-200 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-gray-800">{t.name}</span>
|
||||
{t.isBuiltin && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 border border-gray-200">内置</span>
|
||||
)}
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ml-auto ${
|
||||
t.platform === "wsl" ? "bg-green-50 text-green-600 border-green-200" :
|
||||
t.platform === "win" ? "bg-blue-50 text-blue-600 border-blue-200" :
|
||||
"bg-gray-50 text-gray-500 border-gray-200"
|
||||
}`}>
|
||||
{PLATFORM_LABEL[t.platform] ?? t.platform}
|
||||
</span>
|
||||
</div>
|
||||
{t.description && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">{t.description}</p>
|
||||
)}
|
||||
{t.techStack && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{t.techStack.split(",").map((tag) => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded-full bg-indigo-50 text-indigo-500">
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{t.initCommands && (
|
||||
<p className="text-[11px] text-gray-300 mt-1 font-mono">$ {t.initCommands.split("\n")[0]}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3/4:执行日志 ── */}
|
||||
{(step === "executing" || step === "done") && (
|
||||
<div className="space-y-1">
|
||||
{logs.map((line, i) => (
|
||||
<div key={i} className={`text-xs font-mono leading-relaxed ${
|
||||
line.startsWith("❌") ? "text-red-600" :
|
||||
line.startsWith("✅") ? "text-green-600 font-semibold" :
|
||||
line.startsWith("⚙") ? "text-blue-600" :
|
||||
line.startsWith("⚠") ? "text-orange-500" :
|
||||
line.startsWith("📁") || line.startsWith("📄") ? "text-gray-700" :
|
||||
"text-gray-400"
|
||||
}`}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
{step === "executing" && (
|
||||
<div className="text-xs text-gray-400 animate-pulse mt-2">运行中…</div>
|
||||
)}
|
||||
{step === "done" && !logs.some((l) => l.startsWith("❌")) && (
|
||||
<div className="mt-4 bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-sm text-green-700 text-center">
|
||||
🎉 项目已创建完成!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex justify-between items-center px-6 py-3 border-t border-gray-100 shrink-0">
|
||||
<div>
|
||||
{step === "template" && (
|
||||
<button
|
||||
onClick={() => setStep("config")}
|
||||
className="px-4 py-2 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
← 上一步
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{step === "done" ? "关闭" : "取消"}
|
||||
</button>
|
||||
{step === "config" && (
|
||||
<button
|
||||
onClick={() => setStep("template")}
|
||||
disabled={!parentPath.trim() || !projectName.trim()}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
下一步 →
|
||||
</button>
|
||||
)}
|
||||
{step === "template" && (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!selectedTemplateId}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
开始创建
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
src/components/manage/TagsManager.tsx
Normal file
163
src/components/manage/TagsManager.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getTags, createTag, updateTag, deleteTag, type ProjectTag } from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
"#8b5cf6", "#3b82f6", "#10b981", "#f59e0b",
|
||||
"#ef4444", "#ec4899", "#06b6d4", "#84cc16",
|
||||
"#f97316", "#6366f1",
|
||||
];
|
||||
|
||||
export function TagsManager() {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const qc = useQueryClient();
|
||||
const { data: tags = [] } = useQuery({ queryKey: ["tags"], queryFn: getTags });
|
||||
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newColor, setNewColor] = useState(PRESET_COLORS[0]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editColor, setEditColor] = useState("");
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await createTag(newName.trim(), newColor);
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
setNewName("");
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (tag: ProjectTag) => {
|
||||
setEditingId(tag.id);
|
||||
setEditName(tag.name);
|
||||
setEditColor(tag.color);
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editingId || !editName.trim()) return;
|
||||
try {
|
||||
await updateTag(editingId, editName.trim(), editColor);
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
setEditingId(null);
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteTag(id);
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
showToast("已删除");
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 新建标签 */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">新建标签</h3>
|
||||
<div className="flex items-center gap-3 p-4 bg-white rounded-xl border border-gray-200">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
placeholder="标签名称"
|
||||
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-violet-200"
|
||||
/>
|
||||
{/* 颜色选择 */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setNewColor(c)}
|
||||
className={`w-5 h-5 rounded-full border-2 transition-transform ${newColor === c ? "border-gray-600 scale-110" : "border-transparent"}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 预览 */}
|
||||
<span
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium text-white shrink-0"
|
||||
style={{ backgroundColor: newColor }}
|
||||
>
|
||||
{newName || "预览"}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim()}
|
||||
className="px-3 py-2 rounded-lg bg-violet-600 text-white text-sm hover:bg-violet-700 disabled:opacity-40 shrink-0"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签列表 */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
已有标签 <span className="text-gray-300 font-normal ml-1">({tags.length})</span>
|
||||
</h3>
|
||||
{tags.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-8">还没有标签,在上方新建</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tags.map((tag) => (
|
||||
<div key={tag.id} className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border border-gray-200">
|
||||
{editingId === tag.id ? (
|
||||
<>
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleUpdate()}
|
||||
className="flex-1 border border-gray-200 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-violet-200"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setEditColor(c)}
|
||||
className={`w-5 h-5 rounded-full border-2 transition-transform ${editColor === c ? "border-gray-600 scale-110" : "border-transparent"}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium text-white shrink-0"
|
||||
style={{ backgroundColor: editColor }}
|
||||
>
|
||||
{editName || "预览"}
|
||||
</span>
|
||||
<button onClick={handleUpdate} className="px-3 py-1.5 rounded-lg bg-violet-600 text-white text-xs hover:bg-violet-700 shrink-0">保存</button>
|
||||
<button onClick={() => setEditingId(null)} className="px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 text-xs hover:bg-gray-50 shrink-0">取消</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300 font-mono">{tag.color}</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button onClick={() => startEdit(tag)} className="text-xs text-gray-400 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-50">编辑</button>
|
||||
<button onClick={() => handleDelete(tag.id)} className="text-xs text-red-400 hover:text-red-600 px-2 py-1 rounded hover:bg-red-50">删除</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
src/components/manage/TemplateEditor.tsx
Normal file
271
src/components/manage/TemplateEditor.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getTemplate, saveTemplate, type ProjectTemplate } from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import { Dialog } from "../ui/Dialog";
|
||||
|
||||
interface Props {
|
||||
templateId: string | null; // null = 新建
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
_key: string;
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const PLATFORM_OPTIONS = [
|
||||
{ value: "both", label: "通用(Win + WSL)" },
|
||||
{ value: "win", label: "仅 Windows" },
|
||||
{ value: "wsl", label: "仅 WSL / Linux" },
|
||||
];
|
||||
|
||||
export function TemplateEditor({ templateId, onClose }: Props) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [loading, setLoading] = useState(!!templateId);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isBuiltin, setIsBuiltin] = useState(false);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [platform, setPlatform] = useState("both");
|
||||
const [techStack, setTechStack] = useState("");
|
||||
const [initCommands, setInitCommands] = useState("");
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateId) { setLoading(false); return; }
|
||||
getTemplate(templateId).then((t: ProjectTemplate) => {
|
||||
setName(t.name);
|
||||
setDescription(t.description ?? "");
|
||||
setPlatform(t.platform);
|
||||
setTechStack(t.techStack ?? "");
|
||||
setInitCommands(t.initCommands ?? "");
|
||||
setIsBuiltin(t.isBuiltin);
|
||||
const entries = t.files.map((f) => ({
|
||||
_key: crypto.randomUUID(),
|
||||
path: f.path,
|
||||
content: f.content,
|
||||
}));
|
||||
setFiles(entries);
|
||||
if (entries.length > 0) setSelectedKey(entries[0]._key);
|
||||
}).catch((e) => showToast(`❌ 加载失败: ${e}`))
|
||||
.finally(() => setLoading(false));
|
||||
}, [templateId]);
|
||||
|
||||
const addFile = () => {
|
||||
const entry: FileEntry = { _key: crypto.randomUUID(), path: "", content: "" };
|
||||
setFiles((prev) => [...prev, entry]);
|
||||
setSelectedKey(entry._key);
|
||||
};
|
||||
|
||||
const removeFile = (key: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f._key !== key));
|
||||
setSelectedKey((prev) => {
|
||||
if (prev !== key) return prev;
|
||||
const remaining = files.filter((f) => f._key !== key);
|
||||
return remaining[0]?._key ?? null;
|
||||
});
|
||||
};
|
||||
|
||||
const updateFile = (key: string, field: "path" | "content", value: string) => {
|
||||
setFiles((prev) => prev.map((f) => f._key === key ? { ...f, [field]: value } : f));
|
||||
};
|
||||
|
||||
const selectedFile = files.find((f) => f._key === selectedKey) ?? null;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) { showToast("❌ 模板名称不能为空"); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveTemplate({
|
||||
id: templateId ?? undefined,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
platform,
|
||||
techStack: techStack.trim() || undefined,
|
||||
initCommands: initCommands.trim() || undefined,
|
||||
files: files
|
||||
.filter((f) => f.path.trim())
|
||||
.map((f) => ({ path: f.path.trim(), content: f.content })),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["templates"] });
|
||||
showToast(templateId ? "已更新" : "已创建");
|
||||
onClose();
|
||||
} catch (e) {
|
||||
showToast(`❌ 保存失败: ${e}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={templateId ? (isBuiltin ? `查看内置模板:${name}` : `编辑模板:${name}`) : "新建模板"}
|
||||
onClose={onClose}
|
||||
fixedHeight="82vh"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40 text-gray-400 text-sm">加载中…</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* ── 元信息表单 ── */}
|
||||
<div className="shrink-0 px-6 py-4 border-b border-gray-100 grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">模板名称 *</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isBuiltin}
|
||||
placeholder="如:pnpm Monorepo"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">描述</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isBuiltin}
|
||||
placeholder="一句话说明用途"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">适用平台</label>
|
||||
<select
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none"
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
disabled={isBuiltin}
|
||||
>
|
||||
{PLATFORM_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">技术栈标签(逗号分隔)</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
value={techStack}
|
||||
onChange={(e) => setTechStack(e.target.value)}
|
||||
disabled={isBuiltin}
|
||||
placeholder="Node,pnpm,TypeScript"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
初始化命令(每行一条,支持 # 注释)
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200 resize-none"
|
||||
rows={3}
|
||||
value={initCommands}
|
||||
onChange={(e) => setInitCommands(e.target.value)}
|
||||
disabled={isBuiltin}
|
||||
placeholder={"pnpm install\n# git init"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 文件编辑区 ── */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* 左侧文件列表 */}
|
||||
<div className="w-52 shrink-0 border-r border-gray-100 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100 shrink-0">
|
||||
<span className="text-xs font-semibold text-gray-500">文件列表</span>
|
||||
{!isBuiltin && (
|
||||
<button
|
||||
onClick={addFile}
|
||||
className="text-blue-500 hover:text-blue-700 text-lg leading-none font-light"
|
||||
title="添加文件"
|
||||
>+</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{files.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">暂无文件</p>
|
||||
)}
|
||||
{files.map((f) => (
|
||||
<div
|
||||
key={f._key}
|
||||
onClick={() => setSelectedKey(f._key)}
|
||||
className={`group flex items-center gap-1.5 px-3 py-2 cursor-pointer text-xs font-mono border-b border-gray-50 ${
|
||||
selectedKey === f._key
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{f.path || <span className="text-gray-300 italic">未命名</span>}</span>
|
||||
{!isBuiltin && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); removeFile(f._key); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 shrink-0"
|
||||
>×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧编辑区 */}
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<div className="shrink-0 px-3 py-2 border-b border-gray-100 flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400">路径:</span>
|
||||
<input
|
||||
className="flex-1 border border-gray-200 rounded px-2 py-1 text-xs font-mono outline-none focus:ring-1 focus:ring-blue-200"
|
||||
value={selectedFile.path}
|
||||
onChange={(e) => updateFile(selectedFile._key, "path", e.target.value)}
|
||||
disabled={isBuiltin}
|
||||
placeholder="如:package.json 或 src/index.ts"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<textarea
|
||||
className="absolute inset-0 w-full h-full px-3 py-3 text-xs font-mono resize-none outline-none border-0 bg-gray-50"
|
||||
value={selectedFile.content}
|
||||
onChange={(e) => updateFile(selectedFile._key, "content", e.target.value)}
|
||||
disabled={isBuiltin}
|
||||
placeholder={"文件内容\n可使用变量:{{project_name}} {{project_name_kebab}} {{project_name_snake}}"}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-300 text-sm">
|
||||
{isBuiltin ? "点击左侧文件查看内容" : "点击左侧文件或 + 新增文件"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 底部按钮 ── */}
|
||||
<div className="shrink-0 flex justify-end gap-3 px-6 py-3 border-t border-gray-100">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{isBuiltin ? "关闭" : "取消"}
|
||||
</button>
|
||||
{!isBuiltin && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import {
|
||||
detectEditors, getSettings, getSpaces, searchEditorPath, updateSettings, getWslDistros,
|
||||
type EditorCandidate,
|
||||
@ -183,6 +186,7 @@ export function SettingsPage() {
|
||||
const [editors, setEditors] = useState<EditorEntry[]>([]);
|
||||
const [activeIds, setActiveIds] = useState<string[]>([]); // 多选
|
||||
const [wslDistro, setWslDistro] = useState("Ubuntu");
|
||||
const [wslOpenMode, setWslOpenMode] = useState("wsl_internal");
|
||||
const [wslDistros, setWslDistros] = useState<string[]>([]);
|
||||
const [detectingWsl, setDetectingWsl] = useState(false);
|
||||
const [wslError, setWslError] = useState("");
|
||||
@ -199,6 +203,7 @@ export function SettingsPage() {
|
||||
setActiveIds(old ? [old] : []);
|
||||
}
|
||||
setWslDistro(settings.wsl_distro ?? "Ubuntu");
|
||||
setWslOpenMode(settings.wsl_open_mode ?? "wsl_internal");
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
@ -242,7 +247,7 @@ export function SettingsPage() {
|
||||
};
|
||||
|
||||
const saveWSL = async () => {
|
||||
await updateSettings({ wsl_distro: wslDistro }, spaceId);
|
||||
await updateSettings({ wsl_distro: wslDistro, wsl_open_mode: wslOpenMode }, spaceId);
|
||||
qc.invalidateQueries({ queryKey: ["settings", spaceId] });
|
||||
showToast("已保存 ✓");
|
||||
};
|
||||
@ -394,6 +399,52 @@ export function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 编辑器启动方式 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-2">编辑器启动方式</label>
|
||||
<div className="space-y-2">
|
||||
{([
|
||||
{
|
||||
value: "wsl_internal",
|
||||
label: "从 WSL 内部启动(推荐)",
|
||||
desc: `wsl -d ${wslDistro} -- <editor> <linux-path>`,
|
||||
},
|
||||
{
|
||||
value: "remote_flag",
|
||||
label: "Remote WSL 参数",
|
||||
desc: `<editor> --remote wsl+${wslDistro} <linux-path>`,
|
||||
},
|
||||
{
|
||||
value: "unc_path",
|
||||
label: "Windows UNC 路径(不走 Remote)",
|
||||
desc: `<editor> \\\\wsl.localhost\\${wslDistro}\\<path>`,
|
||||
},
|
||||
] as const).map(({ value, label, desc }) => (
|
||||
<label
|
||||
key={value}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
wslOpenMode === value
|
||||
? "border-blue-300 bg-blue-50"
|
||||
: "border-gray-100 bg-gray-50 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="wslOpenMode"
|
||||
value={value}
|
||||
checked={wslOpenMode === value}
|
||||
onChange={() => setWslOpenMode(value)}
|
||||
className="mt-0.5 accent-blue-600 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800">{label}</div>
|
||||
<code className="text-xs font-mono text-gray-400 break-all">{desc}</code>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={saveWSL}
|
||||
@ -412,6 +463,105 @@ export function SettingsPage() {
|
||||
onSave={handleSaveEditor}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpdateSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 检查更新区块 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
type UpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "latest" }
|
||||
| { status: "available"; version: string; notes: string; downloadAndInstall: () => Promise<void> }
|
||||
| { status: "downloading"; progress: number }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
function UpdateSection() {
|
||||
const [appVersion, setAppVersion] = useState<string>("");
|
||||
const [state, setState] = useState<UpdateState>({ status: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
getVersion().then(setAppVersion).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleCheck = async () => {
|
||||
setState({ status: "checking" });
|
||||
try {
|
||||
const update = await checkUpdate();
|
||||
if (!update?.available) {
|
||||
setState({ status: "latest" });
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
status: "available",
|
||||
version: update.version,
|
||||
notes: update.body ?? "",
|
||||
downloadAndInstall: async () => {
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
await update.downloadAndInstall((event) => {
|
||||
if (event.event === "Started") {
|
||||
total = event.data.contentLength ?? 0;
|
||||
setState({ status: "downloading", progress: 0 });
|
||||
} else if (event.event === "Progress") {
|
||||
downloaded += event.data.chunkLength;
|
||||
setState({ status: "downloading", progress: total > 0 ? Math.round((downloaded / total) * 100) : 0 });
|
||||
} else if (event.event === "Finished") {
|
||||
setState({ status: "idle" });
|
||||
}
|
||||
});
|
||||
await relaunch();
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
setState({ status: "error", message: String(e) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-xl border border-gray-200 shadow-sm px-6 py-5">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-4">关于 & 更新</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">Dev Manager</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">当前版本 v{appVersion}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{state.status === "available" && (
|
||||
<span className="text-xs text-blue-600 bg-blue-50 border border-blue-200 px-2 py-0.5 rounded-full">
|
||||
v{state.version} 可用
|
||||
</span>
|
||||
)}
|
||||
{state.status === "latest" && (
|
||||
<span className="text-xs text-green-600">已是最新版本</span>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<span className="text-xs text-red-500" title={state.message}>检查失败</span>
|
||||
)}
|
||||
{state.status === "downloading" && (
|
||||
<span className="text-xs text-blue-600">下载中 {state.progress}%</span>
|
||||
)}
|
||||
{state.status === "available" ? (
|
||||
<button
|
||||
onClick={state.downloadAndInstall}
|
||||
className="px-4 py-1.5 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
立即更新
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCheck}
|
||||
disabled={state.status === "checking" || state.status === "downloading"}
|
||||
className="px-4 py-1.5 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{state.status === "checking" ? "检查中…" : "检查更新"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,9 +3,22 @@ import { useUIStore } from "../../store/ui";
|
||||
export function Toast() {
|
||||
const toast = useUIStore((s) => s.toast);
|
||||
if (!toast) return null;
|
||||
|
||||
const isError = toast.startsWith("❌");
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 z-50 bg-gray-900 text-white text-sm px-4 py-2 rounded-lg shadow-lg pointer-events-none animate-fade-in">
|
||||
{toast}
|
||||
<div className={`fixed bottom-10 left-1/2 -translate-x-1/2 z-50 text-sm px-4 py-2 rounded-lg shadow-lg animate-fade-in flex items-center gap-2 max-w-[80vw] ${
|
||||
isError ? "bg-red-900 text-white pointer-events-auto" : "bg-gray-900 text-white pointer-events-none"
|
||||
}`}>
|
||||
<span className="break-all">{toast}</span>
|
||||
{isError && (
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(toast).catch(() => {})}
|
||||
className="shrink-0 text-xs px-2 py-0.5 rounded border border-red-400 text-red-200 hover:bg-red-700 transition-colors"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ export interface Project {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
tech_stack?: string;
|
||||
tags?: string;
|
||||
repo_url?: string;
|
||||
upstream_url?: string;
|
||||
default_branch?: string;
|
||||
@ -50,6 +51,7 @@ export interface ProductGroup {
|
||||
name: string;
|
||||
description?: string;
|
||||
space_id?: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface ProjectGroupMap {
|
||||
@ -79,6 +81,9 @@ export const createProject = (input: Omit<Project, "updated_at" | "workspace_id"
|
||||
export const updateProject = (id: string, input: Partial<Omit<Project, "id" | "updated_at">>) =>
|
||||
invoke<void>("update_project", { id, input });
|
||||
|
||||
export const updateProjectTags = (id: string, tags: string) =>
|
||||
invoke<void>("update_project_tags", { id, tags });
|
||||
|
||||
export const deleteProject = (id: string, deleteLocal?: boolean) =>
|
||||
invoke<void>("delete_project", { id, deleteLocal });
|
||||
|
||||
@ -116,6 +121,9 @@ export const updateGroup = (id: string, input: { name: string; description?: str
|
||||
|
||||
export const deleteGroup = (id: string) => invoke<void>("delete_group", { id });
|
||||
|
||||
/** 按 ids 数组顺序批量写入 sort_order */
|
||||
export const reorderGroups = (ids: string[]) => invoke<void>("reorder_groups", { ids });
|
||||
|
||||
export const getGroupMaps = () => invoke<ProjectGroupMap[]>("get_group_maps");
|
||||
|
||||
export const addGroupMember = (projectId: string, groupId: string, role?: string) =>
|
||||
@ -424,12 +432,39 @@ export const gitListBranches = (localPath: string) =>
|
||||
export const gitCheckoutBranch = (localPath: string, branchName: string) =>
|
||||
invoke<void>("git_checkout_branch", { localPath, branchName });
|
||||
|
||||
// ── WSL v2rayA 代理 ───────────────────────────────────────────────────────────
|
||||
// ── WSL 代理同步 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** 返回 "running" | "stopped" | "wsl_unavailable" */
|
||||
export const getV2rayaStatus = () => invoke<string>("get_v2raya_status");
|
||||
export const startV2raya = () => invoke<void>("start_v2raya");
|
||||
export const stopV2raya = () => invoke<void>("stop_v2raya");
|
||||
export const getWslIp = () => invoke<string>("get_wsl_ip");
|
||||
|
||||
/** 读取 v2rayN 代理端口,写入 WSL /etc/profile.d/v2rayn-proxy.sh */
|
||||
export const syncWslProxy = (dbPath?: string) =>
|
||||
invoke<string>("sync_wsl_proxy", { dbPath });
|
||||
|
||||
export interface WslProxyStatus {
|
||||
configured: boolean;
|
||||
port?: number;
|
||||
/** /etc/environment 中记录的 IP 与当前网关不一致(Windows 重启后常见) */
|
||||
stale?: boolean;
|
||||
configured_ip?: string;
|
||||
current_ip?: string;
|
||||
/** /etc/bash.bashrc 中的 source 行是否存在(非登录 shell 代理是否生效) */
|
||||
bashrc_ok?: boolean;
|
||||
}
|
||||
export const getWslProxyStatus = () => invoke<WslProxyStatus>("get_wsl_proxy_status");
|
||||
export const clearWslProxy = () => invoke<void>("clear_wsl_proxy");
|
||||
export const testWslProxyIp = () => invoke<string>("test_wsl_proxy_ip");
|
||||
export const testWinProxyIp = () => invoke<string>("test_win_proxy_ip");
|
||||
|
||||
export interface ActiveV2raynNode {
|
||||
index_id: string;
|
||||
remarks: string;
|
||||
address: string;
|
||||
port: number;
|
||||
config_type: number;
|
||||
share_link: string;
|
||||
}
|
||||
export const getActiveV2raynNode = (dbPath?: string) =>
|
||||
invoke<ActiveV2raynNode>("get_active_v2rayn_node", { dbPath });
|
||||
|
||||
// ── v2rayN 节点导入 ───────────────────────────────────────────────────────────
|
||||
|
||||
@ -579,6 +614,69 @@ export const resolveWslPath = (path: string) =>
|
||||
export const getWslDistros = () =>
|
||||
invoke<string[]>("get_wsl_distros");
|
||||
|
||||
// ── 项目模板 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TemplateFile {
|
||||
id: string;
|
||||
template_id: string;
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ProjectTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
platform: string; // "win" | "wsl" | "both"
|
||||
techStack?: string;
|
||||
initCommands?: string;
|
||||
isBuiltin: boolean;
|
||||
createdAt?: string;
|
||||
files: TemplateFile[];
|
||||
}
|
||||
|
||||
export interface SaveTemplateInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
platform: string;
|
||||
techStack?: string;
|
||||
initCommands?: string;
|
||||
files: { path: string; content: string }[];
|
||||
}
|
||||
|
||||
export interface CreateProjectResult {
|
||||
projectId: string;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export const listTemplates = () =>
|
||||
invoke<ProjectTemplate[]>("list_templates");
|
||||
|
||||
export const getTemplate = (id: string) =>
|
||||
invoke<ProjectTemplate>("get_template", { id });
|
||||
|
||||
export const saveTemplate = (input: SaveTemplateInput) =>
|
||||
invoke<string>("save_template", { input });
|
||||
|
||||
export const deleteTemplate = (id: string) =>
|
||||
invoke<void>("delete_template", { id });
|
||||
|
||||
export const createProjectFromTemplate = (
|
||||
templateId: string,
|
||||
parentPath: string,
|
||||
projectName: string,
|
||||
platform: string,
|
||||
spaceId?: string,
|
||||
) =>
|
||||
invoke<CreateProjectResult>("create_project_from_template", {
|
||||
templateId,
|
||||
parentPath,
|
||||
projectName,
|
||||
platform,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
export const registerAndLinkRepo = (
|
||||
projectId: string,
|
||||
repoUrl: string,
|
||||
@ -587,6 +685,120 @@ export const registerAndLinkRepo = (
|
||||
defaultBranch?: string,
|
||||
) => invoke<void>("register_and_link_repo", { projectId, repoUrl, name, description, defaultBranch });
|
||||
|
||||
// ── 项目标签 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProjectTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ProfileTagMap {
|
||||
profile_id: string;
|
||||
tag_id: string;
|
||||
}
|
||||
|
||||
export const getTags = () => invoke<ProjectTag[]>("get_tags");
|
||||
export const createTag = (name: string, color: string) => invoke<string>("create_tag", { name, color });
|
||||
export const updateTag = (id: string, name: string, color: string) => invoke<void>("update_tag", { id, name, color });
|
||||
export const deleteTag = (id: string) => invoke<void>("delete_tag", { id });
|
||||
export const getProjectTags = (workspaceId: string) => invoke<ProjectTag[]>("get_project_tags", { workspaceId });
|
||||
export const setProjectTags = (workspaceId: string, tagIds: string[]) => invoke<void>("set_project_tags", { workspaceId, tagIds });
|
||||
export const getAllProjectTagMaps = () => invoke<ProfileTagMap[]>("get_all_project_tag_maps");
|
||||
|
||||
// ── CI/CD 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CicdConfig {
|
||||
id: string;
|
||||
project_id: string;
|
||||
/** 'web_ssh' | 'tauri_oss' */
|
||||
config_type: string;
|
||||
trigger_branch: string;
|
||||
build_command?: string;
|
||||
node_version?: string;
|
||||
// web_ssh 专属
|
||||
server_host?: string;
|
||||
server_user?: string;
|
||||
deploy_path?: string;
|
||||
start_command?: string;
|
||||
// tauri_oss 专属
|
||||
oss_endpoint?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowResult {
|
||||
content: string;
|
||||
path: string;
|
||||
secrets: string[];
|
||||
}
|
||||
|
||||
export const getCicdConfig = (projectId: string) =>
|
||||
invoke<CicdConfig | null>("get_cicd_config", { projectId });
|
||||
|
||||
export const saveCicdConfig = (input: CicdConfig) =>
|
||||
invoke<CicdConfig>("save_cicd_config", { input });
|
||||
|
||||
/** 生成 workflow 文件写入磁盘,projectPath 必须是 Windows 可访问路径 */
|
||||
export const generateWorkflow = (projectId: string, projectPath: string) =>
|
||||
invoke<WorkflowResult>("generate_workflow", { projectId, projectPath });
|
||||
|
||||
// ── GitHub Actions API(前端直接调用,使用已存储的 token)────────────────────
|
||||
|
||||
export interface ActionsRun {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string; // "queued" | "in_progress" | "completed"
|
||||
conclusion: string | null; // "success" | "failure" | "cancelled" | null
|
||||
head_branch: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
html_url: string;
|
||||
run_number: number;
|
||||
}
|
||||
|
||||
/** 解析 repo_url 为 {owner}/{repo} 格式,失败返回 null */
|
||||
export function parseRepoPath(repoUrl: string): string | null {
|
||||
const m = repoUrl.match(/github\.com[/:](.+?)(?:\.git)?$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** 获取最近 N 次 Actions run */
|
||||
export async function fetchActionsRuns(
|
||||
token: string,
|
||||
repoPath: string,
|
||||
perPage = 5
|
||||
): Promise<ActionsRun[]> {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${repoPath}/actions/runs?per_page=${perPage}`,
|
||||
{ headers: { Authorization: `Bearer ${token}`, "User-Agent": "dev-manager-tauri" } }
|
||||
);
|
||||
if (!res.ok) throw new Error(`GitHub API ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.workflow_runs ?? [];
|
||||
}
|
||||
|
||||
/** 手动触发 workflow_dispatch */
|
||||
export async function triggerWorkflow(
|
||||
token: string,
|
||||
repoPath: string,
|
||||
workflowFile: string,
|
||||
branch: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${repoPath}/actions/workflows/${workflowFile}/dispatches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": "dev-manager-tauri",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ref: branch }),
|
||||
}
|
||||
);
|
||||
if (!res.ok && res.status !== 204) throw new Error(`触发失败: ${res.status}`);
|
||||
}
|
||||
|
||||
/** 根据节点数据构造 share link(前端完成,避免 Rust 侧 URL 编码依赖) */
|
||||
export function buildShareLink(node: V2raynNode): string {
|
||||
const remarks = encodeURIComponent(node.remarks);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user