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:
lanrtop 2026-04-01 23:12:23 +09:00
parent 93617565bf
commit 533c069e43
36 changed files with 5042 additions and 443 deletions

131
.github/workflows/release.yml vendored Normal file
View 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
View File

@ -23,3 +23,6 @@ src-tauri/target/
# 系统
.DS_Store
Thumbs.db
# 防止签名密钥被误提交Windows ~ 路径展开问题)
~/

View File

@ -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
View File

@ -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
View File

@ -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"

View File

@ -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"

View 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)
}

View File

@ -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())?;

View File

@ -1,7 +1,7 @@
use crate::db::pool;
use crate::models::GitRepo;
use rusqlite::params;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
// ── 输入结构体 ─────────────────────────────────────────────────────────────────

View File

@ -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_orderindex 即为新顺序)
#[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())?;

View File

@ -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;

View File

@ -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 默认登录用户写入 ~/.bashrcroot 的 ~ 是 /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/environmentPAM 全局,覆盖所有 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(&current_ip) { Some(current_ip) } else { None };
// 若无法提取 configured_ip如 /etc/environment 被手动修改),保守地标为 stale
let stale = match (&configured_ip, &current_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(&reg_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> {

View File

@ -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())?;

View File

@ -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),
}
}

View File

@ -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());
}

View File

@ -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 + 全局 keygithub_client_id/secret

View File

@ -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]

View 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)
}

View 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],
);
}
}
}

View File

@ -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");
}

View File

@ -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");

View File

@ -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,

View File

@ -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
}
}
}

View 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_pathWSL 项目则转换 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 tagv1.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_KEYcat ~/.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>
);
}

View File

@ -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>
);
}

View File

@ -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)}
/>
)}

View File

@ -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>

View File

@ -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)
}

View File

@ -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>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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);