diff --git a/app/[locale]/questionnaire/[id]/result/page.tsx b/app/[locale]/questionnaire/[id]/result/page.tsx index 1bd73f2..039b1ae 100644 --- a/app/[locale]/questionnaire/[id]/result/page.tsx +++ b/app/[locale]/questionnaire/[id]/result/page.tsx @@ -1,10 +1,10 @@ 'use client'; import { notFound } from 'next/navigation'; -import { useEffect, useState, useMemo, use, useRef } from 'react'; +import { useEffect, useMemo, use, useState } from 'react'; +import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Questionnaire } from '@/types'; -import Link from 'next/link'; import { ResultContainer } from '@/components/questionnaire/result/public/ResultContainer'; import { AnswerList } from '@/components/questionnaire/result/public/AnswerList'; import { ResultAnalysis } from '@/components/questionnaire/result/analysis/ResultAnalysis'; @@ -12,8 +12,7 @@ import { useQuestionnaire } from '@/hooks/useQuestionnaire'; 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'; +import { getProfiles, getRecords } from '@/lib/assessment-db'; export default function QuestionnaireResultPage({ params, @@ -25,14 +24,10 @@ export default function QuestionnaireResultPage({ const [decodedAnswers, setDecodedAnswers] = useState(null); const [record, setRecord] = useState(null); const [profileName, setProfileName] = useState('未命名档案'); - const analysisRef = useRef(null); const t = useScopedI18n('app.questionnaire.result'); - // Get the questionnaire with specified id from questionnaire data const questionnaire = useQuestionnaire(id) as Questionnaire; - const recordId = record?.id; - // Load results from tab-local storage. Answers are intentionally not kept in the URL. useEffect(() => { if (!questionnaire || !questionnaire.details) { return; @@ -40,6 +35,7 @@ export default function QuestionnaireResultPage({ const stored = loadResult(id); setDecodedAnswers(stored?.answers || null); + async function loadRecord() { if (stored?.profileId) { const profiles = await getProfiles(); @@ -51,28 +47,10 @@ export default function QuestionnaireResultPage({ } setLoading(false); } + void loadRecord(); }, [id, questionnaire]); - useEffect(() => { - if (!recordId || !analysisRef.current) return; - const timer = window.setTimeout(() => { - const text = analysisRef.current?.innerText.trim() || ''; - if (!text) return; - 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); - }, [record, recordId]); - - // Construct question-option text pairs for copying result data const questionnaireResults: Record = useMemo(() => { if (!questionnaire || !decodedAnswers) return {}; const obj: Record = {}; @@ -80,35 +58,34 @@ export default function QuestionnaireResultPage({ const val = decodedAnswers[idx]; if (val === undefined) return; const option = questionnaire.renderOptions(q.id).find( - (o) => String(o.value) === String(val) + (o) => String(o.value) === String(val), ); obj[q.content] = option ? option.content : String(val); }); return obj; }, [decodedAnswers, questionnaire]); - // If data not found, show 404 page if (!questionnaire || !questionnaire.details) { return notFound(); } if (loading) { return ( -
-
+
+
); } if (!decodedAnswers || decodedAnswers.length !== questionnaire.questions.length) { return ( -
-
-

+
+
+

{questionnaire.title} - {t('resultNotFoundTitle')}

-

{t('resultNotFoundDesc')}

-
@@ -117,8 +94,8 @@ export default function QuestionnaireResultPage({ } return ( - -
- -
+
); } diff --git a/components/questionnaire/test/QuestionnaireTest.tsx b/components/questionnaire/test/QuestionnaireTest.tsx index cb8f4d5..6b305d7 100644 --- a/components/questionnaire/test/QuestionnaireTest.tsx +++ b/components/questionnaire/test/QuestionnaireTest.tsx @@ -199,14 +199,17 @@ export function Questionnaire({ answer: option?.content || value, }; }); + const scoreSummary = buildScoreSummary(id, resultAnswers); const record = await addAssessmentRecord({ profileId: profile.id, questionnaireId: id, questionnaireTitle: questionnaire.title, + questionnaireVersion: scoreSummary.questionnaireVersion, + scoreVersion: scoreSummary.scoreVersion, category: questionnaire.category, completedAt, answers: recordedAnswers, - scoreSummary: buildScoreSummary(id, resultAnswers), + scoreSummary, retestSuitable: questionnaire.evaluation?.retestSuitable, recommendedInterval: questionnaire.evaluation?.recommendedInterval, }); diff --git a/components/records/RecordsDashboard.tsx b/components/records/RecordsDashboard.tsx index eedf353..b5f2cc6 100644 --- a/components/records/RecordsDashboard.tsx +++ b/components/records/RecordsDashboard.tsx @@ -32,6 +32,41 @@ function displayDate(value: string) { return new Date(value).toLocaleString('zh-CN', { hour12: false }); } +function directionText(direction?: ScoreMetric['direction']) { + switch (direction) { + case 'risk': + return '高分代表风险或困扰升高'; + case 'protective': + return '高分代表改善或保护因素更高'; + case 'ability': + return '高分代表表现更好'; + case 'trait': + return '高分代表该特质更明显'; + case 'mixed': + return '高分含义需按维度查看'; + default: + return '高分含义需结合量表说明'; + } +} + +function changeText(current: ScoreMetric, previous?: ScoreMetric) { + if (!previous) return '基线'; + const diff = current.value - previous.value; + if (diff === 0) return '无变化'; + const signed = `${diff > 0 ? '+' : ''}${diff}`; + switch (current.direction) { + case 'risk': + return diff > 0 ? `风险升高 ${signed}` : `风险降低 ${signed}`; + case 'protective': + case 'ability': + return diff > 0 ? `改善 ${signed}` : `下降 ${signed}`; + case 'trait': + return diff > 0 ? `特质增强 ${signed}` : `特质减弱 ${signed}`; + default: + return `变化 ${signed}`; + } +} + function MetricBar({ metric }: { metric: ScoreMetric }) { const width = metric.max ? Math.max(0, Math.min(100, metric.value / metric.max * 100)) : 0; return ( @@ -244,14 +279,15 @@ export function RecordsDashboard() { {trendGroups.map((group) => (

{group[0].questionnaireTitle}

{group[0].recommendedInterval && 建议间隔:{group[0].recommendedInterval}}
+

{group[0].scoreSummary.highScoreMeaning} · {directionText(group[0].scoreSummary.direction)}

- - {group.map((record, index) => { const metrics = record.scoreSummary.primary ? [record.scoreSummary.primary] : record.scoreSummary.metrics; const previous = group[index - 1]?.scoreSummary.primary; return ; })} +
时间分数与上次相比
{displayDate(record.completedAt)}{metrics.map((item) => {item.label} {item.value}{item.max ? `/${item.max}` : ''})}{record.scoreSummary.primary && previous ? `${record.scoreSummary.primary.value - previous.value > 0 ? '+' : ''}${record.scoreSummary.primary.value - previous.value}` : '基线'}
+ {group.map((record, index) => { const metrics = record.scoreSummary.primary ? [record.scoreSummary.primary] : record.scoreSummary.metrics; const primary = record.scoreSummary.primary; const previous = group[index - 1]?.scoreSummary.primary; return ; })}
时间分数高分方向与上次相比
{displayDate(record.completedAt)}{metrics.map((item) => {item.label} {item.value}{item.max ? `/${item.max}` : ''})}{directionText(primary?.direction || record.scoreSummary.direction)}{primary ? changeText(primary, previous) : '看各维度'}
))} -

趋势只呈现分数变化,不自动判断改善或恶化;不同量表的高分方向并不相同。

+

趋势会按量表方向解释变化;敏感量表只作为自评追踪参考,不等同于诊断。

)} diff --git a/lib/assessment-db.ts b/lib/assessment-db.ts index 3a323e5..3dc5820 100644 --- a/lib/assessment-db.ts +++ b/lib/assessment-db.ts @@ -10,6 +10,7 @@ import { encryptRecordForDevice, isEncryptedRecord, } from '@/lib/record-crypto'; +import { buildScoreSummary } from '@/lib/score-summary'; const DB_NAME = 'mindscope'; const DB_VERSION = 1; @@ -47,6 +48,34 @@ function requestResult(request: IDBRequest): Promise { }); } +function normalizeRecord(record: AssessmentRecord): AssessmentRecord { + if (record.questionnaireVersion && record.scoreVersion && record.scoreSummary?.questionnaireVersion) { + return record; + } + const scoreSummary = buildScoreSummary( + record.questionnaireId, + record.answers.map((answer) => answer.value), + ); + return { + ...record, + questionnaireVersion: record.questionnaireVersion || scoreSummary.questionnaireVersion, + scoreVersion: record.scoreVersion || scoreSummary.scoreVersion, + scoreSummary: { + ...scoreSummary, + ...record.scoreSummary, + questionnaireVersion: record.scoreSummary?.questionnaireVersion || scoreSummary.questionnaireVersion, + scoreVersion: record.scoreSummary?.scoreVersion || scoreSummary.scoreVersion, + min: record.scoreSummary?.min ?? scoreSummary.min, + max: record.scoreSummary?.max ?? scoreSummary.max, + reverseItems: record.scoreSummary?.reverseItems || scoreSummary.reverseItems, + direction: record.scoreSummary?.direction || scoreSummary.direction, + highScoreMeaning: record.scoreSummary?.highScoreMeaning || scoreSummary.highScoreMeaning, + thresholds: record.scoreSummary?.thresholds || scoreSummary.thresholds, + scoringStatus: record.scoreSummary?.scoringStatus || scoreSummary.scoringStatus, + }, + }; +} + export function getActiveProfileId() { return localStorage.getItem(ACTIVE_PROFILE_KEY); } @@ -150,7 +179,7 @@ export async function getRecords(profileId?: string): Promise; - const records = await Promise.all(typedRecords.map(decryptDeviceRecord)); + const records = (await Promise.all(typedRecords.map(decryptDeviceRecord))).map(normalizeRecord); const plaintextRecords = typedRecords.filter((record) => !isEncryptedRecord(record)) as AssessmentRecord[]; if (plaintextRecords.length) { await migratePlaintextRecords(plaintextRecords); diff --git a/lib/assessment-export.ts b/lib/assessment-export.ts index 78b92e2..57a4394 100644 --- a/lib/assessment-export.ts +++ b/lib/assessment-export.ts @@ -1,39 +1,74 @@ -import { AssessmentProfile, AssessmentRecord } from '@/lib/assessment-types'; +import { AssessmentProfile, AssessmentRecord, ScoreThreshold } from '@/lib/assessment-types'; function date(value: string) { return new Date(value).toLocaleString('zh-CN', { hour12: false }); } +function thresholdsToText(thresholds: ScoreThreshold[]) { + if (!thresholds.length) return '无固定阈值'; + return thresholds.map((item) => `${item.min} 分起:${item.label}`).join(';'); +} + +function scoreRange(record: AssessmentRecord) { + const { min, max } = record.scoreSummary; + return max === undefined ? `最低 ${min}` : `${min} - ${max}`; +} + export function recordToMarkdown(record: AssessmentRecord, profileName: string) { const lines = [ - `# ${record.questionnaireTitle}测评记录`, '', + `# ${record.questionnaireTitle}测评记录`, + '', '## 基本信息', - `- 档案:${profileName}`, + `- 档案:${profileName || '未命名档案'}`, `- 测评时间:${date(record.completedAt)}`, `- 量表编号:${record.questionnaireId}`, - `- 题目数量:${record.answers.length}`, '', + `- 量表版本:${record.questionnaireVersion}`, + `- 计分版本:${record.scoreVersion}`, + `- 题目数量:${record.answers.length}`, + '', + '## 计分规则', + `- 分数范围:${scoreRange(record)}`, + `- 高分含义:${record.scoreSummary.highScoreMeaning}`, + `- 反向题:${record.scoreSummary.reverseItems.length ? record.scoreSummary.reverseItems.join('、') : '无'}`, + `- 临界值:${thresholdsToText(record.scoreSummary.thresholds)}`, + `- 计分状态:${record.scoreSummary.scoringStatus === 'structured' ? '结构化计分' : '原始分保存'}`, + '', ]; + 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})` : ''}`); + const range = item.max !== undefined ? ` / ${item.max}` : ''; + const level = item.level ? `(${item.level})` : ''; + lines.push(`- ${item.label}:${item.value}${range}${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('## 使用说明', '本记录仅供自我了解、教育和研究参考,不构成医学或心理诊断。'); + 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}的完整测评档案`, '', + `# ${profile.name}的完整测评档案`, + '', `- 导出时间:${date(new Date().toISOString())}`, - `- 测评次数:${records.length}`, '', - '---', '', + `- 测评次数:${records.length}`, + '', + '---', + '', ]; records .slice() diff --git a/lib/assessment-types.ts b/lib/assessment-types.ts index 20714d4..b5a193d 100644 --- a/lib/assessment-types.ts +++ b/lib/assessment-types.ts @@ -1,3 +1,5 @@ +export type ScoreDirection = 'risk' | 'protective' | 'ability' | 'trait' | 'mixed'; + export interface AssessmentProfile { id: string; name: string; @@ -5,18 +7,37 @@ export interface AssessmentProfile { updatedAt: string; } +export interface ScoreThreshold { + min: number; + label: string; + note?: string; +} + export interface ScoreMetric { key: string; label: string; value: number; + min?: number; max?: number; level?: string; + direction?: ScoreDirection; + highScoreMeaning?: string; + thresholds?: ScoreThreshold[]; } export interface ScoreSummary { primary?: ScoreMetric; metrics: ScoreMetric[]; note?: string; + questionnaireVersion: string; + scoreVersion: string; + min: number; + max?: number; + reverseItems: number[]; + direction: ScoreDirection; + highScoreMeaning: string; + thresholds: ScoreThreshold[]; + scoringStatus: 'structured' | 'raw'; } export interface RecordedAnswer { @@ -31,6 +52,8 @@ export interface AssessmentRecord { profileId: string; questionnaireId: string; questionnaireTitle: string; + questionnaireVersion: string; + scoreVersion: string; category: string; completedAt: string; answers: RecordedAnswer[]; @@ -57,6 +80,8 @@ export interface EncryptedAssessmentRecord { profileId: string; questionnaireId: string; questionnaireTitle: string; + questionnaireVersion: string; + scoreVersion: string; category: string; completedAt: string; encrypted: true; diff --git a/lib/record-crypto.ts b/lib/record-crypto.ts index 28617c1..57462cf 100644 --- a/lib/record-crypto.ts +++ b/lib/record-crypto.ts @@ -111,6 +111,8 @@ export async function encryptRecordForDevice(record: AssessmentRecord): Promise< profileId: record.profileId, questionnaireId: record.questionnaireId, questionnaireTitle: record.questionnaireTitle, + questionnaireVersion: record.questionnaireVersion, + scoreVersion: record.scoreVersion, category: record.category, completedAt: record.completedAt, encrypted: true, @@ -136,6 +138,8 @@ export async function encryptRecordForAnonymousProfile( profileId: record.profileId, questionnaireId: record.questionnaireId, questionnaireTitle: record.questionnaireTitle, + questionnaireVersion: record.questionnaireVersion, + scoreVersion: record.scoreVersion, category: record.category, completedAt: record.completedAt, encrypted: true, diff --git a/lib/score-summary.test.ts b/lib/score-summary.test.ts index 2397254..46654bc 100644 --- a/lib/score-summary.test.ts +++ b/lib/score-summary.test.ts @@ -5,6 +5,12 @@ 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: '中重度' }); + expect(summary).toMatchObject({ + questionnaireVersion: 'phq-9-zh-v1', + scoreVersion: 'mindscope-phq9-v1', + scoringStatus: 'structured', + direction: 'risk', + }); }); it('converts WHO-5 raw scores to a percentage', () => { @@ -14,7 +20,8 @@ describe('buildScoreSummary', () => { 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: '中度' }); + expect(summary.primary).toMatchObject({ value: 16, max: 40, level: '中等' }); + expect(summary.reverseItems).toEqual([4, 5, 7, 8]); }); it('calculates all three DASS-21 dimensions independently', () => { @@ -43,5 +50,6 @@ describe('buildScoreSummary', () => { const summary = buildScoreSummary('unknown', ['1', '2', '3']); expect(summary.primary).toMatchObject({ label: '原始作答总和', value: 6 }); expect(summary.note).toContain('不替代'); + expect(summary.scoringStatus).toBe('raw'); }); }); diff --git a/lib/score-summary.ts b/lib/score-summary.ts index 5573d42..f100f43 100644 --- a/lib/score-summary.ts +++ b/lib/score-summary.ts @@ -1,37 +1,53 @@ -import { ScoreMetric, ScoreSummary } from '@/lib/assessment-types'; +import { ScoreDirection, ScoreMetric, ScoreSummary, ScoreThreshold } from '@/lib/assessment-types'; +import { getScoringMetadata, MetricDefinition, QuestionnaireScoringMetadata } from '@/lib/scoring-metadata'; 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 { calculateRIASECResults } from '@/components/questionnaire/test/private/RIASECCalculator'; import { calculateSchwartzResults, higherOrderNames, } from '@/components/questionnaire/test/private/SchwartzCalculator'; -const levels: Record = { - minimal: '极轻或无', - mild: '轻度', - moderate: '中度', - moderately_severe: '中重度', - severe: '重度', - normal: '正常范围', - extremely_severe: '极重度', - low: '较低', - high: '较高', - subthreshold: '亚阈值', - no_insomnia: '无明显失眠', +const fallbackMetadata: QuestionnaireScoringMetadata = { + questionnaireVersion: 'unknown-questionnaire-v1', + scoreVersion: 'mindscope-raw-v1', + min: 0, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表原始作答总和更高,具体含义需要结合量表说明查看。', + thresholds: [], + note: '该量表暂以结构化原始分保存,用于保留完整记录与后续复核,不替代量表结果页中的正式解释。', }; -const metric = (key: string, label: string, value: number, max?: number, level?: string): ScoreMetric => ({ - key, - label, - value, - max, - level: level ? levels[level] || level : undefined, -}); +const labelMap: Record = { + extraversion: '外向性', + agreeableness: '宜人性', + conscientiousness: '尽责性', + emotionalStability: '情绪稳定性', + openness: '开放性', + neuroticism: '神经质', + honestyHumility: '诚实谦逊', + emotionality: '情绪性', + depression: '抑郁', + anxiety: '焦虑', + stress: '压力', + opennessToChange: '开放变化', + conservation: '保守稳定', + selfEnhancement: '自我提升', + selfTranscendence: '自我超越', + R: '现实型', + I: '研究型', + A: '艺术型', + S: '社会型', + E: '企业型', + C: '常规型', + total: '总分', + raw: '原始作答总和', +}; function sum(answers: string[]) { return answers.reduce((total, answer) => total + (Number(answer) || 0), 0); @@ -41,106 +57,180 @@ 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]); +function metadataFor(questionnaireId: string) { + return getScoringMetadata(questionnaireId) || { + ...fallbackMetadata, + questionnaireVersion: `${questionnaireId || 'unknown'}-v1`, + }; +} + +function thresholdLabel(value: number, thresholds: ScoreThreshold[]) { + if (!thresholds.length) return undefined; + return thresholds.reduce((current, threshold) => ( + value >= threshold.min ? threshold.label : current + ), thresholds[0].label); +} + +function metricDefinition(meta: QuestionnaireScoringMetadata, key: string): MetricDefinition | undefined { + return meta.metrics?.find((item) => item.key === key); +} + +function makeMetric( + meta: QuestionnaireScoringMetadata, + key: string, + label: string, + value: number, + overrides: Partial = {}, +): ScoreMetric { + const definition = metricDefinition(meta, key); + const min = overrides.min ?? definition?.min ?? meta.min; + const max = overrides.max ?? definition?.max ?? meta.max; + const thresholds = overrides.thresholds ?? definition?.thresholds ?? meta.thresholds; + return { + key, + label, + value, + min, + max, + direction: overrides.direction ?? definition?.direction ?? meta.direction, + highScoreMeaning: overrides.highScoreMeaning ?? definition?.highScoreMeaning ?? meta.highScoreMeaning, + thresholds, + level: overrides.level ?? thresholdLabel(value, thresholds), + }; +} + +function finalize( + questionnaireId: string, + metrics: ScoreMetric[], + primary?: ScoreMetric, + options: { note?: string; scoringStatus?: ScoreSummary['scoringStatus'] } = {}, +): ScoreSummary { + const meta = metadataFor(questionnaireId); + return { + primary, + metrics, + note: options.note ?? meta.note, + questionnaireVersion: meta.questionnaireVersion, + scoreVersion: meta.scoreVersion, + min: meta.min, + max: meta.max, + reverseItems: meta.reverseItems, + direction: meta.direction, + highScoreMeaning: meta.highScoreMeaning, + thresholds: meta.thresholds, + scoringStatus: options.scoringStatus ?? 'structured', + }; } export function buildScoreSummary(questionnaireId: string, answers: string[]): ScoreSummary { + const meta = metadataFor(questionnaireId); + if (questionnaireId === 'bigfive') { const result = calculateBigFiveResults(answers); - const names: Record = { - extraversion: '外向性', - agreeableness: '宜人性', - conscientiousness: '尽责性', - emotionalStability: '情绪稳定性', - openness: '开放性', - }; - return { metrics: Object.entries(result).map(([key, value]) => metric(key, names[key], value.score, 50)) }; + const metrics = Object.entries(result).map(([key, value]) => ( + makeMetric(meta, key, labelMap[key] || key, value.score, { min: 10, max: 50 }) + )); + return finalize(questionnaireId, metrics); } 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 max = version === 120 ? 120 : 300; - return { metrics: Object.entries(result.domains).map(([key, value]) => metric(key, names[key], value.score, max)) }; + const perDomainMax = version === 120 ? 120 : 300; + const metrics = Object.entries(result.domains).map(([key, value]) => ( + makeMetric(meta, key, labelMap[key] || key, value.score, { + min: value.itemCount, + max: perDomainMax, + }) + )); + return finalize(questionnaireId, metrics); } if (questionnaireId === 'hexaco') { - 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)) }; + const metrics = Object.entries(calculateHEXACOResults(answers)).map(([key, value]) => ( + makeMetric(meta, key, labelMap[key] || key, Number(value.toFixed(2)), { min: 1, max: 5 }) + )); + return finalize(questionnaireId, metrics); } 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)), - }; + const metrics = Object.entries(result.scores).map(([key, value]) => ( + makeMetric(meta, key, labelMap[key] || key, value.score, { min: 0, max: 40 }) + )); + const primary = makeMetric(meta, 'holland', `霍兰德代码:${result.hollandCode}`, result.ranking[0][1].score, { + min: 0, + max: 40, + }); + return finalize(questionnaireId, metrics, primary); } 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 = { - 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] }; + const metrics = Object.entries(result.higherOrderScores).map(([key, value]) => ( + makeMetric(meta, key, higherOrderNames[key as keyof typeof higherOrderNames] || labelMap[key] || key, Number(value.toFixed(2)), { + min: 1, + max: 5, + }) + )); + return finalize(questionnaireId, metrics); } if (questionnaireId === 'dass21') { + const answerMap = keyed(answers); 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)) }; + const metrics = Object.entries(groups).map(([key, ids]) => { + const value = ids.reduce((total, id) => total + Number(answerMap[id] || 0), 0) * 2; + return makeMetric(meta, key, labelMap[key] || key, value, { min: 0, max: 42 }); + }); + return finalize(questionnaireId, metrics); } 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] }; + const score = answers.reduce((total, answer, index) => { + const value = Number(answer) || 0; + return total + (reverse.has(index + 1) ? 4 - value : value); + }, 0); + const primary = makeMetric(meta, 'total', 'PSS-10 总分', score, { min: 0, max: 40 }); + return finalize(questionnaireId, [primary], 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 original = answers.reduce((total, answer, index) => { + const value = Number(answer) || 0; + return total + (reverse.has(index + 1) ? 5 - value : value); + }, 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 = makeMetric(meta, 'total', 'SDS 标准分', score, { min: 25, max: 100 }); + return finalize(questionnaireId, [primary], primary); } - const primary = metric('raw', '原始作答总和', raw); - return { - primary, - metrics: [primary], - note: '该数值仅用于保存和核对作答,不替代量表结果页中的正式解释。', + const raw = sum(answers); + const single: Record = { + phq9: { label: 'PHQ-9 总分', value: raw, max: 27 }, + gad7: { label: 'GAD-7 总分', value: raw, max: 21 }, + isi: { label: 'ISI 总分', value: raw, max: 28 }, + bdi2: { label: 'BDI-II 总分', value: raw, max: 63 }, + who5: { label: 'WHO-5 百分制得分', value: raw * 4, max: 100 }, + crt: { label: 'CRT 答对题数', value: raw, max: 7 }, }; + + if (single[questionnaireId]) { + const item = single[questionnaireId]; + const primary = makeMetric(meta, 'total', item.label, item.value, { min: meta.min, max: item.max ?? meta.max }); + return finalize(questionnaireId, [primary], primary); + } + + const primary = makeMetric(meta, 'raw', '原始作答总和', raw, { + min: meta.min, + max: meta.max, + direction: meta.direction as ScoreDirection, + }); + return finalize(questionnaireId, [primary], primary, { scoringStatus: 'raw' }); } diff --git a/lib/scoring-metadata.ts b/lib/scoring-metadata.ts new file mode 100644 index 0000000..ad2ba29 --- /dev/null +++ b/lib/scoring-metadata.ts @@ -0,0 +1,361 @@ +import { ScoreDirection, ScoreThreshold } from '@/lib/assessment-types'; + +export interface MetricDefinition { + key: string; + label: string; + min: number; + max?: number; + direction?: ScoreDirection; + highScoreMeaning?: string; + thresholds?: ScoreThreshold[]; +} + +export interface QuestionnaireScoringMetadata { + questionnaireVersion: string; + scoreVersion: string; + min: number; + max?: number; + reverseItems: number[]; + direction: ScoreDirection; + highScoreMeaning: string; + thresholds: ScoreThreshold[]; + metrics?: MetricDefinition[]; + note?: string; +} + +const severityThresholds: ScoreThreshold[] = [ + { min: 0, label: '极轻或无' }, + { min: 5, label: '轻度' }, + { min: 10, label: '中度' }, + { min: 15, label: '中重度' }, + { min: 20, label: '重度' }, +]; + +const none: ScoreThreshold[] = []; + +export const scoringMetadata: Record = { + bigfive: { + questionnaireVersion: 'ipip-big-five-50-zh-v1', + scoreVersion: 'mindscope-bigfive-50-v1', + min: 10, + max: 50, + reverseItems: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50], + direction: 'trait', + highScoreMeaning: '高分代表该人格特质更明显,不代表好坏。', + thresholds: none, + }, + 'bigfive-120': { + questionnaireVersion: 'ipip-neo-120-zh-v1', + scoreVersion: 'mindscope-ipip-neo-120-v1', + min: 24, + max: 120, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表该人格领域或侧面更明显,不代表好坏。', + thresholds: none, + }, + 'bigfive-300': { + questionnaireVersion: 'ipip-neo-300-zh-v1', + scoreVersion: 'mindscope-ipip-neo-300-v1', + min: 60, + max: 300, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表该人格领域或侧面更明显,不代表好坏。', + thresholds: none, + }, + 'self-esteem': { + questionnaireVersion: 'rosenberg-self-esteem-zh-v1', + scoreVersion: 'mindscope-rse-v1', + min: 0, + max: 30, + reverseItems: [2, 5, 6, 8, 9], + direction: 'protective', + highScoreMeaning: '高分通常代表自尊水平更高。', + thresholds: [{ min: 0, label: '偏低' }, { min: 15, label: '中等' }, { min: 25, label: '较高' }], + }, + grit: { + questionnaireVersion: 'grit-s-zh-v1', + scoreVersion: 'mindscope-grit-v1', + min: 1, + max: 5, + reverseItems: [1, 3, 5, 6], + direction: 'trait', + highScoreMeaning: '高分代表坚持与兴趣稳定性更明显。', + thresholds: none, + }, + 'self-control': { + questionnaireVersion: 'brief-self-control-zh-v1', + scoreVersion: 'mindscope-self-control-v1', + min: 13, + max: 65, + reverseItems: [1, 2, 3, 4, 5, 7, 9, 10, 12, 13], + direction: 'protective', + highScoreMeaning: '高分通常代表自我控制能力更强。', + thresholds: none, + }, + 'need-for-cognition': { + questionnaireVersion: 'need-for-cognition-18-zh-v1', + scoreVersion: 'mindscope-nfc-v1', + min: 18, + max: 90, + reverseItems: [3, 4, 5, 7, 8, 9, 12, 16, 17], + direction: 'trait', + highScoreMeaning: '高分代表更倾向主动思考和享受认知活动。', + thresholds: none, + }, + maximizer: { + questionnaireVersion: 'maximizer-13-zh-v1', + scoreVersion: 'mindscope-maximizer-v1', + min: 13, + max: 65, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表决策中最大化倾向更明显。', + thresholds: none, + }, + attachment: { + questionnaireVersion: 'ecr-rs-zh-v1', + scoreVersion: 'mindscope-attachment-v1', + min: 1, + max: 7, + reverseItems: [1, 2, 3, 4, 5, 6], + direction: 'mixed', + highScoreMeaning: '高分含义取决于维度,焦虑/回避高分通常代表不安全依恋更明显。', + thresholds: none, + }, + empathy: { + questionnaireVersion: 'iri-28-zh-v1', + scoreVersion: 'mindscope-iri-v1', + min: 0, + max: 28, + reverseItems: [3, 4, 7, 12, 13, 14, 15, 18, 19], + direction: 'mixed', + highScoreMeaning: '高分含义取决于维度,共情维度较高通常代表相关倾向更明显。', + thresholds: none, + }, + 'dark-triad': { + questionnaireVersion: 'short-dark-triad-zh-v1', + scoreVersion: 'mindscope-sd3-v1', + min: 1, + max: 5, + reverseItems: [], + direction: 'risk', + highScoreMeaning: '高分代表黑暗三联相关人格倾向更明显。', + thresholds: none, + }, + hexaco: { + questionnaireVersion: 'hexaco-60-zh-v1', + scoreVersion: 'mindscope-hexaco-v1', + min: 1, + max: 5, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表该人格维度更明显,不代表好坏。', + thresholds: none, + }, + fisher: { + questionnaireVersion: 'fisher-temperament-zh-v1', + scoreVersion: 'mindscope-fisher-v1', + min: 0, + max: 40, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表对应气质倾向更明显。', + thresholds: none, + }, + schwartz: { + questionnaireVersion: 'schwartz-values-zh-v1', + scoreVersion: 'mindscope-schwartz-v1', + min: 1, + max: 5, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表对应价值观优先级更高。', + thresholds: none, + }, + via: { + questionnaireVersion: 'via-48-zh-v1', + scoreVersion: 'mindscope-via-v1', + min: 1, + max: 5, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表对应性格优势更明显。', + thresholds: none, + }, + oeps: { + questionnaireVersion: 'open-enneagram-zh-v1', + scoreVersion: 'mindscope-oeps-v1', + min: 0, + max: 100, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表对应九型人格倾向更明显。', + thresholds: none, + }, + crt: { + questionnaireVersion: 'crt-7-zh-v1', + scoreVersion: 'mindscope-crt-v1', + min: 0, + max: 7, + reverseItems: [], + direction: 'ability', + highScoreMeaning: '高分代表答对题数更多,分析性反思表现更好。', + thresholds: [{ min: 0, label: '较低' }, { min: 3, label: '中等' }, { min: 6, label: '较高' }], + }, + riasec: { + questionnaireVersion: 'riasec-zh-v1', + scoreVersion: 'mindscope-riasec-v1', + min: 0, + max: 40, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表对应职业兴趣类型更明显。', + thresholds: none, + }, + 'career-anchors': { + questionnaireVersion: 'career-anchors-zh-v1', + scoreVersion: 'mindscope-career-anchors-v1', + min: 1, + max: 6, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表对应职业锚更重要。', + thresholds: none, + }, + dass21: { + questionnaireVersion: 'dass-21-zh-v1', + scoreVersion: 'mindscope-dass21-v1', + min: 0, + max: 42, + reverseItems: [], + direction: 'risk', + highScoreMeaning: '高分代表抑郁、焦虑或压力相关困扰更明显。', + thresholds: [{ min: 0, label: '正常' }, { min: 10, label: '轻度' }, { min: 14, label: '中度' }, { min: 21, label: '重度' }, { min: 28, label: '极重度' }], + }, + who5: { + questionnaireVersion: 'who-5-zh-v1', + scoreVersion: 'mindscope-who5-v1', + min: 0, + max: 100, + reverseItems: [], + direction: 'protective', + highScoreMeaning: '高分代表主观幸福感更高。', + thresholds: [{ min: 0, label: '需要关注' }, { min: 50, label: '一般或较好' }], + }, + ocd: { + questionnaireVersion: 'ybocs-self-report-zh-v1', + scoreVersion: 'mindscope-ocd-v1', + min: 0, + max: 40, + reverseItems: [], + direction: 'risk', + highScoreMeaning: '高分代表强迫相关困扰更明显。', + thresholds: [{ min: 0, label: '亚临床' }, { min: 8, label: '轻度' }, { min: 16, label: '中度' }, { min: 24, label: '重度' }, { min: 32, label: '极重度' }], + }, + scl90: { + questionnaireVersion: 'scl-90-zh-v1', + scoreVersion: 'mindscope-scl90-v1', + min: 90, + max: 450, + reverseItems: [], + direction: 'risk', + highScoreMeaning: '高分代表近期心理症状负担更高。', + thresholds: [{ min: 90, label: '较低' }, { min: 160, label: '需关注' }, { min: 200, label: '较高' }], + }, + sds: { + questionnaireVersion: 'zung-sds-zh-v1', + scoreVersion: 'mindscope-sds-v1', + min: 25, + max: 100, + reverseItems: [2, 5, 6, 11, 12, 14, 16, 17, 18, 20], + direction: 'risk', + highScoreMeaning: '高分代表抑郁相关症状更明显。', + thresholds: [{ min: 0, label: '正常范围' }, { min: 53, label: '轻度' }, { min: 63, label: '中度' }, { min: 73, label: '重度' }], + }, + gad7: { + questionnaireVersion: 'gad-7-zh-v1', + scoreVersion: 'mindscope-gad7-v1', + min: 0, + max: 21, + reverseItems: [], + direction: 'risk', + highScoreMeaning: '高分代表焦虑相关症状更明显。', + thresholds: [{ min: 0, label: '极轻或无' }, { min: 5, label: '轻度' }, { min: 10, label: '中度' }, { min: 15, label: '重度' }], + }, + phq9: { + questionnaireVersion: 'phq-9-zh-v1', + scoreVersion: 'mindscope-phq9-v1', + min: 0, + max: 27, + reverseItems: [], + direction: 'risk', + highScoreMeaning: '高分代表抑郁相关症状更明显。', + thresholds: severityThresholds, + }, + pss10: { + questionnaireVersion: 'pss-10-zh-v1', + scoreVersion: 'mindscope-pss10-v1', + min: 0, + max: 40, + reverseItems: [4, 5, 7, 8], + direction: 'risk', + highScoreMeaning: '高分代表主观压力更高。', + thresholds: [{ min: 0, label: '较低' }, { min: 14, label: '中等' }, { min: 27, label: '较高' }], + }, + bdi2: { + questionnaireVersion: 'bdi-ii-zh-v1', + scoreVersion: 'mindscope-bdi2-v1', + min: 0, + max: 63, + reverseItems: [], + direction: 'risk', + highScoreMeaning: '高分代表抑郁相关症状更明显。', + thresholds: [{ min: 0, label: '极轻或无' }, { min: 14, label: '轻度' }, { min: 20, label: '中度' }, { min: 29, label: '重度' }], + }, + isi: { + questionnaireVersion: 'isi-zh-v1', + scoreVersion: 'mindscope-isi-v1', + min: 0, + max: 28, + reverseItems: [], + direction: 'risk', + highScoreMeaning: '高分代表失眠困扰更明显。', + thresholds: [{ min: 0, label: '无明显失眠' }, { min: 8, label: '亚阈值' }, { min: 15, label: '中度' }, { min: 22, label: '重度' }], + }, + adhd: { + questionnaireVersion: 'asrs-v1-1-zh-v1', + scoreVersion: 'mindscope-adhd-v1', + min: 0, + max: 72, + reverseItems: [], + direction: 'risk', + highScoreMeaning: '高分代表注意力、冲动或执行功能相关困扰更明显。', + thresholds: [{ min: 0, label: '较低' }, { min: 24, label: '需关注' }], + }, + gd: { + questionnaireVersion: 'gaming-disorder-questionnaire-zh-v1', + scoreVersion: 'mindscope-gd-v1', + min: 0, + max: 36, + reverseItems: [], + direction: 'risk', + highScoreMeaning: '高分代表游戏相关困扰更明显。', + thresholds: [{ min: 0, label: '较低' }, { min: 20, label: '需关注' }], + }, + npd: { + questionnaireVersion: 'npi-16-zh-v1', + scoreVersion: 'mindscope-npi16-v1', + min: 0, + max: 16, + reverseItems: [], + direction: 'trait', + highScoreMeaning: '高分代表自恋相关人格倾向更明显。', + thresholds: none, + }, +}; + +export function getScoringMetadata(questionnaireId: string) { + return scoringMetadata[questionnaireId]; +}