feat: 增加本地测评档案与长期追踪
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
AssessmentProfile,
|
||||
AssessmentRecord,
|
||||
MindScopeBackup,
|
||||
} from '@/lib/assessment-types';
|
||||
|
||||
const DB_NAME = 'mindscope';
|
||||
const DB_VERSION = 1;
|
||||
const PROFILE_STORE = 'profiles';
|
||||
const RECORD_STORE = 'records';
|
||||
const ACTIVE_PROFILE_KEY = 'mindscope_active_profile';
|
||||
|
||||
function createId(prefix: string) {
|
||||
return `${prefix}_${Date.now()}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(PROFILE_STORE)) {
|
||||
db.createObjectStore(PROFILE_STORE, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(RECORD_STORE)) {
|
||||
const records = db.createObjectStore(RECORD_STORE, { keyPath: 'id' });
|
||||
records.createIndex('profileId', 'profileId');
|
||||
records.createIndex('questionnaireId', 'questionnaireId');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export function getActiveProfileId() {
|
||||
return localStorage.getItem(ACTIVE_PROFILE_KEY);
|
||||
}
|
||||
|
||||
export function setActiveProfileId(profileId: string) {
|
||||
localStorage.setItem(ACTIVE_PROFILE_KEY, profileId);
|
||||
window.dispatchEvent(new CustomEvent('mindscope-profile-change'));
|
||||
}
|
||||
|
||||
export async function getProfiles(): Promise<AssessmentProfile[]> {
|
||||
const db = await openDatabase();
|
||||
const profiles = await requestResult(
|
||||
db.transaction(PROFILE_STORE, 'readonly').objectStore(PROFILE_STORE).getAll(),
|
||||
);
|
||||
db.close();
|
||||
return profiles.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
}
|
||||
|
||||
export async function createProfile(name: string): Promise<AssessmentProfile> {
|
||||
const now = new Date().toISOString();
|
||||
const profile: AssessmentProfile = {
|
||||
id: createId('profile'),
|
||||
name: name.trim(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const db = await openDatabase();
|
||||
await requestResult(
|
||||
db.transaction(PROFILE_STORE, 'readwrite').objectStore(PROFILE_STORE).add(profile),
|
||||
);
|
||||
db.close();
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function ensureActiveProfile(): Promise<AssessmentProfile> {
|
||||
const profiles = await getProfiles();
|
||||
const activeId = getActiveProfileId();
|
||||
const active = profiles.find((profile) => profile.id === activeId);
|
||||
if (active) return active;
|
||||
const profile = profiles[0] || (await createProfile('我'));
|
||||
setActiveProfileId(profile.id);
|
||||
return profile;
|
||||
}
|
||||
|
||||
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));
|
||||
if (profile) {
|
||||
await requestResult(
|
||||
store.put({ ...profile, name: name.trim(), updatedAt: new Date().toISOString() }),
|
||||
);
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
export async function deleteProfile(profileId: string) {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction([PROFILE_STORE, RECORD_STORE], 'readwrite');
|
||||
transaction.objectStore(PROFILE_STORE).delete(profileId);
|
||||
const index = transaction.objectStore(RECORD_STORE).index('profileId');
|
||||
const recordKeys = await requestResult(index.getAllKeys(profileId));
|
||||
recordKeys.forEach((key) => transaction.objectStore(RECORD_STORE).delete(key));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
export async function addAssessmentRecord(
|
||||
record: Omit<AssessmentRecord, 'id'>,
|
||||
): Promise<AssessmentRecord> {
|
||||
const completeRecord = { ...record, id: createId('record') };
|
||||
const db = await openDatabase();
|
||||
await requestResult(
|
||||
db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE).add(completeRecord),
|
||||
);
|
||||
db.close();
|
||||
return completeRecord;
|
||||
}
|
||||
|
||||
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));
|
||||
if (record && record.analysisText !== analysisText) {
|
||||
await requestResult(store.put({ ...record, analysisText }));
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
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
|
||||
? await requestResult(store.index('profileId').getAll(profileId))
|
||||
: await requestResult(store.getAll());
|
||||
db.close();
|
||||
return records.sort((a, b) => b.completedAt.localeCompare(a.completedAt));
|
||||
}
|
||||
|
||||
export async function deleteRecord(recordId: string) {
|
||||
const db = await openDatabase();
|
||||
await requestResult(
|
||||
db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE).delete(recordId),
|
||||
);
|
||||
db.close();
|
||||
}
|
||||
|
||||
export async function exportBackup(): Promise<MindScopeBackup> {
|
||||
return {
|
||||
format: 'mindscope-backup',
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
profiles: await getProfiles(),
|
||||
records: await getRecords(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function importBackup(backup: MindScopeBackup) {
|
||||
if (backup.format !== 'mindscope-backup' || backup.version !== 1) {
|
||||
throw new Error('不支持的备份格式');
|
||||
}
|
||||
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));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
export async function clearAllAssessmentData() {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction([PROFILE_STORE, RECORD_STORE], 'readwrite');
|
||||
transaction.objectStore(PROFILE_STORE).clear();
|
||||
transaction.objectStore(RECORD_STORE).clear();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
db.close();
|
||||
localStorage.removeItem(ACTIVE_PROFILE_KEY);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AssessmentProfile, AssessmentRecord } from '@/lib/assessment-types';
|
||||
|
||||
function date(value: string) {
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
export function recordToMarkdown(record: AssessmentRecord, profileName: string) {
|
||||
const lines = [
|
||||
`# ${record.questionnaireTitle}测评记录`, '',
|
||||
'## 基本信息',
|
||||
`- 档案:${profileName}`,
|
||||
`- 测评时间:${date(record.completedAt)}`,
|
||||
`- 量表编号:${record.questionnaireId}`,
|
||||
`- 题目数量:${record.answers.length}`, '',
|
||||
];
|
||||
if (record.scoreSummary.metrics.length) {
|
||||
lines.push('## 分数摘要');
|
||||
record.scoreSummary.metrics.forEach((item) => {
|
||||
lines.push(`- ${item.label}:${item.value}${item.max ? ` / ${item.max}` : ''}${item.level ? `(${item.level})` : ''}`);
|
||||
});
|
||||
if (record.scoreSummary.note) lines.push('', record.scoreSummary.note);
|
||||
lines.push('');
|
||||
}
|
||||
if (record.analysisText) lines.push('## 结果说明', record.analysisText, '');
|
||||
lines.push('## 完整问答');
|
||||
record.answers.forEach((item, index) => lines.push(`${index + 1}. ${item.question}`, ` 回答:${item.answer}`, ''));
|
||||
lines.push('## 使用说明', '本记录仅供自我了解、教育和研究参考,不构成医学或心理诊断。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function profileToMarkdown(profile: AssessmentProfile, records: AssessmentRecord[]) {
|
||||
const lines = [
|
||||
`# ${profile.name}的完整测评档案`, '',
|
||||
`- 导出时间:${date(new Date().toISOString())}`,
|
||||
`- 测评次数:${records.length}`, '',
|
||||
'---', '',
|
||||
];
|
||||
records
|
||||
.slice()
|
||||
.sort((a, b) => a.completedAt.localeCompare(b.completedAt))
|
||||
.forEach((record) => lines.push(recordToMarkdown(record, profile.name), '', '---', ''));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function downloadText(filename: string, content: string, type: string) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface AssessmentProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ScoreMetric {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
max?: number;
|
||||
level?: string;
|
||||
}
|
||||
|
||||
export interface ScoreSummary {
|
||||
primary?: ScoreMetric;
|
||||
metrics: ScoreMetric[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface RecordedAnswer {
|
||||
questionId: number;
|
||||
question: string;
|
||||
value: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface AssessmentRecord {
|
||||
id: string;
|
||||
profileId: string;
|
||||
questionnaireId: string;
|
||||
questionnaireTitle: string;
|
||||
category: string;
|
||||
completedAt: string;
|
||||
answers: RecordedAnswer[];
|
||||
scoreSummary: ScoreSummary;
|
||||
analysisText?: string;
|
||||
retestSuitable?: boolean;
|
||||
recommendedInterval?: '1年' | '3个月' | '一次即可';
|
||||
}
|
||||
|
||||
export interface EncryptedMindScopeBackup {
|
||||
format: 'mindscope-encrypted-backup';
|
||||
version: 1;
|
||||
algorithm: 'AES-GCM';
|
||||
iterations: number;
|
||||
salt: string;
|
||||
iv: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface MindScopeBackup {
|
||||
format: 'mindscope-backup';
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
profiles: AssessmentProfile[];
|
||||
records: AssessmentRecord[];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decryptBackup, encryptBackup } from '@/lib/backup-crypto';
|
||||
import { MindScopeBackup } from '@/lib/assessment-types';
|
||||
|
||||
const backup: MindScopeBackup = {
|
||||
format: 'mindscope-backup',
|
||||
version: 1,
|
||||
exportedAt: '2026-06-23T00:00:00.000Z',
|
||||
profiles: [],
|
||||
records: [],
|
||||
};
|
||||
|
||||
describe('encrypted backups', () => {
|
||||
it('round trips a backup with the correct password', async () => {
|
||||
const encrypted = await encryptBackup(backup, 'correct-password');
|
||||
expect(encrypted.data).not.toContain('mindscope-backup');
|
||||
await expect(decryptBackup(encrypted, 'correct-password')).resolves.toEqual(backup);
|
||||
});
|
||||
|
||||
it('rejects an incorrect password', async () => {
|
||||
const encrypted = await encryptBackup(backup, 'correct-password');
|
||||
await expect(decryptBackup(encrypted, 'wrong-password')).rejects.toThrow('密码错误');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { EncryptedMindScopeBackup, MindScopeBackup } from '@/lib/assessment-types';
|
||||
|
||||
const 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 deriveKey(password: string, salt: Uint8Array, iterations: number) {
|
||||
const material = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(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'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptBackup(backup: MindScopeBackup, password: string): Promise<EncryptedMindScopeBackup> {
|
||||
if (password.length < 8) throw new Error('备份密码至少需要 8 个字符');
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveKey(password, salt, ITERATIONS);
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: toArrayBuffer(iv) },
|
||||
key,
|
||||
new TextEncoder().encode(JSON.stringify(backup)),
|
||||
);
|
||||
return {
|
||||
format: 'mindscope-encrypted-backup',
|
||||
version: 1,
|
||||
algorithm: 'AES-GCM',
|
||||
iterations: ITERATIONS,
|
||||
salt: toBase64(salt),
|
||||
iv: toBase64(iv),
|
||||
data: toBase64(new Uint8Array(encrypted)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptBackup(backup: EncryptedMindScopeBackup, password: string): Promise<MindScopeBackup> {
|
||||
if (backup.format !== 'mindscope-encrypted-backup' || backup.version !== 1) {
|
||||
throw new Error('不支持的加密备份格式');
|
||||
}
|
||||
try {
|
||||
const salt = fromBase64(backup.salt);
|
||||
const iv = fromBase64(backup.iv);
|
||||
const key = await deriveKey(password, salt, backup.iterations);
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: toArrayBuffer(iv) },
|
||||
key,
|
||||
toArrayBuffer(fromBase64(backup.data)),
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(decrypted)) as MindScopeBackup;
|
||||
} catch {
|
||||
throw new Error('密码错误或备份文件已损坏');
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -1,19 +1,28 @@
|
||||
const RESULT_STORAGE_KEY = 'questionnaire_result';
|
||||
|
||||
interface StoredResult {
|
||||
export interface StoredResult {
|
||||
answers: string[];
|
||||
savedAt: string;
|
||||
profileId?: string;
|
||||
recordId?: string;
|
||||
}
|
||||
|
||||
function resultKey(questionnaireId: string) {
|
||||
return `${RESULT_STORAGE_KEY}_${questionnaireId}`;
|
||||
}
|
||||
|
||||
export function saveResult(questionnaireId: string, answers: string[]) {
|
||||
export function saveResult(
|
||||
questionnaireId: string,
|
||||
answers: string[],
|
||||
profileId?: string,
|
||||
recordId?: string,
|
||||
) {
|
||||
try {
|
||||
const result: StoredResult = {
|
||||
answers,
|
||||
savedAt: new Date().toISOString(),
|
||||
profileId,
|
||||
recordId,
|
||||
};
|
||||
sessionStorage.setItem(resultKey(questionnaireId), JSON.stringify(result));
|
||||
return true;
|
||||
@@ -23,7 +32,7 @@ export function saveResult(questionnaireId: string, answers: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadResult(questionnaireId: string): string[] | null {
|
||||
export function loadResult(questionnaireId: string): StoredResult | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(resultKey(questionnaireId));
|
||||
if (!raw) {
|
||||
@@ -31,7 +40,7 @@ export function loadResult(questionnaireId: string): string[] | null {
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<StoredResult>;
|
||||
return Array.isArray(parsed.answers) ? parsed.answers : null;
|
||||
return Array.isArray(parsed.answers) ? parsed as StoredResult : null;
|
||||
} catch (error) {
|
||||
console.error('Failed to load result:', error);
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildScoreSummary } from '@/lib/score-summary';
|
||||
|
||||
describe('buildScoreSummary', () => {
|
||||
it('applies PHQ-9 severity thresholds', () => {
|
||||
const summary = buildScoreSummary('phq9', Array(9).fill('2'));
|
||||
expect(summary.primary).toMatchObject({ value: 18, max: 27, level: '中重度' });
|
||||
});
|
||||
|
||||
it('converts WHO-5 raw scores to a percentage', () => {
|
||||
const summary = buildScoreSummary('who5', Array(5).fill('5'));
|
||||
expect(summary.primary).toMatchObject({ value: 100, max: 100 });
|
||||
});
|
||||
|
||||
it('reverse scores the positive PSS-10 items', () => {
|
||||
const summary = buildScoreSummary('pss10', Array(10).fill('0'));
|
||||
expect(summary.primary).toMatchObject({ value: 16, max: 40, level: '中度' });
|
||||
});
|
||||
|
||||
it('calculates all three DASS-21 dimensions independently', () => {
|
||||
const summary = buildScoreSummary('dass21', Array(21).fill('1'));
|
||||
expect(summary.metrics.map((item) => item.value)).toEqual([14, 14, 14]);
|
||||
});
|
||||
|
||||
it('keeps neutral Big Five answers neutral after reverse scoring', () => {
|
||||
const summary = buildScoreSummary('bigfive', Array(50).fill('3'));
|
||||
expect(summary.metrics).toHaveLength(5);
|
||||
expect(summary.metrics.every((item) => item.value === 30 && item.max === 50)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps neutral HEXACO answers at the midpoint', () => {
|
||||
const summary = buildScoreSummary('hexaco', Array(60).fill('3'));
|
||||
expect(summary.metrics).toHaveLength(6);
|
||||
expect(summary.metrics.every((item) => item.value === 3 && item.max === 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies SDS reverse scoring and standard-score conversion', () => {
|
||||
const summary = buildScoreSummary('sds', Array(20).fill('1'));
|
||||
expect(summary.primary).toMatchObject({ value: 63, max: 100, level: '中度' });
|
||||
});
|
||||
|
||||
it('falls back to a clearly labelled raw answer sum', () => {
|
||||
const summary = buildScoreSummary('unknown', ['1', '2', '3']);
|
||||
expect(summary.primary).toMatchObject({ label: '原始作答总和', value: 6 });
|
||||
expect(summary.note).toContain('不替代');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ScoreMetric, ScoreSummary } from '@/lib/assessment-types';
|
||||
import {
|
||||
calculateBigFiveResults,
|
||||
calculateIpipNeoResults,
|
||||
ipipNeoItemsByVersion,
|
||||
} from '@/components/questionnaire/test/private/BigFiveCalculator';
|
||||
import { calculateHEXACOResults } from '@/components/questionnaire/test/private/HEXACOCalculator';
|
||||
import { calculateRIASECResults, riasecTypes } from '@/components/questionnaire/test/private/RIASECCalculator';
|
||||
import {
|
||||
calculateSchwartzResults,
|
||||
higherOrderNames,
|
||||
} 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: '无明显失眠',
|
||||
};
|
||||
|
||||
const metric = (key: string, label: string, value: number, max?: number, level?: string): ScoreMetric => ({
|
||||
key, label, value, max, level: level ? levels[level] || level : undefined,
|
||||
});
|
||||
|
||||
function sum(answers: string[]) {
|
||||
return answers.reduce((total, answer) => total + (Number(answer) || 0), 0);
|
||||
}
|
||||
|
||||
function keyed(answers: string[]) {
|
||||
return Object.fromEntries(answers.map((answer, index) => [index + 1, answer]));
|
||||
}
|
||||
|
||||
function severity(score: number, thresholds: Array<[number, string]>) {
|
||||
return thresholds.reduce((current, [minimum, label]) => score >= minimum ? label : current, thresholds[0][1]);
|
||||
}
|
||||
|
||||
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: '开放性' };
|
||||
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 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: '开放性' };
|
||||
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)) };
|
||||
}
|
||||
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)) };
|
||||
}
|
||||
|
||||
const raw = sum(answers);
|
||||
const single: Record<string, { label: string; max: number; score?: number; level?: string }> = {
|
||||
phq9: { label: 'PHQ-9 总分', max: 27, level: severity(raw, [[0, 'minimal'], [5, 'mild'], [10, 'moderate'], [15, 'moderately_severe'], [20, 'severe']]) },
|
||||
gad7: { label: 'GAD-7 总分', max: 21, level: severity(raw, [[0, 'minimal'], [5, 'mild'], [10, 'moderate'], [15, 'severe']]) },
|
||||
isi: { label: 'ISI 总分', max: 28, level: severity(raw, [[0, 'no_insomnia'], [8, 'subthreshold'], [15, 'moderate'], [22, 'severe']]) },
|
||||
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 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);
|
||||
const score = Math.round(original * 1.25);
|
||||
const primary = metric('total', 'SDS 标准分', score, 100, severity(score, [[0, 'normal'], [53, 'mild'], [63, 'moderate'], [73, 'severe']]));
|
||||
return { primary, metrics: [primary] };
|
||||
}
|
||||
|
||||
const primary = metric('raw', '原始作答总和', raw);
|
||||
return { primary, metrics: [primary], note: '该数值仅用于保存和核对作答,不替代量表结果页中的正式解释。' };
|
||||
}
|
||||
Reference in New Issue
Block a user