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 existingItem = saasConfigs.find((c) => c.key_path === `models.${model.id}`); if (existingItem && !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 (existingItem) { await saasClient.request('PUT', `/api/v1/config/items/${existingItem.id}`, 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 ( ); }