feat: 发布 v0.5.0 加密存储与匿名同步
This commit is contained in:
+55
-23
@@ -1,38 +1,70 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, ClipboardList } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Bot, FileText, LockKeyhole, ShieldCheck } from 'lucide-react';
|
||||
import { HomeModeSelector } from '@/components/home/HomeModeSelector';
|
||||
|
||||
const details = [
|
||||
{
|
||||
icon: LockKeyhole,
|
||||
title: '本地记录加密',
|
||||
desc: '测评记录写入浏览器 IndexedDB 前会先加密;旧记录读取后会自动迁移为密文。',
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: '服务器只存密文',
|
||||
desc: '匿名同步使用代号和恢复口令派生密钥,记录在浏览器加密后再上传。',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: '完整导出',
|
||||
desc: '可导出 Markdown / JSON,也可以复制给 ChatGPT 做长期解读。',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-3.5rem)] border-t bg-background">
|
||||
<section className="container max-w-4xl mx-auto px-4 py-16 md:py-24">
|
||||
<main className="min-h-[calc(100vh-3.5rem)] bg-background">
|
||||
<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-4">
|
||||
<div className="inline-flex h-10 w-10 items-center justify-center rounded border bg-muted">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-semibold tracking-normal">
|
||||
心理量表测试
|
||||
<h1 className="text-4xl font-semibold leading-tight tracking-normal md:text-5xl">
|
||||
MindScope
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<Button size="lg" asChild>
|
||||
<Link href="/questionnaire" className="gap-2">
|
||||
开始测试
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{details.map((item) => (
|
||||
<div key={item.title} className="border p-4">
|
||||
<div className="mb-2 flex items-center gap-2 font-medium">
|
||||
<item.icon className="h-4 w-4" />
|
||||
{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 rounded p-4">人格、认知、情绪状态</div>
|
||||
<div className="border rounded p-4">自动计算结果</div>
|
||||
<div className="border rounded p-4">可复制完整记录</div>
|
||||
<div className="border-l-2 border-primary pl-4 text-sm leading-6 text-muted-foreground">
|
||||
结果仅供自我了解、教育和研究参考,不构成医学或心理诊断。
|
||||
不建议填写真实姓名、手机号、住址等身份信息。
|
||||
</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>
|
||||
|
||||
<HomeModeSelector />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useScopedI18n } from '@/locales/client';
|
||||
import { loadResult } from '@/lib/result-storage';
|
||||
import { AssessmentRecord } from '@/lib/assessment-types';
|
||||
import { getProfiles, getRecords, updateRecordAnalysis } from '@/lib/assessment-db';
|
||||
import { syncAnonymousRecord } from '@/lib/anonymous-client';
|
||||
|
||||
export default function QuestionnaireResultPage({
|
||||
params,
|
||||
@@ -58,11 +59,18 @@ export default function QuestionnaireResultPage({
|
||||
const timer = window.setTimeout(() => {
|
||||
const text = analysisRef.current?.innerText.trim() || '';
|
||||
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);
|
||||
if (updated) {
|
||||
void syncAnonymousRecord(updated).catch((error) => {
|
||||
console.error('Failed to sync anonymous record analysis:', error);
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [recordId]);
|
||||
}, [record, recordId]);
|
||||
|
||||
// Construct question-option text pairs for copying result data
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user