feat: 升级结构化计分可信度

This commit is contained in:
2026-06-23 02:21:09 +02:00
parent e3825c5a4e
commit ca77aa0896
10 changed files with 705 additions and 139 deletions
+13 -38
View File
@@ -1,10 +1,10 @@
'use client'; 'use client';
import { notFound } from 'next/navigation'; 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 { Button } from '@/components/ui/button';
import { Questionnaire } from '@/types'; import { Questionnaire } from '@/types';
import Link from 'next/link';
import { ResultContainer } from '@/components/questionnaire/result/public/ResultContainer'; import { ResultContainer } from '@/components/questionnaire/result/public/ResultContainer';
import { AnswerList } from '@/components/questionnaire/result/public/AnswerList'; import { AnswerList } from '@/components/questionnaire/result/public/AnswerList';
import { ResultAnalysis } from '@/components/questionnaire/result/analysis/ResultAnalysis'; import { ResultAnalysis } from '@/components/questionnaire/result/analysis/ResultAnalysis';
@@ -12,8 +12,7 @@ import { useQuestionnaire } from '@/hooks/useQuestionnaire';
import { useScopedI18n } from '@/locales/client'; import { useScopedI18n } from '@/locales/client';
import { loadResult } from '@/lib/result-storage'; import { loadResult } from '@/lib/result-storage';
import { AssessmentRecord } from '@/lib/assessment-types'; import { AssessmentRecord } from '@/lib/assessment-types';
import { getProfiles, getRecords, updateRecordAnalysis } from '@/lib/assessment-db'; import { getProfiles, getRecords } from '@/lib/assessment-db';
import { syncAnonymousRecord } from '@/lib/anonymous-client';
export default function QuestionnaireResultPage({ export default function QuestionnaireResultPage({
params, params,
@@ -25,14 +24,10 @@ export default function QuestionnaireResultPage({
const [decodedAnswers, setDecodedAnswers] = useState<string[] | null>(null); const [decodedAnswers, setDecodedAnswers] = useState<string[] | null>(null);
const [record, setRecord] = useState<AssessmentRecord | null>(null); const [record, setRecord] = useState<AssessmentRecord | null>(null);
const [profileName, setProfileName] = useState('未命名档案'); const [profileName, setProfileName] = useState('未命名档案');
const analysisRef = useRef<HTMLDivElement>(null);
const t = useScopedI18n('app.questionnaire.result'); const t = useScopedI18n('app.questionnaire.result');
// Get the questionnaire with specified id from questionnaire data
const questionnaire = useQuestionnaire(id) as Questionnaire; 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(() => { useEffect(() => {
if (!questionnaire || !questionnaire.details) { if (!questionnaire || !questionnaire.details) {
return; return;
@@ -40,6 +35,7 @@ export default function QuestionnaireResultPage({
const stored = loadResult(id); const stored = loadResult(id);
setDecodedAnswers(stored?.answers || null); setDecodedAnswers(stored?.answers || null);
async function loadRecord() { async function loadRecord() {
if (stored?.profileId) { if (stored?.profileId) {
const profiles = await getProfiles(); const profiles = await getProfiles();
@@ -51,28 +47,10 @@ export default function QuestionnaireResultPage({
} }
setLoading(false); setLoading(false);
} }
void loadRecord(); void loadRecord();
}, [id, questionnaire]); }, [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<string, string> = useMemo(() => { const questionnaireResults: Record<string, string> = useMemo(() => {
if (!questionnaire || !decodedAnswers) return {}; if (!questionnaire || !decodedAnswers) return {};
const obj: Record<string, string> = {}; const obj: Record<string, string> = {};
@@ -80,35 +58,34 @@ export default function QuestionnaireResultPage({
const val = decodedAnswers[idx]; const val = decodedAnswers[idx];
if (val === undefined) return; if (val === undefined) return;
const option = questionnaire.renderOptions(q.id).find( 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); obj[q.content] = option ? option.content : String(val);
}); });
return obj; return obj;
}, [decodedAnswers, questionnaire]); }, [decodedAnswers, questionnaire]);
// If data not found, show 404 page
if (!questionnaire || !questionnaire.details) { if (!questionnaire || !questionnaire.details) {
return notFound(); return notFound();
} }
if (loading) { if (loading) {
return ( return (
<div className="flex justify-center items-center min-h-screen"> <div className="flex min-h-screen items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div> <div className="h-12 w-12 animate-spin rounded-full border-b-2 border-blue-500" />
</div> </div>
); );
} }
if (!decodedAnswers || decodedAnswers.length !== questionnaire.questions.length) { if (!decodedAnswers || decodedAnswers.length !== questionnaire.questions.length) {
return ( return (
<div className="flex justify-center items-center min-h-screen md:p-4 p-2"> <div className="flex min-h-screen items-center justify-center p-2 md:p-4">
<div className="max-w-6xl w-full bg-white rounded-lg shadow-lg md:p-8 p-4 border"> <div className="w-full max-w-6xl rounded-lg border bg-white p-4 shadow-lg md:p-8">
<h1 className="text-2xl font-bold mb-6"> <h1 className="mb-6 text-2xl font-bold">
{questionnaire.title} - {t('resultNotFoundTitle')} {questionnaire.title} - {t('resultNotFoundTitle')}
</h1> </h1>
<p className="text-gray-700 mb-6">{t('resultNotFoundDesc')}</p> <p className="mb-6 text-gray-700">{t('resultNotFoundDesc')}</p>
<Button> <Button asChild>
<Link href={`/questionnaire/${id}`}>{t('retryTest')}</Link> <Link href={`/questionnaire/${id}`}>{t('retryTest')}</Link>
</Button> </Button>
</div> </div>
@@ -131,9 +108,7 @@ export default function QuestionnaireResultPage({
answers={decodedAnswers} answers={decodedAnswers}
renderOptions={questionnaire.renderOptions} renderOptions={questionnaire.renderOptions}
/> />
<div ref={analysisRef}>
<ResultAnalysis questionnaireId={id} answers={decodedAnswers} /> <ResultAnalysis questionnaireId={id} answers={decodedAnswers} />
</div>
</ResultContainer> </ResultContainer>
); );
} }
@@ -199,14 +199,17 @@ export function Questionnaire({
answer: option?.content || value, answer: option?.content || value,
}; };
}); });
const scoreSummary = buildScoreSummary(id, resultAnswers);
const record = await addAssessmentRecord({ const record = await addAssessmentRecord({
profileId: profile.id, profileId: profile.id,
questionnaireId: id, questionnaireId: id,
questionnaireTitle: questionnaire.title, questionnaireTitle: questionnaire.title,
questionnaireVersion: scoreSummary.questionnaireVersion,
scoreVersion: scoreSummary.scoreVersion,
category: questionnaire.category, category: questionnaire.category,
completedAt, completedAt,
answers: recordedAnswers, answers: recordedAnswers,
scoreSummary: buildScoreSummary(id, resultAnswers), scoreSummary,
retestSuitable: questionnaire.evaluation?.retestSuitable, retestSuitable: questionnaire.evaluation?.retestSuitable,
recommendedInterval: questionnaire.evaluation?.recommendedInterval, recommendedInterval: questionnaire.evaluation?.recommendedInterval,
}); });
+39 -3
View File
@@ -32,6 +32,41 @@ function displayDate(value: string) {
return new Date(value).toLocaleString('zh-CN', { hour12: false }); 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 }) { function MetricBar({ metric }: { metric: ScoreMetric }) {
const width = metric.max ? Math.max(0, Math.min(100, metric.value / metric.max * 100)) : 0; const width = metric.max ? Math.max(0, Math.min(100, metric.value / metric.max * 100)) : 0;
return ( return (
@@ -244,14 +279,15 @@ export function RecordsDashboard() {
{trendGroups.map((group) => ( {trendGroups.map((group) => (
<div key={group[0].questionnaireId}> <div key={group[0].questionnaireId}>
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2"><h2 className="text-lg font-semibold">{group[0].questionnaireTitle}</h2>{group[0].recommendedInterval && <span className="text-sm text-muted-foreground">{group[0].recommendedInterval}</span>}</div> <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2"><h2 className="text-lg font-semibold">{group[0].questionnaireTitle}</h2>{group[0].recommendedInterval && <span className="text-sm text-muted-foreground">{group[0].recommendedInterval}</span>}</div>
<p className="mb-3 text-sm text-muted-foreground">{group[0].scoreSummary.highScoreMeaning} · {directionText(group[0].scoreSummary.direction)}</p>
<div className="overflow-x-auto border"> <div className="overflow-x-auto border">
<table className="w-full min-w-[540px] text-sm"><thead className="bg-muted/50"><tr><th className="p-3 text-left"></th><th className="p-3 text-left"></th><th className="p-3 text-left"></th></tr></thead> <table className="w-full min-w-[640px] text-sm"><thead className="bg-muted/50"><tr><th className="p-3 text-left"></th><th className="p-3 text-left"></th><th className="p-3 text-left"></th><th className="p-3 text-left"></th></tr></thead>
<tbody>{group.map((record, index) => { const metrics = record.scoreSummary.primary ? [record.scoreSummary.primary] : record.scoreSummary.metrics; const previous = group[index - 1]?.scoreSummary.primary; return <tr key={record.id} className="border-t"><td className="p-3">{displayDate(record.completedAt)}</td><td className="p-3">{metrics.map((item) => <span key={item.key} className="mr-4">{item.label} {item.value}{item.max ? `/${item.max}` : ''}</span>)}</td><td className="p-3">{record.scoreSummary.primary && previous ? `${record.scoreSummary.primary.value - previous.value > 0 ? '+' : ''}${record.scoreSummary.primary.value - previous.value}` : '基线'}</td></tr>; })}</tbody> <tbody>{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 <tr key={record.id} className="border-t"><td className="p-3">{displayDate(record.completedAt)}</td><td className="p-3">{metrics.map((item) => <span key={item.key} className="mr-4">{item.label} {item.value}{item.max ? `/${item.max}` : ''}</span>)}</td><td className="p-3">{directionText(primary?.direction || record.scoreSummary.direction)}</td><td className="p-3">{primary ? changeText(primary, previous) : '看各维度'}</td></tr>; })}</tbody>
</table> </table>
</div> </div>
</div> </div>
))} ))}
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground"></p>
</section> </section>
)} )}
+30 -1
View File
@@ -10,6 +10,7 @@ import {
encryptRecordForDevice, encryptRecordForDevice,
isEncryptedRecord, isEncryptedRecord,
} from '@/lib/record-crypto'; } from '@/lib/record-crypto';
import { buildScoreSummary } from '@/lib/score-summary';
const DB_NAME = 'mindscope'; const DB_NAME = 'mindscope';
const DB_VERSION = 1; const DB_VERSION = 1;
@@ -47,6 +48,34 @@ function requestResult<T>(request: IDBRequest<T>): Promise<T> {
}); });
} }
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() { export function getActiveProfileId() {
return localStorage.getItem(ACTIVE_PROFILE_KEY); return localStorage.getItem(ACTIVE_PROFILE_KEY);
} }
@@ -150,7 +179,7 @@ export async function getRecords(profileId?: string): Promise<AssessmentRecord[]
db.close(); db.close();
const typedRecords = storedRecords as Array<AssessmentRecord | EncryptedAssessmentRecord>; const typedRecords = storedRecords as Array<AssessmentRecord | EncryptedAssessmentRecord>;
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[]; const plaintextRecords = typedRecords.filter((record) => !isEncryptedRecord(record)) as AssessmentRecord[];
if (plaintextRecords.length) { if (plaintextRecords.length) {
await migratePlaintextRecords(plaintextRecords); await migratePlaintextRecords(plaintextRecords);
+45 -10
View File
@@ -1,39 +1,74 @@
import { AssessmentProfile, AssessmentRecord } from '@/lib/assessment-types'; import { AssessmentProfile, AssessmentRecord, ScoreThreshold } from '@/lib/assessment-types';
function date(value: string) { function date(value: string) {
return new Date(value).toLocaleString('zh-CN', { hour12: false }); 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) { export function recordToMarkdown(record: AssessmentRecord, profileName: string) {
const lines = [ const lines = [
`# ${record.questionnaireTitle}测评记录`, '', `# ${record.questionnaireTitle}测评记录`,
'',
'## 基本信息', '## 基本信息',
`- 档案:${profileName}`, `- 档案:${profileName || '未命名档案'}`,
`- 测评时间:${date(record.completedAt)}`, `- 测评时间:${date(record.completedAt)}`,
`- 量表编号:${record.questionnaireId}`, `- 量表编号:${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) { if (record.scoreSummary.metrics.length) {
lines.push('## 分数摘要'); lines.push('## 分数摘要');
record.scoreSummary.metrics.forEach((item) => { 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); if (record.scoreSummary.note) lines.push('', record.scoreSummary.note);
lines.push(''); lines.push('');
} }
if (record.analysisText) lines.push('## 结果说明', record.analysisText, ''); if (record.analysisText) lines.push('## 结果说明', record.analysisText, '');
lines.push('## 完整问答'); lines.push('## 完整问答');
record.answers.forEach((item, index) => lines.push(`${index + 1}. ${item.question}`, ` 回答:${item.answer}`, '')); record.answers.forEach((item, index) => {
lines.push('## 使用说明', '本记录仅供自我了解、教育和研究参考,不构成医学或心理诊断。'); lines.push(`${index + 1}. ${item.question}`, ` 回答:${item.answer}`, '');
});
lines.push(
'## 使用说明',
'本记录仅供自我了解、教育和研究参考,不构成医学或心理诊断。',
);
return lines.join('\n'); return lines.join('\n');
} }
export function profileToMarkdown(profile: AssessmentProfile, records: AssessmentRecord[]) { export function profileToMarkdown(profile: AssessmentProfile, records: AssessmentRecord[]) {
const lines = [ const lines = [
`# ${profile.name}的完整测评档案`, '', `# ${profile.name}的完整测评档案`,
'',
`- 导出时间:${date(new Date().toISOString())}`, `- 导出时间:${date(new Date().toISOString())}`,
`- 测评次数:${records.length}`, '', `- 测评次数:${records.length}`,
'---', '', '',
'---',
'',
]; ];
records records
.slice() .slice()
+25
View File
@@ -1,3 +1,5 @@
export type ScoreDirection = 'risk' | 'protective' | 'ability' | 'trait' | 'mixed';
export interface AssessmentProfile { export interface AssessmentProfile {
id: string; id: string;
name: string; name: string;
@@ -5,18 +7,37 @@ export interface AssessmentProfile {
updatedAt: string; updatedAt: string;
} }
export interface ScoreThreshold {
min: number;
label: string;
note?: string;
}
export interface ScoreMetric { export interface ScoreMetric {
key: string; key: string;
label: string; label: string;
value: number; value: number;
min?: number;
max?: number; max?: number;
level?: string; level?: string;
direction?: ScoreDirection;
highScoreMeaning?: string;
thresholds?: ScoreThreshold[];
} }
export interface ScoreSummary { export interface ScoreSummary {
primary?: ScoreMetric; primary?: ScoreMetric;
metrics: ScoreMetric[]; metrics: ScoreMetric[];
note?: string; note?: string;
questionnaireVersion: string;
scoreVersion: string;
min: number;
max?: number;
reverseItems: number[];
direction: ScoreDirection;
highScoreMeaning: string;
thresholds: ScoreThreshold[];
scoringStatus: 'structured' | 'raw';
} }
export interface RecordedAnswer { export interface RecordedAnswer {
@@ -31,6 +52,8 @@ export interface AssessmentRecord {
profileId: string; profileId: string;
questionnaireId: string; questionnaireId: string;
questionnaireTitle: string; questionnaireTitle: string;
questionnaireVersion: string;
scoreVersion: string;
category: string; category: string;
completedAt: string; completedAt: string;
answers: RecordedAnswer[]; answers: RecordedAnswer[];
@@ -57,6 +80,8 @@ export interface EncryptedAssessmentRecord {
profileId: string; profileId: string;
questionnaireId: string; questionnaireId: string;
questionnaireTitle: string; questionnaireTitle: string;
questionnaireVersion: string;
scoreVersion: string;
category: string; category: string;
completedAt: string; completedAt: string;
encrypted: true; encrypted: true;
+4
View File
@@ -111,6 +111,8 @@ export async function encryptRecordForDevice(record: AssessmentRecord): Promise<
profileId: record.profileId, profileId: record.profileId,
questionnaireId: record.questionnaireId, questionnaireId: record.questionnaireId,
questionnaireTitle: record.questionnaireTitle, questionnaireTitle: record.questionnaireTitle,
questionnaireVersion: record.questionnaireVersion,
scoreVersion: record.scoreVersion,
category: record.category, category: record.category,
completedAt: record.completedAt, completedAt: record.completedAt,
encrypted: true, encrypted: true,
@@ -136,6 +138,8 @@ export async function encryptRecordForAnonymousProfile(
profileId: record.profileId, profileId: record.profileId,
questionnaireId: record.questionnaireId, questionnaireId: record.questionnaireId,
questionnaireTitle: record.questionnaireTitle, questionnaireTitle: record.questionnaireTitle,
questionnaireVersion: record.questionnaireVersion,
scoreVersion: record.scoreVersion,
category: record.category, category: record.category,
completedAt: record.completedAt, completedAt: record.completedAt,
encrypted: true, encrypted: true,
+9 -1
View File
@@ -5,6 +5,12 @@ describe('buildScoreSummary', () => {
it('applies PHQ-9 severity thresholds', () => { it('applies PHQ-9 severity thresholds', () => {
const summary = buildScoreSummary('phq9', Array(9).fill('2')); const summary = buildScoreSummary('phq9', Array(9).fill('2'));
expect(summary.primary).toMatchObject({ value: 18, max: 27, level: '中重度' }); 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', () => { it('converts WHO-5 raw scores to a percentage', () => {
@@ -14,7 +20,8 @@ describe('buildScoreSummary', () => {
it('reverse scores the positive PSS-10 items', () => { it('reverse scores the positive PSS-10 items', () => {
const summary = buildScoreSummary('pss10', Array(10).fill('0')); 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', () => { it('calculates all three DASS-21 dimensions independently', () => {
@@ -43,5 +50,6 @@ describe('buildScoreSummary', () => {
const summary = buildScoreSummary('unknown', ['1', '2', '3']); const summary = buildScoreSummary('unknown', ['1', '2', '3']);
expect(summary.primary).toMatchObject({ label: '原始作答总和', value: 6 }); expect(summary.primary).toMatchObject({ label: '原始作答总和', value: 6 });
expect(summary.note).toContain('不替代'); expect(summary.note).toContain('不替代');
expect(summary.scoringStatus).toBe('raw');
}); });
}); });
+172 -82
View File
@@ -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 { import {
calculateBigFiveResults, calculateBigFiveResults,
calculateIpipNeoResults, calculateIpipNeoResults,
ipipNeoItemsByVersion, ipipNeoItemsByVersion,
} from '@/components/questionnaire/test/private/BigFiveCalculator'; } from '@/components/questionnaire/test/private/BigFiveCalculator';
import { calculateHEXACOResults } from '@/components/questionnaire/test/private/HEXACOCalculator'; 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 { import {
calculateSchwartzResults, calculateSchwartzResults,
higherOrderNames, higherOrderNames,
} from '@/components/questionnaire/test/private/SchwartzCalculator'; } from '@/components/questionnaire/test/private/SchwartzCalculator';
const levels: Record<string, string> = { const fallbackMetadata: QuestionnaireScoringMetadata = {
minimal: '极轻或无', questionnaireVersion: 'unknown-questionnaire-v1',
mild: '轻度', scoreVersion: 'mindscope-raw-v1',
moderate: '中度', min: 0,
moderately_severe: '中重度', reverseItems: [],
severe: '重度', direction: 'trait',
normal: '正常范围', highScoreMeaning: '高分代表原始作答总和更高,具体含义需要结合量表说明查看。',
extremely_severe: '极重度', thresholds: [],
low: '较低', note: '该量表暂以结构化原始分保存,用于保留完整记录与后续复核,不替代量表结果页中的正式解释。',
high: '较高',
subthreshold: '亚阈值',
no_insomnia: '无明显失眠',
}; };
const metric = (key: string, label: string, value: number, max?: number, level?: string): ScoreMetric => ({ const labelMap: Record<string, string> = {
key, extraversion: '外向性',
label, agreeableness: '宜人性',
value, conscientiousness: '尽责性',
max, emotionalStability: '情绪稳定性',
level: level ? levels[level] || level : undefined, openness: '开放性',
}); neuroticism: '神经质',
honestyHumility: '诚实谦逊',
emotionality: '情绪性',
depression: '抑郁',
anxiety: '焦虑',
stress: '压力',
opennessToChange: '开放变化',
conservation: '保守稳定',
selfEnhancement: '自我提升',
selfTranscendence: '自我超越',
R: '现实型',
I: '研究型',
A: '艺术型',
S: '社会型',
E: '企业型',
C: '常规型',
total: '总分',
raw: '原始作答总和',
};
function sum(answers: string[]) { function sum(answers: string[]) {
return answers.reduce((total, answer) => total + (Number(answer) || 0), 0); 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])); return Object.fromEntries(answers.map((answer, index) => [index + 1, answer]));
} }
function severity(score: number, thresholds: Array<[number, string]>) { function metadataFor(questionnaireId: string) {
return thresholds.reduce((current, [minimum, label]) => score >= minimum ? label : current, thresholds[0][1]); 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> = {},
): 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 { export function buildScoreSummary(questionnaireId: string, answers: string[]): ScoreSummary {
const meta = metadataFor(questionnaireId);
if (questionnaireId === 'bigfive') { if (questionnaireId === 'bigfive') {
const result = calculateBigFiveResults(answers); const result = calculateBigFiveResults(answers);
const names: Record<string, string> = { const metrics = Object.entries(result).map(([key, value]) => (
extraversion: '外向性', makeMetric(meta, key, labelMap[key] || key, value.score, { min: 10, max: 50 })
agreeableness: '宜人性', ));
conscientiousness: '尽责性', return finalize(questionnaireId, metrics);
emotionalStability: '情绪稳定性',
openness: '开放性',
};
return { metrics: Object.entries(result).map(([key, value]) => metric(key, names[key], value.score, 50)) };
} }
if (questionnaireId === 'bigfive-120' || questionnaireId === 'bigfive-300') { if (questionnaireId === 'bigfive-120' || questionnaireId === 'bigfive-300') {
const version = questionnaireId === 'bigfive-120' ? 120 : 300; const version = questionnaireId === 'bigfive-120' ? 120 : 300;
const result = calculateIpipNeoResults(answers, [...ipipNeoItemsByVersion[version]]); const result = calculateIpipNeoResults(answers, [...ipipNeoItemsByVersion[version]]);
const names: Record<string, string> = { const perDomainMax = version === 120 ? 120 : 300;
neuroticism: '神经质', const metrics = Object.entries(result.domains).map(([key, value]) => (
extraversion: '外向性', makeMetric(meta, key, labelMap[key] || key, value.score, {
openness: '开放性', min: value.itemCount,
agreeableness: '宜人性', max: perDomainMax,
conscientiousness: '尽责性', })
}; ));
const max = version === 120 ? 120 : 300; return finalize(questionnaireId, metrics);
return { metrics: Object.entries(result.domains).map(([key, value]) => metric(key, names[key], value.score, max)) };
} }
if (questionnaireId === 'hexaco') { if (questionnaireId === 'hexaco') {
const names: Record<string, string> = { const metrics = Object.entries(calculateHEXACOResults(answers)).map(([key, value]) => (
honestyHumility: '诚实谦逊', makeMetric(meta, key, labelMap[key] || key, Number(value.toFixed(2)), { min: 1, max: 5 })
emotionality: '情绪性', ));
extraversion: '外向性', return finalize(questionnaireId, metrics);
agreeableness: '宜人性',
conscientiousness: '尽责性',
openness: '开放性',
};
return { metrics: Object.entries(calculateHEXACOResults(answers)).map(([key, value]) => metric(key, names[key], Number(value.toFixed(2)), 5)) };
} }
if (questionnaireId === 'riasec') { if (questionnaireId === 'riasec') {
const result = calculateRIASECResults(answers); const result = calculateRIASECResults(answers);
return { const metrics = Object.entries(result.scores).map(([key, value]) => (
primary: metric('holland', `霍兰德代码 ${result.hollandCode}`, result.ranking[0][1].score, 40), makeMetric(meta, key, labelMap[key] || key, value.score, { min: 0, max: 40 })
metrics: Object.entries(result.scores).map(([key, value]) => metric(key, riasecTypes[key as keyof typeof riasecTypes].name, value.score, 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') { if (questionnaireId === 'schwartz') {
const result = calculateSchwartzResults(answers); 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 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,
const raw = sum(answers); max: 5,
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']]) }, return finalize(questionnaireId, metrics);
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') { if (questionnaireId === 'dass21') {
const answerMap = keyed(answers);
const groups = { const groups = {
depression: [3, 5, 10, 13, 16, 17, 21], depression: [3, 5, 10, 13, 16, 17, 21],
anxiety: [2, 4, 7, 9, 15, 19, 20], anxiety: [2, 4, 7, 9, 15, 19, 20],
stress: [1, 6, 8, 11, 12, 14, 18], stress: [1, 6, 8, 11, 12, 14, 18],
}; };
const names = { depression: '抑郁', anxiety: '焦虑', stress: '压力' }; const metrics = Object.entries(groups).map(([key, ids]) => {
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 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') { if (questionnaireId === 'pss10') {
const reverse = new Set([4, 5, 7, 8]); 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 score = answers.reduce((total, answer, index) => {
const primary = metric('total', 'PSS-10 总分', score, 40, severity(score, [[0, 'low'], [14, 'moderate'], [27, 'high']])); const value = Number(answer) || 0;
return { primary, metrics: [primary] }; 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') { if (questionnaireId === 'sds') {
const reverse = new Set([2, 5, 6, 11, 12, 14, 16, 17, 18, 20]); 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 score = Math.round(original * 1.25);
const primary = metric('total', 'SDS 标准分', score, 100, severity(score, [[0, 'normal'], [53, 'mild'], [63, 'moderate'], [73, 'severe']])); const primary = makeMetric(meta, 'total', 'SDS 标准分', score, { min: 25, max: 100 });
return { primary, metrics: [primary] }; return finalize(questionnaireId, [primary], primary);
} }
const primary = metric('raw', '原始作答总和', raw); const raw = sum(answers);
return { const single: Record<string, { label: string; value: number; max?: number }> = {
primary, phq9: { label: 'PHQ-9 总分', value: raw, max: 27 },
metrics: [primary], gad7: { label: 'GAD-7 总分', value: raw, max: 21 },
note: '该数值仅用于保存和核对作答,不替代量表结果页中的正式解释。', 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' });
} }
+361
View File
@@ -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<string, QuestionnaireScoringMetadata> = {
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];
}