From e3825c5a4eb48fcfd35fe603f951827a73a003f0 Mon Sep 17 00:00:00 2001 From: mikemoi Date: Tue, 23 Jun 2026 01:48:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=91=E5=B8=83=20v0.5.0=20=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=E5=AD=98=E5=82=A8=E4=B8=8E=E5=8C=BF=E5=90=8D=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 26 ++- app/[locale]/page.tsx | 78 +++++-- .../questionnaire/[id]/result/page.tsx | 12 +- app/api/anonymous/profile/route.ts | 42 ++++ app/api/anonymous/records/route.ts | 35 +++ components/Navbar.tsx | 14 +- components/home/HomeModeSelector.tsx | 80 +++++++ components/questionnaire/List.tsx | 9 +- .../result/public/ResultContainer.tsx | 58 +++-- .../questionnaire/test/QuestionnaireTest.tsx | 4 + components/records/AnonymousSyncPanel.tsx | 205 ++++++++++++++++++ components/records/RecordsDashboard.tsx | 3 + lib/anonymous-client.ts | 105 +++++++++ lib/anonymous-store.ts | 185 ++++++++++++++++ lib/assessment-db.ts | 40 +++- lib/assessment-types.ts | 23 ++ lib/record-crypto.ts | 169 +++++++++++++++ lib/score-summary.ts | 70 +++++- package.json | 2 +- 20 files changed, 1091 insertions(+), 70 deletions(-) create mode 100644 app/api/anonymous/profile/route.ts create mode 100644 app/api/anonymous/records/route.ts create mode 100644 components/home/HomeModeSelector.tsx create mode 100644 components/records/AnonymousSyncPanel.tsx create mode 100644 lib/anonymous-client.ts create mode 100644 lib/anonymous-store.ts create mode 100644 lib/record-crypto.ts diff --git a/.gitignore b/.gitignore index 4beda59..5f2400e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ .DS_Store *.pem story-personality.zip +/data/ # debug npm-debug.log* diff --git a/README.md b/README.md index 11b9238..5cc8318 100644 --- a/README.md +++ b/README.md @@ -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 字号和更宽松行高 - 选项整行可点击,触控高度不低于 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.2.0 | 2026-06-23 | 多人档案、完整历史、趋势、统一画像、MD/JSON 导出、加密备份及自动测试 | | v0.1.0 | 2026-06-22 | 中文量表列表、浏览器答题、本地计分、草稿保存和单次结果导出 | diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 7f59049..4810101 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -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 ( -
-
+
+
-
- -
-

- 心理量表测试 +

+ MindScope

-

- 选择一个量表,完成答题后查看结果。数据只在当前浏览器中处理,结果仅供参考,不构成医疗诊断。 +

+ 中文心理量表与个人测评档案。先选择保存模式,再开始答题。 + 默认本地加密;需要跨设备时,可以使用匿名加密同步。

- +
+ {details.map((item) => ( +
+
+ + {item.title} +
+

{item.desc}

+
+ ))} +
-
-
人格、认知、情绪状态
-
自动计算结果
-
可复制完整记录
+
+ 结果仅供自我了解、教育和研究参考,不构成医学或心理诊断。 + 不建议填写真实姓名、手机号、住址等身份信息。 +
+ +
+
+ + 给 AI 解读 +
+

+ 完成测评后可以复制完整记录给 ChatGPT。若想长期追踪变化,可以在对话里明确说: + “请记住我的测评背景和后续变化”。 +

+ +
-
+ ); } diff --git a/app/[locale]/questionnaire/[id]/result/page.tsx b/app/[locale]/questionnaire/[id]/result/page.tsx index 2f20939..1bd73f2 100644 --- a/app/[locale]/questionnaire/[id]/result/page.tsx +++ b/app/[locale]/questionnaire/[id]/result/page.tsx @@ -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 = useMemo(() => { diff --git a/app/api/anonymous/profile/route.ts b/app/api/anonymous/profile/route.ts new file mode 100644 index 0000000..842484d --- /dev/null +++ b/app/api/anonymous/profile/route.ts @@ -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); + } +} diff --git a/app/api/anonymous/records/route.ts b/app/api/anonymous/records/route.ts new file mode 100644 index 0000000..55a133a --- /dev/null +++ b/app/api/anonymous/records/route.ts @@ -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); + } +} diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 85d9db9..9b2a277 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -12,8 +12,8 @@ export function Navbar() { return (
- - + + {t('title')} @@ -22,23 +22,19 @@ export function Navbar() {