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
+1
View File
@@ -22,6 +22,7 @@
.DS_Store .DS_Store
*.pem *.pem
story-personality.zip story-personality.zip
/data/
# debug # debug
npm-debug.log* npm-debug.log*
+24 -2
View File
@@ -6,9 +6,29 @@
## 当前版本 ## 当前版本
**v0.3.0**,发布于 2026-06-23。 **v0.5.0**,发布于 2026-06-23。
本次版本专门优化 iPhone、Android 手机和 iPad 的答题体验,桌面端功能保持不变 本次版本升级为加密存储
- 本地测评记录写入 IndexedDB 前会先使用 AES-GCM 加密
- 旧的本地明文记录会在读取后自动迁移为密文记录
- 匿名服务器同步改为浏览器端加密,服务器只保存密文记录
- 匿名档案仍使用“代号 + 恢复口令”,恢复口令不明文保存
- 首页重新设计,明确说明本地加密、匿名加密同步和 AI 解读用途
上一版本新增匿名档案模式,仍然保留本地优先:
本次版本新增匿名档案模式,仍然保留本地优先:
- 可使用“代号 + 恢复口令”进入匿名档案
- 恢复口令只保存哈希,不保存明文
- 可把当前本地档案的测评记录同步到服务器
- 已登录匿名档案时,新完成的测评会自动同步
- 支持删除服务器上的匿名档案与全部远端记录
- 服务端默认使用 `data/anonymous-store.json` 轻量存储,适合自用 VPS 起步
- 修复档案页、首页、导出 Markdown 和分数摘要中的部分中文显示问题
上一版本已优化 iPhone、Android 手机和 iPad 的答题体验,桌面端功能保持不变:
- 中文题目在手机端使用 17px 字号和更宽松行高 - 中文题目在手机端使用 17px 字号和更宽松行高
- 选项整行可点击,触控高度不低于 48px - 选项整行可点击,触控高度不低于 48px
@@ -23,6 +43,8 @@
| 版本 | 日期 | 主要变化 | | 版本 | 日期 | 主要变化 |
| --- | --- | --- | | --- | --- | --- |
| v0.5.0 | 2026-06-23 | 本地记录加密、旧记录自动迁移、匿名同步端到端加密、首页加密说明 |
| v0.4.0 | 2026-06-23 | 匿名档案模式、服务器同步接口、远端记录同步、远端档案删除和部分中文显示修复 |
| v0.3.0 | 2026-06-23 | iOS、手机和平板答题阅读、触控、折叠导航和安全区优化 | | v0.3.0 | 2026-06-23 | iOS、手机和平板答题阅读、触控、折叠导航和安全区优化 |
| v0.2.0 | 2026-06-23 | 多人档案、完整历史、趋势、统一画像、MD/JSON 导出、加密备份及自动测试 | | v0.2.0 | 2026-06-23 | 多人档案、完整历史、趋势、统一画像、MD/JSON 导出、加密备份及自动测试 |
| v0.1.0 | 2026-06-22 | 中文量表列表、浏览器答题、本地计分、草稿保存和单次结果导出 | | v0.1.0 | 2026-06-22 | 中文量表列表、浏览器答题、本地计分、草稿保存和单次结果导出 |
+55 -23
View File
@@ -1,38 +1,70 @@
import Link from 'next/link'; import { Bot, FileText, LockKeyhole, ShieldCheck } from 'lucide-react';
import { ArrowRight, ClipboardList } from 'lucide-react'; import { HomeModeSelector } from '@/components/home/HomeModeSelector';
import { Button } from '@/components/ui/button';
const details = [
{
icon: LockKeyhole,
title: '本地记录加密',
desc: '测评记录写入浏览器 IndexedDB 前会先加密;旧记录读取后会自动迁移为密文。',
},
{
icon: ShieldCheck,
title: '服务器只存密文',
desc: '匿名同步使用代号和恢复口令派生密钥,记录在浏览器加密后再上传。',
},
{
icon: FileText,
title: '完整导出',
desc: '可导出 Markdown / JSON,也可以复制给 ChatGPT 做长期解读。',
},
];
export default function Home() { export default function Home() {
return ( return (
<div className="min-h-[calc(100vh-3.5rem)] border-t bg-background"> <main className="min-h-[calc(100vh-3.5rem)] bg-background">
<section className="container max-w-4xl mx-auto px-4 py-16 md:py-24"> <section className="safe-x mx-auto grid max-w-6xl gap-8 py-8 md:grid-cols-[1fr_380px] md:py-14">
<div className="space-y-8"> <div className="space-y-8">
<div className="space-y-4"> <div className="space-y-4">
<div className="inline-flex h-10 w-10 items-center justify-center rounded border bg-muted"> <h1 className="text-4xl font-semibold leading-tight tracking-normal md:text-5xl">
<ClipboardList className="h-5 w-5" /> MindScope
</div>
<h1 className="text-3xl md:text-5xl font-semibold tracking-normal">
</h1> </h1>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl"> <p className="max-w-2xl text-base leading-7 text-muted-foreground md:text-lg">
使
</p> </p>
</div> </div>
<Button size="lg" asChild> <div className="grid gap-3 md:grid-cols-3">
<Link href="/questionnaire" className="gap-2"> {details.map((item) => (
<div key={item.title} className="border p-4">
<ArrowRight className="h-5 w-5" /> <div className="mb-2 flex items-center gap-2 font-medium">
</Link> <item.icon className="h-4 w-4" />
</Button> {item.title}
</div>
<p className="text-sm leading-6 text-muted-foreground">{item.desc}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-4 text-sm text-muted-foreground"> <div className="border-l-2 border-primary pl-4 text-sm leading-6 text-muted-foreground">
<div className="border rounded p-4"></div>
<div className="border rounded p-4"></div>
<div className="border rounded p-4"></div> </div>
<div className="border bg-muted/30 p-4">
<div className="mb-2 flex items-center gap-2 font-medium">
<Bot className="h-4 w-4" />
AI
</div>
<p className="text-sm leading-6 text-muted-foreground">
ChatGPT
</p>
</div> </div>
</div> </div>
<HomeModeSelector />
</section> </section>
</div> </main>
); );
} }
@@ -13,6 +13,7 @@ import { useScopedI18n } from '@/locales/client';
import { loadResult } from '@/lib/result-storage'; import { loadResult } from '@/lib/result-storage';
import { AssessmentRecord } from '@/lib/assessment-types'; import { AssessmentRecord } from '@/lib/assessment-types';
import { getProfiles, getRecords, updateRecordAnalysis } from '@/lib/assessment-db'; import { getProfiles, getRecords, updateRecordAnalysis } from '@/lib/assessment-db';
import { syncAnonymousRecord } from '@/lib/anonymous-client';
export default function QuestionnaireResultPage({ export default function QuestionnaireResultPage({
params, params,
@@ -58,11 +59,18 @@ export default function QuestionnaireResultPage({
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const text = analysisRef.current?.innerText.trim() || ''; const text = analysisRef.current?.innerText.trim() || '';
if (!text) return; if (!text) return;
setRecord((current) => current ? { ...current, analysisText: text } : current); if (record?.analysisText === text) return;
const updated = record ? { ...record, analysisText: text } : null;
setRecord(updated);
void updateRecordAnalysis(recordId, text); void updateRecordAnalysis(recordId, text);
if (updated) {
void syncAnonymousRecord(updated).catch((error) => {
console.error('Failed to sync anonymous record analysis:', error);
});
}
}, 100); }, 100);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [recordId]); }, [record, recordId]);
// Construct question-option text pairs for copying result data // Construct question-option text pairs for copying result data
const questionnaireResults: Record<string, string> = useMemo(() => { const questionnaireResults: Record<string, string> = useMemo(() => {
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import {
AnonymousStoreError,
deleteAnonymousProfile,
loginAnonymousProfile,
} from '@/lib/anonymous-store';
export const runtime = 'nodejs';
async function readCredentials(request: Request) {
const body = await request.json().catch(() => ({}));
return {
codeName: String(body.codeName || ''),
password: String(body.password || ''),
};
}
function errorResponse(error: unknown) {
if (error instanceof AnonymousStoreError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error(error);
return NextResponse.json({ error: '匿名档案服务暂时不可用' }, { status: 500 });
}
export async function POST(request: Request) {
try {
const { codeName, password } = await readCredentials(request);
return NextResponse.json(await loginAnonymousProfile(codeName, password));
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
const { codeName, password } = await readCredentials(request);
return NextResponse.json(await deleteAnonymousProfile(codeName, password));
} catch (error) {
return errorResponse(error);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { AnonymousStoreError, addAnonymousRecord } from '@/lib/anonymous-store';
import { EncryptedAssessmentRecord } from '@/lib/assessment-types';
export const runtime = 'nodejs';
function errorResponse(error: unknown) {
if (error instanceof AnonymousStoreError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error(error);
return NextResponse.json({ error: '匿名档案服务暂时不可用' }, { status: 500 });
}
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => ({}));
const encryptedRecord = body.encryptedRecord as EncryptedAssessmentRecord | undefined;
if (
!encryptedRecord?.questionnaireId ||
encryptedRecord.encrypted !== true ||
encryptedRecord.payload?.format !== 'mindscope-encrypted-record'
) {
return NextResponse.json({ error: '加密测评记录格式不完整' }, { status: 400 });
}
const saved = await addAnonymousRecord(
String(body.codeName || ''),
String(body.password || ''),
encryptedRecord,
);
return NextResponse.json({ record: saved });
} catch (error) {
return errorResponse(error);
}
}
+5 -9
View File
@@ -12,8 +12,8 @@ export function Navbar() {
return ( return (
<header className="border-b"> <header className="border-b">
<div className="safe-x container mx-auto flex h-14 max-w-6xl items-center justify-between"> <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"> <Link href="/" className="flex items-center gap-2 text-lg font-medium">
<span className="w-8 h-8 border rounded flex items-center justify-center"> <span className="flex h-8 w-8 items-center justify-center rounded border">
<ClipboardList className="h-4 w-4" /> <ClipboardList className="h-4 w-4" />
</span> </span>
<span className="hidden md:block">{t('title')}</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"> <nav className="flex items-center gap-4 text-sm">
<Link <Link
href="/" href="/"
className="font-medium hover:text-foreground transition-colors" className={`${pathname === '/' ? 'font-medium' : 'text-muted-foreground'} transition-colors hover:text-foreground`}
> >
{t('introduce')} {t('introduce')}
</Link> </Link>
<Link <Link
href="/questionnaire" href="/questionnaire"
className={`${ className={`${pathname.startsWith('/questionnaire') ? 'font-medium' : 'text-muted-foreground'} transition-colors hover:text-foreground`}
pathname.startsWith('/questionnaire')
? 'font-medium'
: 'text-muted-foreground'
} hover:text-foreground transition-colors`}
> >
{t('questionsList')} {t('questionsList')}
</Link> </Link>
<Link <Link
href="/records" 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" /> <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'; 'use client';
import { Search } from 'lucide-react'; import { Bot, Search } from 'lucide-react';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import Link from 'next/link'; 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"> <div className="container px-4 py-6 max-w-6xl mx-auto">
<h1 className="text-2xl font-medium mb-6">{t('title')}</h1> <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 */} {/* Search bar */}
<div className="mb-4 relative"> <div className="mb-4 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <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 Link from 'next/link';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Bot, Copy, Download, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useScopedI18n } from '@/locales/client'; import { useScopedI18n } from '@/locales/client';
import { Copy, Download, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { Questionnaire } from '@/types'; import { Questionnaire } from '@/types';
import { AssessmentRecord } from '@/lib/assessment-types'; import { AssessmentRecord } from '@/lib/assessment-types';
import { recordToMarkdown } from '@/lib/assessment-export'; import { recordToMarkdown } from '@/lib/assessment-export';
@@ -19,7 +19,25 @@ interface ResultContainerProps {
profileName?: string; 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( const t = useScopedI18n(
'component.questionnaire.result.public.resultContainer' 'component.questionnaire.result.public.resultContainer'
); );
@@ -37,7 +55,7 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
const buildResultMarkdown = () => { const buildResultMarkdown = () => {
if (record) { if (record) {
return recordToMarkdown(record, profileName); return `${aiPrompt()}\n\n${recordToMarkdown(record, profileName)}`;
} }
if (!questionnaire || !answers || !questionnaireResults) { if (!questionnaire || !answers || !questionnaireResults) {
return null; return null;
@@ -45,7 +63,7 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
const currentTime = new Date().toLocaleString(); 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.basicInfo')}\n`;
resultData += `- ${t('copyTemplate.questionnaireName')}: ${questionnaire.title}\n`; resultData += `- ${t('copyTemplate.questionnaireName')}: ${questionnaire.title}\n`;
resultData += `- ${t('copyTemplate.questionnaireId')}: ${id}\n`; resultData += `- ${t('copyTemplate.questionnaireId')}: ${id}\n`;
@@ -96,10 +114,11 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
toast.success(t('downloadResultDataSuccess')); toast.success(t('downloadResultDataSuccess'));
}; };
return ( return (
<div className="flex justify-center items-center min-h-screen md:p-4 p-2"> <div className="flex min-h-screen items-center justify-center p-2 md:p-4">
<div className="max-w-6xl w-full bg-white rounded-lg shadow-lg md:p-8 p-4 border"> <div className="w-full max-w-6xl rounded-lg border bg-white p-4 shadow-lg md:p-8">
<h1 className="text-2xl font-bold mb-6"> <h1 className="mb-6 text-2xl font-bold">
{title} - {t('resultText')} {title} - {t('resultText')}
</h1> </h1>
@@ -107,29 +126,36 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
<div className="space-y-6">{children}</div> <div className="space-y-6">{children}</div>
</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"> <Button variant="outline" className="w-full sm:w-auto">
<Link href={`/questionnaire/${id}`}>{t('backToDetail')}</Link> <Link href={`/questionnaire/${id}`}>{t('backToDetail')}</Link>
</Button> </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"> <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')} {t('copyResultLink')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={handleCopyResultData} onClick={handleCopyResultData}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<FileText className="w-4 h-4 mr-2" /> <FileText className="mr-2 h-4 w-4" />
{t('copyResultData')} AI
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={handleDownloadResultData} onClick={handleDownloadResultData}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<Download className="w-4 h-4 mr-2" /> <Download className="mr-2 h-4 w-4" />
{t('downloadResultData')} {t('downloadResultData')}
</Button> </Button>
<Button asChild className="w-full sm:w-auto"> <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 { AssessmentProfile } from '@/lib/assessment-types';
import { addAssessmentRecord, ensureActiveProfile } from '@/lib/assessment-db'; import { addAssessmentRecord, ensureActiveProfile } from '@/lib/assessment-db';
import { buildScoreSummary } from '@/lib/score-summary'; import { buildScoreSummary } from '@/lib/score-summary';
import { syncAnonymousRecord } from '@/lib/anonymous-client';
interface QuestionnaireProps { interface QuestionnaireProps {
questionnaire: QuestionnaireType; questionnaire: QuestionnaireType;
@@ -209,6 +210,9 @@ export function Questionnaire({
retestSuitable: questionnaire.evaluation?.retestSuitable, retestSuitable: questionnaire.evaluation?.retestSuitable,
recommendedInterval: questionnaire.evaluation?.recommendedInterval, recommendedInterval: questionnaire.evaluation?.recommendedInterval,
}); });
void syncAnonymousRecord(record).catch((error) => {
console.error('Failed to sync anonymous record:', error);
});
saveResult(id, resultAnswers, profile.id, record.id); saveResult(id, resultAnswers, profile.id, record.id);
router.push(`/questionnaire/${id}/result`); 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'; } from '@/lib/assessment-db';
import { downloadText, profileToMarkdown, recordToMarkdown } from '@/lib/assessment-export'; import { downloadText, profileToMarkdown, recordToMarkdown } from '@/lib/assessment-export';
import { decryptBackup, encryptBackup } from '@/lib/backup-crypto'; import { decryptBackup, encryptBackup } from '@/lib/backup-crypto';
import { AnonymousSyncPanel } from '@/components/records/AnonymousSyncPanel';
type View = 'history' | 'trends' | 'portrait'; type View = 'history' | 'trends' | 'portrait';
@@ -205,6 +206,8 @@ export function RecordsDashboard() {
</div> </div>
</section> </section>
<AnonymousSyncPanel localRecords={records} />
<div className="mb-6 flex border-b" role="tablist" aria-label="档案视图"> <div className="mb-6 flex border-b" role="tablist" aria-label="档案视图">
{([['history', '历史记录'], ['trends', '变化趋势'], ['portrait', '统一画像']] as const).map(([id, 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> <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>
+105
View File
@@ -0,0 +1,105 @@
'use client';
import { AssessmentRecord, EncryptedAssessmentRecord } from '@/lib/assessment-types';
import {
decryptAnonymousRecord,
encryptRecordForAnonymousProfile,
} from '@/lib/record-crypto';
const SESSION_KEY = 'mindscope_anonymous_session';
export interface AnonymousSession {
codeName: string;
password: string;
}
export interface AnonymousProfile {
id: string;
codeName: string;
createdAt: string;
updatedAt: string;
lastSeenAt: string;
}
interface AnonymousEncryptedLoginResult {
profile: AnonymousProfile;
records: EncryptedAssessmentRecord[];
}
export interface AnonymousLoginResult {
profile: AnonymousProfile;
records: AssessmentRecord[];
}
async function parseResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(typeof data.error === 'string' ? data.error : '请求失败');
}
return data as T;
}
export function getAnonymousSession(): AnonymousSession | null {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AnonymousSession>;
return parsed.codeName && parsed.password
? { codeName: parsed.codeName, password: parsed.password }
: null;
} catch {
return null;
}
}
export function saveAnonymousSession(session: AnonymousSession) {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
}
export function clearAnonymousSession() {
sessionStorage.removeItem(SESSION_KEY);
}
async function decryptLoginResult(
encryptedResult: AnonymousEncryptedLoginResult,
session: AnonymousSession,
): Promise<AnonymousLoginResult> {
const records = await Promise.all(
encryptedResult.records.map((record) => decryptAnonymousRecord(record, session.codeName, session.password)),
);
return {
profile: encryptedResult.profile,
records: records.sort((a, b) => b.completedAt.localeCompare(a.completedAt)),
};
}
export async function loginAnonymousProfile(session: AnonymousSession) {
const encryptedResult = await fetch('/api/anonymous/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(session),
}).then((response) => parseResponse<AnonymousEncryptedLoginResult>(response));
saveAnonymousSession(session);
return decryptLoginResult(encryptedResult, session);
}
export async function syncAnonymousRecord(record: AssessmentRecord, session = getAnonymousSession()) {
if (!session) return null;
const encryptedRecord = await encryptRecordForAnonymousProfile(record, session.codeName, session.password);
const result = await fetch('/api/anonymous/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...session, encryptedRecord }),
}).then((response) => parseResponse<{ record: EncryptedAssessmentRecord }>(response));
return decryptAnonymousRecord(result.record, session.codeName, session.password);
}
export async function deleteAnonymousProfile(session: AnonymousSession) {
const result = await fetch('/api/anonymous/profile', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(session),
}).then((response) => parseResponse<{ ok: boolean }>(response));
clearAnonymousSession();
return result;
}
+185
View File
@@ -0,0 +1,185 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { pbkdf2Sync, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { EncryptedAssessmentRecord } from '@/lib/assessment-types';
const STORE_PATH = path.join(process.cwd(), 'data', 'anonymous-store.json');
const HASH_ITERATIONS = 120_000;
const KEY_LENGTH = 32;
export interface AnonymousProfile {
id: string;
codeName: string;
passwordHash: string;
salt: string;
createdAt: string;
updatedAt: string;
lastSeenAt: string;
}
export interface PublicAnonymousProfile {
id: string;
codeName: string;
createdAt: string;
updatedAt: string;
lastSeenAt: string;
}
interface AnonymousStore {
version: 2;
profiles: AnonymousProfile[];
records: EncryptedAssessmentRecord[];
}
export class AnonymousStoreError extends Error {
constructor(
message: string,
public status = 400,
) {
super(message);
}
}
function normalizeCodeName(codeName: string) {
return codeName.trim().replace(/\s+/g, ' ').slice(0, 40);
}
function assertCredentials(codeName: string, password: string) {
const normalized = normalizeCodeName(codeName);
if (normalized.length < 2) {
throw new AnonymousStoreError('代号至少需要 2 个字符');
}
if (password.length < 4 || password.length > 128) {
throw new AnonymousStoreError('恢复口令需要 4 到 128 个字符');
}
return normalized;
}
function hashPassword(password: string, salt: string) {
return pbkdf2Sync(password, salt, HASH_ITERATIONS, KEY_LENGTH, 'sha256').toString('hex');
}
function verifyPassword(password: string, profile: AnonymousProfile) {
const expected = Buffer.from(profile.passwordHash, 'hex');
const actual = Buffer.from(hashPassword(password, profile.salt), 'hex');
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
function toPublicProfile(profile: AnonymousProfile): PublicAnonymousProfile {
return {
id: profile.id,
codeName: profile.codeName,
createdAt: profile.createdAt,
updatedAt: profile.updatedAt,
lastSeenAt: profile.lastSeenAt,
};
}
async function loadStore(): Promise<AnonymousStore> {
try {
const raw = await readFile(STORE_PATH, 'utf8');
const parsed = JSON.parse(raw) as Partial<AnonymousStore>;
return {
version: 2,
profiles: Array.isArray(parsed.profiles) ? parsed.profiles : [],
records: Array.isArray(parsed.records)
? parsed.records.filter((record) => record?.encrypted === true)
: [],
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { version: 2, profiles: [], records: [] };
}
throw error;
}
}
async function saveStore(store: AnonymousStore) {
await mkdir(path.dirname(STORE_PATH), { recursive: true });
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tempPath, JSON.stringify(store, null, 2), 'utf8');
await rename(tempPath, STORE_PATH);
}
async function withStore<T>(callback: (store: AnonymousStore) => Promise<T> | T) {
const store = await loadStore();
const result = await callback(store);
await saveStore(store);
return result;
}
export async function loginAnonymousProfile(codeName: string, password: string) {
const normalized = assertCredentials(codeName, password);
return withStore((store) => {
const now = new Date().toISOString();
const existing = store.profiles.find(
(profile) => profile.codeName.toLowerCase() === normalized.toLowerCase(),
);
if (existing) {
if (!verifyPassword(password, existing)) {
throw new AnonymousStoreError('代号或恢复口令不正确', 401);
}
existing.lastSeenAt = now;
existing.updatedAt = now;
return {
profile: toPublicProfile(existing),
records: store.records
.filter((record) => record.profileId === existing.id)
.sort((a, b) => b.completedAt.localeCompare(a.completedAt)),
};
}
const salt = randomBytes(16).toString('hex');
const profile: AnonymousProfile = {
id: `anon_${randomUUID()}`,
codeName: normalized,
passwordHash: hashPassword(password, salt),
salt,
createdAt: now,
updatedAt: now,
lastSeenAt: now,
};
store.profiles.push(profile);
return { profile: toPublicProfile(profile), records: [] };
});
}
export async function addAnonymousRecord(
codeName: string,
password: string,
encryptedRecord: EncryptedAssessmentRecord,
) {
const { profile } = await loginAnonymousProfile(codeName, password);
return withStore((store) => {
const now = new Date().toISOString();
const serverRecord: EncryptedAssessmentRecord = {
...encryptedRecord,
id: encryptedRecord.id.startsWith('server_') ? encryptedRecord.id : `server_${encryptedRecord.id}`,
profileId: profile.id,
};
const existingIndex = store.records.findIndex(
(item) => item.id === serverRecord.id && item.profileId === profile.id,
);
if (existingIndex >= 0) {
store.records[existingIndex] = serverRecord;
} else {
store.records.push(serverRecord);
}
const owner = store.profiles.find((item) => item.id === profile.id);
if (owner) {
owner.updatedAt = now;
owner.lastSeenAt = now;
}
return serverRecord;
});
}
export async function deleteAnonymousProfile(codeName: string, password: string) {
const { profile } = await loginAnonymousProfile(codeName, password);
return withStore((store) => {
store.profiles = store.profiles.filter((item) => item.id !== profile.id);
store.records = store.records.filter((item) => item.profileId !== profile.id);
return { ok: true };
});
}
+34 -6
View File
@@ -1,8 +1,15 @@
import { import {
AssessmentProfile, AssessmentProfile,
AssessmentRecord, AssessmentRecord,
EncryptedAssessmentRecord,
MindScopeBackup, MindScopeBackup,
} from '@/lib/assessment-types'; } from '@/lib/assessment-types';
import {
clearDeviceRecordKey,
decryptDeviceRecord,
encryptRecordForDevice,
isEncryptedRecord,
} from '@/lib/record-crypto';
const DB_NAME = 'mindscope'; const DB_NAME = 'mindscope';
const DB_VERSION = 1; const DB_VERSION = 1;
@@ -87,7 +94,7 @@ export async function ensureActiveProfile(): Promise<AssessmentProfile> {
export async function renameProfile(profileId: string, name: string) { export async function renameProfile(profileId: string, name: string) {
const db = await openDatabase(); const db = await openDatabase();
const store = db.transaction(PROFILE_STORE, 'readwrite').objectStore(PROFILE_STORE); const store = db.transaction(PROFILE_STORE, 'readwrite').objectStore(PROFILE_STORE);
const profile = await requestResult(store.get(profileId)); const profile = await requestResult<AssessmentProfile | undefined>(store.get(profileId));
if (profile) { if (profile) {
await requestResult( await requestResult(
store.put({ ...profile, name: name.trim(), updatedAt: new Date().toISOString() }), store.put({ ...profile, name: name.trim(), updatedAt: new Date().toISOString() }),
@@ -114,9 +121,10 @@ export async function addAssessmentRecord(
record: Omit<AssessmentRecord, 'id'>, record: Omit<AssessmentRecord, 'id'>,
): Promise<AssessmentRecord> { ): Promise<AssessmentRecord> {
const completeRecord = { ...record, id: createId('record') }; const completeRecord = { ...record, id: createId('record') };
const encrypted = await encryptRecordForDevice(completeRecord);
const db = await openDatabase(); const db = await openDatabase();
await requestResult( await requestResult(
db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE).add(completeRecord), db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE).add(encrypted),
); );
db.close(); db.close();
return completeRecord; return completeRecord;
@@ -125,9 +133,10 @@ export async function addAssessmentRecord(
export async function updateRecordAnalysis(recordId: string, analysisText: string) { export async function updateRecordAnalysis(recordId: string, analysisText: string) {
const db = await openDatabase(); const db = await openDatabase();
const store = db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE); const store = db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE);
const record = await requestResult(store.get(recordId)); const stored = await requestResult<AssessmentRecord | EncryptedAssessmentRecord | undefined>(store.get(recordId));
const record = stored ? await decryptDeviceRecord(stored) : undefined;
if (record && record.analysisText !== analysisText) { if (record && record.analysisText !== analysisText) {
await requestResult(store.put({ ...record, analysisText })); await requestResult(store.put(await encryptRecordForDevice({ ...record, analysisText })));
} }
db.close(); db.close();
} }
@@ -135,13 +144,29 @@ export async function updateRecordAnalysis(recordId: string, analysisText: strin
export async function getRecords(profileId?: string): Promise<AssessmentRecord[]> { export async function getRecords(profileId?: string): Promise<AssessmentRecord[]> {
const db = await openDatabase(); const db = await openDatabase();
const store = db.transaction(RECORD_STORE, 'readonly').objectStore(RECORD_STORE); const store = db.transaction(RECORD_STORE, 'readonly').objectStore(RECORD_STORE);
const records = profileId const storedRecords = profileId
? await requestResult(store.index('profileId').getAll(profileId)) ? await requestResult(store.index('profileId').getAll(profileId))
: await requestResult(store.getAll()); : await requestResult(store.getAll());
db.close(); db.close();
const typedRecords = storedRecords as Array<AssessmentRecord | EncryptedAssessmentRecord>;
const records = await Promise.all(typedRecords.map(decryptDeviceRecord));
const plaintextRecords = typedRecords.filter((record) => !isEncryptedRecord(record)) as AssessmentRecord[];
if (plaintextRecords.length) {
await migratePlaintextRecords(plaintextRecords);
}
return records.sort((a, b) => b.completedAt.localeCompare(a.completedAt)); return records.sort((a, b) => b.completedAt.localeCompare(a.completedAt));
} }
async function migratePlaintextRecords(records: AssessmentRecord[]) {
const db = await openDatabase();
const store = db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE);
for (const record of records) {
await requestResult(store.put(await encryptRecordForDevice(record)));
}
db.close();
}
export async function deleteRecord(recordId: string) { export async function deleteRecord(recordId: string) {
const db = await openDatabase(); const db = await openDatabase();
await requestResult( await requestResult(
@@ -167,7 +192,9 @@ export async function importBackup(backup: MindScopeBackup) {
const db = await openDatabase(); const db = await openDatabase();
const transaction = db.transaction([PROFILE_STORE, RECORD_STORE], 'readwrite'); const transaction = db.transaction([PROFILE_STORE, RECORD_STORE], 'readwrite');
backup.profiles.forEach((profile) => transaction.objectStore(PROFILE_STORE).put(profile)); backup.profiles.forEach((profile) => transaction.objectStore(PROFILE_STORE).put(profile));
backup.records.forEach((record) => transaction.objectStore(RECORD_STORE).put(record)); for (const record of backup.records) {
transaction.objectStore(RECORD_STORE).put(await encryptRecordForDevice(record));
}
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => resolve(); transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);
@@ -186,4 +213,5 @@ export async function clearAllAssessmentData() {
}); });
db.close(); db.close();
localStorage.removeItem(ACTIVE_PROFILE_KEY); localStorage.removeItem(ACTIVE_PROFILE_KEY);
clearDeviceRecordKey();
} }
+23
View File
@@ -40,6 +40,29 @@ export interface AssessmentRecord {
recommendedInterval?: '1年' | '3个月' | '一次即可'; recommendedInterval?: '1年' | '3个月' | '一次即可';
} }
export interface EncryptedRecordPayload {
format: 'mindscope-encrypted-record';
version: 1;
algorithm: 'AES-GCM';
keyScope: 'device' | 'anonymous-profile';
kdf?: 'PBKDF2-SHA256';
iterations?: number;
salt?: string;
iv: string;
data: string;
}
export interface EncryptedAssessmentRecord {
id: string;
profileId: string;
questionnaireId: string;
questionnaireTitle: string;
category: string;
completedAt: string;
encrypted: true;
payload: EncryptedRecordPayload;
}
export interface EncryptedMindScopeBackup { export interface EncryptedMindScopeBackup {
format: 'mindscope-encrypted-backup'; format: 'mindscope-encrypted-backup';
version: 1; version: 1;
+169
View File
@@ -0,0 +1,169 @@
'use client';
import {
AssessmentRecord,
EncryptedAssessmentRecord,
EncryptedRecordPayload,
} from '@/lib/assessment-types';
const DEVICE_KEY_STORAGE = 'mindscope_device_record_key';
const ANONYMOUS_ITERATIONS = 250_000;
function toBase64(bytes: Uint8Array) {
let binary = '';
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return btoa(binary);
}
function fromBase64(value: string) {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
async function importAesKey(raw: Uint8Array) {
return crypto.subtle.importKey(
'raw',
toArrayBuffer(raw),
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt'],
);
}
async function getDeviceKey() {
let raw = localStorage.getItem(DEVICE_KEY_STORAGE);
if (!raw) {
const bytes = crypto.getRandomValues(new Uint8Array(32));
raw = toBase64(bytes);
localStorage.setItem(DEVICE_KEY_STORAGE, raw);
}
return importAesKey(fromBase64(raw));
}
async function deriveAnonymousKey(codeName: string, password: string, salt: Uint8Array, iterations = ANONYMOUS_ITERATIONS) {
const material = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(`${codeName.trim().toLowerCase()}:${password}`),
'PBKDF2',
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: toArrayBuffer(salt), iterations },
material,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
async function encryptWithKey(
record: AssessmentRecord,
key: CryptoKey,
keyScope: EncryptedRecordPayload['keyScope'],
extra?: Partial<EncryptedRecordPayload>,
) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: toArrayBuffer(iv) },
key,
new TextEncoder().encode(JSON.stringify(record)),
);
return {
format: 'mindscope-encrypted-record',
version: 1,
algorithm: 'AES-GCM',
keyScope,
...extra,
iv: toBase64(iv),
data: toBase64(new Uint8Array(encrypted)),
} satisfies EncryptedRecordPayload;
}
async function decryptWithKey(payload: EncryptedRecordPayload, key: CryptoKey) {
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: toArrayBuffer(fromBase64(payload.iv)) },
key,
toArrayBuffer(fromBase64(payload.data)),
);
return JSON.parse(new TextDecoder().decode(decrypted)) as AssessmentRecord;
}
export function isEncryptedRecord(value: unknown): value is EncryptedAssessmentRecord {
return Boolean(
value &&
typeof value === 'object' &&
(value as EncryptedAssessmentRecord).encrypted === true &&
(value as EncryptedAssessmentRecord).payload?.format === 'mindscope-encrypted-record',
);
}
export async function encryptRecordForDevice(record: AssessmentRecord): Promise<EncryptedAssessmentRecord> {
const key = await getDeviceKey();
return {
id: record.id,
profileId: record.profileId,
questionnaireId: record.questionnaireId,
questionnaireTitle: record.questionnaireTitle,
category: record.category,
completedAt: record.completedAt,
encrypted: true,
payload: await encryptWithKey(record, key, 'device'),
};
}
export async function decryptDeviceRecord(record: AssessmentRecord | EncryptedAssessmentRecord): Promise<AssessmentRecord> {
if (!isEncryptedRecord(record)) return record;
const key = await getDeviceKey();
return decryptWithKey(record.payload, key);
}
export async function encryptRecordForAnonymousProfile(
record: AssessmentRecord,
codeName: string,
password: string,
): Promise<EncryptedAssessmentRecord> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveAnonymousKey(codeName, password, salt);
return {
id: record.id.startsWith('server_') ? record.id : `server_${record.id}`,
profileId: record.profileId,
questionnaireId: record.questionnaireId,
questionnaireTitle: record.questionnaireTitle,
category: record.category,
completedAt: record.completedAt,
encrypted: true,
payload: await encryptWithKey(record, key, 'anonymous-profile', {
kdf: 'PBKDF2-SHA256',
iterations: ANONYMOUS_ITERATIONS,
salt: toBase64(salt),
}),
};
}
export async function decryptAnonymousRecord(
record: EncryptedAssessmentRecord,
codeName: string,
password: string,
): Promise<AssessmentRecord> {
if (record.payload.keyScope !== 'anonymous-profile' || !record.payload.salt || !record.payload.iterations) {
throw new Error('远端记录加密格式不正确');
}
const key = await deriveAnonymousKey(codeName, password, fromBase64(record.payload.salt), record.payload.iterations);
const decrypted = await decryptWithKey(record.payload, key);
return {
...decrypted,
id: record.id,
profileId: record.profileId,
};
}
export function clearDeviceRecordKey() {
localStorage.removeItem(DEVICE_KEY_STORAGE);
}
+60 -10
View File
@@ -12,13 +12,25 @@ import {
} from '@/components/questionnaire/test/private/SchwartzCalculator'; } from '@/components/questionnaire/test/private/SchwartzCalculator';
const levels: Record<string, string> = { const levels: Record<string, string> = {
minimal: '极轻或无', mild: '轻度', moderate: '中度', moderately_severe: '中重度', minimal: '极轻或无',
severe: '重度', normal: '正常范围', extremely_severe: '极重度', low: '较低', mild: '轻度',
high: '较高', subthreshold: '亚阈值', no_insomnia: '无明显失眠', moderate: '中度',
moderately_severe: '中重度',
severe: '重度',
normal: '正常范围',
extremely_severe: '极重度',
low: '较低',
high: '较高',
subthreshold: '亚阈值',
no_insomnia: '无明显失眠',
}; };
const metric = (key: string, label: string, value: number, max?: number, level?: string): ScoreMetric => ({ const metric = (key: string, label: string, value: number, max?: number, level?: string): ScoreMetric => ({
key, label, value, max, level: level ? levels[level] || level : undefined, key,
label,
value,
max,
level: level ? levels[level] || level : undefined,
}); });
function sum(answers: string[]) { function sum(answers: string[]) {
@@ -36,24 +48,50 @@ function severity(score: number, thresholds: Array<[number, string]>) {
export function buildScoreSummary(questionnaireId: string, answers: string[]): ScoreSummary { export function buildScoreSummary(questionnaireId: string, answers: string[]): ScoreSummary {
if (questionnaireId === 'bigfive') { if (questionnaireId === 'bigfive') {
const result = calculateBigFiveResults(answers); const result = calculateBigFiveResults(answers);
const names: Record<string, string> = { extraversion: '外向性', agreeableness: '宜人性', conscientiousness: '尽责性', emotionalStability: '情绪稳定性', openness: '开放性' }; const names: Record<string, string> = {
extraversion: '外向性',
agreeableness: '宜人性',
conscientiousness: '尽责性',
emotionalStability: '情绪稳定性',
openness: '开放性',
};
return { metrics: Object.entries(result).map(([key, value]) => metric(key, names[key], value.score, 50)) }; return { metrics: Object.entries(result).map(([key, value]) => metric(key, names[key], value.score, 50)) };
} }
if (questionnaireId === 'bigfive-120' || questionnaireId === 'bigfive-300') { if (questionnaireId === 'bigfive-120' || questionnaireId === 'bigfive-300') {
const version = questionnaireId === 'bigfive-120' ? 120 : 300; const version = questionnaireId === 'bigfive-120' ? 120 : 300;
const result = calculateIpipNeoResults(answers, [...ipipNeoItemsByVersion[version]]); const result = calculateIpipNeoResults(answers, [...ipipNeoItemsByVersion[version]]);
const names: Record<string, string> = { neuroticism: '神经质', extraversion: '外向性', openness: '开放性', agreeableness: '宜人性', conscientiousness: '尽责性' }; const names: Record<string, string> = {
neuroticism: '神经质',
extraversion: '外向性',
openness: '开放性',
agreeableness: '宜人性',
conscientiousness: '尽责性',
};
const max = version === 120 ? 120 : 300; const max = version === 120 ? 120 : 300;
return { metrics: Object.entries(result.domains).map(([key, value]) => metric(key, names[key], value.score, max)) }; return { metrics: Object.entries(result.domains).map(([key, value]) => metric(key, names[key], value.score, max)) };
} }
if (questionnaireId === 'hexaco') { if (questionnaireId === 'hexaco') {
const names: Record<string, string> = { honestyHumility: '诚实谦逊', emotionality: '情绪性', extraversion: '外向性', agreeableness: '宜人性', conscientiousness: '尽责性', openness: '开放性' }; const names: Record<string, string> = {
honestyHumility: '诚实谦逊',
emotionality: '情绪性',
extraversion: '外向性',
agreeableness: '宜人性',
conscientiousness: '尽责性',
openness: '开放性',
};
return { metrics: Object.entries(calculateHEXACOResults(answers)).map(([key, value]) => metric(key, names[key], Number(value.toFixed(2)), 5)) }; return { metrics: Object.entries(calculateHEXACOResults(answers)).map(([key, value]) => metric(key, names[key], Number(value.toFixed(2)), 5)) };
} }
if (questionnaireId === 'riasec') { if (questionnaireId === 'riasec') {
const result = calculateRIASECResults(answers); const result = calculateRIASECResults(answers);
return { primary: metric('holland', `霍兰德代码 ${result.hollandCode}`, result.ranking[0][1].score, 40), metrics: Object.entries(result.scores).map(([key, value]) => metric(key, riasecTypes[key as keyof typeof riasecTypes].name, value.score, 40)) }; return {
primary: metric('holland', `霍兰德代码 ${result.hollandCode}`, result.ranking[0][1].score, 40),
metrics: Object.entries(result.scores).map(([key, value]) => metric(key, riasecTypes[key as keyof typeof riasecTypes].name, value.score, 40)),
};
} }
if (questionnaireId === 'schwartz') { if (questionnaireId === 'schwartz') {
const result = calculateSchwartzResults(answers); const result = calculateSchwartzResults(answers);
return { metrics: Object.entries(result.higherOrderScores).map(([key, value]) => metric(key, higherOrderNames[key as keyof typeof higherOrderNames], Number(value.toFixed(2)), 5)) }; return { metrics: Object.entries(result.higherOrderScores).map(([key, value]) => metric(key, higherOrderNames[key as keyof typeof higherOrderNames], Number(value.toFixed(2)), 5)) };
@@ -67,22 +105,30 @@ export function buildScoreSummary(questionnaireId: string, answers: string[]): S
bdi2: { label: 'BDI-II 总分', max: 63, level: severity(raw, [[0, 'minimal'], [14, 'mild'], [20, 'moderate'], [29, 'severe']]) }, bdi2: { label: 'BDI-II 总分', max: 63, level: severity(raw, [[0, 'minimal'], [14, 'mild'], [20, 'moderate'], [29, 'severe']]) },
who5: { label: 'WHO-5 百分制得分', max: 100, score: raw * 4 }, who5: { label: 'WHO-5 百分制得分', max: 100, score: raw * 4 },
}; };
if (single[questionnaireId]) { if (single[questionnaireId]) {
const item = single[questionnaireId]; const item = single[questionnaireId];
const primary = metric('total', item.label, item.score ?? raw, item.max, item.level); const primary = metric('total', item.label, item.score ?? raw, item.max, item.level);
return { primary, metrics: [primary] }; return { primary, metrics: [primary] };
} }
if (questionnaireId === 'dass21') { if (questionnaireId === 'dass21') {
const groups = { depression: [3, 5, 10, 13, 16, 17, 21], anxiety: [2, 4, 7, 9, 15, 19, 20], stress: [1, 6, 8, 11, 12, 14, 18] }; const groups = {
depression: [3, 5, 10, 13, 16, 17, 21],
anxiety: [2, 4, 7, 9, 15, 19, 20],
stress: [1, 6, 8, 11, 12, 14, 18],
};
const names = { depression: '抑郁', anxiety: '焦虑', stress: '压力' }; const names = { depression: '抑郁', anxiety: '焦虑', stress: '压力' };
return { metrics: Object.entries(groups).map(([key, ids]) => metric(key, names[key as keyof typeof names], ids.reduce((total, id) => total + Number(keyed(answers)[id] || 0), 0) * 2, 42)) }; return { metrics: Object.entries(groups).map(([key, ids]) => metric(key, names[key as keyof typeof names], ids.reduce((total, id) => total + Number(keyed(answers)[id] || 0), 0) * 2, 42)) };
} }
if (questionnaireId === 'pss10') { if (questionnaireId === 'pss10') {
const reverse = new Set([4, 5, 7, 8]); const reverse = new Set([4, 5, 7, 8]);
const score = answers.reduce((total, answer, index) => total + (reverse.has(index + 1) ? 4 - Number(answer) : Number(answer)), 0); const score = answers.reduce((total, answer, index) => total + (reverse.has(index + 1) ? 4 - Number(answer) : Number(answer)), 0);
const primary = metric('total', 'PSS-10 总分', score, 40, severity(score, [[0, 'low'], [14, 'moderate'], [27, 'high']])); const primary = metric('total', 'PSS-10 总分', score, 40, severity(score, [[0, 'low'], [14, 'moderate'], [27, 'high']]));
return { primary, metrics: [primary] }; return { primary, metrics: [primary] };
} }
if (questionnaireId === 'sds') { if (questionnaireId === 'sds') {
const reverse = new Set([2, 5, 6, 11, 12, 14, 16, 17, 18, 20]); const reverse = new Set([2, 5, 6, 11, 12, 14, 16, 17, 18, 20]);
const original = answers.reduce((total, answer, index) => total + (reverse.has(index + 1) ? 5 - Number(answer) : Number(answer)), 0); const original = answers.reduce((total, answer, index) => total + (reverse.has(index + 1) ? 5 - Number(answer) : Number(answer)), 0);
@@ -92,5 +138,9 @@ export function buildScoreSummary(questionnaireId: string, answers: string[]): S
} }
const primary = metric('raw', '原始作答总和', raw); const primary = metric('raw', '原始作答总和', raw);
return { primary, metrics: [primary], note: '该数值仅用于保存和核对作答,不替代量表结果页中的正式解释。' }; return {
primary,
metrics: [primary],
note: '该数值仅用于保存和核对作答,不替代量表结果页中的正式解释。',
};
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mindscope", "name": "mindscope",
"version": "0.3.0", "version": "0.5.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",