206 lines
7.5 KiB
TypeScript
206 lines
7.5 KiB
TypeScript
'use client';
|
|
|
|
import { FormEvent, useEffect, useState } from 'react';
|
|
import { Cloud, Download, FileJson, LogOut, RefreshCw, Trash2, UploadCloud } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { AssessmentRecord } from '@/lib/assessment-types';
|
|
import {
|
|
AnonymousLoginResult,
|
|
AnonymousSession,
|
|
clearAnonymousSession,
|
|
deleteAnonymousProfile,
|
|
getAnonymousSession,
|
|
loginAnonymousProfile,
|
|
syncAnonymousRecord,
|
|
} from '@/lib/anonymous-client';
|
|
import { downloadText, profileToMarkdown } from '@/lib/assessment-export';
|
|
|
|
interface AnonymousSyncPanelProps {
|
|
localRecords: AssessmentRecord[];
|
|
}
|
|
|
|
function displayDate(value: string) {
|
|
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
|
}
|
|
|
|
export function AnonymousSyncPanel({ localRecords }: AnonymousSyncPanelProps) {
|
|
const [codeName, setCodeName] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [session, setSession] = useState<AnonymousSession | null>(null);
|
|
const [remote, setRemote] = useState<AnonymousLoginResult | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const login = async (nextSession: AnonymousSession) => {
|
|
setBusy(true);
|
|
try {
|
|
const result = await loginAnonymousProfile(nextSession);
|
|
setSession(nextSession);
|
|
setRemote(result);
|
|
setCodeName(nextSession.codeName);
|
|
setPassword('');
|
|
toast.success('已进入匿名加密档案');
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : '匿名档案登录失败');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const saved = getAnonymousSession();
|
|
if (!saved) return;
|
|
setSession(saved);
|
|
setCodeName(saved.codeName);
|
|
void login(saved);
|
|
}, []);
|
|
|
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
void login({ codeName: codeName.trim(), password });
|
|
};
|
|
|
|
const logout = () => {
|
|
clearAnonymousSession();
|
|
setSession(null);
|
|
setRemote(null);
|
|
setPassword('');
|
|
toast.success('已退出匿名档案');
|
|
};
|
|
|
|
const syncAll = async () => {
|
|
if (!session || !localRecords.length) return;
|
|
setBusy(true);
|
|
try {
|
|
await Promise.all(localRecords.map((record) => syncAnonymousRecord(record, session)));
|
|
const result = await loginAnonymousProfile(session);
|
|
setRemote(result);
|
|
toast.success('当前本地记录已加密同步');
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : '同步失败');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const removeRemoteProfile = async () => {
|
|
if (!session) return;
|
|
if (!window.confirm('删除这个匿名档案在服务器上的全部密文记录?本地记录不会删除。')) return;
|
|
setBusy(true);
|
|
try {
|
|
await deleteAnonymousProfile(session);
|
|
setSession(null);
|
|
setRemote(null);
|
|
toast.success('服务器匿名档案已删除');
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : '删除失败');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const downloadRemoteMarkdown = () => {
|
|
if (!remote) return;
|
|
const profile = {
|
|
id: remote.profile.id,
|
|
name: remote.profile.codeName,
|
|
createdAt: remote.profile.createdAt,
|
|
updatedAt: remote.profile.updatedAt,
|
|
};
|
|
downloadText(`${remote.profile.codeName}-服务器测评档案.md`, profileToMarkdown(profile, remote.records), 'text/markdown;charset=utf-8');
|
|
};
|
|
|
|
const downloadRemoteJson = () => {
|
|
if (!remote) return;
|
|
downloadText(
|
|
`${remote.profile.codeName}-mindscope-remote.json`,
|
|
JSON.stringify({ format: 'mindscope-anonymous-export', version: 1, exportedAt: new Date().toISOString(), profile: remote.profile, records: remote.records }, null, 2),
|
|
'application/json;charset=utf-8',
|
|
);
|
|
};
|
|
|
|
return (
|
|
<section className="mb-8 border-y py-5">
|
|
<div className="grid gap-5 md:grid-cols-[1fr_1.2fr] md:items-start">
|
|
<div>
|
|
<div className="mb-2 flex items-center gap-2 font-medium">
|
|
<Cloud className="h-4 w-4" />
|
|
匿名加密档案
|
|
</div>
|
|
<p className="text-sm leading-6 text-muted-foreground">
|
|
可选同步到服务器。只使用代号和恢复口令,不需要真实姓名、邮箱或手机号。
|
|
记录会先在浏览器中加密,服务器只保存密文。
|
|
</p>
|
|
</div>
|
|
|
|
{!session ? (
|
|
<form className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]" onSubmit={submit}>
|
|
<Input
|
|
value={codeName}
|
|
onChange={(event) => setCodeName(event.target.value)}
|
|
placeholder="代号,不填真实姓名"
|
|
autoComplete="username"
|
|
/>
|
|
<Input
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
placeholder="恢复口令"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
/>
|
|
<Button disabled={busy || !codeName.trim() || password.length < 4}>
|
|
{busy ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Cloud className="h-4 w-4" />}
|
|
进入
|
|
</Button>
|
|
</form>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<div className="font-medium">{remote?.profile.codeName || session.codeName}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
服务器密文记录 {remote?.records.length || 0} 条
|
|
{remote?.profile.lastSeenAt ? ` · 最近进入 ${displayDate(remote.profile.lastSeenAt)}` : ''}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button variant="outline" onClick={() => void syncAll()} disabled={busy || !localRecords.length}>
|
|
{busy ? <RefreshCw className="h-4 w-4 animate-spin" /> : <UploadCloud className="h-4 w-4" />}
|
|
加密同步本地记录
|
|
</Button>
|
|
<Button variant="outline" onClick={logout}>
|
|
<LogOut className="h-4 w-4" />
|
|
退出
|
|
</Button>
|
|
<Button variant="outline" onClick={downloadRemoteMarkdown} disabled={!remote?.records.length}>
|
|
<Download className="h-4 w-4" />
|
|
远端 MD
|
|
</Button>
|
|
<Button variant="outline" onClick={downloadRemoteJson} disabled={!remote?.records.length}>
|
|
<FileJson className="h-4 w-4" />
|
|
远端 JSON
|
|
</Button>
|
|
<Button variant="ghost" size="icon" title="删除服务器匿名档案" onClick={() => void removeRemoteProfile()}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{!!remote?.records.length && (
|
|
<div className="max-h-52 overflow-auto border text-sm">
|
|
{remote.records.slice(0, 8).map((record) => (
|
|
<div key={record.id} className="flex justify-between gap-3 border-b p-3 last:border-b-0">
|
|
<span>{record.questionnaireTitle}</span>
|
|
<span className="shrink-0 text-muted-foreground">{displayDate(record.completedAt)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|