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
+5 -9
View File
@@ -12,8 +12,8 @@ export function Navbar() {
return (
<header className="border-b">
<div className="safe-x container mx-auto flex h-14 max-w-6xl items-center justify-between">
<Link href="/" className="text-lg font-medium flex items-center gap-2">
<span className="w-8 h-8 border rounded flex items-center justify-center">
<Link href="/" className="flex items-center gap-2 text-lg font-medium">
<span className="flex h-8 w-8 items-center justify-center rounded border">
<ClipboardList className="h-4 w-4" />
</span>
<span className="hidden md:block">{t('title')}</span>
@@ -22,23 +22,19 @@ export function Navbar() {
<nav className="flex items-center gap-4 text-sm">
<Link
href="/"
className="font-medium hover:text-foreground transition-colors"
className={`${pathname === '/' ? 'font-medium' : 'text-muted-foreground'} transition-colors hover:text-foreground`}
>
{t('introduce')}
</Link>
<Link
href="/questionnaire"
className={`${
pathname.startsWith('/questionnaire')
? 'font-medium'
: 'text-muted-foreground'
} hover:text-foreground transition-colors`}
className={`${pathname.startsWith('/questionnaire') ? 'font-medium' : 'text-muted-foreground'} transition-colors hover:text-foreground`}
>
{t('questionsList')}
</Link>
<Link
href="/records"
className={`${pathname.startsWith('/records') ? 'font-medium' : 'text-muted-foreground'} flex items-center gap-1.5 hover:text-foreground transition-colors`}
className={`${pathname.startsWith('/records') ? 'font-medium' : 'text-muted-foreground'} flex items-center gap-1.5 transition-colors hover:text-foreground`}
>
<FolderClock className="h-4 w-4" />
+80
View File
@@ -0,0 +1,80 @@
'use client';
import { FormEvent, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Cloud, HardDrive, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { loginAnonymousProfile } from '@/lib/anonymous-client';
export function HomeModeSelector() {
const router = useRouter();
const [codeName, setCodeName] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const startLocal = () => {
router.push('/questionnaire');
};
const startAnonymous = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
try {
await loginAnonymousProfile({ codeName: codeName.trim(), password });
toast.success('已进入匿名加密档案');
router.push('/questionnaire');
} catch (error) {
toast.error(error instanceof Error ? error.message : '匿名档案进入失败');
} finally {
setLoading(false);
}
};
return (
<div className="grid gap-3">
<div className="border p-4">
<div className="mb-3 flex items-center gap-2 font-medium">
<HardDrive className="h-4 w-4" />
</div>
<p className="mb-4 text-sm leading-6 text-muted-foreground">
使
</p>
<Button className="w-full" onClick={startLocal}>
</Button>
</div>
<form className="border p-4" onSubmit={startAnonymous}>
<div className="mb-3 flex items-center gap-2 font-medium">
<Cloud className="h-4 w-4" />
</div>
<p className="mb-4 text-sm leading-6 text-muted-foreground">
</p>
<div className="grid gap-2">
<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 className="w-full" disabled={loading || !codeName.trim() || password.length < 4}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Cloud className="h-4 w-4" />}
</Button>
</div>
</form>
</div>
);
}
+8 -1
View File
@@ -1,5 +1,5 @@
'use client';
import { Search } from 'lucide-react';
import { Bot, Search } from 'lucide-react';
import { useState, useCallback } from 'react';
import Link from 'next/link';
@@ -53,6 +53,13 @@ export default function QuestionnaireList() {
<div className="container px-4 py-6 max-w-6xl mx-auto">
<h1 className="text-2xl font-medium mb-6">{t('title')}</h1>
<div className="mb-5 flex gap-3 border bg-muted/30 p-3 text-sm leading-6 text-muted-foreground">
<Bot className="mt-0.5 h-4 w-4 shrink-0 text-foreground" />
<p>
ChatGPT
</p>
</div>
{/* Search bar */}
<div className="mb-4 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -1,9 +1,9 @@
import Link from 'next/link';
import { ReactNode } from 'react';
import { Bot, Copy, Download, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useScopedI18n } from '@/locales/client';
import { Copy, Download, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { Questionnaire } from '@/types';
import { AssessmentRecord } from '@/lib/assessment-types';
import { recordToMarkdown } from '@/lib/assessment-export';
@@ -19,7 +19,25 @@ interface ResultContainerProps {
profileName?: string;
}
export function ResultContainer({ title, id, children, questionnaire, answers, questionnaireResults, record, profileName = '未命名档案' }: ResultContainerProps) {
function aiPrompt() {
return [
'请根据以下测评记录,用中文进行温和、非诊断式解读。',
'请重点分析:主要倾向、可能优势、需要留意的风险、后续可执行建议。',
'如果我明确要求你记住,请只记住适合长期追踪的测评背景和变化,不要记住真实身份信息。',
'注意:这不是医学或心理诊断。',
].join('\n');
}
export function ResultContainer({
title,
id,
children,
questionnaire,
answers,
questionnaireResults,
record,
profileName = '未命名档案',
}: ResultContainerProps) {
const t = useScopedI18n(
'component.questionnaire.result.public.resultContainer'
);
@@ -37,7 +55,7 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
const buildResultMarkdown = () => {
if (record) {
return recordToMarkdown(record, profileName);
return `${aiPrompt()}\n\n${recordToMarkdown(record, profileName)}`;
}
if (!questionnaire || !answers || !questionnaireResults) {
return null;
@@ -45,7 +63,7 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
const currentTime = new Date().toLocaleString();
let resultData = `# ${t('copyTemplate.title')}\n\n`;
let resultData = `${aiPrompt()}\n\n# ${t('copyTemplate.title')}\n\n`;
resultData += `## ${t('copyTemplate.basicInfo')}\n`;
resultData += `- ${t('copyTemplate.questionnaireName')}: ${questionnaire.title}\n`;
resultData += `- ${t('copyTemplate.questionnaireId')}: ${id}\n`;
@@ -96,10 +114,11 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
URL.revokeObjectURL(url);
toast.success(t('downloadResultDataSuccess'));
};
return (
<div className="flex justify-center items-center min-h-screen md:p-4 p-2">
<div className="max-w-6xl w-full bg-white rounded-lg shadow-lg md:p-8 p-4 border">
<h1 className="text-2xl font-bold mb-6">
<div className="flex min-h-screen items-center justify-center p-2 md:p-4">
<div className="w-full max-w-6xl rounded-lg border bg-white p-4 shadow-lg md:p-8">
<h1 className="mb-6 text-2xl font-bold">
{title} - {t('resultText')}
</h1>
@@ -107,29 +126,36 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
<div className="space-y-6">{children}</div>
</div>
<div className="flex flex-col sm:flex-row sm:justify-between gap-4 mt-8">
<div className="mb-6 flex gap-3 border bg-muted/30 p-3 text-sm leading-6 text-muted-foreground">
<Bot className="mt-0.5 h-4 w-4 shrink-0 text-foreground" />
<p>
ChatGPT ChatGPT
</p>
</div>
<div className="mt-8 flex flex-col gap-4 sm:flex-row sm:justify-between">
<Button variant="outline" className="w-full sm:w-auto">
<Link href={`/questionnaire/${id}`}>{t('backToDetail')}</Link>
</Button>
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button variant="outline" onClick={handleCopyResultLink} className="w-full sm:w-auto">
<Copy className="w-4 h-4 mr-2" />
<Copy className="mr-2 h-4 w-4" />
{t('copyResultLink')}
</Button>
<Button
<Button
variant="outline"
onClick={handleCopyResultData}
onClick={handleCopyResultData}
className="w-full sm:w-auto"
>
<FileText className="w-4 h-4 mr-2" />
{t('copyResultData')}
<FileText className="mr-2 h-4 w-4" />
AI
</Button>
<Button
variant="outline"
onClick={handleDownloadResultData}
className="w-full sm:w-auto"
>
<Download className="w-4 h-4 mr-2" />
<Download className="mr-2 h-4 w-4" />
{t('downloadResultData')}
</Button>
<Button asChild className="w-full sm:w-auto">
@@ -14,6 +14,7 @@ import { ProfilePicker } from '@/components/records/ProfilePicker';
import { AssessmentProfile } from '@/lib/assessment-types';
import { addAssessmentRecord, ensureActiveProfile } from '@/lib/assessment-db';
import { buildScoreSummary } from '@/lib/score-summary';
import { syncAnonymousRecord } from '@/lib/anonymous-client';
interface QuestionnaireProps {
questionnaire: QuestionnaireType;
@@ -209,6 +210,9 @@ export function Questionnaire({
retestSuitable: questionnaire.evaluation?.retestSuitable,
recommendedInterval: questionnaire.evaluation?.recommendedInterval,
});
void syncAnonymousRecord(record).catch((error) => {
console.error('Failed to sync anonymous record:', error);
});
saveResult(id, resultAnswers, profile.id, record.id);
router.push(`/questionnaire/${id}/result`);
+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>