feat: 发布 v0.5.0 加密存储与匿名同步

This commit is contained in:
2026-06-23 01:48:01 +02:00
parent 81a70137a9
commit e3825c5a4e
20 changed files with 1091 additions and 70 deletions
+205
View File
@@ -0,0 +1,205 @@
'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>
);
}
+3
View File
@@ -21,6 +21,7 @@ import {
} from '@/lib/assessment-db';
import { downloadText, profileToMarkdown, recordToMarkdown } from '@/lib/assessment-export';
import { decryptBackup, encryptBackup } from '@/lib/backup-crypto';
import { AnonymousSyncPanel } from '@/components/records/AnonymousSyncPanel';
type View = 'history' | 'trends' | 'portrait';
@@ -205,6 +206,8 @@ export function RecordsDashboard() {
</div>
</section>
<AnonymousSyncPanel localRecords={records} />
<div className="mb-6 flex border-b" role="tablist" aria-label="档案视图">
{([['history', '历史记录'], ['trends', '变化趋势'], ['portrait', '统一画像']] as const).map(([id, label]) => (
<button key={id} role="tab" aria-selected={view === id} onClick={() => setView(id)} className={`border-b-2 px-4 py-3 text-sm font-medium ${view === id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground'}`}>{label}</button>