Files
zclaw_openfang/desktop/src/components/SaaS/ConfigMigrationWizard.tsx
iven a644988ca3
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(desktop): ConfigMigrationWizard PUT 使用 config item ID 替代布尔值
M7-02: exists 是 boolean,不能用作 URL 路径参数。
改为使用 saasConfigs.find() 获取完整对象,
用 existing.id 作为 PUT 路径参数。
2026-04-04 18:27:31 +08:00

342 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<SyncDirection>('local-to-saas');
const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<'success' | 'partial' | null>(null);
const [error, setError] = useState<string | null>(null);
// Data
const [localModels, setLocalModels] = useState<LocalModel[]>([]);
const [saasConfigs, setSaasConfigs] = useState<SaaSConfigItem[]>([]);
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(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 existing = saasConfigs.find((c) => c.key_path === `models.${model.id}`);
if (existing && !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 (existing) {
await saasClient.request<unknown>('PUT', `/api/v1/config/items/${existing.id}`, body);
} else {
await saasClient.request<unknown>('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));
localStorage.setItem('zclaw-config-dirty.model.custom', '1');
} 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));
localStorage.setItem('zclaw-config-dirty.model.custom', '1');
}
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 (
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Upload className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700"></span>
</div>
{step > 1 && (
<button onClick={() => setStep((step - 1) as 1 | 2)} className="text-xs text-gray-500 hover:text-gray-700 cursor-pointer">
<ArrowLeft className="w-3.5 h-3.5 inline" />
</button>
)}
</div>
{/* Step 1: Direction & Preview */}
{step === 1 && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
SaaS
</p>
<div className="space-y-2">
<DirectionOption
label="本地 → SaaS"
description={`${localCount} 个本地模型推送到 SaaS 平台`}
selected={direction === 'local-to-saas'}
onClick={() => setDirection('local-to-saas')}
/>
<DirectionOption
label="SaaS → 本地"
description={`从 SaaS 平台拉取 ${saasCount} 项配置到本地`}
selected={direction === 'saas-to-local'}
onClick={() => setDirection('saas-to-local')}
/>
<DirectionOption
label="双向合并"
description="合并两边配置,冲突时保留本地版本"
selected={direction === 'merge'}
onClick={() => setDirection('merge')}
/>
</div>
<button
onClick={() => setStep(2)}
disabled={localCount === 0 && saasCount === 0}
className="w-full py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
<ArrowRight className="w-4 h-4 inline" />
</button>
</div>
)}
{/* Step 2: Resolve conflicts */}
{step === 2 && (
<div className="space-y-4">
{conflicts.length > 0 ? (
<>
<p className="text-sm text-amber-600">
{conflicts.length} {direction === 'local-to-saas' ? '本地' : 'SaaS'}
</p>
<div className="space-y-1.5">
{conflicts.map((c) => (
<label key={c.key} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50 cursor-pointer text-sm">
<input
type="checkbox"
checked={selectedKeys.has(c.key)}
onChange={(e) => {
setSelectedKeys((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(c.key);
else next.delete(c.key);
return next;
});
}}
className="rounded"
/>
<span className="font-medium text-gray-800">{c.key}</span>
<span className="text-xs text-gray-400 truncate">
({direction === 'local-to-saas' ? '本地' : 'SaaS'}: {c.saasValue})
</span>
</label>
))}
</div>
</>
) : (
<div className="flex items-center gap-2 text-sm text-emerald-600">
<Check className="w-4 h-4" />
<span></span>
</div>
)}
<button
onClick={() => { setStep(3); executeSync(); }}
className="w-full py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition-colors"
>
{isSyncing ? (
<><Loader2 className="w-4 h-4 inline animate-spin" /> ...</>
) : (
<><ArrowRight className="w-4 h-4 inline" /> </>
)}
</button>
</div>
)}
{/* Step 3: Result */}
{step === 3 && (
<div className="space-y-4">
{syncResult === 'success' ? (
<div className="flex items-center gap-2 text-sm text-emerald-600">
<Check className="w-5 h-5" />
<span></span>
</div>
) : syncResult === 'partial' ? (
<div className="flex items-center gap-2 text-amber-600">
<Check className="w-5 h-5" />
<span>{conflicts.length} </span>
</div>
) : error ? (
<div className="text-sm text-red-500">{error}</div>
) : null}
<div className="flex gap-2">
<button
onClick={reset}
className="flex-1 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
>
<RefreshCw className="w-3.5 h-3.5 inline" />
</button>
<button
onClick={onDone}
className="flex-1 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition-colors"
>
</button>
</div>
</div>
)}
</div>
);
}
function DirectionOption({
label,
description,
selected,
onClick,
}: {
label: string;
description: string;
selected: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`w-full text-left p-3 rounded-lg border transition-colors cursor-pointer ${
selected ? 'border-emerald-500 bg-emerald-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-sm font-medium text-gray-800">{label}</div>
<div className="text-xs text-gray-500">{description}</div>
</button>
);
}