diff --git a/components/questionnaire/result/public/ResultContainer.tsx b/components/questionnaire/result/public/ResultContainer.tsx
index 70b58e4..7b09c16 100644
--- a/components/questionnaire/result/public/ResultContainer.tsx
+++ b/components/questionnaire/result/public/ResultContainer.tsx
@@ -1,9 +1,9 @@
import Link from 'next/link';
import { ReactNode } from 'react';
+import { Bot, Copy, Download, FileText } from 'lucide-react';
+import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useScopedI18n } from '@/locales/client';
-import { Copy, Download, FileText } from 'lucide-react';
-import { toast } from 'sonner';
import { Questionnaire } from '@/types';
import { AssessmentRecord } from '@/lib/assessment-types';
import { recordToMarkdown } from '@/lib/assessment-export';
@@ -19,7 +19,25 @@ interface ResultContainerProps {
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(
'component.questionnaire.result.public.resultContainer'
);
@@ -37,7 +55,7 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
const buildResultMarkdown = () => {
if (record) {
- return recordToMarkdown(record, profileName);
+ return `${aiPrompt()}\n\n${recordToMarkdown(record, profileName)}`;
}
if (!questionnaire || !answers || !questionnaireResults) {
return null;
@@ -45,7 +63,7 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
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.questionnaireName')}: ${questionnaire.title}\n`;
resultData += `- ${t('copyTemplate.questionnaireId')}: ${id}\n`;
@@ -96,10 +114,11 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
URL.revokeObjectURL(url);
toast.success(t('downloadResultDataSuccess'));
};
+
return (
-
-
-
+
+
+
{title} - {t('resultText')}
@@ -107,29 +126,36 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
{children}
-
+
+
+
+ 可以复制完整记录给 ChatGPT 解读。若想长期追踪,可以在 ChatGPT 中明确说“请记住我的测评背景和后续变化”;不建议提交真实姓名、手机号等身份信息。
+
+
+
+
-
+
-
+
+
{([['history', '历史记录'], ['trends', '变化趋势'], ['portrait', '统一画像']] as const).map(([id, label]) => (
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}
diff --git a/lib/anonymous-client.ts b/lib/anonymous-client.ts
new file mode 100644
index 0000000..4ef482f
--- /dev/null
+++ b/lib/anonymous-client.ts
@@ -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
(response: Response): Promise {
+ 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;
+ 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 {
+ 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(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;
+}
diff --git a/lib/anonymous-store.ts b/lib/anonymous-store.ts
new file mode 100644
index 0000000..e8fd607
--- /dev/null
+++ b/lib/anonymous-store.ts
@@ -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 {
+ try {
+ const raw = await readFile(STORE_PATH, 'utf8');
+ const parsed = JSON.parse(raw) as Partial;
+ 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(callback: (store: AnonymousStore) => Promise | 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 };
+ });
+}
diff --git a/lib/assessment-db.ts b/lib/assessment-db.ts
index 177d9f0..3a323e5 100644
--- a/lib/assessment-db.ts
+++ b/lib/assessment-db.ts
@@ -1,8 +1,15 @@
import {
AssessmentProfile,
AssessmentRecord,
+ EncryptedAssessmentRecord,
MindScopeBackup,
} from '@/lib/assessment-types';
+import {
+ clearDeviceRecordKey,
+ decryptDeviceRecord,
+ encryptRecordForDevice,
+ isEncryptedRecord,
+} from '@/lib/record-crypto';
const DB_NAME = 'mindscope';
const DB_VERSION = 1;
@@ -87,7 +94,7 @@ export async function ensureActiveProfile(): Promise {
export async function renameProfile(profileId: string, name: string) {
const db = await openDatabase();
const store = db.transaction(PROFILE_STORE, 'readwrite').objectStore(PROFILE_STORE);
- const profile = await requestResult(store.get(profileId));
+ const profile = await requestResult(store.get(profileId));
if (profile) {
await requestResult(
store.put({ ...profile, name: name.trim(), updatedAt: new Date().toISOString() }),
@@ -114,9 +121,10 @@ export async function addAssessmentRecord(
record: Omit,
): Promise {
const completeRecord = { ...record, id: createId('record') };
+ const encrypted = await encryptRecordForDevice(completeRecord);
const db = await openDatabase();
await requestResult(
- db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE).add(completeRecord),
+ db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE).add(encrypted),
);
db.close();
return completeRecord;
@@ -125,9 +133,10 @@ export async function addAssessmentRecord(
export async function updateRecordAnalysis(recordId: string, analysisText: string) {
const db = await openDatabase();
const store = db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE);
- const record = await requestResult(store.get(recordId));
+ const stored = await requestResult(store.get(recordId));
+ const record = stored ? await decryptDeviceRecord(stored) : undefined;
if (record && record.analysisText !== analysisText) {
- await requestResult(store.put({ ...record, analysisText }));
+ await requestResult(store.put(await encryptRecordForDevice({ ...record, analysisText })));
}
db.close();
}
@@ -135,13 +144,29 @@ export async function updateRecordAnalysis(recordId: string, analysisText: strin
export async function getRecords(profileId?: string): Promise {
const db = await openDatabase();
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.getAll());
db.close();
+
+ const typedRecords = storedRecords as Array;
+ 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));
}
+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) {
const db = await openDatabase();
await requestResult(
@@ -167,7 +192,9 @@ export async function importBackup(backup: MindScopeBackup) {
const db = await openDatabase();
const transaction = db.transaction([PROFILE_STORE, RECORD_STORE], 'readwrite');
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((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
@@ -186,4 +213,5 @@ export async function clearAllAssessmentData() {
});
db.close();
localStorage.removeItem(ACTIVE_PROFILE_KEY);
+ clearDeviceRecordKey();
}
diff --git a/lib/assessment-types.ts b/lib/assessment-types.ts
index 38e691c..20714d4 100644
--- a/lib/assessment-types.ts
+++ b/lib/assessment-types.ts
@@ -40,6 +40,29 @@ export interface AssessmentRecord {
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 {
format: 'mindscope-encrypted-backup';
version: 1;
diff --git a/lib/record-crypto.ts b/lib/record-crypto.ts
new file mode 100644
index 0000000..28617c1
--- /dev/null
+++ b/lib/record-crypto.ts
@@ -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,
+) {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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);
+}
diff --git a/lib/score-summary.ts b/lib/score-summary.ts
index aab4c2a..5573d42 100644
--- a/lib/score-summary.ts
+++ b/lib/score-summary.ts
@@ -12,13 +12,25 @@ import {
} from '@/components/questionnaire/test/private/SchwartzCalculator';
const levels: Record = {
- minimal: '极轻或无', mild: '轻度', moderate: '中度', moderately_severe: '中重度',
- severe: '重度', normal: '正常范围', extremely_severe: '极重度', low: '较低',
- high: '较高', subthreshold: '亚阈值', no_insomnia: '无明显失眠',
+ minimal: '极轻或无',
+ mild: '轻度',
+ 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 => ({
- key, label, value, max, level: level ? levels[level] || level : undefined,
+ key,
+ label,
+ value,
+ max,
+ level: level ? levels[level] || level : undefined,
});
function sum(answers: string[]) {
@@ -36,24 +48,50 @@ function severity(score: number, thresholds: Array<[number, string]>) {
export function buildScoreSummary(questionnaireId: string, answers: string[]): ScoreSummary {
if (questionnaireId === 'bigfive') {
const result = calculateBigFiveResults(answers);
- const names: Record = { extraversion: '外向性', agreeableness: '宜人性', conscientiousness: '尽责性', emotionalStability: '情绪稳定性', openness: '开放性' };
+ const names: Record = {
+ extraversion: '外向性',
+ agreeableness: '宜人性',
+ conscientiousness: '尽责性',
+ emotionalStability: '情绪稳定性',
+ openness: '开放性',
+ };
return { metrics: Object.entries(result).map(([key, value]) => metric(key, names[key], value.score, 50)) };
}
+
if (questionnaireId === 'bigfive-120' || questionnaireId === 'bigfive-300') {
const version = questionnaireId === 'bigfive-120' ? 120 : 300;
const result = calculateIpipNeoResults(answers, [...ipipNeoItemsByVersion[version]]);
- const names: Record = { neuroticism: '神经质', extraversion: '外向性', openness: '开放性', agreeableness: '宜人性', conscientiousness: '尽责性' };
+ const names: Record = {
+ neuroticism: '神经质',
+ extraversion: '外向性',
+ openness: '开放性',
+ agreeableness: '宜人性',
+ conscientiousness: '尽责性',
+ };
const max = version === 120 ? 120 : 300;
return { metrics: Object.entries(result.domains).map(([key, value]) => metric(key, names[key], value.score, max)) };
}
+
if (questionnaireId === 'hexaco') {
- const names: Record = { honestyHumility: '诚实谦逊', emotionality: '情绪性', extraversion: '外向性', agreeableness: '宜人性', conscientiousness: '尽责性', openness: '开放性' };
+ const names: Record = {
+ honestyHumility: '诚实谦逊',
+ emotionality: '情绪性',
+ extraversion: '外向性',
+ agreeableness: '宜人性',
+ conscientiousness: '尽责性',
+ openness: '开放性',
+ };
return { metrics: Object.entries(calculateHEXACOResults(answers)).map(([key, value]) => metric(key, names[key], Number(value.toFixed(2)), 5)) };
}
+
if (questionnaireId === 'riasec') {
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') {
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)) };
@@ -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']]) },
who5: { label: 'WHO-5 百分制得分', max: 100, score: raw * 4 },
};
+
if (single[questionnaireId]) {
const item = single[questionnaireId];
const primary = metric('total', item.label, item.score ?? raw, item.max, item.level);
return { primary, metrics: [primary] };
}
+
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: '压力' };
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') {
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 primary = metric('total', 'PSS-10 总分', score, 40, severity(score, [[0, 'low'], [14, 'moderate'], [27, 'high']]));
return { primary, metrics: [primary] };
}
+
if (questionnaireId === 'sds') {
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);
@@ -92,5 +138,9 @@ export function buildScoreSummary(questionnaireId: string, answers: string[]): S
}
const primary = metric('raw', '原始作答总和', raw);
- return { primary, metrics: [primary], note: '该数值仅用于保存和核对作答,不替代量表结果页中的正式解释。' };
+ return {
+ primary,
+ metrics: [primary],
+ note: '该数值仅用于保存和核对作答,不替代量表结果页中的正式解释。',
+ };
}
diff --git a/package.json b/package.json
index 0acc486..2bbdbb1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mindscope",
- "version": "0.3.0",
+ "version": "0.5.0",
"private": true,
"scripts": {
"dev": "next dev",