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
+105
View File
@@ -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;
}
+185
View File
@@ -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
View File
@@ -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<AssessmentProfile> {
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<AssessmentProfile | undefined>(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<AssessmentRecord, 'id'>,
): Promise<AssessmentRecord> {
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<AssessmentRecord | EncryptedAssessmentRecord | undefined>(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<AssessmentRecord[]> {
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<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));
}
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<void>((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();
}
+23
View File
@@ -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;
+169
View File
@@ -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
View File
@@ -12,13 +12,25 @@ import {
} from '@/components/questionnaire/test/private/SchwartzCalculator';
const levels: Record<string, string> = {
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<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)) };
}
if (questionnaireId === 'bigfive-120' || questionnaireId === 'bigfive-300') {
const version = questionnaireId === 'bigfive-120' ? 120 : 300;
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;
return { metrics: Object.entries(result.domains).map(([key, value]) => metric(key, names[key], value.score, max)) };
}
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)) };
}
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: '该数值仅用于保存和核对作答,不替代量表结果页中的正式解释。',
};
}