feat: 发布 v0.5.0 加密存储与匿名同步
This commit is contained in:
@@ -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*
|
||||||
|
|||||||
@@ -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
@@ -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(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
测评档案
|
测评档案
|
||||||
|
|||||||
@@ -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';
|
'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,13 +126,20 @@ 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
|
||||||
@@ -121,15 +147,15 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
|
|||||||
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`);
|
||||||
|
|||||||
@@ -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';
|
} 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user