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
+55 -23
View File
@@ -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(() => {
+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);
}
}