refactor: 重构数据库连接使用PostgreSQL替代SQLite feat(auth): 增加JWT验证的audience和issuer检查 feat(crypto): 添加AES-256-GCM字段加密支持 feat(api): 集成utoipa实现OpenAPI文档 fix(admin): 修复配置项表单验证逻辑 style: 统一代码格式与类型定义 docs: 更新技术栈文档说明PostgreSQL
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
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 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<unknown>('PUT', `/api/v1/config/items/${existingItem.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));
|
||
} 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 (
|
||
<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>
|
||
);
|
||
}
|