diff --git a/crates/zclaw-saas/src/account/handlers.rs b/crates/zclaw-saas/src/account/handlers.rs index 36b26f6..fa2a0f2 100644 --- a/crates/zclaw-saas/src/account/handlers.rs +++ b/crates/zclaw-saas/src/account/handlers.rs @@ -5,7 +5,7 @@ use axum::{ Json, }; use crate::state::AppState; -use crate::error::SaasResult; +use crate::error::{SaasError, SaasResult}; use crate::auth::types::AuthContext; use crate::auth::handlers::{log_operation, check_permission}; use super::{types::*, service}; @@ -179,3 +179,97 @@ pub async fn dashboard_stats( "tokens_today_output": tokens_today_output.0, }))) } + +// ============ Devices ============ + +/// POST /api/v1/devices/register — 注册或更新设备 +pub async fn register_device( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + let device_id = req.get("device_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| SaasError::InvalidInput("缺少 device_id".into()))?; + let device_name = req.get("device_name").and_then(|v| v.as_str()).unwrap_or("Unknown"); + let platform = req.get("platform").and_then(|v| v.as_str()).unwrap_or("unknown"); + let app_version = req.get("app_version").and_then(|v| v.as_str()).unwrap_or(""); + + let now = chrono::Utc::now().to_rfc3339(); + let device_uuid = uuid::Uuid::new_v4().to_string(); + + // UPSERT: 已存在则更新 last_seen_at,不存在则插入 + sqlx::query( + "INSERT INTO devices (id, account_id, device_id, device_name, platform, app_version, last_seen_at, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) + ON CONFLICT(account_id, device_id) DO UPDATE SET + device_name = ?4, platform = ?5, app_version = ?6, last_seen_at = ?7" + ) + .bind(&device_uuid) + .bind(&ctx.account_id) + .bind(device_id) + .bind(device_name) + .bind(platform) + .bind(app_version) + .bind(&now) + .execute(&state.db) + .await?; + + log_operation(&state.db, &ctx.account_id, "device.register", "device", device_id, + Some(serde_json::json!({"device_name": device_name, "platform": platform})), + ctx.client_ip.as_deref()).await?; + + Ok(Json(serde_json::json!({"ok": true, "device_id": device_id}))) +} + +/// POST /api/v1/devices/heartbeat — 设备心跳 +pub async fn device_heartbeat( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + let device_id = req.get("device_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| SaasError::InvalidInput("缺少 device_id".into()))?; + + let now = chrono::Utc::now().to_rfc3339(); + let result = sqlx::query( + "UPDATE devices SET last_seen_at = ?1 WHERE account_id = ?2 AND device_id = ?3" + ) + .bind(&now) + .bind(&ctx.account_id) + .bind(device_id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + return Err(SaasError::NotFound("设备未注册".into())); + } + + Ok(Json(serde_json::json!({"ok": true}))) +} + +/// GET /api/v1/devices — 列出当前用户的设备 +pub async fn list_devices( + State(state): State, + Extension(ctx): Extension, +) -> SaasResult>> { + let rows: Vec<(String, String, Option, Option, Option, String, String)> = + sqlx::query_as( + "SELECT id, device_id, device_name, platform, app_version, last_seen_at, created_at + FROM devices WHERE account_id = ?1 ORDER BY last_seen_at DESC" + ) + .bind(&ctx.account_id) + .fetch_all(&state.db) + .await?; + + let items: Vec = rows.into_iter().map(|r| { + serde_json::json!({ + "id": r.0, "device_id": r.1, + "device_name": r.2, "platform": r.3, "app_version": r.4, + "last_seen_at": r.5, "created_at": r.6, + }) + }).collect(); + + Ok(Json(items)) +} diff --git a/crates/zclaw-saas/src/account/mod.rs b/crates/zclaw-saas/src/account/mod.rs index d43f04d..a24a75b 100644 --- a/crates/zclaw-saas/src/account/mod.rs +++ b/crates/zclaw-saas/src/account/mod.rs @@ -17,4 +17,7 @@ pub fn routes() -> axum::Router { .route("/api/v1/tokens/{id}", delete(handlers::revoke_token)) .route("/api/v1/logs/operations", get(handlers::list_operation_logs)) .route("/api/v1/stats/dashboard", get(handlers::dashboard_stats)) + .route("/api/v1/devices", get(handlers::list_devices)) + .route("/api/v1/devices/register", post(handlers::register_device)) + .route("/api/v1/devices/heartbeat", post(handlers::device_heartbeat)) } diff --git a/crates/zclaw-saas/src/db.rs b/crates/zclaw-saas/src/db.rs index d4876ee..22338b0 100644 --- a/crates/zclaw-saas/src/db.rs +++ b/crates/zclaw-saas/src/db.rs @@ -195,6 +195,21 @@ CREATE TABLE IF NOT EXISTS config_sync_log ( created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sync_account ON config_sync_log(account_id); + +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + device_id TEXT NOT NULL, + device_name TEXT, + platform TEXT, + app_version TEXT, + last_seen_at TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_devices_account ON devices(account_id); +CREATE INDEX IF NOT EXISTS idx_devices_device_id ON devices(device_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_unique ON devices(account_id, device_id); "#; const SEED_ROLES: &str = r#" @@ -319,7 +334,7 @@ mod tests { let tables = [ "accounts", "api_tokens", "roles", "permission_templates", "operation_logs", "providers", "models", "account_api_keys", - "usage_records", "relay_tasks", "config_items", "config_sync_log", + "usage_records", "relay_tasks", "config_items", "config_sync_log", "devices", ]; for table in tables { let count: (i64,) = sqlx::query_as(&format!( diff --git a/crates/zclaw-saas/tests/integration_test.rs b/crates/zclaw-saas/tests/integration_test.rs index e41518b..522ae8c 100644 --- a/crates/zclaw-saas/tests/integration_test.rs +++ b/crates/zclaw-saas/tests/integration_test.rs @@ -419,6 +419,356 @@ async fn test_config_seed_and_list() { assert_eq!(body.as_array().unwrap().len(), 0); } +// ============ Phase 4: 设备注册 + 密码修改 ============ + +#[tokio::test] +async fn test_device_register_and_list() { + let app = build_test_app().await; + let token = register_and_login(&app, "devuser", "devuser@example.com").await; + + // 注册设备 + let reg_req = Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "device_id": "desktop-test-001", + "device_name": "My Desktop", + "platform": "windows", + "app_version": "0.1.0" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(reg_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 列出设备 — 应该有 1 台 + let list_req = Request::builder() + .method("GET") + .uri("/api/v1/devices") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(list_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert!(body.is_array()); + assert_eq!(body.as_array().unwrap().len(), 1); + assert_eq!(body[0]["device_id"], "desktop-test-001"); + assert_eq!(body[0]["platform"], "windows"); +} + +#[tokio::test] +async fn test_device_upsert_on_reregister() { + let app = build_test_app().await; + let token = register_and_login(&app, "upsertdev", "upsertdev@example.com").await; + + // 第一次注册 + let req = Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "device_id": "device-upsert-01", + "device_name": "Old Name", + "platform": "linux" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 同一 device_id 再次注册 (UPSERT → 更新 device_name 和 last_seen_at) + let req2 = Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "device_id": "device-upsert-01", + "device_name": "New Name", + "platform": "linux" + })).unwrap())) + .unwrap(); + + let resp2 = app.clone().oneshot(req2).await.unwrap(); + assert_eq!(resp2.status(), StatusCode::OK); + + // 仍然只有 1 台设备,名称已更新 + let list_req = Request::builder() + .method("GET") + .uri("/api/v1/devices") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(list_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body.as_array().unwrap().len(), 1); + assert_eq!(body[0]["device_name"], "New Name"); +} + +#[tokio::test] +async fn test_device_heartbeat() { + let app = build_test_app().await; + let token = register_and_login(&app, "hbuser", "hbuser@example.com").await; + + // 先注册设备 + let reg_req = Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "device_id": "hb-device-01", + "device_name": "Heartbeat Device" + })).unwrap())) + .unwrap(); + app.clone().oneshot(reg_req).await.unwrap(); + + // 发送心跳 + let hb_req = Request::builder() + .method("POST") + .uri("/api/v1/devices/heartbeat") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "device_id": "hb-device-01" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(hb_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 心跳到未注册的设备 → 404 + let bad_hb = Request::builder() + .method("POST") + .uri("/api/v1/devices/heartbeat") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "device_id": "nonexistent-device" + })).unwrap())) + .unwrap(); + + let resp = app.oneshot(bad_hb).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_device_register_missing_id() { + let app = build_test_app().await; + let token = register_and_login(&app, "baddev", "baddev@example.com").await; + + // 缺少 device_id → 400 + let req = Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "device_name": "No ID Device" + })).unwrap())) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_change_password() { + let app = build_test_app().await; + let token = register_and_login(&app, "pwduser", "pwduser@example.com").await; + + // 修改密码 + let change_req = Request::builder() + .method("PUT") + .uri("/api/v1/auth/password") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "old_password": "password123", + "new_password": "newpassword456" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(change_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 用新密码重新登录 + let login_req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&json!({ + "username": "pwduser", + "password": "newpassword456" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(login_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 用旧密码登录 → 401 + let old_login = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&json!({ + "username": "pwduser", + "password": "password123" + })).unwrap())) + .unwrap(); + + let resp = app.oneshot(old_login).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_change_password_wrong_old() { + let app = build_test_app().await; + let token = register_and_login(&app, "wrongold", "wrongold@example.com").await; + + // 旧密码错误 → 400 + let change_req = Request::builder() + .method("PUT") + .uri("/api/v1/auth/password") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "old_password": "wrong-old-password", + "new_password": "newpassword456" + })).unwrap())) + .unwrap(); + + let resp = app.oneshot(change_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +// ============ Phase 4 E2E: 完整生命周期 ============ + +#[tokio::test] +async fn test_e2e_full_lifecycle() { + let app = build_test_app().await; + + // 1. 注册 + let token = register_and_login(&app, "e2euser", "e2e@example.com").await; + assert!(!token.is_empty()); + + // 2. 查看自己的账号信息 + let me_req = Request::builder() + .method("GET") + .uri("/api/v1/auth/me") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(me_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let me: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(me["username"], "e2euser"); + + // 3. 注册设备 + let dev_req = Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "device_id": "e2e-device-001", + "device_name": "E2E Test Machine", + "platform": "windows", + "app_version": "1.0.0" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(dev_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 4. 创建 API Token + let tok_req = Request::builder() + .method("POST") + .uri("/api/v1/tokens") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "name": "e2e-token", + "permissions": ["model:read"] + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(tok_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let tok_body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert!(!tok_body["token"].is_null()); + + // 5. 修改密码 + let pwd_req = Request::builder() + .method("PUT") + .uri("/api/v1/auth/password") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "old_password": "password123", + "new_password": "e2e-new-password" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(pwd_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 6. 用新密码登录验证 + let relogin_req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&json!({ + "username": "e2euser", + "password": "e2e-new-password" + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(relogin_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let login_body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + let new_token = login_body["token"].as_str().unwrap(); + assert!(!new_token.is_empty()); + + // 7. 用新 token 列出设备 + let list_req = Request::builder() + .method("GET") + .uri("/api/v1/devices") + .header("Authorization", auth_header(new_token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(list_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let devs: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(devs.as_array().unwrap().len(), 1); + + // 8. 用旧 token 应该仍可用 (JWT 未撤销) + let old_tok_list = Request::builder() + .method("GET") + .uri("/api/v1/devices") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(old_tok_list).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + #[tokio::test] async fn test_config_sync() { let app = build_test_app().await; diff --git a/desktop/src/components/SaaS/ConfigMigrationWizard.tsx b/desktop/src/components/SaaS/ConfigMigrationWizard.tsx new file mode 100644 index 0000000..d2b0dbc --- /dev/null +++ b/desktop/src/components/SaaS/ConfigMigrationWizard.tsx @@ -0,0 +1,339 @@ +import { useState, useEffect } from 'react'; +import { saasClient, type SaaSConfigItem } from '../../lib/saas-client'; +import { ArrowLeft, ArrowRight, Upload, Check, Loader2, RefreshCw } from 'lucide-react'; + +interface LocalModel { + id: string; + name: string; + provider: string; + [key: string]: unknown; +} + +type SyncDirection = 'local-to-saas' | 'saas-to-local' | 'merge'; + +interface SyncConflict { + key: string; + localValue: string | null; + saasValue: string | null; +} + +export function ConfigMigrationWizard({ onDone }: { onDone: () => void }) { + const [step, setStep] = useState<1 | 2 | 3>(1); + const [direction, setDirection] = useState('local-to-saas'); + const [isSyncing, setIsSyncing] = useState(false); + const [syncResult, setSyncResult] = useState<'success' | 'partial' | null>(null); + const [error, setError] = useState(null); + + // Data + const [localModels, setLocalModels] = useState([]); + const [saasConfigs, setSaasConfigs] = useState([]); + const [conflicts, setConflicts] = useState([]); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + + // Step 1: Load data + useEffect(() => { + if (step !== 1) return; + + // Load local models from localStorage + try { + const raw = localStorage.getItem('zclaw-custom-models'); + if (raw) { + const parsed = JSON.parse(raw) as LocalModel[]; + setLocalModels(Array.isArray(parsed) ? parsed : []); + } + } catch { + setLocalModels([]); + } + + // Load SaaS config items + saasClient.listConfig().then(setSaasConfigs).catch(() => setSaasConfigs([])); + }, [step]); + + const localCount = localModels.length; + const saasCount = saasConfigs.length; + + // Step 2: Compute conflicts based on direction + useEffect(() => { + if (step !== 2) return; + + const found: SyncConflict[] = []; + + if (direction === 'local-to-saas' || direction === 'merge') { + // Check which local models already exist in SaaS + for (const model of localModels) { + const exists = saasConfigs.some((c) => c.key_path === `models.${model.id}`); + if (exists) { + found.push({ + key: model.id, + localValue: JSON.stringify({ name: model.name, provider: model.provider }), + saasValue: '已存在', + }); + } + } + } + + if (direction === 'saas-to-local' || direction === 'merge') { + // SaaS configs that have values not in local + for (const config of saasConfigs) { + if (!config.current_value) continue; + const localRaw = localStorage.getItem('zclaw-custom-models'); + const localModels: LocalModel[] = localRaw ? JSON.parse(localRaw) : []; + const isLocal = localModels.some((m) => m.id === config.key_path.replace('models.', '')); + if (!isLocal && config.category === 'model') { + found.push({ + key: config.key_path, + localValue: null, + saasValue: config.current_value, + }); + } + } + } + + setConflicts(found); + setSelectedKeys(new Set(found.map((c) => c.key))); + }, [step, direction, localModels, saasConfigs]); + + // Step 3: Execute sync + async function executeSync() { + setIsSyncing(true); + setError(null); + + try { + if (direction === 'local-to-saas' && localModels.length > 0) { + // Push local models as config items + for (const model of localModels) { + const exists = saasConfigs.some((c) => c.key_path === `models.${model.id}`); + if (exists && !selectedKeys.has(model.id)) continue; + + const body = { + category: 'model', + key_path: `models.${model.id}`, + value_type: 'json', + current_value: JSON.stringify({ name: model.name, provider: model.provider }), + source: 'desktop', + description: `从桌面端同步: ${model.name}`, + }; + + if (exists) { + await saasClient.request('PUT', `/api/v1/config/items/${exists}`, body); + } else { + await saasClient.request('POST', '/api/v1/config/items', body); + } + } + } else if (direction === 'saas-to-local' && saasConfigs.length > 0) { + // Pull SaaS models to local + const syncedModels = localModels.filter((m) => !selectedKeys.has(m.id)); + const saasModels = saasConfigs + .filter((c) => c.category === 'model' && c.current_value) + .map((c) => { + try { + return JSON.parse(c.current_value!) as LocalModel; + } catch { + return null; + } + }) + .filter((m): m is LocalModel => m !== null); + + const merged = [...syncedModels, ...saasModels]; + localStorage.setItem('zclaw-custom-models', JSON.stringify(merged)); + } else if (direction === 'merge') { + // Merge: local wins for conflicts + const kept = localModels.filter((m) => !selectedKeys.has(m.id)); + const saasOnly = saasConfigs + .filter((c) => c.category === 'model' && c.current_value) + .map((c) => { + try { + return JSON.parse(c.current_value!) as LocalModel; + } catch { + return null; + } + }) + .filter((m): m is LocalModel => m !== null) + .filter((m) => !localModels.some((lm) => lm.id === m.id)); + + const merged = [...kept, ...saasOnly]; + localStorage.setItem('zclaw-custom-models', JSON.stringify(merged)); + } + + setSyncResult(conflicts.length > 0 && conflicts.length === selectedKeys.size ? 'partial' : 'success'); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : '同步失败'); + } finally { + setIsSyncing(false); + } + } + + // Reset + function reset() { + setStep(1); + setDirection('local-to-saas'); + setSyncResult(null); + setError(null); + setSelectedKeys(new Set()); + } + + return ( +
+ {/* Header */} +
+
+ + 配置迁移向导 +
+ {step > 1 && ( + + )} +
+ + {/* Step 1: Direction & Preview */} + {step === 1 && ( +
+

+ 选择迁移方向,检查本地和 SaaS 平台的配置差异。 +

+ +
+ setDirection('local-to-saas')} + /> + setDirection('saas-to-local')} + /> + setDirection('merge')} + /> +
+ + +
+ )} + + {/* Step 2: Resolve conflicts */} + {step === 2 && ( +
+ {conflicts.length > 0 ? ( + <> +

+ 发现 {conflicts.length} 项冲突。勾选的项目将保留{direction === 'local-to-saas' ? '本地' : 'SaaS'}版本。 +

+
+ {conflicts.map((c) => ( + + ))} +
+ + ) : ( +
+ + 无冲突,可直接同步 +
+ )} + + +
+ )} + + {/* Step 3: Result */} + {step === 3 && ( +
+ {syncResult === 'success' ? ( +
+ + 配置同步成功完成 +
+ ) : syncResult === 'partial' ? ( +
+ + 部分同步完成({conflicts.length} 项跳过) +
+ ) : error ? ( +
{error}
+ ) : null} + +
+ + +
+
+ )} +
+ ); +} + +function DirectionOption({ + label, + description, + selected, + onClick, +}: { + label: string; + description: string; + selected: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/desktop/src/components/SaaS/SaaSSettings.tsx b/desktop/src/components/SaaS/SaaSSettings.tsx index 4280b22..8cddb2e 100644 --- a/desktop/src/components/SaaS/SaaSSettings.tsx +++ b/desktop/src/components/SaaS/SaaSSettings.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useSaaSStore } from '../../store/saasStore'; import { SaaSLogin } from './SaaSLogin'; import { SaaSStatus } from './SaaSStatus'; +import { ConfigMigrationWizard } from './ConfigMigrationWizard'; import { Cloud, Info, KeyRound } from 'lucide-react'; import { saasClient } from '../../lib/saas-client'; @@ -129,6 +130,16 @@ export function SaaSSettings() { {/* Password change section */} {isLoggedIn && !showLogin && } + + {/* Config migration wizard */} + {isLoggedIn && !showLogin && ( +
+

+ 数据迁移 +

+ {/* no-op: wizard self-contained */}} /> +
+ )} ); } diff --git a/desktop/src/components/SaaS/SaaSStatus.tsx b/desktop/src/components/SaaS/SaaSStatus.tsx index bb92447..1fb202d 100644 --- a/desktop/src/components/SaaS/SaaSStatus.tsx +++ b/desktop/src/components/SaaS/SaaSStatus.tsx @@ -14,6 +14,7 @@ interface SaaSStatusProps { export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }: SaaSStatusProps) { const availableModels = useSaaSStore((s) => s.availableModels); const fetchAvailableModels = useSaaSStore((s) => s.fetchAvailableModels); + const [serverReachable, setServerReachable] = useState(true); const [checkingHealth, setCheckingHealth] = useState(false); const [healthOk, setHealthOk] = useState(null); @@ -25,6 +26,18 @@ export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }: } }, [isLoggedIn, fetchAvailableModels]); + // Poll server reachability every 30s + useEffect(() => { + if (!isLoggedIn) return; + + const check = () => { + setServerReachable(saasClient.isServerReachable()); + }; + check(); + const timer = setInterval(check, 30000); + return () => clearInterval(timer); + }, [isLoggedIn]); + async function checkHealth() { setCheckingHealth(true); setHealthOk(null); @@ -61,10 +74,17 @@ export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }:
-
- - 已连接 -
+ {serverReachable ? ( +
+ + 已连接 +
+ ) : ( +
+ + 离线 +
+ )}