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