feat: 发布 v0.5.0 加密存储与匿名同步
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user