feat: 完善中文心理测评平台

This commit is contained in:
mikemoi
2026-06-22 22:59:01 +02:00
commit 9227c687fc
160 changed files with 16974 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
'use client';
import { Button } from '@/components/ui/button';
import { Questionnaire, QuestionnaireCategory } from '@/types';
interface CategoryFiltersProps {
questionnaires: Questionnaire[];
value: QuestionnaireCategory | null;
onChange: (category: QuestionnaireCategory | null) => void;
}
const categories: Array<{
value: QuestionnaireCategory;
label: string;
}> = [
{ value: '人格', label: '人格特点' },
{ value: '情绪', label: '情绪与压力' },
{ value: '认知', label: '注意与思考' },
{ value: '睡眠', label: '睡眠' },
{ value: '职业', label: '职业兴趣' },
{ value: '心理健康', label: '综合心理健康' },
];
export function CategoryFilters({
questionnaires,
value,
onChange,
}: CategoryFiltersProps) {
const availableCategories = categories.filter((category) =>
questionnaires.some(
(questionnaire) => questionnaire.category === category.value,
),
);
return (
<div className="mb-8">
<div className="mb-3 text-sm font-medium"></div>
<div className="flex flex-wrap gap-2">
<Button
variant={value === null ? 'default' : 'outline'}
size="sm"
aria-pressed={value === null}
onClick={() => onChange(null)}
>
</Button>
{availableCategories.map((category) => (
<Button
key={category.value}
variant={value === category.value ? 'default' : 'outline'}
size="sm"
aria-pressed={value === category.value}
onClick={() => onChange(category.value)}
>
{category.label}
</Button>
))}
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ClipboardList } from 'lucide-react';
import { useScopedI18n } from '@/locales/client';
export function Navbar() {
const pathname = usePathname();
const t = useScopedI18n('component.navBar');
return (
<header className="border-b">
<div className="container flex items-center justify-between h-14 px-4 max-w-6xl mx-auto">
<Link href="/" className="text-lg font-medium flex items-center gap-2">
<span className="w-8 h-8 border rounded flex items-center justify-center">
<ClipboardList className="h-4 w-4" />
</span>
<span className="hidden md:block">{t('title')}</span>
</Link>
<nav className="flex items-center gap-4 text-sm">
<Link
href="/"
className="font-medium hover:text-foreground transition-colors"
>
{t('introduce')}
</Link>
<Link
href="/questionnaire"
className={`${
pathname.startsWith('/questionnaire')
? 'font-medium'
: 'text-muted-foreground'
} hover:text-foreground transition-colors`}
>
{t('questionsList')}
</Link>
</nav>
</div>
</header>
);
}
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { Search } from 'lucide-react';
import { useState, useCallback } from 'react';
import Link from 'next/link';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { CategoryFilters } from '@/components/CategoryFilters';
import { useScopedI18n } from '@/locales/client';
import { useQuestionnaire } from '@/hooks/useQuestionnaire';
import { Questionnaire, QuestionnaireCategory } from '@/types';
export default function QuestionnaireList() {
const questionnaires = useQuestionnaire();
const t = useScopedI18n('component.questionnaire.list');
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] =
useState<QuestionnaireCategory | null>(null);
const handleCategoryChange = useCallback(
(category: QuestionnaireCategory | null) => {
setSelectedCategory(category);
},
[],
);
// Filter questionnaires based on search terms and the selected purpose.
const filteredQuestionnaires = (questionnaires as Questionnaire[]).filter((q) => {
// Text search filtering
const matchesSearch =
q.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.tags.some((tag) =>
tag.toLowerCase().includes(searchQuery.toLowerCase())
);
const matchesCategory =
selectedCategory === null || q.category === selectedCategory;
return matchesSearch && matchesCategory;
});
return (
<div className="flex flex-col min-h-screen">
<main className="flex-1">
<div className="container px-4 py-6 max-w-6xl mx-auto">
<h1 className="text-2xl font-medium mb-6">{t('title')}</h1>
{/* Search bar */}
<div className="mb-4 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={t('searchPlaceholder')}
className="pl-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<CategoryFilters
questionnaires={questionnaires as Questionnaire[]}
value={selectedCategory}
onChange={handleCategoryChange}
/>
{/* Questionnaire list */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{filteredQuestionnaires.length > 0 ? (
filteredQuestionnaires.map((questionnaire) => (
<Card key={questionnaire.id}>
<CardHeader>
<CardTitle>{questionnaire.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 h-12">
<p
className="text-sm text-muted-foreground"
style={{
display: '-webkit-box',
WebkitLineClamp: '2',
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{questionnaire.description}
</p>
</div>
</CardContent>
<CardFooter className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">
{questionnaire.time}
</span>
<Link href={`/questionnaire/${questionnaire.id}/details`}>
<Button className="cursor-pointer">
{t('detailButton')}
</Button>
</Link>
</CardFooter>
</Card>
))
) : (
<div className="col-span-3 text-center py-8 text-muted-foreground">
{t('noMatch')}
</div>
)}
</div>
</div>
</main>
</div>
);
}
@@ -0,0 +1,163 @@
'use client';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { ArrowLeft, ArrowRight, Clock, FileText } from 'lucide-react';
import { useScopedI18n } from '@/locales/client';
import { Questionnaire } from '@/types';
interface QuestionnaireDetailsPageProps {
questionnaire: Questionnaire;
}
export default function QuestionnaireDetailsPage({
questionnaire,
}: QuestionnaireDetailsPageProps) {
const t = useScopedI18n('app.questionnaire.page');
const { title, details, evaluation, id } = questionnaire;
return (
<div className="container px-4 py-8 max-w-6xl mx-auto">
<nav className="mb-6">
<Link
href="/questionnaire"
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary"
>
<ArrowLeft className="w-4 h-4 mr-1" />
</Link>
</nav>
<div className="mb-8">
<h1 className="text-3xl font-bold mb-4">{title}</h1>
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground mb-6">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
<span>{details.questionCount}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>{details.evaluationTime}</span>
</div>
</div>
{evaluation && (
<dl className="mb-6 grid max-w-2xl grid-cols-1 border sm:grid-cols-3">
<div className="p-3">
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 font-medium">{evaluation.academicRecognition}</dd>
</div>
<div className="border-t p-3 sm:border-l sm:border-t-0">
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 font-medium">
{evaluation.retestSuitable ? '是' : '否'}
</dd>
</div>
<div className="border-t p-3 sm:border-l sm:border-t-0">
<dt className="text-xs text-muted-foreground"></dt>
<dd className="mt-1 font-medium">{evaluation.recommendedInterval}</dd>
</div>
</dl>
)}
<Link href={`/questionnaire/${id}`}>
<Button size="lg" className="px-8 py-6 text-lg gap-2">
<ArrowRight className="w-5 h-5" />
</Button>
</Link>
</div>
<div className="prose prose-gray max-w-none">
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">{t('introduction')}</h2>
<p className="text-gray-700 leading-relaxed">
{details.introduction}
</p>
</section>
{details.instructions && (
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">{t('instructions')}</h2>
<p className="text-gray-700 leading-relaxed">
{details.instructions}
</p>
</section>
)}
{details.scoringMethod && details.scoringMethod.length > 0 && (
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">
{t('scoringMethod')}
</h2>
<ul className="list-disc pl-6 space-y-2 text-gray-700">
{details.scoringMethod.map((method) => (
<li key={method} className="leading-relaxed">
{method}
</li>
))}
</ul>
</section>
)}
{details.dimensions && details.dimensions.length > 0 && (
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">{t('dimensions')}</h2>
<ol className="list-decimal pl-6 space-y-3 text-gray-700">
{details.dimensions.map((dim) => (
<li key={dim.name} className="leading-relaxed">
<strong className="text-gray-900">{dim.name}</strong>
{dim.description}
</li>
))}
</ol>
</section>
)}
{details.notes && details.notes.length > 0 && (
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">{t('notes')}</h2>
<ol className="list-decimal pl-6 space-y-2 text-gray-700">
{details.notes.map((note) => (
<li key={note} className="leading-relaxed">
{note}
</li>
))}
</ol>
</section>
)}
{details.references && details.references.length > 0 && (
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">{t('references')}</h2>
<ul className="list-disc pl-6 space-y-2">
{details.references.map((ref) => (
<li key={ref.text}>
<a
href={ref.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{ref.text}
</a>
</li>
))}
</ul>
</section>
)}
</div>
<div className="mt-12 pt-8 border-t">
<div className="text-center">
<p className="text-gray-600 mb-4"></p>
<Link href={`/questionnaire/${id}`}>
<Button size="lg" className="px-8 py-6 text-lg gap-2">
<ArrowRight className="w-5 h-5" />
</Button>
</Link>
</div>
</div>
</div>
);
}
@@ -0,0 +1,134 @@
'use client';
import React from 'react';
import { useScopedI18n } from '@/locales/client';
import { calculateADHDResults } from '../../test/private/ADHDCalculator';
function useLabels() {
const t = useScopedI18n('components.adhdResult');
return {
totalScore: t('totalScore'),
inattention: t('inattention'),
hyperactivity: t('hyperactivity'),
partAScore: t('partAScore'),
screeningResult: t('screeningResult'),
severityLevel: t('severityLevel'),
positiveScreen: t('positiveScreen'),
negativeScreen: t('negativeScreen'),
partAPositiveResponses: t('partAPositiveResponses'),
basedOnTotalScore: t('basedOnTotalScore'),
recommendations: t('recommendations'),
importantNotes: t('importantNotes'),
severityLevels: {
low: t('severityLevels.low'),
mild: t('severityLevels.mild'),
moderate: t('severityLevels.moderate'),
high: t('severityLevels.high'),
},
notes: {
screening: t('notes.screening'),
symptoms: t('notes.symptoms'),
evaluation: t('notes.evaluation'),
},
recommendationTexts: {
positive: t('recommendationTexts.positive'),
negative: t('recommendationTexts.negative'),
},
};
}
export function ADHDResult({
answers,
}: {
answers: string[];
}) {
const labels = useLabels();
// Convert answers array to object format expected by calculator
const answersObj: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersObj[index + 1] = answer;
});
const results = calculateADHDResults({ answers: answersObj, questions: [] });
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'low': return 'text-green-600';
case 'mild': return 'text-yellow-600';
case 'moderate': return 'text-orange-600';
case 'high': return 'text-red-600';
default: return 'text-gray-600';
}
};
const getSeverityLabel = (severity: string) => {
return labels.severityLevels[severity as keyof typeof labels.severityLevels] || 'Unknown';
};
return (
<div className="mt-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard title={labels.totalScore} value={results.totalScore} />
<MetricCard title={labels.inattention} value={results.factorScores.inattention} />
<MetricCard title={labels.hyperactivity} value={results.factorScores.hyperactivity} />
<MetricCard title={labels.partAScore} value={results.factorScores.partA} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-medium text-gray-500 mb-2">{labels.screeningResult}</h3>
<div className={`text-lg font-semibold ${results.screeningPositive ? 'text-orange-600' : 'text-green-600'}`}>
{results.screeningPositive ? labels.positiveScreen : labels.negativeScreen}
</div>
<p className="text-sm text-gray-600 mt-1">
{labels.partAPositiveResponses}: {results.partAPositive}/6
</p>
</div>
<div className="bg-white border rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-medium text-gray-500 mb-2">{labels.severityLevel}</h3>
<div className={`text-lg font-semibold ${getSeverityColor(results.severity)}`}>
{getSeverityLabel(results.severity)}
</div>
<p className="text-sm text-gray-600 mt-1">
{labels.basedOnTotalScore}: {results.totalScore}/72
</p>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 mb-2">{labels.recommendations}</h3>
<p className="text-sm text-blue-700">
{results.screeningPositive ? labels.recommendationTexts.positive : labels.recommendationTexts.negative}
</p>
</div>
<div className="bg-gray-50 border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-2">{labels.importantNotes}</h3>
<ul className="text-sm text-gray-600 space-y-1">
<li> {labels.notes.screening}</li>
<li> {labels.notes.symptoms}</li>
<li> {labels.notes.evaluation}</li>
</ul>
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div
className={`bg-white border rounded-lg p-4 flex flex-col items-center shadow-sm ${className}`}
>
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className="text-2xl font-semibold text-indigo-600">{value}</span>
</div>
);
}
@@ -0,0 +1,84 @@
'use client';
import { calculateAttachmentResults } from '../../test/private/AttachmentCalculator';
interface AttachmentResultProps {
answers: string[];
}
const patternText = {
secure: '相对安全型',
preoccupied: '焦虑偏高型',
dismissive: '回避偏高型',
fearful: '焦虑与回避都偏高',
};
const patternDescription = {
secure: '你在当前重要关系中通常较能亲近、信任和表达需求,不安全感相对较低。',
preoccupied: '你可能较容易担心关系不够稳定,或需要更多确认来获得安全感。',
dismissive: '你可能更习惯保持独立和距离,不太愿意依靠他人或表达脆弱。',
fearful: '你可能既渴望亲近,又担心受伤或被拒绝,因此在关系中容易拉扯。',
};
function width(value: number) {
return `${Math.max(0, Math.min(100, ((value - 1) / 6) * 100))}%`;
}
export function AttachmentResult({ answers }: AttachmentResultProps) {
const results = calculateAttachmentResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<MetricCard title="依恋回避" value={`${results.avoidance.toFixed(2)}/7`} />
<MetricCard title="依恋焦虑" value={`${results.anxiety.toFixed(2)}/7`} />
<MetricCard title="模式提示" value={patternText[results.pattern]} />
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<BarCard title="依恋回避" value={results.avoidance} />
<BarCard title="依恋焦虑" value={results.anxiety} />
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-sm">{patternDescription[results.pattern]}</p>
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
</div>
</div>
);
}
function MetricCard({ title, value }: { title: string; value: string }) {
return (
<div className="rounded-lg bg-gray-50 p-4 text-center">
<div className="text-sm text-muted-foreground">{title}</div>
<div className="mt-1 text-2xl font-semibold text-indigo-600">{value}</div>
</div>
);
}
function BarCard({ title, value }: { title: string; value: number }) {
return (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-semibold">{title}</h4>
<span className="text-sm text-muted-foreground">
{value.toFixed(2)} / 7
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: width(value) }}
/>
</div>
</div>
);
}
@@ -0,0 +1,281 @@
'use client';
import React from 'react';
import { calculateBDI2Results } from '../../test/private/BDI2Calculator';
import { useScopedI18n } from '@/locales/client';
interface BDI2ResultProps {
answers: string[];
}
export function BDI2Result({ answers }: BDI2ResultProps) {
const t = useScopedI18n('components.bdi2Result');
const tCommon = useScopedI18n('common');
// Convert answer format to the format required by calculator
const answersMap: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersMap[index + 1] = answer;
});
const results = calculateBDI2Results({
answers: answersMap,
questions: []
});
const severityNames = {
minimal: t('severity.minimal'),
mild: t('severity.mild'),
moderate: t('severity.moderate'),
severe: t('severity.severe')
};
const severityDescriptions = {
minimal: t('severityDescriptions.minimal'),
mild: t('severityDescriptions.mild'),
moderate: t('severityDescriptions.moderate'),
severe: t('severityDescriptions.severe')
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case "minimal": return "text-green-600 bg-green-50 border-green-200";
case "mild": return "text-yellow-600 bg-yellow-50 border-yellow-200";
case "moderate": return "text-orange-600 bg-orange-50 border-orange-200";
case "severe": return "text-red-600 bg-red-50 border-red-200";
default: return "text-gray-600 bg-gray-50 border-gray-200";
}
};
const subscaleInfo = {
emotional: { name: t('subscales.emotional'), maxScore: 18, description: t('subscaleDescriptions.emotional') },
cognitive: { name: t('subscales.cognitive'), maxScore: 15, description: t('subscaleDescriptions.cognitive') },
somatic: { name: t('subscales.somatic'), maxScore: 15, description: t('subscaleDescriptions.somatic') },
behavioral: { name: t('subscales.behavioral'), maxScore: 15, description: t('subscaleDescriptions.behavioral') }
};
const questionTexts = [
t('symptoms.0'), t('symptoms.1'), t('symptoms.2'), t('symptoms.3'), t('symptoms.4'),
t('symptoms.5'), t('symptoms.6'), t('symptoms.7'), t('symptoms.8'), t('symptoms.9'),
t('symptoms.10'), t('symptoms.11'), t('symptoms.12'), t('symptoms.13'), t('symptoms.14'),
t('symptoms.15'), t('symptoms.16'), t('symptoms.17'), t('symptoms.18'), t('symptoms.19'),
t('symptoms.20')
];
return (
<div className="mt-6 space-y-6">
{/* Emergency warning */}
{results.suicidalIdeation && (
<div className="bg-red-100 border-2 border-red-300 rounded-lg p-6 shadow-sm">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-8 w-8 text-red-600" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-lg font-bold text-red-800">{t('labels.emergency_reminder')}</h3>
<div className="text-sm font-medium text-red-700 mt-1">
{t('crisis.suicide_warning')}
<ul className="mt-2 ml-4 space-y-1">
<li>{t('crisis.hotline')}</li>
<li>{t('crisis.hospital')}</li>
<li>{t('crisis.doctor')}</li>
<li>{t('crisis.support')}</li>
</ul>
</div>
</div>
</div>
</div>
)}
{/* Overall score */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard title={tCommon('labels.total_score')} value={`${results.totalScore}/63`} />
<MetricCard title={tCommon('labels.high_score_items')} value={`${results.highScoreItemCount}/21`} />
<MetricCard
title={tCommon('labels.severity_level')}
value={severityNames[results.severity as keyof typeof severityNames] || "未知"}
className={getSeverityColor(results.severity).split(' ')[0]}
/>
</div>
</div>
{/* Severity level description */}
<div className={`border rounded-lg p-6 shadow-sm ${getSeverityColor(results.severity)}`}>
<h3 className="text-lg font-semibold mb-3">{t('labels.result_interpretation')}</h3>
<p className="text-sm mb-4">
{severityDescriptions[results.severity as keyof typeof severityDescriptions] || "评估结果异常,请重新测试。"}
</p>
<div className="space-y-2 text-sm">
<div><strong>{t('labels.scoring_criteria')}</strong></div>
<ul className="ml-4 space-y-1">
<li>{t('scoring.range_0_13')}</li>
<li>{t('scoring.range_14_19')}</li>
<li>{t('scoring.range_20_28')}</li>
<li>{t('scoring.range_29_63')}</li>
</ul>
</div>
</div>
{/* Symptom dimension analysis */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.symptom_dimension_analysis')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(subscaleInfo).map(([key, info]) => {
const score = results.factorScores[key] as number;
const percentage = (score / info.maxScore) * 100;
return (
<div key={key} className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<span className="font-medium">{info.name}</span>
<span className="text-sm text-gray-600">{score}/{info.maxScore}</span>
</div>
<div className="mb-2">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${percentage >= 75 ? "bg-red-400" :
percentage >= 50 ? "bg-orange-400" :
percentage >= 25 ? "bg-yellow-400" : "bg-green-400"
}`}
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
<div className="text-sm text-gray-600">
{info.description}
</div>
</div>
);
})}
</div>
</div>
{/* Item analysis */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.item_detailed_analysis')}</h3>
<div className="space-y-2">
{results.itemAnalysis.map((item: any, index: number) => (
<div key={item.questionId} className={`flex items-center justify-between p-3 rounded-lg ${item.questionId === 9 && item.score >= 1 ? 'bg-red-50 border border-red-200' : 'bg-gray-50'
}`}>
<div className="flex-1">
<span className="text-sm font-medium">
{index + 1}. {questionTexts[index]}
</span>
{item.questionId === 9 && item.score >= 1 && (
<div className="text-xs text-red-600 mt-1">{t('labels.suicide_risk_attention')}</div>
)}
</div>
<div className="flex items-center space-x-2">
<span className={`text-lg font-semibold ${item.questionId === 9 && item.score >= 1 ? 'text-red-700' :
item.score >= 2 ? 'text-red-600' :
item.score >= 1 ? 'text-yellow-600' : 'text-green-600'
}`}>
{item.score}
</span>
{item.isHigh && item.questionId !== 9 && (
<span className="px-2 py-1 text-xs bg-yellow-100 text-yellow-800 rounded-full">
{tCommon('labels.needs_attention')}
</span>
)}
</div>
</div>
))}
</div>
</div>
{/* Professional advice */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.professional_advice')}</h3>
<div className="space-y-4 text-sm text-gray-700">
{results.severity === "minimal" ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="text-green-800">
<strong>{t('advice.maintain_good_state')}</strong>
<ul className="mt-2 ml-4 space-y-1">
<li>{t('advice.maintain_good_state_item_1')}</li>
<li>{t('advice.maintain_good_state_item_2')}</li>
<li>{t('advice.maintain_good_state_item_3')}</li>
<li>{t('advice.maintain_good_state_item_4')}</li>
</ul>
</div>
</div>
) : (
<div>
<strong>{t('advice.depression_management')}</strong>
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-2">{t('advice.daily_management')}</h4>
<ul className="text-blue-800 space-y-1 text-sm">
<li>{t('advice.daily_management_item_1')}</li>
<li>{t('advice.daily_management_item_2')}</li>
<li>{t('advice.daily_management_item_3')}</li>
<li>{t('advice.daily_management_item_4')}</li>
<li>{t('advice.daily_management_item_5')}</li>
</ul>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h4 className="font-medium text-purple-900 mb-2">{t('advice.social_support')}</h4>
<ul className="text-purple-800 space-y-1 text-sm">
<li>{t('advice.social_support_item_1')}</li>
<li>{t('advice.social_support_item_2')}</li>
<li>{t('advice.social_support_item_3')}</li>
<li>{t('advice.social_support_item_4')}</li>
<li>{t('advice.social_support_item_5')}</li>
</ul>
</div>
</div>
</div>
)}
{(results.severity === "moderate" || results.severity === "severe") && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
<div className="text-orange-900">
<strong>{t('advice.professional_treatment')}</strong>
<ul className="mt-2 ml-4 space-y-1">
<li>{t('advice.professional_treatment_item_1')}</li>
<li>{t('advice.professional_treatment_item_2')}</li>
<li>{t('advice.professional_treatment_item_3')}</li>
<li>{t('advice.professional_treatment_item_4')}</li>
<li>{t('advice.professional_treatment_item_5')}</li>
</ul>
</div>
</div>
)}
<div className="bg-gray-50 border border-gray-200 rounded p-3">
<p className="text-gray-800">
<strong>{t('labels.important_reminder')}</strong>{t('disclaimer')}
</p>
</div>
</div>
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div className="bg-gray-50 rounded-lg p-4 flex flex-col items-center">
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className={`text-2xl font-semibold ${className || 'text-indigo-600'}`}>
{value}
</span>
</div>
);
}
@@ -0,0 +1,197 @@
'use client';
import {
calculateBigFiveResults,
calculateIpipNeoResults,
ipipNeoItemsByVersion,
} from '../../test/private/BigFiveCalculator';
import { ipipNeoFacets } from '@/questionairies/bigfive/neo-data';
interface BigFiveResultProps {
answers: string[];
version?: 50 | 120 | 300;
}
interface ScoreResult {
score: number;
average: number;
itemCount?: number;
}
const domainLabels = {
neuroticism: {
name: '神经质',
desc: '情绪敏感、压力反应以及体验焦虑、烦躁等负面情绪的倾向。',
},
extraversion: {
name: '外向性',
desc: '社交投入、自信表达、活跃度和积极情绪。',
},
openness: {
name: '开放性',
desc: '想象力、审美、求知、尝新和价值观开放程度。',
},
agreeableness: {
name: '宜人性',
desc: '信任、真诚、合作、利他、谦逊和同情倾向。',
},
conscientiousness: {
name: '尽责性',
desc: '自我效能、条理、责任、自律、成就追求和谨慎。',
},
};
const shortLabels = {
extraversion: domainLabels.extraversion,
agreeableness: domainLabels.agreeableness,
conscientiousness: domainLabels.conscientiousness,
emotionalStability: {
name: '情绪稳定性',
desc: '情绪平稳、压力耐受和较少焦虑烦躁。',
},
openness: domainLabels.openness,
};
function level(average: number) {
if (average >= 3.8) return '较高';
if (average <= 2.4) return '较低';
return '中等';
}
function barWidth(average: number) {
return `${Math.max(0, Math.min(100, ((average - 1) / 4) * 100))}%`;
}
function ScoreCard({
name,
desc,
result,
maxScore,
}: {
name: string;
desc: string;
result: ScoreResult;
maxScore: number;
}) {
return (
<div className="border rounded-lg p-5 bg-white shadow-sm">
<div className="flex items-start justify-between gap-4 mb-3">
<div>
<h4 className="font-semibold">{name}</h4>
<p className="text-sm text-muted-foreground mt-1">{desc}</p>
</div>
<div className="text-right shrink-0">
<div className="text-2xl font-semibold text-indigo-600">
{result.score}
</div>
<div className="text-xs text-muted-foreground">/ {maxScore}</div>
</div>
</div>
<div className="h-2 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full bg-indigo-500"
style={{ width: barWidth(result.average) }}
/>
</div>
<div className="mt-3 text-sm">
<span className="font-medium">{level(result.average)}</span>
<span className="text-muted-foreground ml-2">
{result.average.toFixed(2)} / 5
</span>
</div>
</div>
);
}
export function BigFiveResult({ answers, version = 50 }: BigFiveResultProps) {
if (version === 50) {
const results = calculateBigFiveResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm text-muted-foreground">
50
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(shortLabels).map(([key, info]) => (
<ScoreCard
key={key}
name={info.name}
desc={info.desc}
result={results[key] as ScoreResult}
maxScore={50}
/>
))}
</div>
</div>
);
}
const items = ipipNeoItemsByVersion[version];
const results = calculateIpipNeoResults(answers, [...items]);
const domainItemCount = version === 120 ? 24 : 60;
const facetItemCount = version === 120 ? 4 : 10;
return (
<div className="mt-6 space-y-8">
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-2">
IPIP-NEO {version}
</h3>
<p className="text-sm text-muted-foreground">
30
</p>
</div>
<section>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(domainLabels).map(([key, info]) => (
<ScoreCard
key={key}
name={info.name}
desc={info.desc}
result={results.domains[key] as ScoreResult}
maxScore={domainItemCount * 5}
/>
))}
</div>
</section>
<section>
<h3 className="text-lg font-semibold mb-4">30</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(ipipNeoFacets).map(([key, facet]) => {
const result = results.facets[key] as ScoreResult;
return (
<div key={key} className="border rounded-lg bg-white p-4">
<div className="flex items-center justify-between gap-3">
<h4 className="font-medium">{facet.name}</h4>
<span className="text-sm font-semibold text-indigo-600">
{result.score} / {facetItemCount * 5}
</span>
</div>
<div className="mt-3 h-1.5 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: barWidth(result.average) }}
/>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{level(result.average)} · {result.average.toFixed(2)} / 5
</div>
</div>
);
})}
</div>
</section>
<div className="bg-gray-50 border rounded-lg p-4 text-sm text-gray-700">
</div>
</div>
);
}
@@ -0,0 +1,59 @@
'use client';
import { calculateCRTResults } from '../../test/private/CRTCalculator';
interface CRTResultProps {
answers: string[];
}
function summary(score: number) {
if (score >= 6) return '反思推理表现较强,能较好地抑制直觉错误。';
if (score >= 3) return '反思推理表现中等,有些题目能停下来重新检查。';
return '本次更容易受直觉答案影响,建议在关键判断中刻意放慢。';
}
export function CRTResult({ answers }: CRTResultProps) {
const result = calculateCRTResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">CRT </h3>
<div className="flex items-end gap-3">
<span className="text-4xl font-semibold text-indigo-600">
{result.score}
</span>
<span className="text-muted-foreground mb-1">/ {result.total} </span>
</div>
<p className="text-sm text-gray-700 mt-4">{summary(result.score)}</p>
</div>
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h4 className="font-semibold mb-3"></h4>
<div className="space-y-2">
{result.items.map((item) => (
<div
key={item.questionId}
className="flex items-center justify-between rounded border p-3 text-sm"
>
<span> {item.questionId} </span>
<span
className={
item.isCorrect
? 'text-green-700 font-medium'
: 'text-red-700 font-medium'
}
>
{item.isCorrect ? '正确' : `错误,正确选项:${item.correct}`}
</span>
</div>
))}
</div>
</div>
<div className="bg-gray-50 border rounded-lg p-4 text-sm text-gray-700">
CRT
</div>
</div>
);
}
@@ -0,0 +1,76 @@
'use client';
import { calculateCareerAnchorsResults, CareerAnchor } from '../../test/private/CareerAnchorsCalculator';
interface CareerAnchorsResultProps {
answers: string[];
}
const descriptions: Record<CareerAnchor, string> = {
technical: "适合持续积累专业深度,通过专业能力获得成就和认可。",
managerial: "适合整合资源、带团队、处理复杂组织目标和责任。",
autonomy: "适合自由度较高、能自主安排方法和节奏的工作模式。",
security: "适合稳定、规则明确、风险可控且保障清晰的环境。",
entrepreneurial: "适合从零创造、开拓机会、主导项目或业务方向。",
service: "适合使命驱动、对他人或社会有明确贡献感的工作。",
challenge: "适合高难度、高竞争、需要突破限制的问题场景。",
lifestyle: "适合能与健康、家庭、自由时间和整体生活协调的职业路径。",
};
function width(score: number) {
return `${Math.max(0, Math.min(100, ((score - 1) / 4) * 100))}%`;
}
export function CareerAnchorsResult({ answers }: CareerAnchorsResultProps) {
const results = calculateCareerAnchorsResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">Career Anchors </h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MetricCard title="主要职业锚" value={results.primary.name} score={results.primary.score.toFixed(2)} />
<MetricCard title="辅助职业锚" value={results.secondary.name} score={results.secondary.score.toFixed(2)} />
</div>
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3">
{results.primary.name} + {results.secondary.name}
</h3>
<p className="text-sm">
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{results.ranked.map((item) => (
<div key={item.id} className="border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<h4 className="font-semibold">{item.name}</h4>
<span className="text-sm text-muted-foreground">{item.score.toFixed(2)} / 5</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div className="h-full rounded-full bg-emerald-500" style={{ width: width(item.score) }} />
</div>
<p className="mt-3 text-sm text-gray-700">{descriptions[item.id]}</p>
</div>
))}
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
</div>
</div>
);
}
function MetricCard({ title, value, score }: { title: string; value: string; score: string }) {
return (
<div className="rounded-lg bg-gray-50 p-4 text-center">
<div className="text-sm text-muted-foreground">{title}</div>
<div className="mt-1 text-2xl font-semibold text-indigo-600">{value}</div>
<div className="text-xs text-muted-foreground">{score} / 5</div>
</div>
);
}
@@ -0,0 +1,271 @@
'use client';
import React from 'react';
import { calculateDASS21Results } from '../../test/private/DASS21Calculator';
import { useScopedI18n } from '@/locales/client';
interface DASS21ResultProps {
answers: string[];
}
export function DASS21Result({ answers }: DASS21ResultProps) {
const t = useScopedI18n('components.dass21Result');
// Convert answer format to the format required by calculator
const answersMap: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersMap[index + 1] = answer;
});
const results = calculateDASS21Results({
answers: answersMap,
questions: []
});
const severityNames = {
normal: t('severity.normal'),
mild: t('severity.mild'),
moderate: t('severity.moderate'),
severe: t('severity.severe'),
extremely_severe: t('severity.extremely_severe')
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case "normal": return "text-green-600 bg-green-50 border-green-200";
case "mild": return "text-yellow-600 bg-yellow-50 border-yellow-200";
case "moderate": return "text-orange-600 bg-orange-50 border-orange-200";
case "severe": return "text-red-600 bg-red-50 border-red-200";
case "extremely_severe": return "text-red-700 bg-red-100 border-red-300";
default: return "text-gray-600 bg-gray-50 border-gray-200";
}
};
const dimensionInfo = {
depression: {
name: t('dimensions.depression'),
score: results.depressionScore,
severity: results.depressionSeverity,
description: t('descriptions.depression'),
maxScore: 42
},
anxiety: {
name: t('dimensions.anxiety'),
score: results.anxietyScore,
severity: results.anxietySeverity,
description: t('descriptions.anxiety'),
maxScore: 42
},
stress: {
name: t('dimensions.stress'),
score: results.stressScore,
severity: results.stressSeverity,
description: t('descriptions.stress'),
maxScore: 42
}
};
return (
<div className="mt-6 space-y-6">
{/* Overall score */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<MetricCard title={t('labels.total_score')} value={`${results.totalScore}/63`} />
<MetricCard title={t('labels.depression_score')} value={`${results.depressionScore}/42`} />
<MetricCard title={t('labels.anxiety_score')} value={`${results.anxietyScore}/42`} />
<MetricCard title={t('labels.stress_score')} value={`${results.stressScore}/42`} />
</div>
</div>
{/* Three-dimension analysis */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.three_dimension_analysis')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{Object.entries(dimensionInfo).map(([key, info]) => (
<div key={key} className={`border rounded-lg p-4 ${getSeverityColor(info.severity)}`}>
<div className="text-center mb-3">
<h4 className="font-semibold text-lg">{info.name}</h4>
<div className="text-2xl font-bold mt-2">{info.score}</div>
<div className="text-sm opacity-75">/{info.maxScore}</div>
</div>
<div className="mb-3">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${info.severity === "normal" ? "bg-green-400" :
info.severity === "mild" ? "bg-yellow-400" :
info.severity === "moderate" ? "bg-orange-400" :
info.severity === "severe" ? "bg-red-400" : "bg-red-600"
}`}
style={{ width: `${(info.score / info.maxScore) * 100}%` }}
></div>
</div>
</div>
<div className="text-center">
<span className={`inline-block px-3 py-1 rounded-full text-sm font-medium ${info.severity === "normal" ? "bg-green-100 text-green-800" :
info.severity === "mild" ? "bg-yellow-100 text-yellow-800" :
info.severity === "moderate" ? "bg-orange-100 text-orange-800" :
info.severity === "severe" ? "bg-red-100 text-red-800" : "bg-red-200 text-red-900"
}`}>
{severityNames[info.severity as keyof typeof severityNames]}
</span>
</div>
<div className="text-sm text-center mt-2 opacity-75">
{info.description}
</div>
</div>
))}
</div>
</div>
{/* Severity level standards */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.scoring_criteria')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<h4 className="font-medium text-green-700">{t('labels.depression_dimension')}</h4>
<div className="text-sm space-y-1">
<div>{t('scoring.depression.normal')}</div>
<div>{t('scoring.depression.mild')}</div>
<div>{t('scoring.depression.moderate')}</div>
<div>{t('scoring.depression.severe')}</div>
<div>{t('scoring.depression.extremely_severe')}</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-blue-700">{t('labels.anxiety_dimension')}</h4>
<div className="text-sm space-y-1">
<div>{t('scoring.anxiety.normal')}</div>
<div>{t('scoring.anxiety.mild')}</div>
<div>{t('scoring.anxiety.moderate')}</div>
<div>{t('scoring.anxiety.severe')}</div>
<div>{t('scoring.anxiety.extremely_severe')}</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-purple-700">{t('labels.stress_dimension')}</h4>
<div className="text-sm space-y-1">
<div>{t('scoring.stress.normal')}</div>
<div>{t('scoring.stress.mild')}</div>
<div>{t('scoring.stress.moderate')}</div>
<div>{t('scoring.stress.severe')}</div>
<div>{t('scoring.stress.extremely_severe')}</div>
</div>
</div>
</div>
</div>
{/* Result interpretation and recommendations */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.result_interpretation_advice')}</h3>
<div className="space-y-4">
{/* Overall assessment */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-2">{t('labels.overall_assessment')}</h4>
<div className="text-sm text-blue-800">
{results.isSevere ? (
<p>{t('assessment.severe_message')}</p>
) : (
<p>{t('assessment.normal_message')}</p>
)}
</div>
</div>
{/* Dimension-specific recommendations */}
{results.depressionSeverity !== "normal" && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 className="font-medium text-green-900 mb-2">{t('advice.depression_dimension')}</h4>
<div className="text-sm text-green-800 space-y-1">
<p>{t('advice.depression_item_1')}</p>
<p>{t('advice.depression_item_2')}</p>
<p>{t('advice.depression_item_3')}</p>
<p>{t('advice.depression_item_4')}</p>
{(results.depressionSeverity === "severe" || results.depressionSeverity === "extremely_severe") && (
<p className="font-medium">{t('advice.depression_severe')}</p>
)}
</div>
</div>
)}
{results.anxietySeverity !== "normal" && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h4 className="font-medium text-yellow-900 mb-2">{t('advice.anxiety_dimension')}</h4>
<div className="text-sm text-yellow-800 space-y-1">
<p>{t('advice.anxiety_item_1')}</p>
<p>{t('advice.anxiety_item_2')}</p>
<p>{t('advice.anxiety_item_3')}</p>
<p>{t('advice.anxiety_item_4')}</p>
{(results.anxietySeverity === "severe" || results.anxietySeverity === "extremely_severe") && (
<p className="font-medium">{t('advice.anxiety_severe')}</p>
)}
</div>
</div>
)}
{results.stressSeverity !== "normal" && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h4 className="font-medium text-purple-900 mb-2">{t('advice.stress_dimension')}</h4>
<div className="text-sm text-purple-800 space-y-1">
<p>{t('advice.stress_item_1')}</p>
<p>{t('advice.stress_item_2')}</p>
<p>{t('advice.stress_item_3')}</p>
<p>{t('advice.stress_item_4')}</p>
{(results.stressSeverity === "severe" || results.stressSeverity === "extremely_severe") && (
<p className="font-medium">{t('advice.stress_severe')}</p>
)}
</div>
</div>
)}
{/* Severe situation warning */}
{results.isSevere && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<div className="text-sm font-medium text-red-800">
{t('labels.important_reminder')}{t('warning.severe_distress')}
</div>
</div>
</div>
</div>
)}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<p className="text-gray-800 text-sm">
<strong>{t('labels.note')}</strong>{t('disclaimer')}
</p>
</div>
</div>
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div className="bg-gray-50 rounded-lg p-4 flex flex-col items-center">
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className={`text-2xl font-semibold ${className || 'text-indigo-600'}`}>
{value}
</span>
</div>
);
}
@@ -0,0 +1,58 @@
'use client';
import { calculateDarkTriadResults } from '../../test/private/DarkTriadCalculator';
interface DarkTriadResultProps {
answers: string[];
}
function width(value: number) {
return `${Math.max(0, Math.min(100, ((value - 1) / 4) * 100))}%`;
}
export function DarkTriadResult({ answers }: DarkTriadResultProps) {
const results = calculateDarkTriadResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<BarCard title="马基雅维利主义" value={results.machiavellianism} />
<BarCard title="自恋" value={results.narcissism} />
<BarCard title="冷酷冲动" value={results.psychopathy} />
</div>
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-sm">
</p>
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
</div>
</div>
);
}
function BarCard({ title, value }: { title: string; value: number }) {
return (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-semibold">{title}</h4>
<span className="text-sm text-muted-foreground">
{value.toFixed(2)} / 5
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: width(value) }}
/>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
'use client';
import { calculateEmpathyResults } from '../../test/private/EmpathyCalculator';
interface EmpathyResultProps {
answers: string[];
}
function width(value: number) {
return `${Math.max(0, Math.min(100, ((value - 1) / 4) * 100))}%`;
}
export function EmpathyResult({ answers }: EmpathyResultProps) {
const results = calculateEmpathyResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<BarCard title="观点采择" value={results.perspectiveTaking} />
<BarCard title="共情关怀" value={results.empathicConcern} />
<BarCard title="个人痛苦" value={results.personalDistress} />
<BarCard title="想象代入" value={results.fantasy} />
</div>
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-sm">
怀
</p>
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
IRI更适合看四个分项组合
</div>
</div>
);
}
function BarCard({ title, value }: { title: string; value: number }) {
return (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-semibold">{title}</h4>
<span className="text-sm text-muted-foreground">
{value.toFixed(2)} / 5
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: width(value) }}
/>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
'use client';
import { calculateFisherResults, FisherDimension } from '../../test/private/FisherCalculator';
interface FisherResultProps {
answers: string[];
}
const descriptions: Record<FisherDimension, string> = {
explorer: '偏好新鲜体验、自由探索和灵活变化,适合开放、变化快、允许试错的环境。',
builder: '重视秩序、责任和稳定关系,适合目标清楚、节奏可靠、需要长期维护的环境。',
director: '偏好逻辑、效率和直接决策,适合需要分析、判断、系统设计和明确目标的环境。',
negotiator: '重视共情、意义和关系协调,适合需要理解人、连接观点和处理复杂情境的环境。',
};
const colorClass: Record<FisherDimension, string> = {
explorer: 'bg-emerald-500',
builder: 'bg-blue-500',
director: 'bg-indigo-500',
negotiator: 'bg-rose-500',
};
export function FisherResult({ answers }: FisherResultProps) {
const results = calculateFisherResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">Fisher </h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MetricCard title="主要气质" value={results.primary.name} score={`${results.primary.score}/40`} />
<MetricCard title="辅助气质" value={results.secondary.name} score={`${results.secondary.score}/40`} />
</div>
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3">
{results.primary.name} + {results.secondary.name}
</h3>
<p className="text-sm">
{results.primary.name}{results.secondary.name}Fisher模型更适合看
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{results.ranked.map((item) => (
<div key={item.id} className="border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<h4 className="font-semibold">{item.name}</h4>
<span className="text-sm text-muted-foreground">{item.score}/40</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className={`h-full rounded-full ${colorClass[item.id]}`}
style={{ width: `${item.percentage}%` }}
/>
</div>
<p className="mt-3 text-sm text-gray-700">{descriptions[item.id]}</p>
</div>
))}
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
Fisher气质量表用于人格风格参考使
</div>
</div>
);
}
function MetricCard({ title, value, score }: { title: string; value: string; score: string }) {
return (
<div className="rounded-lg bg-gray-50 p-4 text-center">
<div className="text-sm text-muted-foreground">{title}</div>
<div className="mt-1 text-2xl font-semibold text-indigo-600">{value}</div>
<div className="text-xs text-muted-foreground">{score}</div>
</div>
);
}
@@ -0,0 +1,198 @@
'use client';
import React from 'react';
import { calculateGAD7Results } from '../../test/private/GAD7Calculator';
import { useScopedI18n } from '@/locales/client';
interface GAD7ResultProps {
answers: string[];
}
export function GAD7Result({ answers }: GAD7ResultProps) {
const t = useScopedI18n('components.gad7Result');
// Convert answer format to the format required by calculator
const answersMap: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersMap[index + 1] = answer;
});
const results = calculateGAD7Results({
answers: answersMap,
questions: []
});
const severityNames = {
minimal: t('severity.minimal'),
mild: t('severity.mild'),
moderate: t('severity.moderate'),
severe: t('severity.severe')
};
const severityDescriptions = {
minimal: t('severityDescriptions.minimal'),
mild: t('severityDescriptions.mild'),
moderate: t('severityDescriptions.moderate'),
severe: t('severityDescriptions.severe')
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case "minimal": return "text-green-600 bg-green-50 border-green-200";
case "mild": return "text-yellow-600 bg-yellow-50 border-yellow-200";
case "moderate": return "text-orange-600 bg-orange-50 border-orange-200";
case "severe": return "text-red-600 bg-red-50 border-red-200";
default: return "text-gray-600 bg-gray-50 border-gray-200";
}
};
const questionTexts = [
t('questions.0'), t('questions.1'), t('questions.2'), t('questions.3'),
t('questions.4'), t('questions.5'), t('questions.6')
];
return (
<div className="mt-6 space-y-6">
{/* Overall score */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard title={t('labels.total_score')} value={`${results.totalScore}/21`} />
<MetricCard title={t('labels.high_score_items')} value={`${results.highScoreItemCount}/7`} />
<MetricCard
title={t('labels.anxiety_level')}
value={severityNames[results.severity as keyof typeof severityNames] || t('labels.unknown')}
className={getSeverityColor(results.severity).split(' ')[0]}
/>
</div>
</div>
{/* Severity level description */}
<div className={`border rounded-lg p-6 shadow-sm ${getSeverityColor(results.severity)}`}>
<h3 className="text-lg font-semibold mb-3">{t('labels.result_interpretation')}</h3>
<p className="text-sm mb-4">
{severityDescriptions[results.severity as keyof typeof severityDescriptions] || "评估结果异常,请重新测试。"}
</p>
<div className="space-y-2 text-sm">
<div><strong>{t('labels.scoring_criteria')}</strong></div>
<ul className="ml-4 space-y-1">
<li>{t('scoring.range_0_4')}</li>
<li>{t('scoring.range_5_9')}</li>
<li>{t('scoring.range_10_14')}</li>
<li>{t('scoring.range_15_21')}</li>
</ul>
</div>
</div>
{/* Item analysis */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.item_analysis')}</h3>
<div className="space-y-3">
{results.itemAnalysis.map((item: any, index: number) => (
<div key={item.questionId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex-1">
<span className="text-sm font-medium">
{index + 1}. {questionTexts[index]}
</span>
</div>
<div className="flex items-center space-x-2">
<span className={`text-lg font-semibold ${item.score >= 2 ? 'text-red-600' :
item.score >= 1 ? 'text-yellow-600' : 'text-green-600'
}`}>
{item.score}
</span>
{item.isHigh && (
<span className="px-2 py-1 text-xs bg-red-100 text-red-800 rounded-full">
{t('labels.needs_attention')}
</span>
)}
</div>
</div>
))}
</div>
{results.highScoreItemCount > 0 && (
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h4 className="font-medium text-yellow-900 mb-2">{t('labels.high_score_item_alert')}</h4>
<div className="text-sm text-yellow-800">
{t('highScoreAlert.message', { count: results.highScoreItemCount })}
</div>
</div>
)}
</div>
{/* Professional advice */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.professional_advice')}</h3>
<div className="space-y-3 text-sm text-gray-700">
{results.severity === "minimal" ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="text-green-800">
<strong>{t('advice.maintain_good_state')}</strong>
<ul className="mt-2 ml-4 space-y-1">
<li>{t('advice.maintain_good_state_item_1')}</li>
<li>{t('advice.maintain_good_state_item_2')}</li>
<li>{t('advice.maintain_good_state_item_3')}</li>
<li>{t('advice.maintain_good_state_item_4')}</li>
</ul>
</div>
</div>
) : (
<div>
<strong>{t('advice.self_management_advice')}</strong>
<ul className="mt-2 ml-4 space-y-1">
<li>{t('advice.self_management_item_1')}</li>
<li>{t('advice.self_management_item_2')}</li>
<li>{t('advice.self_management_item_3')}</li>
<li>{t('advice.self_management_item_4')}</li>
<li>{t('advice.self_management_item_5')}</li>
</ul>
</div>
)}
{(results.severity === "moderate" || results.severity === "severe") && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<div className="text-sm font-medium text-red-800">
{t('advice.professional_help_message', { severity: severityNames[results.severity as keyof typeof severityNames] })}
</div>
</div>
</div>
</div>
)}
<div className="bg-blue-50 border border-blue-200 rounded p-3">
<p className="text-blue-800">
<strong>{t('labels.note')}</strong>{t('disclaimer')}
</p>
</div>
</div>
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div className="bg-gray-50 rounded-lg p-4 flex flex-col items-center">
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className={`text-2xl font-semibold ${className || 'text-indigo-600'}`}>
{value}
</span>
</div>
);
}
@@ -0,0 +1,149 @@
'use client';
import React from 'react';
import { useScopedI18n } from '@/locales/client';
import { calculateGDResults } from '../../test/private/GDCalculator';
function useLabels() {
const t = useScopedI18n('components.gdResult');
return {
totalScore: t('totalScore'),
scorePercentage: t('scorePercentage'),
elevatedItems: t('elevatedItems'),
genderIdentity: t('genderIdentity'),
socialRole: t('socialRole'),
physicalDysphoria: t('physicalDysphoria'),
genderExpression: t('genderExpression'),
overallAssessment: t('overallAssessment'),
recommendations: t('recommendations'),
importantNotes: t('importantNotes'),
understandingResults: t('understandingResults'),
factorScores: t('factorScores'),
interpretationLevels: {
low: t('interpretationLevels.low'),
mild: t('interpretationLevels.mild'),
moderate: t('interpretationLevels.moderate'),
high: t('interpretationLevels.high'),
},
factorDescriptions: {
genderIdentity: t('factorDescriptions.genderIdentity'),
socialRole: t('factorDescriptions.socialRole'),
physicalDysphoria: t('factorDescriptions.physicalDysphoria'),
genderExpression: t('factorDescriptions.genderExpression'),
},
notes: {
purpose: t('notes.purpose'),
substitute: t('notes.substitute'),
complexity: t('notes.complexity'),
professional: t('notes.professional'),
},
recommendationTexts: {
high: t('recommendationTexts.high'),
low: t('recommendationTexts.low'),
},
};
}
export function GDResult({
answers,
}: {
answers: string[];
}) {
const labels = useLabels();
// Convert answers array to object format expected by calculator
const answersObj: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersObj[index + 1] = answer;
});
const results = calculateGDResults({ answers: answersObj, questions: [] });
const getInterpretationColor = (interpretation: string) => {
switch (interpretation) {
case 'low': return 'text-green-600';
case 'mild': return 'text-yellow-600';
case 'moderate': return 'text-orange-600';
case 'high': return 'text-red-600';
default: return 'text-gray-600';
}
};
const getInterpretationLabel = (interpretation: string) => {
return labels.interpretationLevels[interpretation as keyof typeof labels.interpretationLevels] || 'Unknown';
};
return (
<div className="mt-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<MetricCard title={labels.totalScore} value={results.totalScore} />
<MetricCard title={labels.scorePercentage} value={`${results.scorePercentage}%`} />
<MetricCard title={labels.elevatedItems} value={results.positiveItemCount} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard title={labels.genderIdentity} value={results.factorScores.genderIdentity} />
<MetricCard title={labels.socialRole} value={results.factorScores.socialRole} />
<MetricCard title={labels.physicalDysphoria} value={results.factorScores.physicalDysphoria} />
<MetricCard title={labels.genderExpression} value={results.factorScores.genderExpression} />
</div>
<div className="bg-white border rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-medium text-gray-500 mb-2">{labels.overallAssessment}</h3>
<div className={`text-lg font-semibold ${getInterpretationColor(results.interpretation)}`}>
{getInterpretationLabel(results.interpretation)} Level
</div>
<p className="text-sm text-gray-600 mt-1">
Score: {results.totalScore}/189 ({results.scorePercentage}%)
</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 mb-2">{labels.recommendations}</h3>
<p className="text-sm text-blue-700">
{results.scorePercentage >= 50 ? labels.recommendationTexts.high : labels.recommendationTexts.low}
</p>
</div>
<div className="bg-gray-50 border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-2">{labels.importantNotes}</h3>
<ul className="text-sm text-gray-600 space-y-1">
<li> {labels.notes.purpose}</li>
<li> {labels.notes.substitute}</li>
<li> {labels.notes.complexity}</li>
<li> {labels.notes.professional}</li>
</ul>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-purple-800 mb-2">{labels.understandingResults}</h3>
<div className="text-sm text-purple-700 space-y-2">
<p><strong>{labels.factorScores}:</strong></p>
<ul className="ml-4 space-y-1">
<li> {labels.genderIdentity}: {labels.factorDescriptions.genderIdentity}</li>
<li> {labels.socialRole}: {labels.factorDescriptions.socialRole}</li>
<li> {labels.physicalDysphoria}: {labels.factorDescriptions.physicalDysphoria}</li>
<li> {labels.genderExpression}: {labels.factorDescriptions.genderExpression}</li>
</ul>
</div>
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div
className={`bg-white border rounded-lg p-4 flex flex-col items-center shadow-sm ${className}`}
>
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className="text-2xl font-semibold text-indigo-600">{value}</span>
</div>
);
}
@@ -0,0 +1,96 @@
'use client';
import { calculateGritResults } from '../../test/private/GritCalculator';
interface GritResultProps {
answers: string[];
}
const levelText = {
low: '坚毅程度偏低',
moderate: '坚毅程度中等',
high: '坚毅程度较高',
};
const levelDescription = {
low: '你当前在长期目标上的持续投入或兴趣稳定性可能偏弱。更适合从目标拆小、减少干扰、明确反馈开始,而不是单靠意志力硬撑。',
moderate: '你具备一定的坚持能力,但在目标很长期、反馈很慢或兴趣变化较快时,可能会出现波动。',
high: '你通常能围绕长期目标持续努力,也较不容易被短期挫折带偏。继续保留调整目标的弹性会更稳。',
};
function width(value: number) {
return `${Math.max(0, Math.min(100, ((value - 1) / 4) * 100))}%`;
}
export function GritResult({ answers }: GritResultProps) {
const results = calculateGritResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<MetricCard title="总平均分" value={results.totalAverage.toFixed(2)} />
<MetricCard
title="努力坚持"
value={results.perseveranceAverage.toFixed(2)}
/>
<MetricCard
title="兴趣稳定"
value={results.consistencyAverage.toFixed(2)}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<BarCard title="努力坚持" value={results.perseveranceAverage} />
<BarCard title="兴趣稳定" value={results.consistencyAverage} />
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3">{levelText[results.level]}</h3>
<p className="text-sm">{levelDescription[results.level]}</p>
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
Grit
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: string;
}
function MetricCard({ title, value }: MetricCardProps) {
return (
<div className="rounded-lg bg-gray-50 p-4 text-center">
<div className="text-sm text-muted-foreground">{title}</div>
<div className="mt-1 text-2xl font-semibold text-indigo-600">
{value}
</div>
<div className="text-xs text-muted-foreground">/ 5</div>
</div>
);
}
function BarCard({ title, value }: { title: string; value: number }) {
return (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-semibold">{title}</h4>
<span className="text-sm text-muted-foreground">
{value.toFixed(2)} / 5
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: width(value) }}
/>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
'use client';
import { calculateHEXACOResults } from '../../test/private/HEXACOCalculator';
interface HEXACOResultProps {
answers: string[];
}
const labels = [
['诚实谦逊', 'honestyHumility'],
['情绪性', 'emotionality'],
['外向性', 'extraversion'],
['宜人性', 'agreeableness'],
['尽责性', 'conscientiousness'],
['开放性', 'openness'],
] as const;
function width(value: number) {
return `${Math.max(0, Math.min(100, ((value - 1) / 4) * 100))}%`;
}
export function HEXACOResult({ answers }: HEXACOResultProps) {
const results = calculateHEXACOResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">HEXACO </h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{labels.map(([label, key]) => (
<BarCard key={key} title={label} value={results[key]} />
))}
</div>
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-sm">
HEXACO Big Five
</p>
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
Big FiveRIASEC
</div>
</div>
);
}
function BarCard({ title, value }: { title: string; value: number }) {
return (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-semibold">{title}</h4>
<span className="text-sm text-muted-foreground">
{value.toFixed(2)} / 5
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: width(value) }}
/>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
'use client';
import React from 'react';
import { calculateISIResults } from '../../test/private/ISICalculator';
import { useScopedI18n } from '@/locales/client';
interface ISIResultProps {
answers: string[];
}
export function ISIResult({ answers }: ISIResultProps) {
const t = useScopedI18n('components.isiResult');
const answersMap: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersMap[index + 1] = answer;
});
const results = calculateISIResults({
answers: answersMap,
questions: []
});
const severityNames = {
no_insomnia: t('severity.no_insomnia'),
subthreshold: t('severity.subthreshold'),
moderate: t('severity.moderate'),
severe: t('severity.severe')
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case "no_insomnia": return "text-green-600 bg-green-50 border-green-200";
case "subthreshold": return "text-yellow-600 bg-yellow-50 border-yellow-200";
case "moderate": return "text-orange-600 bg-orange-50 border-orange-200";
case "severe": return "text-red-600 bg-red-50 border-red-200";
default: return "text-gray-600 bg-gray-50 border-gray-200";
}
};
return (
<div className="mt-6 space-y-6">
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard title={t('labels.total_score')} value={`${results.totalScore}/28`} />
<MetricCard title={t('labels.high_score_items')} value={`${results.highScoreItemCount}/7`} />
<MetricCard
title={t('labels.insomnia_level')}
value={severityNames[results.severity as keyof typeof severityNames] || t('labels.unknown')}
className={getSeverityColor(results.severity).split(' ')[0]}
/>
</div>
</div>
<div className={`border rounded-lg p-6 shadow-sm ${getSeverityColor(results.severity)}`}>
<h3 className="text-lg font-semibold mb-3">{t('labels.result_interpretation')}</h3>
<div className="space-y-2 text-sm">
<div><strong>{t('labels.scoring_criteria')}</strong></div>
<ul className="ml-4 space-y-1">
<li>{t('scoring.range_0_7')}</li>
<li>{t('scoring.range_8_14')}</li>
<li>{t('scoring.range_15_21')}</li>
<li>{t('scoring.range_22_28')}</li>
</ul>
</div>
</div>
{results.isSevere && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-sm font-medium text-red-800">
{t('advice.sleep_specialist_message')}
</div>
</div>
)}
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div className="bg-gray-50 rounded-lg p-4 flex flex-col items-center">
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className={`text-2xl font-semibold ${className || 'text-indigo-600'}`}>
{value}
</span>
</div>
);
}
@@ -0,0 +1,83 @@
'use client';
import { calculateMaximizerResults } from '../../test/private/MaximizerCalculator';
interface MaximizerResultProps {
answers: string[];
}
const levelText = {
satisficer: '满意型选择风格',
balanced: '平衡型选择风格',
maximizer: '最大化选择风格',
};
const levelDescription = {
satisficer: '你更倾向于在达到标准后做决定,选择成本较低,也更容易保持行动效率。',
balanced: '你会在重要选择中认真比较,但通常不会让比较无限扩大。这个区间相对灵活。',
maximizer: '你更倾向于追求最优选择,会投入较多比较和信息搜集。优势是标准高,风险是决策成本和后悔感增加。',
};
function width(value: number) {
return `${Math.max(0, Math.min(100, ((value - 1) / 6) * 100))}%`;
}
export function MaximizerResult({ answers }: MaximizerResultProps) {
const results = calculateMaximizerResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<MetricCard title="平均分" value={`${results.average.toFixed(2)}/7`} />
<MetricCard title="选择风格" value={levelText[results.level]} />
<MetricCard title="题目数" value="13题" />
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<BarCard title="高标准" value={results.highStandards} />
<BarCard title="选项搜索" value={results.search} />
<BarCard title="决策困难" value={results.difficulty} />
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-sm">{levelDescription[results.level]}</p>
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
</div>
</div>
);
}
function MetricCard({ title, value }: { title: string; value: string }) {
return (
<div className="rounded-lg bg-gray-50 p-4 text-center">
<div className="text-sm text-muted-foreground">{title}</div>
<div className="mt-1 text-2xl font-semibold text-indigo-600">{value}</div>
</div>
);
}
function BarCard({ title, value }: { title: string; value: number }) {
return (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-semibold">{title}</h4>
<span className="text-sm text-muted-foreground">
{value.toFixed(2)} / 7
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: width(value) }}
/>
</div>
</div>
);
}
@@ -0,0 +1,181 @@
'use client';
import React from 'react';
import { useScopedI18n } from '@/locales/client';
import { calculateNPDResults } from '../../test/private/NPDCalculator';
function useLabels() {
const t = useScopedI18n('components.npdResult');
return {
totalScore: t('totalScore'),
percentile: t('percentile'),
leadership: t('leadership'),
exhibitionism: t('exhibitionism'),
narcissisticTraitsLevel: t('narcissisticTraitsLevel'),
dominantTrait: t('dominantTrait'),
entitlement: t('entitlement'),
interpretation: t('interpretation'),
understandingTraits: t('understandingTraits'),
factorBreakdown: t('factorBreakdown'),
importantNotes: t('importantNotes'),
healthyVsProblematic: t('healthyVsProblematic'),
interpretationLevels: {
low: t('interpretationLevels.low'),
average: t('interpretationLevels.average'),
above_average: t('interpretationLevels.above_average'),
high: t('interpretationLevels.high'),
},
traitLabels: {
leadership: t('traitLabels.leadership'),
exhibitionism: t('traitLabels.exhibitionism'),
entitlement: t('traitLabels.entitlement'),
},
factorDescriptions: {
leadership: t('factorDescriptions.leadership'),
exhibitionism: t('factorDescriptions.exhibitionism'),
entitlement: t('factorDescriptions.entitlement'),
},
notes: {
continuum: t('notes.continuum'),
adaptive: t('notes.adaptive'),
disorder: t('notes.disorder'),
purpose: t('notes.purpose'),
population: t('notes.population'),
},
healthyAspects: t('healthyAspects'),
potentialConcerns: t('potentialConcerns'),
balanceKey: t('balanceKey'),
recommendationTexts: {
high: t('recommendationTexts.high'),
above_average: t('recommendationTexts.above_average'),
low: t('recommendationTexts.low'),
},
};
}
export function NPDResult({
answers,
}: {
answers: string[];
}) {
const labels = useLabels();
// Convert answers array to object format expected by calculator
const answersObj: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersObj[index + 1] = answer;
});
const results = calculateNPDResults({ answers: answersObj, questions: [] });
const getInterpretationColor = (interpretation: string) => {
switch (interpretation) {
case 'low': return 'text-green-600';
case 'average': return 'text-blue-600';
case 'above_average': return 'text-yellow-600';
case 'high': return 'text-red-600';
default: return 'text-gray-600';
}
};
const getInterpretationLabel = (interpretation: string) => {
return labels.interpretationLevels[interpretation as keyof typeof labels.interpretationLevels] || 'Unknown';
};
const getDominantTraitLabel = (trait: string) => {
return labels.traitLabels[trait as keyof typeof labels.traitLabels] || 'Unknown';
};
return (
<div className="mt-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard title={labels.totalScore} value={`${results.totalScore}/16`} />
<MetricCard title={labels.percentile} value={`${results.percentile}th`} />
<MetricCard title={labels.leadership} value={results.factorScores.leadership} />
<MetricCard title={labels.exhibitionism} value={results.factorScores.exhibitionism} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white border rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-medium text-gray-500 mb-2">{labels.narcissisticTraitsLevel}</h3>
<div className={`text-lg font-semibold ${getInterpretationColor(results.interpretation)}`}>
{getInterpretationLabel(results.interpretation)}
</div>
<p className="text-sm text-gray-600 mt-1">
Score: {results.totalScore}/16 ({results.percentile}th percentile)
</p>
</div>
<div className="bg-white border rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-medium text-gray-500 mb-2">{labels.dominantTrait}</h3>
<div className="text-lg font-semibold text-purple-600">
{getDominantTraitLabel(results.dominantTrait)}
</div>
<p className="text-sm text-gray-600 mt-1">
{labels.entitlement}: {results.factorScores.entitlement}
</p>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 mb-2">{labels.interpretation}</h3>
<p className="text-sm text-blue-700">
{results.interpretation === "high"
? labels.recommendationTexts.high
: results.interpretation === "above_average"
? labels.recommendationTexts.above_average
: labels.recommendationTexts.low}
</p>
</div>
<div className="bg-gray-50 border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-2">{labels.understandingTraits}</h3>
<div className="text-sm text-gray-600 space-y-2">
<p><strong>{labels.factorBreakdown}:</strong></p>
<ul className="ml-4 space-y-1">
<li> <strong>{labels.traitLabels.leadership} ({results.factorScores.leadership}):</strong> {labels.factorDescriptions.leadership}</li>
<li> <strong>{labels.traitLabels.exhibitionism} ({results.factorScores.exhibitionism}):</strong> {labels.factorDescriptions.exhibitionism}</li>
<li> <strong>{labels.traitLabels.entitlement} ({results.factorScores.entitlement}):</strong> {labels.factorDescriptions.entitlement}</li>
</ul>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-yellow-800 mb-2">{labels.importantNotes}</h3>
<ul className="text-sm text-yellow-700 space-y-1">
<li> {labels.notes.continuum}</li>
<li> {labels.notes.adaptive}</li>
<li> {labels.notes.disorder}</li>
<li> {labels.notes.purpose}</li>
<li> {labels.notes.population}</li>
</ul>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-green-800 mb-2">{labels.healthyVsProblematic}</h3>
<div className="text-sm text-green-700 space-y-2">
<p><strong>{labels.healthyAspects}</strong></p>
<p><strong>{labels.potentialConcerns}</strong></p>
<p>{labels.balanceKey}</p>
</div>
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div
className={`bg-white border rounded-lg p-4 flex flex-col items-center shadow-sm ${className}`}
>
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className="text-2xl font-semibold text-indigo-600">{value}</span>
</div>
);
}
@@ -0,0 +1,82 @@
'use client';
import { calculateNeedForCognitionResults } from '../../test/private/NeedForCognitionCalculator';
interface NeedForCognitionResultProps {
answers: string[];
}
const levelText = {
low: '认知需求偏低',
moderate: '认知需求中等',
high: '认知需求较高',
};
const levelDescription = {
low: '你可能更偏好清晰、直接、低认知负荷的处理方式。面对复杂问题时,降低进入门槛和明确收益会更有帮助。',
moderate: '你会根据情境投入思考:重要、有兴趣或有价值的问题更容易激发你深入加工。',
high: '你通常喜欢深入思考、分析复杂问题和寻找新解法。注意在低风险问题上保留效率感,会更平衡。',
};
function width(value: number) {
return `${Math.max(0, Math.min(100, ((value - 1) / 4) * 100))}%`;
}
export function NeedForCognitionResult({ answers }: NeedForCognitionResultProps) {
const results = calculateNeedForCognitionResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<MetricCard title="总分" value={`${results.total}/90`} />
<MetricCard title="平均分" value={`${results.average.toFixed(2)}/5`} />
<MetricCard title="结果水平" value={levelText[results.level]} />
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<BarCard title="思考投入" value={results.thinkingAverage} />
<BarCard title="智力挑战偏好" value={results.challengeAverage} />
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-sm">{levelDescription[results.level]}</p>
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
CRT结果结合理解
</div>
</div>
);
}
function MetricCard({ title, value }: { title: string; value: string }) {
return (
<div className="rounded-lg bg-gray-50 p-4 text-center">
<div className="text-sm text-muted-foreground">{title}</div>
<div className="mt-1 text-2xl font-semibold text-indigo-600">{value}</div>
</div>
);
}
function BarCard({ title, value }: { title: string; value: number }) {
return (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-semibold">{title}</h4>
<span className="text-sm text-muted-foreground">
{value.toFixed(2)} / 5
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: width(value) }}
/>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
'use client';
import React from 'react';
import { useScopedI18n } from '@/locales/client';
function useLabels() {
const t = useScopedI18n('components.ocdResult');
return {
total: t('totalScore'),
obs: t('obsessionsScore'),
comp: t('compulsionsScore'),
severity: t('severity'),
severityMap: {
1: t('severityLevel.1'),
2: t('severityLevel.2'),
3: t('severityLevel.3'),
4: t('severityLevel.4'),
5: t('severityLevel.5'),
} as Record<number, string>,
};
}
export function OCDResult({
answers,
}: {
answers: string[];
}) {
const labels = useLabels();
const toNumber = (v: string | undefined) => Number(v) || 0;
let obsessionsScore = 0;
let compulsionsScore = 0;
for (let i = 1; i <= 10; i++) {
const score = toNumber(answers[i - 1]); // array index start 0
if (i <= 5) obsessionsScore += score;
else compulsionsScore += score;
}
const totalScore = obsessionsScore + compulsionsScore;
let severityLevel = 0;
if (totalScore >= 32) severityLevel = 5;
else if (totalScore >= 24) severityLevel = 4;
else if (totalScore >= 16) severityLevel = 3;
else if (totalScore >= 8) severityLevel = 2;
else severityLevel = 1;
return (
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard title={labels.total} value={totalScore} />
<MetricCard title={labels.obs} value={obsessionsScore} />
<MetricCard title={labels.comp} value={compulsionsScore} />
<MetricCard
title={labels.severity}
value={`${labels.severityMap[severityLevel] || '?'} (${severityLevel})`}
className="md:col-span-3"
/>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div
className={`bg-white border rounded-lg p-4 flex flex-col items-center shadow-sm ${className}`}
>
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className="text-2xl font-semibold text-indigo-600">{value}</span>
</div>
);
}
@@ -0,0 +1,66 @@
'use client';
import { calculateOEPSResults } from '../../test/private/OEPSCalculator';
interface OEPSResultProps {
answers: string[];
}
function barWidth(value: number) {
return `${Math.max(0, Math.min(100, (value / 5) * 100))}%`;
}
export function OEPSResult({ answers }: OEPSResultProps) {
const result = calculateOEPSResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm text-muted-foreground">
</p>
<div className="mt-4 rounded-lg border bg-indigo-50 border-indigo-200 p-4">
<div className="text-2xl font-semibold text-indigo-700">
{result.top.name}
</div>
<p className="text-sm text-indigo-900 mt-2">{result.top.description}</p>
<p className="text-sm text-indigo-900 mt-2">
{result.top.average.toFixed(2)} / 5
</p>
</div>
</div>
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h4 className="font-semibold mb-4"></h4>
<div className="space-y-3">
{result.ranked.map((item) => (
<div key={item.type} className="rounded border p-4">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<div className="font-medium">{item.name}</div>
<div className="text-sm text-muted-foreground">
{item.description}
</div>
</div>
<div className="text-right font-semibold text-indigo-600">
{item.average.toFixed(2)}
</div>
</div>
<div className="h-2 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full bg-indigo-500"
style={{ width: barWidth(item.average) }}
/>
</div>
</div>
))}
</div>
</div>
<div className="bg-gray-50 border rounded-lg p-4 text-sm text-gray-700">
OEPS
</div>
</div>
);
}
@@ -0,0 +1,240 @@
'use client';
import React from 'react';
import { calculatePHQ9Results } from '../../test/private/PHQ9Calculator';
import { useScopedI18n } from '@/locales/client';
interface PHQ9ResultProps {
answers: string[];
}
export function PHQ9Result({ answers }: PHQ9ResultProps) {
const t = useScopedI18n('components.phq9Result');
// Convert answer format to the format required by calculator
const answersMap: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersMap[index + 1] = answer;
});
const results = calculatePHQ9Results({
answers: answersMap,
questions: []
});
const severityNames = {
minimal: t('severity.minimal'),
mild: t('severity.mild'),
moderate: t('severity.moderate'),
moderately_severe: t('severity.moderately_severe'),
severe: t('severity.severe')
};
const severityDescriptions = {
minimal: t('severityDescriptions.minimal'),
mild: t('severityDescriptions.mild'),
moderate: t('severityDescriptions.moderate'),
moderately_severe: t('severityDescriptions.moderately_severe'),
severe: t('severityDescriptions.severe')
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case "minimal": return "text-green-600 bg-green-50 border-green-200";
case "mild": return "text-yellow-600 bg-yellow-50 border-yellow-200";
case "moderate": return "text-orange-600 bg-orange-50 border-orange-200";
case "moderately_severe": return "text-red-600 bg-red-50 border-red-200";
case "severe": return "text-red-700 bg-red-100 border-red-300";
default: return "text-gray-600 bg-gray-50 border-gray-200";
}
};
const questionTexts = [
t('questions.0'), t('questions.1'), t('questions.2'), t('questions.3'), t('questions.4'),
t('questions.5'), t('questions.6'), t('questions.7'), t('questions.8')
];
return (
<div className="mt-6 space-y-6">
{/* Overall score */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard title={t('labels.total_score')} value={`${results.totalScore}/27`} />
<MetricCard title={t('labels.high_score_items')} value={`${results.highScoreItemCount}/9`} />
<MetricCard
title={t('labels.depression_level')}
value={severityNames[results.severity as keyof typeof severityNames] || t('labels.unknown')}
className={getSeverityColor(results.severity).split(' ')[0]}
/>
</div>
</div>
{/* Important warning */}
{results.suicidalIdeation && (
<div className="bg-red-100 border-2 border-red-300 rounded-lg p-6 shadow-sm">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-8 w-8 text-red-600" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-lg font-bold text-red-800">{t('labels.emergency_reminder')}</h3>
<div className="text-sm font-medium text-red-700 mt-1">
{t('crisis.suicide_warning')}
<ul className="mt-2 ml-4 space-y-1">
<li>{t('crisis.hotline')}</li>
<li>{t('crisis.hospital')}</li>
<li>{t('crisis.doctor')}</li>
<li>{t('crisis.support')}</li>
</ul>
</div>
</div>
</div>
</div>
)}
{/* Severity level description */}
<div className={`border rounded-lg p-6 shadow-sm ${getSeverityColor(results.severity)}`}>
<h3 className="text-lg font-semibold mb-3">{t('labels.result_interpretation')}</h3>
<p className="text-sm mb-4">
{severityDescriptions[results.severity as keyof typeof severityDescriptions] || "评估结果异常,请重新测试。"}
</p>
<div className="space-y-2 text-sm">
<div><strong>{t('labels.scoring_criteria')}</strong></div>
<ul className="ml-4 space-y-1">
<li>{t('scoring.range_0_4')}</li>
<li>{t('scoring.range_5_9')}</li>
<li>{t('scoring.range_10_14')}</li>
<li>{t('scoring.range_15_19')}</li>
<li>{t('scoring.range_20_27')}</li>
</ul>
</div>
{results.majorDepressionCriteria && (
<div className="mt-4 bg-yellow-100 border border-yellow-300 rounded p-3">
<div className="text-yellow-900 font-medium">
{t('clinical.major_depression_warning')}
</div>
</div>
)}
</div>
{/* Item analysis */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.item_analysis')}</h3>
<div className="space-y-3">
{results.itemAnalysis.map((item: any, index: number) => (
<div key={item.questionId} className={`flex items-center justify-between p-3 rounded-lg ${
item.questionId === 9 && item.score >= 1 ? 'bg-red-50 border border-red-200' : 'bg-gray-50'
}`}>
<div className="flex-1">
<span className="text-sm font-medium">
{index + 1}. {questionTexts[index]}
</span>
{item.questionId === 9 && item.score >= 1 && (
<div className="text-xs text-red-600 mt-1">{t('labels.needs_immediate_attention')}</div>
)}
</div>
<div className="flex items-center space-x-2">
<span className={`text-lg font-semibold ${
item.questionId === 9 && item.score >= 1 ? 'text-red-700' :
item.score >= 2 ? 'text-red-600' :
item.score >= 1 ? 'text-yellow-600' : 'text-green-600'
}`}>
{item.score}
</span>
{item.isHigh && item.questionId !== 9 && (
<span className="px-2 py-1 text-xs bg-yellow-100 text-yellow-800 rounded-full">
{t('labels.needs_attention')}
</span>
)}
</div>
</div>
))}
</div>
{results.highScoreItemCount > 0 && (
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-2">{t('labels.high_score_item_analysis')}</h4>
<div className="text-sm text-blue-800">
{t('highScoreAnalysis.message', { count: results.highScoreItemCount })}
</div>
</div>
)}
</div>
{/* Professional advice */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.professional_advice')}</h3>
<div className="space-y-3 text-sm text-gray-700">
{results.severity === "minimal" ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="text-green-800">
<strong>{t('advice.maintain_good_state')}</strong>
<ul className="mt-2 ml-4 space-y-1">
<li>{t('advice.maintain_good_state_item_1')}</li>
<li>{t('advice.maintain_good_state_item_2')}</li>
<li>{t('advice.maintain_good_state_item_3')}</li>
<li>{t('advice.maintain_good_state_item_4')}</li>
</ul>
</div>
</div>
) : (
<div>
<strong>{t('advice.self_management_advice')}</strong>
<ul className="mt-2 ml-4 space-y-1">
<li>{t('advice.self_management_item_1')}</li>
<li>{t('advice.self_management_item_2')}</li>
<li>{t('advice.self_management_item_3')}</li>
<li>{t('advice.self_management_item_4')}</li>
<li>{t('advice.self_management_item_5')}</li>
<li>{t('advice.self_management_item_6')}</li>
</ul>
</div>
)}
{(results.severity === "moderate" || results.severity === "moderately_severe" || results.severity === "severe") && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
<div className="text-orange-900">
<strong>{t('advice.professional_treatment')}</strong>
<ul className="mt-2 ml-4 space-y-1">
<li>{t('advice.professional_treatment_item_1')}</li>
<li>{t('advice.professional_treatment_item_2')}</li>
<li>{t('advice.professional_treatment_item_3')}</li>
<li>{t('advice.professional_treatment_item_4')}</li>
</ul>
</div>
</div>
)}
<div className="bg-blue-50 border border-blue-200 rounded p-3">
<p className="text-blue-800">
<strong>{t('labels.note')}</strong>{t('disclaimer')}
</p>
</div>
</div>
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div className="bg-gray-50 rounded-lg p-4 flex flex-col items-center">
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className={`text-2xl font-semibold ${className || 'text-indigo-600'}`}>
{value}
</span>
</div>
);
}
@@ -0,0 +1,284 @@
'use client';
import React from 'react';
import { calculatePSS10Results } from '../../test/private/PSS10Calculator';
import { useScopedI18n } from '@/locales/client';
interface PSS10ResultProps {
answers: string[];
}
export function PSS10Result({ answers }: PSS10ResultProps) {
const t = useScopedI18n('components.pss10Result');
// Convert answer format to the format required by calculator
const answersMap: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersMap[index + 1] = answer;
});
const results = calculatePSS10Results({
answers: answersMap,
questions: []
});
const severityDescriptions = {
low: t('severityDescriptions.low'),
moderate: t('severityDescriptions.moderate'),
high: t('severityDescriptions.high')
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case "low": return "text-green-600 bg-green-50 border-green-200";
case "moderate": return "text-yellow-600 bg-yellow-50 border-yellow-200";
case "high": return "text-red-600 bg-red-50 border-red-200";
default: return "text-gray-600 bg-gray-50 border-gray-200";
}
};
const questionTexts = [
t('questions.0'), t('questions.1'), t('questions.2'), t('questions.3'), t('questions.4'),
t('questions.5'), t('questions.6'), t('questions.7'), t('questions.8'), t('questions.9')
];
const getScoreInterpretation = (score: number) => {
if (score <= 13) return { level: t('scoreInterpretation.low_level'), color: "text-green-600", desc: t('scoreInterpretation.low_desc') };
if (score <= 26) return { level: t('scoreInterpretation.moderate_level'), color: "text-yellow-600", desc: t('scoreInterpretation.moderate_desc') };
return { level: t('scoreInterpretation.high_level'), color: "text-red-600", desc: t('scoreInterpretation.high_desc') };
};
const scoreInterp = getScoreInterpretation(results.totalScore);
return (
<div className="mt-6 space-y-6">
{/* Overall score */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<MetricCard title={t('labels.total_score')} value={`${results.totalScore}/40`} />
<MetricCard title={t('labels.stress_perception')} value={`${results.stressPerceptionScore}/24`} />
<MetricCard title={t('labels.coping_ability')} value={`${results.copingAbilityScore}/16`} />
<MetricCard
title={t('labels.stress_level')}
value={scoreInterp.level}
className={scoreInterp.color}
/>
</div>
</div>
{/* Severity level description */}
<div className={`border rounded-lg p-6 shadow-sm ${getSeverityColor(results.severity)}`}>
<h3 className="text-lg font-semibold mb-3">{t('labels.result_interpretation')}</h3>
<p className="text-sm mb-4">
{severityDescriptions[results.severity as keyof typeof severityDescriptions]}
</p>
<div className="space-y-3 text-sm">
<div>
<strong>{t('labels.score_interpretation')}</strong>
<ul className="mt-1 ml-4 space-y-1">
<li> {t('scoring.total_range')}</li>
<li> {t('scoring.stress_perception_desc')}</li>
<li> {t('scoring.coping_ability_desc')}</li>
</ul>
</div>
<div>
<strong>{t('labels.reference_standards')}</strong>
<ul className="mt-1 ml-4 space-y-1">
<li> {t('scoring.range_0_13')}</li>
<li> {t('scoring.range_14_26')}</li>
<li> {t('scoring.range_27_40')}</li>
</ul>
</div>
</div>
</div>
{/* Subscale analysis */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.subscale_analysis')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Stress perception */}
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium mb-3 flex items-center">
<span className="w-3 h-3 bg-red-400 rounded-full mr-2"></span>
{t('subscales.stress_perception_title')} ({results.stressPerceptionScore}/24)
</h4>
<div className="space-y-2 text-sm">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-red-400 h-2 rounded-full"
style={{ width: `${(results.stressPerceptionScore / 24) * 100}%` }}
></div>
</div>
<p className="text-gray-700">
{t('subscales.stress_perception_desc')}
</p>
</div>
</div>
{/* Coping ability */}
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium mb-3 flex items-center">
<span className="w-3 h-3 bg-blue-400 rounded-full mr-2"></span>
{t('subscales.coping_ability_title')} ({results.copingAbilityScore}/16)
</h4>
<div className="space-y-2 text-sm">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-400 h-2 rounded-full"
style={{ width: `${(results.copingAbilityScore / 16) * 100}%` }}
></div>
</div>
<p className="text-gray-700">
{t('subscales.coping_ability_desc')}
</p>
</div>
</div>
</div>
</div>
{/* Item analysis */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.item_analysis')}</h3>
<div className="space-y-3">
{results.itemAnalysis.map((item: any, index: number) => (
<div key={item.questionId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex-1">
<div className="flex items-center">
<span className="text-sm font-medium">
{index + 1}. {questionTexts[index]}
</span>
{item.isReverse && (
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
{t('labels.reverse_scoring')}
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{t('labels.original_score')}: {item.originalScore} {item.isReverse ? `${t('labels.actual_score')}: ${item.actualScore}` : ''}
</div>
</div>
<div className="flex items-center space-x-2">
<span className={`text-lg font-semibold ${
item.actualScore >= 3 ? 'text-red-600' :
item.actualScore >= 2 ? 'text-yellow-600' : 'text-green-600'
}`}>
{item.actualScore}
</span>
{item.isHigh && (
<span className="px-2 py-1 text-xs bg-red-100 text-red-800 rounded-full">
{t('labels.high_stress')}
</span>
)}
</div>
</div>
))}
</div>
{results.highScoreItemCount > 0 && (
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h4 className="font-medium text-yellow-900 mb-2">{t('labels.high_score_reminder')}</h4>
<div className="text-sm text-yellow-800">
{t('highScoreAnalysis.message', { count: results.highScoreItemCount })}
</div>
</div>
)}
</div>
{/* Stress management recommendations */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.stress_management_advice')}</h3>
<div className="space-y-4 text-sm text-gray-700">
{results.severity === "low" ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="text-green-800">
<strong>{t('advice.maintain_good_state')}</strong>
<ul className="mt-2 ml-4 space-y-1">
<li>{t('advice.maintain_good_state_item_1')}</li>
<li>{t('advice.maintain_good_state_item_2')}</li>
<li>{t('advice.maintain_good_state_item_3')}</li>
<li>{t('advice.maintain_good_state_item_4')}</li>
</ul>
</div>
</div>
) : (
<div>
<strong>{t('advice.stress_management_strategies')}</strong>
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Short-term strategies */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-2">{t('advice.short_term_strategies')}</h4>
<ul className="text-blue-800 space-y-1 text-sm">
<li>{t('advice.short_term_item_1')}</li>
<li>{t('advice.short_term_item_2')}</li>
<li>{t('advice.short_term_item_3')}</li>
<li>{t('advice.short_term_item_4')}</li>
<li>{t('advice.short_term_item_5')}</li>
</ul>
</div>
{/* Long-term strategies */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h4 className="font-medium text-purple-900 mb-2">{t('advice.long_term_strategies')}</h4>
<ul className="text-purple-800 space-y-1 text-sm">
<li>{t('advice.long_term_item_1')}</li>
<li>{t('advice.long_term_item_2')}</li>
<li>{t('advice.long_term_item_3')}</li>
<li>{t('advice.long_term_item_4')}</li>
<li>{t('advice.long_term_item_5')}</li>
</ul>
</div>
</div>
</div>
)}
{results.severity === "high" && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<div className="text-sm font-medium text-red-800">
{t('advice.high_stress_warning')}
</div>
</div>
</div>
</div>
)}
<div className="bg-gray-50 border border-gray-200 rounded p-3">
<p className="text-gray-800">
<strong>{t('labels.note')}</strong>{t('disclaimer')}
</p>
</div>
</div>
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div className="bg-gray-50 rounded-lg p-4 flex flex-col items-center">
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className={`text-2xl font-semibold ${className || 'text-indigo-600'}`}>
{value}
</span>
</div>
);
}
@@ -0,0 +1,79 @@
'use client';
import {
calculateRIASECResults,
riasecTypes,
} from '../../test/private/RIASECCalculator';
interface RIASECResultProps {
answers: string[];
}
const descriptions = {
R: '动手、设备、机械、户外与具体操作',
I: '研究、分析、科学与复杂问题',
A: '创作、设计、表达与开放式任务',
S: '帮助、教学、照护与合作',
E: '领导、说服、销售与组织资源',
C: '数据、记录、流程与明确规范',
};
function barWidth(average: number) {
return `${Math.max(0, Math.min(100, ((average - 1) / 4) * 100))}%`;
}
export function RIASECResult({ answers }: RIASECResultProps) {
const { scores, ranking, hollandCode } = calculateRIASECResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<div className="text-sm text-muted-foreground"> Holland Code</div>
<div className="mt-1 text-4xl font-semibold text-indigo-600">
{hollandCode}
</div>
<p className="mt-3 text-sm text-muted-foreground">
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{ranking.map(([code]) => {
const result = scores[code] as { score: number; average: number };
const type = riasecTypes[code as keyof typeof riasecTypes];
return (
<div key={code} className="rounded-lg border bg-white p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<h4 className="font-semibold">
{code} · {type.name}
</h4>
<p className="mt-1 text-sm text-muted-foreground">
{descriptions[code as keyof typeof descriptions]}
</p>
</div>
<div className="shrink-0 text-right">
<div className="text-xl font-semibold">{result.score}</div>
<div className="text-xs text-muted-foreground">/ 40</div>
</div>
</div>
<div className="mt-4 h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: barWidth(result.average) }}
/>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{result.average.toFixed(2)} / 5
</div>
</div>
);
})}
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
</div>
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import React from 'react';
import { OCDResult } from './OCDResult';
import { SCL90Result } from './SCL90Result';
import { SDSResult } from './SDSResult';
import { GAD7Result } from './GAD7Result';
import { PHQ9Result } from './PHQ9Result';
import { PSS10Result } from './PSS10Result';
import { DASS21Result } from './DASS21Result';
import { BDI2Result } from './BDI2Result';
import { ISIResult } from './ISIResult';
import { ADHDResult } from './ADHDResult';
import { GDResult } from './GDResult';
import { NPDResult } from './NPDResult';
import { BigFiveResult } from './BigFiveResult';
import { CRTResult } from './CRTResult';
import { OEPSResult } from './OEPSResult';
import { RIASECResult } from './RIASECResult';
import { WHO5Result } from './WHO5Result';
import { SelfEsteemResult } from './SelfEsteemResult';
import { GritResult } from './GritResult';
import { SelfControlResult } from './SelfControlResult';
import { NeedForCognitionResult } from './NeedForCognitionResult';
import { MaximizerResult } from './MaximizerResult';
import { AttachmentResult } from './AttachmentResult';
import { EmpathyResult } from './EmpathyResult';
import { DarkTriadResult } from './DarkTriadResult';
import { HEXACOResult } from './HEXACOResult';
import { FisherResult } from './FisherResult';
import { SchwartzResult } from './SchwartzResult';
import { VIAResult } from './VIAResult';
import { CareerAnchorsResult } from './CareerAnchorsResult';
interface Props {
questionnaireId: string;
answers: string[];
}
export function ResultAnalysis({ questionnaireId, answers }: Props) {
switch (questionnaireId) {
case 'bigfive':
return <BigFiveResult answers={answers} version={50} />;
case 'bigfive-120':
return <BigFiveResult answers={answers} version={120} />;
case 'bigfive-300':
return <BigFiveResult answers={answers} version={300} />;
case 'oeps':
return <OEPSResult answers={answers} />;
case 'crt':
return <CRTResult answers={answers} />;
case 'riasec':
return <RIASECResult answers={answers} />;
case 'self-esteem':
return <SelfEsteemResult answers={answers} />;
case 'grit':
return <GritResult answers={answers} />;
case 'self-control':
return <SelfControlResult answers={answers} />;
case 'need-for-cognition':
return <NeedForCognitionResult answers={answers} />;
case 'maximizer':
return <MaximizerResult answers={answers} />;
case 'attachment':
return <AttachmentResult answers={answers} />;
case 'empathy':
return <EmpathyResult answers={answers} />;
case 'dark-triad':
return <DarkTriadResult answers={answers} />;
case 'hexaco':
return <HEXACOResult answers={answers} />;
case 'fisher':
return <FisherResult answers={answers} />;
case 'schwartz':
return <SchwartzResult answers={answers} />;
case 'via':
return <VIAResult answers={answers} />;
case 'career-anchors':
return <CareerAnchorsResult answers={answers} />;
case 'ocd':
return <OCDResult answers={answers} />;
case 'scl90':
return <SCL90Result answers={answers} />;
case 'sds':
return <SDSResult answers={answers} />;
case 'gad7':
return <GAD7Result answers={answers} />;
case 'phq9':
return <PHQ9Result answers={answers} />;
case 'pss10':
return <PSS10Result answers={answers} />;
case 'dass21':
return <DASS21Result answers={answers} />;
case 'who5':
return <WHO5Result answers={answers} />;
case 'bdi2':
return <BDI2Result answers={answers} />;
case 'isi':
return <ISIResult answers={answers} />;
case 'adhd':
return <ADHDResult answers={answers} />;
case 'gd':
return <GDResult answers={answers} />;
case 'npd':
return <NPDResult answers={answers} />;
default:
return (
<div className="mt-6 p-6 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="text-yellow-800">
<h3 className="font-semibold mb-2"></h3>
<p className="text-sm">
ID为 &quot;{questionnaireId}&quot;
</p>
</div>
</div>
);
}
}
@@ -0,0 +1,156 @@
'use client';
import React from 'react';
import { useScopedI18n } from '@/locales/client';
import { calculateSCL90Results } from '../../test/private/SCL90Calculator';
interface SCL90ResultProps {
answers: string[];
}
export function SCL90Result({ answers }: SCL90ResultProps) {
const t = useScopedI18n('components.scl90Result');
const tCommon = useScopedI18n('common');
// Convert answer format to the format required by calculator
const answersMap: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersMap[index + 1] = answer;
});
const results = calculateSCL90Results({
answers: answersMap,
questions: []
});
const factorNames = {
somatization: t('factors.somatization'),
obsessive: t('factors.obsessive'),
interpersonal: t('factors.interpersonal'),
depression: t('factors.depression'),
anxiety: t('factors.anxiety'),
hostility: t('factors.hostility'),
phobic: t('factors.phobic'),
paranoid: t('factors.paranoid'),
psychotic: t('factors.psychotic'),
other: t('factors.other')
};
const severityNames = {
normal: tCommon('severity.normal'),
mild: tCommon('severity.mild'),
moderate: tCommon('severity.moderate'),
severe: tCommon('severity.severe')
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case "normal": return "text-green-600";
case "mild": return "text-yellow-600";
case "moderate": return "text-orange-600";
case "severe": return "text-red-600";
default: return "text-gray-600";
}
};
const getFactorSeverity = (score: number) => {
if (score >= 3) return "severe";
if (score >= 2) return "moderate";
if (score >= 1.5) return "mild";
return "normal";
};
return (
<div className="mt-6 space-y-6">
{/* Overall score */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.overall_assessment')}</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<MetricCard title={tCommon('labels.total_score')} value={results.totalScore} />
<MetricCard title={t('labels.positive_item_count')} value={results.positiveItemCount} />
<MetricCard title={t('labels.positive_symptom_average')} value={results.positiveItemAverage.toFixed(2)} />
<MetricCard
title={tCommon('labels.severity_level')}
value={severityNames[results.severity as keyof typeof severityNames] || t('warnings.unknown_level')}
className={getSeverityColor(results.severity)}
/>
</div>
</div>
{/* Factor scores */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.factor_analysis')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(results.factorScores).map(([factor, score]) => {
const factorSeverity = getFactorSeverity(Number(score));
return (
<div key={factor} className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<span className="font-medium">{factorNames[factor as keyof typeof factorNames]}</span>
<span className={`text-sm px-2 py-1 rounded ${getSeverityColor(factorSeverity)}`}>
{severityNames[factorSeverity as keyof typeof severityNames]}
</span>
</div>
<div className="text-2xl font-bold text-indigo-600">{Number(score).toFixed(2)}</div>
</div>
);
})}
</div>
</div>
{/* Result interpretation */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{tCommon('labels.result_interpretation')}</h3>
<div className="space-y-3 text-sm text-gray-700">
<div>
<strong>{t('clinical.rating_criteria')}</strong>
<ul className="mt-1 ml-4 space-y-1">
<li> {t('clinical.rating_scale')}</li>
</ul>
</div>
<div>
<strong>{t('clinical.judgment_criteria')}</strong>
<ul className="mt-1 ml-4 space-y-1">
<li> {t('clinical.total_score_criteria')}</li>
<li> {t('clinical.factor_score_2')}</li>
<li> {t('clinical.factor_score_3')}</li>
</ul>
</div>
{results.isSevere && (
<div className="bg-red-50 border border-red-200 rounded p-3 mt-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<div className="text-sm font-medium text-red-800">
{t('warnings.severe_condition')}
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div className="bg-gray-50 rounded-lg p-4 flex flex-col items-center">
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className={`text-2xl font-semibold ${className || 'text-indigo-600'}`}>
{value}
</span>
</div>
);
}
@@ -0,0 +1,171 @@
'use client';
import React from 'react';
import { calculateSDSResults } from '../../test/private/SDSCalculator';
import { useScopedI18n } from '@/locales/client';
interface SDSResultProps {
answers: string[];
}
export function SDSResult({ answers }: SDSResultProps) {
const t = useScopedI18n('components.sdsResult');
// Convert answer format to the format required by calculator
const answersMap: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersMap[index + 1] = answer;
});
const results = calculateSDSResults({
answers: answersMap,
questions: []
});
const severityNames = {
normal: t('severity.normal'),
mild: t('severity.mild'),
moderate: t('severity.moderate'),
severe: t('severity.severe')
};
const severityDescriptions = {
normal: t('severityDescriptions.normal'),
mild: t('severityDescriptions.mild'),
moderate: t('severityDescriptions.moderate'),
severe: t('severityDescriptions.severe')
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case "normal": return "text-green-600 bg-green-50 border-green-200";
case "mild": return "text-yellow-600 bg-yellow-50 border-yellow-200";
case "moderate": return "text-orange-600 bg-orange-50 border-orange-200";
case "severe": return "text-red-600 bg-red-50 border-red-200";
default: return "text-gray-600 bg-gray-50 border-gray-200";
}
};
// Get raw score (not multiplied by 1.25)
const rawScore = Math.round(results.totalScore / 1.25);
return (
<div className="mt-6 space-y-6">
{/* Overall score */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard title={t('labels.raw_total_score')} value={`${rawScore}/80`} />
<MetricCard title={t('labels.standard_score')} value={results.totalScore} />
<MetricCard
title={t('labels.depression_level')}
value={severityNames[results.severity as keyof typeof severityNames] || t('labels.unknown')}
className={getSeverityColor(results.severity).split(' ')[0]}
/>
</div>
</div>
{/* Severity level description */}
<div className={`border rounded-lg p-6 shadow-sm ${getSeverityColor(results.severity)}`}>
<h3 className="text-lg font-semibold mb-3">{t('labels.result_interpretation')}</h3>
<p className="text-sm mb-4">
{severityDescriptions[results.severity as keyof typeof severityDescriptions]}
</p>
<div className="space-y-2 text-sm">
<div><strong>{t('labels.scoring_criteria')}</strong></div>
<ul className="ml-4 space-y-1">
<li> {t('scoring.range_0_52')}</li>
<li> {t('scoring.range_53_62')}</li>
<li> {t('scoring.range_63_72')}</li>
<li> {t('scoring.range_73_plus')}</li>
</ul>
</div>
</div>
{/* Item analysis */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.detailed_analysis')}</h3>
<div className="space-y-4">
{/* High score items reminder */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-2">{t('labels.scale_description')}</h4>
<div className="text-sm text-blue-800 space-y-1">
<p> {t('scaleInfo.description_1')}</p>
<p> {t('scaleInfo.description_2')}</p>
<p> {t('scaleInfo.description_3')}</p>
</div>
</div>
{/* Scoring method description */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-2">{t('labels.scoring_method')}</h4>
<div className="text-sm text-gray-700 space-y-1">
<p><strong></strong>{t('scaleInfo.positive_items')}</p>
<p><strong></strong>{t('scaleInfo.reverse_items')}</p>
<p><strong></strong>{t('scaleInfo.option_scoring')}</p>
<p><strong></strong>{t('scaleInfo.reverse_scoring')}</p>
</div>
</div>
{(results.severity === "severe" || results.severity === "moderate") && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<div className="text-sm font-medium text-red-800">
{t('labels.important_reminder')}{t('warnings.depression_reminder')}
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Professional advice */}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">{t('labels.professional_advice')}</h3>
<div className="space-y-3 text-sm text-gray-700">
<div>
<strong>{t('advice.high_score_title')}</strong>
<ul className="mt-2 ml-4 space-y-1">
<li> {t('advice.seek_professional')}</li>
<li> {t('advice.share_feelings')}</li>
<li> {t('advice.maintain_routine')}</li>
<li> {t('advice.avoid_substances')}</li>
<li> {t('advice.suicide_help')}</li>
</ul>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
<p className="text-yellow-800">
<strong>{t('labels.note')}</strong>{t('disclaimer')}
</p>
</div>
</div>
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
className?: string;
}
function MetricCard({ title, value, className = '' }: MetricCardProps) {
return (
<div className="bg-gray-50 rounded-lg p-4 flex flex-col items-center">
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className={`text-2xl font-semibold ${className || 'text-indigo-600'}`}>
{value}
</span>
</div>
);
}
@@ -0,0 +1,133 @@
'use client';
import {
calculateSchwartzResults,
SchwartzHigherOrder,
SchwartzValue,
} from '../../test/private/SchwartzCalculator';
interface SchwartzResultProps {
answers: string[];
}
const valueDescriptions: Record<SchwartzValue, string> = {
selfDirection: '重视独立思考、自由选择和创造。',
stimulation: '重视新奇、挑战、变化和兴奋体验。',
hedonism: '重视愉悦、享受和积极体验。',
achievement: '重视能力表现、努力成果和外部认可。',
power: '重视影响力、资源、地位和掌控感。',
security: '重视稳定、安全、健康和生活保障。',
conformity: '重视规则、克制和对他人影响的边界。',
tradition: '重视文化、家庭、仪式和既有传承。',
benevolence: '重视照顾亲近的人、忠诚和可靠支持。',
universalism: '重视公平、包容、自然和更广泛的人群福祉。',
};
const higherOrderDescriptions: Record<SchwartzHigherOrder, string> = {
opennessToChange: '偏向自由、探索、变化和个人选择。',
conservation: '偏向秩序、稳定、规范和连续性。',
selfEnhancement: '偏向成就、影响力和个人资源增长。',
selfTranscendence: '偏向关怀、公平、包容和超越个人利益。',
};
function width(score: number) {
return `${Math.max(0, Math.min(100, ((score - 1) / 4) * 100))}%`;
}
export function SchwartzResult({ answers }: SchwartzResultProps) {
const results = calculateSchwartzResults(answers);
const topValues = results.rankedValues.slice(0, 3);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">Schwartz </h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{topValues.map((item, index) => (
<MetricCard
key={item.id}
title={`${index + 1} 优先价值`}
value={item.name}
score={item.score.toFixed(2)}
/>
))}
</div>
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3">
{topValues.map((item) => item.name).join(' / ')}
</h3>
<p className="text-sm">
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{results.rankedHigherOrders.map((item) => (
<ScoreCard
key={item.id}
title={item.name}
score={item.score}
description={higherOrderDescriptions[item.id]}
color="bg-indigo-500"
/>
))}
</div>
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{results.rankedValues.map((item) => (
<ScoreCard
key={item.id}
title={item.name}
score={item.score}
description={valueDescriptions[item.id]}
color="bg-emerald-500"
/>
))}
</div>
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
</div>
</div>
);
}
function MetricCard({ title, value, score }: { title: string; value: string; score: string }) {
return (
<div className="rounded-lg bg-gray-50 p-4 text-center">
<div className="text-sm text-muted-foreground">{title}</div>
<div className="mt-1 text-2xl font-semibold text-indigo-600">{value}</div>
<div className="text-xs text-muted-foreground">{score} / 5</div>
</div>
);
}
function ScoreCard({
title,
score,
description,
color,
}: {
title: string;
score: number;
description: string;
color: string;
}) {
return (
<div className="border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<h4 className="font-semibold">{title}</h4>
<span className="text-sm text-muted-foreground">{score.toFixed(2)} / 5</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div className={`h-full rounded-full ${color}`} style={{ width: width(score) }} />
</div>
<p className="mt-3 text-sm text-gray-700">{description}</p>
</div>
);
}
@@ -0,0 +1,89 @@
'use client';
import { calculateSelfControlResults } from '../../test/private/SelfControlCalculator';
interface SelfControlResultProps {
answers: string[];
}
const levelText = {
low: '自控力偏低',
moderate: '自控力中等',
high: '自控力较高',
};
const levelDescription = {
low: '你可能比较容易受到诱惑、情绪或即时满足影响。比起责备自己,更有效的方向通常是减少诱因、降低开始成本,并把计划拆得更具体。',
moderate: '你具备一定的自控能力,但在压力、疲劳或诱惑较强的场景中可能会波动。环境设计和习惯系统会很有帮助。',
high: '你通常能较好地管理冲动、抵抗诱惑并推进计划。继续注意休息和目标弹性,能避免把自律变成过度紧绷。',
};
function width(value: number) {
return `${Math.max(0, Math.min(100, ((value - 1) / 4) * 100))}%`;
}
export function SelfControlResult({ answers }: SelfControlResultProps) {
const results = calculateSelfControlResults(answers);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<MetricCard title="总分" value={`${results.total}/65`} />
<MetricCard title="平均分" value={`${results.average.toFixed(2)}/5`} />
<MetricCard title="结果水平" value={levelText[results.level]} />
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<BarCard title="冲动管理" value={results.impulseAverage} />
<BarCard title="计划执行" value={results.executionAverage} />
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-sm">{levelDescription[results.level]}</p>
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: string;
}
function MetricCard({ title, value }: MetricCardProps) {
return (
<div className="rounded-lg bg-gray-50 p-4 text-center">
<div className="text-sm text-muted-foreground">{title}</div>
<div className="mt-1 text-2xl font-semibold text-indigo-600">
{value}
</div>
</div>
);
}
function BarCard({ title, value }: { title: string; value: number }) {
return (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-semibold">{title}</h4>
<span className="text-sm text-muted-foreground">
{value.toFixed(2)} / 5
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: width(value) }}
/>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
'use client';
import { calculateSelfEsteemResults } from '../../test/private/SelfEsteemCalculator';
interface SelfEsteemResultProps {
answers: string[];
}
const levelText = {
low: '自尊偏低',
moderate: '自尊中等',
high: '自尊较高',
};
const levelDescription = {
low: '你当前的整体自我评价可能偏严苛,容易在挫折、人际评价或压力情境中否定自己。这个结果适合提醒你关注自我接纳、情绪状态和支持系统。',
moderate: '你的整体自尊处在较常见区间,通常能够看到自身价值,但在压力或失败体验下可能仍会有明显波动。',
high: '你对自身价值和能力通常有较稳定、积极的评价。继续保持现实、温和且有弹性的自我认识会更有帮助。',
};
function barWidth(score: number) {
return `${Math.max(0, Math.min(100, (score / 30) * 100))}%`;
}
export function SelfEsteemResult({ answers }: SelfEsteemResultProps) {
const results = calculateSelfEsteemResults(answers);
const levelClass =
results.level === 'high'
? 'border-green-200 bg-green-50 text-green-800'
: results.level === 'moderate'
? 'border-blue-200 bg-blue-50 text-blue-800'
: 'border-yellow-200 bg-yellow-50 text-yellow-800';
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<MetricCard title="总分" value={`${results.total}/30`} />
<MetricCard title="结果水平" value={levelText[results.level]} />
<MetricCard title="题目数" value="10题" />
</div>
<div className="mt-5 h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-indigo-600"
style={{ width: barWidth(results.total) }}
/>
</div>
</div>
<div className={`border p-6 shadow-sm ${levelClass}`}>
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-sm">{levelDescription[results.level]}</p>
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
Rosenberg
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: string;
}
function MetricCard({ title, value }: MetricCardProps) {
return (
<div className="rounded-lg bg-gray-50 p-4 text-center">
<div className="text-sm text-muted-foreground">{title}</div>
<div className="mt-1 text-2xl font-semibold text-indigo-600">
{value}
</div>
</div>
);
}
@@ -0,0 +1,140 @@
'use client';
import { calculateVIAResults, VIAStrength, VIAVirtue } from '../../test/private/VIACalculator';
interface VIAResultProps {
answers: string[];
}
const strengthDescriptions: Record<VIAStrength, string> = {
creativity: "用新颖有效的方式思考和行动。",
curiosity: "主动探索未知,持续提问和观察。",
judgment: "开放评估证据,避免草率判断。",
loveOfLearning: "从学习和掌握新知识中获得满足。",
perspective: "能整合经验,为自己或他人提供有用视角。",
bravery: "在压力、风险或恐惧中坚持重要行动。",
perseverance: "面对困难仍持续完成目标。",
honesty: "真实、可靠,不依赖伪装获得认可。",
zest: "带着能量、投入和生命力行动。",
love: "重视亲密、信任和双向关心。",
kindness: "愿意照顾他人并提供实际帮助。",
socialIntelligence: "理解他人情绪、动机和场景需求。",
teamwork: "愿意合作并承担团队责任。",
fairness: "重视公正、平等和不偏袒。",
leadership: "组织、协调并带动群体前进。",
forgiveness: "能在合适时机放下怨气,重新看待关系。",
humility: "不过度夸大自己,承认仍需学习。",
prudence: "行动前考虑风险、后果和边界。",
selfRegulation: "管理冲动、情绪和行为节奏。",
appreciation: "感受自然、艺术和卓越表现中的美。",
gratitude: "看见并珍惜已经拥有和被给予的东西。",
hope: "相信未来可以改善,并愿意为此行动。",
humor: "用轻松方式缓和压力、连接他人。",
spirituality: "感到自己与更大的意义、信念或整体有关联。",
};
const virtueDescriptions: Record<VIAVirtue, string> = {
wisdom: "认知探索、学习、判断和形成视角的优势。",
courage: "面对困难仍行动、坚持和保持真实的优势。",
humanity: "建立亲密、善意和人际理解的优势。",
justice: "合作、公平和组织群体行动的优势。",
temperance: "克制、审慎、谦逊和修复关系的优势。",
transcendence: "连接意义、美、希望、感恩和幽默的优势。",
};
function width(score: number) {
return `${Math.max(0, Math.min(100, ((score - 1) / 4) * 100))}%`;
}
export function VIAResult({ answers }: VIAResultProps) {
const results = calculateVIAResults(answers);
const signature = results.rankedStrengths.slice(0, 5);
return (
<div className="mt-6 space-y-6">
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">VIA </h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-5">
{signature.map((item, index) => (
<MetricCard key={item.id} title={`优势 ${index + 1}`} value={item.name} score={item.score.toFixed(2)} />
))}
</div>
</div>
<div className="border bg-blue-50 p-6 text-blue-900 shadow-sm">
<h3 className="text-lg font-semibold mb-3">
{signature.map((item) => item.name).join(" / ")}
</h3>
<p className="text-sm">
VIA结果重点看你最自然
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{results.rankedVirtues.map((item) => (
<ScoreCard
key={item.id}
title={item.name}
score={item.score}
description={virtueDescriptions[item.id]}
color="bg-indigo-500"
/>
))}
</div>
<div className="border bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">24</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{results.rankedStrengths.map((item) => (
<ScoreCard
key={item.id}
title={item.name}
score={item.score}
description={strengthDescriptions[item.id]}
color="bg-emerald-500"
/>
))}
</div>
</div>
<div className="border bg-gray-50 p-4 text-sm text-gray-700">
VIA-IS题库5
</div>
</div>
);
}
function MetricCard({ title, value, score }: { title: string; value: string; score: string }) {
return (
<div className="rounded-lg bg-gray-50 p-4 text-center">
<div className="text-sm text-muted-foreground">{title}</div>
<div className="mt-1 text-xl font-semibold text-indigo-600">{value}</div>
<div className="text-xs text-muted-foreground">{score} / 5</div>
</div>
);
}
function ScoreCard({
title,
score,
description,
color,
}: {
title: string;
score: number;
description: string;
color: string;
}) {
return (
<div className="border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<h4 className="font-semibold">{title}</h4>
<span className="text-sm text-muted-foreground">{score.toFixed(2)} / 5</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div className={`h-full rounded-full ${color}`} style={{ width: width(score) }} />
</div>
<p className="mt-3 text-sm text-gray-700">{description}</p>
</div>
);
}
@@ -0,0 +1,104 @@
'use client';
import React from 'react';
import { calculateWHO5Results } from '../../test/private/WHO5Calculator';
interface WHO5ResultProps {
answers: string[];
}
const levelText = {
high: '幸福感较好',
moderate: '幸福感中等',
low: '幸福感偏低',
very_low: '幸福感明显偏低',
};
const levelDescription = {
high: '最近两周的积极情绪、活力和生活兴趣整体较好,可以继续保持目前有帮助的生活节奏。',
moderate: '整体幸福感处在中等区间,可以留意睡眠、压力、社交和日常恢复感的变化。',
low: '幸福感偏低,建议结合PHQ-9、GAD-7或DASS-21进一步了解情绪状态。',
very_low: '幸福感明显偏低,近期可能承受了较多压力或情绪困扰,建议尽快寻求可靠支持或专业评估。',
};
const questions = [
'快乐、心情愉快',
'平静和放松',
'精力充沛、充满活力',
'醒来时清新、休息充分',
'日常生活中有感兴趣的事情',
];
export function WHO5Result({ answers }: WHO5ResultProps) {
const answersMap: { [key: number]: string } = {};
answers.forEach((answer, index) => {
answersMap[index + 1] = answer;
});
const results = calculateWHO5Results({ answers: answersMap });
const levelClass = results.level === 'high'
? 'border-green-200 bg-green-50 text-green-800'
: results.level === 'moderate'
? 'border-blue-200 bg-blue-50 text-blue-800'
: results.level === 'low'
? 'border-yellow-200 bg-yellow-50 text-yellow-800'
: 'border-red-200 bg-red-50 text-red-800';
return (
<div className="mt-6 space-y-6">
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">WHO-5 </h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard title="原始总分" value={`${results.rawScore}/25`} />
<MetricCard title="百分制得分" value={`${results.percentageScore}/100`} />
<MetricCard title="结果水平" value={levelText[results.level]} />
</div>
</div>
<div className={`border rounded-lg p-6 shadow-sm ${levelClass}`}>
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-sm">{levelDescription[results.level]}</p>
{results.needsAttention && (
<p className="text-sm mt-3 font-medium">
WHO-5130-1
</p>
)}
</div>
<div className="bg-white border rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="space-y-3">
{results.itemScores.map((score, index) => (
<div key={questions[index]} className="flex items-center justify-between gap-4 p-3 bg-gray-50 rounded-lg">
<span className="text-sm font-medium">{index + 1}. {questions[index]}</span>
<span className={`text-lg font-semibold ${score <= 1 ? 'text-red-600' : score <= 3 ? 'text-yellow-600' : 'text-green-600'}`}>
{score}/5
</span>
</div>
))}
</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-sm text-gray-700">
WHO-5
</p>
</div>
</div>
);
}
interface MetricCardProps {
title: string;
value: React.ReactNode;
}
function MetricCard({ title, value }: MetricCardProps) {
return (
<div className="bg-gray-50 rounded-lg p-4 flex flex-col items-center">
<span className="text-sm text-gray-500 mb-1">{title}</span>
<span className="text-2xl font-semibold text-indigo-600 text-center">{value}</span>
</div>
);
}
@@ -0,0 +1,43 @@
import { Questionnaire } from '@/types';
import { useScopedI18n } from '@/locales/client';
interface AnswerListProps {
questions: Questionnaire['questions'];
answers: string[]; // array of selected option values in order
renderOptions: (id: number) => { id: number; content: string; value: string }[];
}
export function AnswerList({ questions, answers, renderOptions }: AnswerListProps) {
const t = useScopedI18n('common');
if (!questions || questions.length === 0) return null;
return (
<div className="mt-6">
<h3 className="text-lg font-medium mb-3">{t('answerList.title')}</h3>
<div className="space-y-2">
{questions.map((q, idx) => {
const selectedValue = answers[idx];
const optionContent = selectedValue !== undefined ? (() => {
const opts = renderOptions(q.id) || [];
const found = opts.find(o => String(o.value) === String(selectedValue));
return found ? found.content : `${t('answerList.option')} ${selectedValue}`;
})() : t('answerList.unanswered');
return (
<div
key={q.id}
className="flex items-start gap-2 p-3 bg-gray-50 rounded-md text-sm"
>
<span className="font-medium">{idx + 1}. {q.content}</span>
<span className="ml-auto">
{selectedValue !== undefined ? `${optionContent}` : t('answerList.unanswered')}
</span>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,133 @@
import Link from 'next/link';
import { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { useScopedI18n } from '@/locales/client';
import { Copy, Download, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { Questionnaire } from '@/types';
interface ResultContainerProps {
title: string;
id: string;
children: ReactNode;
questionnaire?: Questionnaire;
answers?: string[];
questionnaireResults?: Record<string, string>;
}
export function ResultContainer({ title, id, children, questionnaire, answers, questionnaireResults }: ResultContainerProps) {
const t = useScopedI18n(
'component.questionnaire.result.public.resultContainer'
);
const handleCopyResultLink = async () => {
try {
await navigator.clipboard.writeText(
`${window.location.origin}${window.location.pathname}`,
);
toast.success(t('copySuccess'));
} catch {
toast.error(t('copyError'));
}
};
const buildResultMarkdown = () => {
if (!questionnaire || !answers || !questionnaireResults) {
return null;
}
const currentTime = new Date().toLocaleString();
let resultData = `# ${t('copyTemplate.title')}\n\n`;
resultData += `## ${t('copyTemplate.basicInfo')}\n`;
resultData += `- ${t('copyTemplate.questionnaireName')}: ${questionnaire.title}\n`;
resultData += `- ${t('copyTemplate.questionnaireId')}: ${id}\n`;
resultData += `- ${t('copyTemplate.assessmentTime')}: ${currentTime}\n`;
resultData += `- ${t('copyTemplate.questionCount')}: ${questionnaire.questions.length}\n\n`;
resultData += `## ${t('copyTemplate.questionsAndAnswers')}\n`;
Object.entries(questionnaireResults).forEach(([question, answer], index) => {
resultData += `${index + 1}. ${question}\n ${t('copyTemplate.answer')}: ${answer}\n\n`;
});
resultData += `## ${t('copyTemplate.usage')}\n`;
resultData += `${t('copyTemplate.disclaimer')}\n\n`;
resultData += `${t('copyTemplate.source')}: ${t('copyTemplate.platform')}\n`;
resultData += `${t('copyTemplate.website')}: ${window.location.origin}\n`;
return resultData;
};
const handleCopyResultData = async () => {
const resultData = buildResultMarkdown();
if (!resultData) {
toast.error(t('copyResultDataError'));
return;
}
try {
await navigator.clipboard.writeText(resultData);
toast.success(t('copyResultDataSuccess'));
} catch {
toast.error(t('copyResultDataError'));
}
};
const handleDownloadResultData = () => {
const resultData = buildResultMarkdown();
if (!resultData) {
toast.error(t('downloadResultDataError'));
return;
}
const blob = new Blob([resultData], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${id}-result.md`;
anchor.click();
URL.revokeObjectURL(url);
toast.success(t('downloadResultDataSuccess'));
};
return (
<div className="flex justify-center items-center min-h-screen md:p-4 p-2">
<div className="max-w-6xl w-full bg-white rounded-lg shadow-lg md:p-8 p-4 border">
<h1 className="text-2xl font-bold mb-6">
{title} - {t('resultText')}
</h1>
<div className="mb-8">
<div className="space-y-6">{children}</div>
</div>
<div className="flex flex-col sm:flex-row sm:justify-between gap-4 mt-8">
<Button variant="outline" className="w-full sm:w-auto">
<Link href={`/questionnaire/${id}`}>{t('backToDetail')}</Link>
</Button>
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCopyResultLink} className="w-full sm:w-auto">
<Copy className="w-4 h-4 mr-2" />
{t('copyResultLink')}
</Button>
<Button
variant="outline"
onClick={handleCopyResultData}
className="w-full sm:w-auto"
>
<FileText className="w-4 h-4 mr-2" />
{t('copyResultData')}
</Button>
<Button
variant="outline"
onClick={handleDownloadResultData}
className="w-full sm:w-auto"
>
<Download className="w-4 h-4 mr-2" />
{t('downloadResultData')}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,238 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Question } from '@/components/questionnaire/test/public/Question';
import { Navigation } from '@/components/questionnaire/test/public/Navigation';
import { ProgressPanel } from '@/components/questionnaire/test/public/ProgressPanel';
import { ProgressBar } from '@/components/questionnaire/test/public/ProgressBar';
import { saveDraft, loadDraft, clearDraft } from '@/lib/storage';
import { saveResult } from '@/lib/result-storage';
import { Questionnaire as QuestionnaireType, QuestionType } from '@/types';
import { useRouter } from 'next/navigation';
import { toast } from "sonner"
interface QuestionnaireProps {
questionnaire: QuestionnaireType;
id: string;
}
export function Questionnaire({
questionnaire,
id,
}: QuestionnaireProps) {
const router = useRouter();
// State management
const [currentPage, setCurrentPage] = useState(1);
const [answers, setAnswers] = useState<{ [key: number]: string }>(() => {
// Load saved answers from local storage
const savedAnswers = loadDraft(id);
return savedAnswers || {};
});
// Create refs to reference each question element
const questionRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
// Flag to indicate whether the questionnaire has been submitted
const hasSubmittedRef = useRef(false);
// Save answers when component unmounts
useEffect(() => {
return () => {
// If user hasn't submitted yet, persist draft on unmount
if (!hasSubmittedRef.current && Object.keys(answers).length > 0) {
saveDraft(id, answers);
}
};
}, [id, answers]);
// Initialize question data - using real questionnaire data
const generateQuestions = (): QuestionType[] => {
// Check the questionnaire for question data
if (!questionnaire.questions || questionnaire.questions.length === 0) {
// If real data is not available, simulated data is used
throw new Error('Questionnaire data not found');
}
// Use real questionnaire data
return questionnaire.questions.map((q, index: number) => {
const options = questionnaire.renderOptions(q.id)
return {
id: index + 1,
content: q.content,
options: options,
}
});
};
const [questions, setQuestions] = useState<QuestionType[]>([]);
const [activePanelQuestion, setActivePanelQuestion] = useState<number | null>(
null
);
// Control the display state of the progress panel
const [showProgressPanel, setShowProgressPanel] = useState(true);
// Number of questions per page
const questionsPerPage = 5;
// Total number of pages
const totalPages = Math.ceil((questions.length || 0) / questionsPerPage);
// Questions on the current page
const currentQuestions = questions.slice(
(currentPage - 1) * questionsPerPage,
currentPage * questionsPerPage
);
// Number of answered questions
const answeredCount = Object.keys(answers).length;
// Calculate completion percentage
const completionPercentage = questions.length
? (answeredCount / questions.length) * 100
: 0;
// This generateQuestions function changes every time useEffect runs
// Solution is to move it inside useEffect or wrap it with useCallback
const generateQuestionsCallback = useCallback(generateQuestions, [
questionnaire,
]);
useEffect(() => {
setQuestions(generateQuestionsCallback());
// Reset the refs object to reassign when the list of issues changes
questionRefs.current = {};
}, [id, questionnaire, generateQuestionsCallback]);
const handleSelect = (questionId: number, value: string) => {
const newAnswers = {
...answers,
[questionId]: value,
};
setAnswers(newAnswers);
// Auto-save answers
if (Object.keys(newAnswers).length < questions.length) {
saveDraft(id, newAnswers);
}
};
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page);
window.scrollTo(0, 0);
}
};
const goToQuestion = (questionId: number) => {
// Calculate which page the question is on
const page = Math.ceil(questionId / questionsPerPage);
// If already on the page, scroll to the question
if (currentPage === page) {
scrollToQuestion(questionId);
} else {
// Otherwise, switch page first, then scroll to question after page loads
setCurrentPage(page);
// Use setTimeout to ensure DOM is updated before scrolling
setTimeout(() => {
scrollToQuestion(questionId);
}, 100);
}
// Set highlight effect
setActivePanelQuestion(questionId);
setTimeout(() => {
setActivePanelQuestion(null);
}, 1500);
};
// Scroll to specific question position
const scrollToQuestion = (questionId: number) => {
const questionElement = questionRefs.current[questionId];
if (questionElement) {
// Get question element's position relative to viewport
const rect = questionElement.getBoundingClientRect();
// Calculate scroll position with slight offset for better visual effect
const scrollTop = window.pageYOffset + rect.top - 100;
// Smooth scroll to question position
window.scrollTo({
top: scrollTop,
behavior: 'smooth',
});
}
};
const handleSubmit = () => {
// Check if all questions are answered first
if (answeredCount < questions.length) {
toast("请先完成所有题目");
return;
}
if (answers) {
// Mark as submitted to prevent saving draft on unmount
hasSubmittedRef.current = true;
// Clear draft before navigation
clearDraft(id);
// Store answers in this browser tab instead of putting them in the URL.
const resultAnswers = questions.map((q) => answers[q.id] ?? '0');
saveResult(id, resultAnswers);
router.push(`/questionnaire/${id}/result`);
}
return;
};
// Toggle progress panel visibility
const toggleProgressPanel = () => {
setShowProgressPanel((prev) => !prev);
};
const setQuestionRef =
(questionId: number) => (el: HTMLDivElement | null) => {
questionRefs.current[questionId] = el;
};
return (
<div className="max-w-3xl mx-auto py-8 px-4">
<h1 className="text-2xl font-bold mb-8">{questionnaire.title}</h1>
<ProgressPanel
questions={questions}
answers={answers}
activePanelQuestion={activePanelQuestion}
goToQuestion={goToQuestion}
showProgressPanel={showProgressPanel}
toggleProgressPanel={toggleProgressPanel}
completionPercentage={completionPercentage}
/>
<ProgressBar completionPercentage={completionPercentage} />
<div className="space-y-6">
{currentQuestions.map((question) => (
<Question
key={question.id}
question={question}
answer={answers[question.id]}
onSelect={handleSelect}
ref={setQuestionRef(question.id)}
/>
))}
</div>
<Navigation
currentPage={currentPage}
totalPages={totalPages}
goToPage={goToPage}
onSubmit={handleSubmit}
isLastPage={currentPage === totalPages}
/>
</div>
);
}
@@ -0,0 +1,89 @@
import { QuestionType } from "@/types";
/**
* ADHD Self-Report Scale calculator parameters interface
*/
interface ADHDCalculatorProps {
/** Answer data, key is question ID, value is selected score */
answers: { [key: number]: string };
/** Questions list */
questions: QuestionType[];
}
/**
* Calculate ADHD Self-Report Scale (ASRS-v1.1) results
*
* @param answers - User answer data, containing question ID and selected score
* @returns Calculation results, including total score, factor scores, severity level and other information
*/
export const calculateADHDResults = ({ answers }: ADHDCalculatorProps): any => {
// ADHD ASRS calculation logic
let totalScore = 0;
// Subscale score initialization: inattention and hyperactivity-impulsivity
let inattentionScore = 0; // Inattention subscale score (questions 1-9)
let hyperactivityScore = 0; // Hyperactivity-Impulsivity subscale score (questions 10-18)
// Part A screening questions (questions 1-6) - critical for screening
let partAScore = 0;
let partAPositive = 0; // Count of positive responses in Part A
// Calculate total score and subscale scores
Object.entries(answers).forEach(([questionId, score]) => {
const questionNum = parseInt(questionId);
const scoreValue = parseInt(score);
totalScore += scoreValue;
// Part A screening questions (questions 1-6)
if (questionNum >= 1 && questionNum <= 6) {
partAScore += scoreValue;
// Part A positive criteria: questions 1-4 score ≥2, questions 5-6 score ≥3
if ((questionNum >= 1 && questionNum <= 4 && scoreValue >= 2) ||
(questionNum >= 5 && questionNum <= 6 && scoreValue >= 3)) {
partAPositive++;
}
}
// Inattention subscale (questions 1-9)
if (questionNum >= 1 && questionNum <= 9) {
inattentionScore += scoreValue;
}
// Hyperactivity-Impulsivity subscale (questions 10-18)
else if (questionNum >= 10 && questionNum <= 18) {
hyperactivityScore += scoreValue;
}
});
// Determine screening result based on Part A
const screeningPositive = partAPositive >= 4;
// Determine severity level based on total score
let severity = "low";
if (totalScore >= 24 && totalScore <= 36) {
severity = "mild";
} else if (totalScore >= 37 && totalScore <= 54) {
severity = "moderate";
} else if (totalScore >= 55) {
severity = "high";
}
// Calculate factor scores
const factorScores: { [key: string]: number } = {
"inattention": inattentionScore,
"hyperactivity": hyperactivityScore,
"partA": partAScore
};
// Return complete calculation results
return {
totalScore,
factorScores,
positiveItemCount: Object.values(answers).filter(score => parseInt(score) >= 2).length,
positiveItemAverage: totalScore / Object.keys(answers).length,
isSevere: severity === "high",
severity,
screeningPositive,
partAPositive,
};
};
@@ -0,0 +1,40 @@
const avoidanceItems = [1, 2, 3, 4, 5, 6];
const anxietyItems = [7, 8, 9];
const reverseAvoidanceItems = new Set([1, 2, 3, 4]);
function scoreItem(answers: string[], item: number) {
const raw = Number(answers[item - 1] || 1);
return reverseAvoidanceItems.has(item) ? 8 - raw : raw;
}
function average(values: number[]) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
export function calculateAttachmentResults(answers: string[]) {
const avoidance = average(avoidanceItems.map((item) => scoreItem(answers, item)));
const anxiety = average(anxietyItems.map((item) => Number(answers[item - 1] || 1)));
let pattern:
| 'secure'
| 'preoccupied'
| 'dismissive'
| 'fearful' = 'secure';
const highAvoidance = avoidance >= 4;
const highAnxiety = anxiety >= 4;
if (highAvoidance && highAnxiety) {
pattern = 'fearful';
} else if (highAvoidance) {
pattern = 'dismissive';
} else if (highAnxiety) {
pattern = 'preoccupied';
}
return {
avoidance,
anxiety,
pattern,
};
}
@@ -0,0 +1,82 @@
import { QuestionType } from "@/types";
interface BDI2CalculatorProps {
answers: { [key: number]: string };
questions: QuestionType[];
}
export const calculateBDI2Results = ({ answers }: BDI2CalculatorProps): any => {
// BDI-II calculation logic
let totalScore = 0;
let suicidalIdeation = false;
// Calculate total score (simple sum)
Object.entries(answers).forEach(([questionId, score]) => {
const scoreValue = parseInt(score);
totalScore += scoreValue;
// Check question 9 (suicidal ideation)
if (parseInt(questionId) === 9 && scoreValue >= 1) {
suicidalIdeation = true;
}
});
// Determine depression severity level
let severity = "minimal";
if (totalScore >= 29) {
severity = "severe";
} else if (totalScore >= 20) {
severity = "moderate";
} else if (totalScore >= 14) {
severity = "mild";
}
// Analyze item scores
const itemAnalysis = Object.entries(answers).map(([questionId, score]) => ({
questionId: parseInt(questionId),
score: parseInt(score),
isHigh: parseInt(score) >= 2 // Scores of 2 or above are considered high score items
}));
const highScoreItems = itemAnalysis.filter(item => item.isHigh);
// Categorize analysis of different symptom domains
const emotionalItems = [1, 2, 4, 5, 10, 14]; // Emotional symptoms
const cognitiveItems = [3, 6, 8, 13, 19]; // Cognitive symptoms
const somaticItems = [15, 16, 18, 20, 21]; // Somatic symptoms
const behavioralItems = [7, 9, 11, 12, 17]; // Behavioral symptoms
const getSubscaleScore = (items: number[]) => {
return items.reduce((sum, itemId) => {
return sum + (answers[itemId] ? parseInt(answers[itemId]) : 0);
}, 0);
};
const emotionalScore = getSubscaleScore(emotionalItems);
const cognitiveScore = getSubscaleScore(cognitiveItems);
const somaticScore = getSubscaleScore(somaticItems);
const behavioralScore = getSubscaleScore(behavioralItems);
return {
totalScore,
severity,
suicidalIdeation,
itemAnalysis,
highScoreItemCount: highScoreItems.length,
emotionalScore,
cognitiveScore,
somaticScore,
behavioralScore,
factorScores: {
"emotional": emotionalScore,
"cognitive": cognitiveScore,
"somatic": somaticScore,
"behavioral": behavioralScore
},
positiveItemCount: highScoreItems.length,
positiveItemAverage: highScoreItems.length > 0
? highScoreItems.reduce((sum, item) => sum + item.score, 0) / highScoreItems.length
: 0,
isSevere: severity === "severe" || suicidalIdeation
};
};
@@ -0,0 +1,85 @@
import {
BigFiveDomain,
IpipNeoItem,
ipipNeo120Items,
ipipNeo300Items,
} from '@/questionairies/bigfive/neo-data';
const reverseItems = new Set([
2, 4, 6, 8, 10, 12, 16, 18, 19, 20, 22, 24, 26, 28, 29, 30, 32, 34,
36, 38, 39, 44, 46, 48, 49,
]);
const dimensions = {
extraversion: [1, 6, 11, 16, 21, 26, 31, 36, 41, 46],
agreeableness: [2, 7, 12, 17, 22, 27, 32, 37, 42, 47],
conscientiousness: [3, 8, 13, 18, 23, 28, 33, 38, 43, 48],
emotionalStability: [4, 9, 14, 19, 24, 29, 34, 39, 44, 49],
openness: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50],
};
export function calculateBigFiveResults(answers: string[]) {
const scoreItem = (questionId: number) => {
const raw = Number(answers[questionId - 1] || 0);
if (!raw) return 0;
return reverseItems.has(questionId) ? 6 - raw : raw;
};
return Object.fromEntries(
Object.entries(dimensions).map(([key, items]) => {
const score = items.reduce((sum, item) => sum + scoreItem(item), 0);
return [
key,
{
score,
average: score / items.length,
},
];
}),
);
}
export const ipipNeoItemsByVersion = {
120: ipipNeo120Items,
300: ipipNeo300Items,
} as const;
export function calculateIpipNeoResults(
answers: string[],
items: IpipNeoItem[],
) {
const domainTotals = new Map<BigFiveDomain, { score: number; count: number }>();
const facetTotals = new Map<string, { score: number; count: number }>();
items.forEach((item, index) => {
const raw = Number(answers[index] || 0);
const score = raw ? (item.reverse ? 6 - raw : raw) : 0;
const domain = domainTotals.get(item.domain) || { score: 0, count: 0 };
domain.score += score;
domain.count += 1;
domainTotals.set(item.domain, domain);
const facet = facetTotals.get(item.facet) || { score: 0, count: 0 };
facet.score += score;
facet.count += 1;
facetTotals.set(item.facet, facet);
});
const toResults = (totals: Map<string, { score: number; count: number }>) =>
Object.fromEntries(
[...totals.entries()].map(([key, value]) => [
key,
{
score: value.score,
average: value.count ? value.score / value.count : 0,
itemCount: value.count,
},
]),
);
return {
domains: toResults(domainTotals),
facets: toResults(facetTotals),
};
}
@@ -0,0 +1,29 @@
const correctAnswers: Record<number, string> = {
1: 'B',
2: 'A',
3: 'B',
4: 'A',
5: 'B',
6: 'B',
7: 'B',
};
export function calculateCRTResults(answers: string[]) {
const items = answers.map((answer, index) => {
const questionId = index + 1;
return {
questionId,
selected: answer,
correct: correctAnswers[questionId],
isCorrect: answer === correctAnswers[questionId],
};
});
const score = items.filter((item) => item.isCorrect).length;
return {
score,
total: Object.keys(correctAnswers).length,
items,
};
}
@@ -0,0 +1,55 @@
export type CareerAnchor =
| "technical"
| "managerial"
| "autonomy"
| "security"
| "entrepreneurial"
| "service"
| "challenge"
| "lifestyle";
const anchorItems: Record<CareerAnchor, number[]> = {
technical: [1, 2, 3, 4, 5],
managerial: [6, 7, 8, 9, 10],
autonomy: [11, 12, 13, 14, 15],
security: [16, 17, 18, 19, 20],
entrepreneurial: [21, 22, 23, 24, 25],
service: [26, 27, 28, 29, 30],
challenge: [31, 32, 33, 34, 35],
lifestyle: [36, 37, 38, 39, 40],
};
export const anchorNames: Record<CareerAnchor, string> = {
technical: "技术/职能能力",
managerial: "综合管理能力",
autonomy: "自主/独立",
security: "安全/稳定",
entrepreneurial: "创业创造",
service: "服务/奉献",
challenge: "纯粹挑战",
lifestyle: "生活方式",
};
function average(values: number[]) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
export function calculateCareerAnchorsResults(answers: string[]) {
const scores = Object.fromEntries(
Object.entries(anchorItems).map(([anchor, items]) => [
anchor,
average(items.map((item) => Number(answers[item - 1] || 1))),
]),
) as Record<CareerAnchor, number>;
const ranked = (Object.keys(scores) as CareerAnchor[])
.map((id) => ({ id, name: anchorNames[id], score: scores[id] }))
.sort((a, b) => b.score - a.score);
return {
scores,
ranked,
primary: ranked[0],
secondary: ranked[1],
};
}
@@ -0,0 +1,95 @@
import { QuestionType } from "@/types";
interface DASS21CalculatorProps {
answers: { [key: number]: string };
questions: QuestionType[];
}
export const calculateDASS21Results = ({ answers }: DASS21CalculatorProps): any => {
// DASS-21 calculation logic
// Depression dimension items: 3, 5, 10, 13, 16, 17, 21
// Anxiety dimension items: 2, 4, 7, 9, 15, 19, 20
// Stress dimension items: 1, 6, 8, 11, 12, 14, 18
const depressionItems = [3, 5, 10, 13, 16, 17, 21];
const anxietyItems = [2, 4, 7, 9, 15, 19, 20];
const stressItems = [1, 6, 8, 11, 12, 14, 18];
let depressionScore = 0;
let anxietyScore = 0;
let stressScore = 0;
let totalScore = 0;
// Calculate dimension scores
Object.entries(answers).forEach(([questionId, score]) => {
const questionNum = parseInt(questionId);
const scoreValue = parseInt(score);
totalScore += scoreValue;
if (depressionItems.includes(questionNum)) {
depressionScore += scoreValue;
} else if (anxietyItems.includes(questionNum)) {
anxietyScore += scoreValue;
} else if (stressItems.includes(questionNum)) {
stressScore += scoreValue;
}
});
// DASS-21 scores need to be multiplied by 2 for comparison with DASS-42
const finalDepressionScore = depressionScore * 2;
const finalAnxietyScore = anxietyScore * 2;
const finalStressScore = stressScore * 2;
// Determine severity level for each dimension
const getDepressionSeverity = (score: number) => {
if (score <= 9) return "normal";
if (score <= 13) return "mild";
if (score <= 20) return "moderate";
if (score <= 27) return "severe";
return "extremely_severe";
};
const getAnxietySeverity = (score: number) => {
if (score <= 7) return "normal";
if (score <= 9) return "mild";
if (score <= 14) return "moderate";
if (score <= 19) return "severe";
return "extremely_severe";
};
const getStressSeverity = (score: number) => {
if (score <= 14) return "normal";
if (score <= 18) return "mild";
if (score <= 25) return "moderate";
if (score <= 33) return "severe";
return "extremely_severe";
};
const depressionSeverity = getDepressionSeverity(finalDepressionScore);
const anxietySeverity = getAnxietySeverity(finalAnxietyScore);
const stressSeverity = getStressSeverity(finalStressScore);
// Determine overall severity level
const isAnySevere = depressionSeverity === "severe" || depressionSeverity === "extremely_severe" ||
anxietySeverity === "severe" || anxietySeverity === "extremely_severe" ||
stressSeverity === "severe" || stressSeverity === "extremely_severe";
return {
totalScore,
depressionScore: finalDepressionScore,
anxietyScore: finalAnxietyScore,
stressScore: finalStressScore,
depressionSeverity,
anxietySeverity,
stressSeverity,
factorScores: {
"depression": finalDepressionScore,
"anxiety": finalAnxietyScore,
"stress": finalStressScore
},
positiveItemCount: Object.values(answers).filter(score => parseInt(score) >= 2).length,
positiveItemAverage: totalScore / Object.keys(answers).length,
isSevere: isAnySevere
};
};
@@ -0,0 +1,28 @@
const dimensions = {
machiavellianism: [1, 2, 3, 4, 5, 6, 7, 8, 9],
narcissism: [10, 11, 12, 13, 14, 15, 16, 17, 18],
psychopathy: [19, 20, 21, 22, 23, 24, 25, 26, 27],
} as const;
const reverseItems = new Set([4, 8, 11, 15, 17, 20, 25]);
function scoreItem(answers: string[], item: number) {
const raw = Number(answers[item - 1] || 1);
return reverseItems.has(item) ? 6 - raw : raw;
}
function average(scores: number[]) {
return scores.reduce((sum, score) => sum + score, 0) / scores.length;
}
function dimensionAverage(answers: string[], items: readonly number[]) {
return average(items.map((item) => scoreItem(answers, item)));
}
export function calculateDarkTriadResults(answers: string[]) {
return {
machiavellianism: dimensionAverage(answers, dimensions.machiavellianism),
narcissism: dimensionAverage(answers, dimensions.narcissism),
psychopathy: dimensionAverage(answers, dimensions.psychopathy),
};
}
@@ -0,0 +1,30 @@
const subscales = {
perspectiveTaking: [1, 2, 3, 4, 5, 6, 7],
empathicConcern: [8, 9, 10, 11, 12, 13, 14],
personalDistress: [15, 16, 17, 18, 19, 20, 21],
fantasy: [22, 23, 24, 25, 26, 27, 28],
} as const;
const reverseItems = new Set([2, 4, 6, 10, 13, 17, 20, 24, 26, 28]);
function scoreItem(answers: string[], item: number) {
const raw = Number(answers[item - 1] || 1);
return reverseItems.has(item) ? 6 - raw : raw;
}
function average(scores: number[]) {
return scores.reduce((sum, score) => sum + score, 0) / scores.length;
}
function subscaleAverage(answers: string[], items: readonly number[]) {
return average(items.map((item) => scoreItem(answers, item)));
}
export function calculateEmpathyResults(answers: string[]) {
return {
perspectiveTaking: subscaleAverage(answers, subscales.perspectiveTaking),
empathicConcern: subscaleAverage(answers, subscales.empathicConcern),
personalDistress: subscaleAverage(answers, subscales.personalDistress),
fantasy: subscaleAverage(answers, subscales.fantasy),
};
}
@@ -0,0 +1,44 @@
const dimensions = {
explorer: [1, 5, 9, 13, 17, 21, 25, 29, 33, 37],
builder: [2, 6, 10, 14, 18, 22, 26, 30, 34, 38],
director: [3, 7, 11, 15, 19, 23, 27, 31, 35, 39],
negotiator: [4, 8, 12, 16, 20, 24, 28, 32, 36, 40],
} as const;
export type FisherDimension = keyof typeof dimensions;
const dimensionNames: Record<FisherDimension, string> = {
explorer: "探索者",
builder: "建设者",
director: "指挥者",
negotiator: "协商者",
};
function scoreDimension(answers: string[], items: readonly number[]) {
return items.reduce((sum, item) => sum + Number(answers[item - 1] || 1), 0);
}
export function calculateFisherResults(answers: string[]) {
const scores = Object.fromEntries(
Object.entries(dimensions).map(([dimension, items]) => [
dimension,
scoreDimension(answers, items),
]),
) as Record<FisherDimension, number>;
const ranked = (Object.keys(scores) as FisherDimension[])
.map((id) => ({
id,
name: dimensionNames[id],
score: scores[id],
percentage: Math.round(((scores[id] - 10) / 30) * 100),
}))
.sort((a, b) => b.score - a.score);
return {
scores,
ranked,
primary: ranked[0],
secondary: ranked[1],
};
}
@@ -0,0 +1,49 @@
import { QuestionType } from "@/types";
interface GAD7CalculatorProps {
answers: { [key: number]: string };
questions: QuestionType[];
}
export const calculateGAD7Results = ({ answers }: GAD7CalculatorProps): any => {
// GAD-7 calculation logic
let totalScore = 0;
// Calculate total score (simple sum)
Object.entries(answers).forEach(([, score]) => {
const scoreValue = parseInt(score);
totalScore += scoreValue;
});
// Determine anxiety severity level
let severity = "minimal";
if (totalScore >= 15) {
severity = "severe";
} else if (totalScore >= 10) {
severity = "moderate";
} else if (totalScore >= 5) {
severity = "mild";
}
// Analyze item scores
const itemAnalysis = Object.entries(answers).map(([questionId, score]) => ({
questionId: parseInt(questionId),
score: parseInt(score),
isHigh: parseInt(score) >= 2 // Scores of 2 or above are considered high score items
}));
const highScoreItems = itemAnalysis.filter(item => item.isHigh);
return {
totalScore,
severity,
itemAnalysis,
highScoreItemCount: highScoreItems.length,
factorScores: {}, // GAD-7 is a single-factor scale
positiveItemCount: highScoreItems.length,
positiveItemAverage: highScoreItems.length > 0
? highScoreItems.reduce((sum, item) => sum + item.score, 0) / highScoreItems.length
: 0,
isSevere: severity === "severe" || severity === "moderate"
};
};
@@ -0,0 +1,109 @@
import { QuestionType } from "@/types";
/**
* Gender Dysphoria Questionnaire calculator parameters interface
*/
interface GDCalculatorProps {
/** Answer data, key is question ID, value is selected score */
answers: { [key: number]: string };
/** Questions list */
questions: QuestionType[];
}
/**
* Calculate Gender Dysphoria Questionnaire (GDQ) results
*
* @param answers - User answer data, containing question ID and selected score
* @returns Calculation results, including total score, factor scores, and other information
*/
export const calculateGDResults = ({ answers }: GDCalculatorProps): any => {
// Gender Dysphoria calculation logic
let totalRawScore = 0;
// Reverse-scored items (questions where agreement indicates comfort with assigned gender)
const reverseItems = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25];
// Calculate total score with reverse scoring
Object.entries(answers).forEach(([questionId, score]) => {
const questionNum = parseInt(questionId);
const scoreValue = parseInt(score);
if (reverseItems.includes(questionNum)) {
// Reverse score: 7 becomes 1, 6 becomes 2, etc.
totalRawScore += (8 - scoreValue);
} else {
totalRawScore += scoreValue;
}
});
// Calculate factor scores based on conceptual dimensions
let genderIdentityScore = 0; // Questions about internal gender identity
let socialRoleScore = 0; // Questions about social gender roles
let physicalDysphoriaScore = 0; // Questions about physical characteristics
let genderExpressionScore = 0; // Questions about gender expression
Object.entries(answers).forEach(([questionId, score]) => {
const questionNum = parseInt(questionId);
let scoreValue = parseInt(score);
// Apply reverse scoring if needed
if (reverseItems.includes(questionNum)) {
scoreValue = 8 - scoreValue;
}
// Assign to factor scores based on question content
if ([2, 4, 6, 17, 18, 20, 22, 24, 27].includes(questionNum)) {
genderIdentityScore += scoreValue;
} else if ([5, 7, 12, 19, 21, 26].includes(questionNum)) {
socialRoleScore += scoreValue;
} else if ([3, 9, 10, 13, 15, 16].includes(questionNum)) {
physicalDysphoriaScore += scoreValue;
} else if ([1, 8, 11, 14, 23, 25].includes(questionNum)) {
genderExpressionScore += scoreValue;
}
});
// Determine general interpretation level
const totalPossibleScore = Object.keys(answers).length * 7;
const scorePercentage = (totalRawScore / totalPossibleScore) * 100;
let interpretation = "low";
if (scorePercentage >= 30 && scorePercentage < 50) {
interpretation = "mild";
} else if (scorePercentage >= 50 && scorePercentage < 70) {
interpretation = "moderate";
} else if (scorePercentage >= 70) {
interpretation = "high";
}
// Calculate factor scores
const factorScores: { [key: string]: number } = {
"genderIdentity": genderIdentityScore,
"socialRole": socialRoleScore,
"physicalDysphoria": physicalDysphoriaScore,
"genderExpression": genderExpressionScore
};
// Count items with scores above neutral (>4 after reverse scoring)
const elevatedItems = Object.entries(answers).filter(([questionId, score]) => {
const questionNum = parseInt(questionId);
let scoreValue = parseInt(score);
if (reverseItems.includes(questionNum)) {
scoreValue = 8 - scoreValue;
}
return scoreValue > 4;
}).length;
// Return complete calculation results
return {
totalScore: totalRawScore,
factorScores,
positiveItemCount: elevatedItems,
positiveItemAverage: totalRawScore / Object.keys(answers).length,
isSevere: interpretation === "high",
interpretation,
scorePercentage: Math.round(scorePercentage),
};
};
@@ -0,0 +1,40 @@
const reverseItems = new Set([1, 3, 5, 6]);
const perseveranceItems = [2, 4, 7, 8];
const consistencyItems = [1, 3, 5, 6];
function scoreItem(answers: string[], item: number) {
const raw = Number(answers[item - 1] || 1);
return reverseItems.has(item) ? 6 - raw : raw;
}
function average(values: number[]) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
export function calculateGritResults(answers: string[]) {
const itemScores = Array.from({ length: 8 }, (_, index) =>
scoreItem(answers, index + 1),
);
const totalAverage = average(itemScores);
const perseveranceAverage = average(
perseveranceItems.map((item) => scoreItem(answers, item)),
);
const consistencyAverage = average(
consistencyItems.map((item) => scoreItem(answers, item)),
);
let level: 'low' | 'moderate' | 'high' = 'moderate';
if (totalAverage < 3) {
level = 'low';
} else if (totalAverage >= 4) {
level = 'high';
}
return {
itemScores,
totalAverage,
perseveranceAverage,
consistencyAverage,
level,
};
}
@@ -0,0 +1,41 @@
const domains = {
honestyHumility: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
emotionality: [11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
extraversion: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30],
agreeableness: [31, 32, 33, 34, 35, 36, 37, 38, 39, 40],
conscientiousness: [41, 42, 43, 44, 45, 46, 47, 48, 49, 50],
openness: [51, 52, 53, 54, 55, 56, 57, 58, 59, 60],
} as const;
const reverseItems = new Set([
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,
52, 54, 56, 58, 60,
]);
function scoreItem(answers: string[], item: number) {
const raw = Number(answers[item - 1] || 1);
return reverseItems.has(item) ? 6 - raw : raw;
}
function average(scores: number[]) {
return scores.reduce((sum, score) => sum + score, 0) / scores.length;
}
function domainAverage(answers: string[], items: readonly number[]) {
return average(items.map((item) => scoreItem(answers, item)));
}
export function calculateHEXACOResults(answers: string[]) {
return {
honestyHumility: domainAverage(answers, domains.honestyHumility),
emotionality: domainAverage(answers, domains.emotionality),
extraversion: domainAverage(answers, domains.extraversion),
agreeableness: domainAverage(answers, domains.agreeableness),
conscientiousness: domainAverage(answers, domains.conscientiousness),
openness: domainAverage(answers, domains.openness),
};
}
@@ -0,0 +1,49 @@
import { QuestionType } from "@/types";
interface ISICalculatorProps {
answers: { [key: number]: string };
questions: QuestionType[];
}
export const calculateISIResults = ({ answers }: ISICalculatorProps): any => {
// ISI calculation logic
let totalScore = 0;
// Calculate total score (simple sum)
Object.entries(answers).forEach(([, score]) => {
const scoreValue = parseInt(score);
totalScore += scoreValue;
});
// Determine insomnia severity level
let severity = "no_insomnia";
if (totalScore >= 22) {
severity = "severe";
} else if (totalScore >= 15) {
severity = "moderate";
} else if (totalScore >= 8) {
severity = "subthreshold";
}
// Analyze item scores
const itemAnalysis = Object.entries(answers).map(([questionId, score]) => ({
questionId: parseInt(questionId),
score: parseInt(score),
isHigh: parseInt(score) >= 3 // Scores of 3 or above are considered high score items
}));
const highScoreItems = itemAnalysis.filter(item => item.isHigh);
return {
totalScore,
severity,
itemAnalysis,
highScoreItemCount: highScoreItems.length,
factorScores: {},
positiveItemCount: highScoreItems.length,
positiveItemAverage: highScoreItems.length > 0
? highScoreItems.reduce((sum, item) => sum + item.score, 0) / highScoreItems.length
: 0,
isSevere: severity === "severe" || severity === "moderate"
};
};
@@ -0,0 +1,32 @@
const highStandardItems = [1, 4, 5, 10, 13];
const searchItems = [2, 3, 6, 8, 9, 12];
const difficultyItems = [7, 11];
function average(scores: number[]) {
return scores.reduce((sum, score) => sum + score, 0) / scores.length;
}
function scoreItems(answers: string[], items: number[]) {
return average(items.map((item) => Number(answers[item - 1] || 1)));
}
export function calculateMaximizerResults(answers: string[]) {
const itemScores = answers.slice(0, 13).map((answer) => Number(answer || 1));
const averageScore = average(itemScores);
let level: 'satisficer' | 'balanced' | 'maximizer' = 'balanced';
if (averageScore < 3.25) {
level = 'satisficer';
} else if (averageScore >= 4.75) {
level = 'maximizer';
}
return {
itemScores,
average: averageScore,
highStandards: scoreItems(answers, highStandardItems),
search: scoreItems(answers, searchItems),
difficulty: scoreItems(answers, difficultyItems),
level,
};
}
@@ -0,0 +1,92 @@
import { QuestionType } from "@/types";
/**
* Narcissistic Personality Inventory calculator parameters interface
*/
interface NPDCalculatorProps {
/** Answer data, key is question ID, value is selected score */
answers: { [key: number]: string };
/** Questions list */
questions: QuestionType[];
}
/**
* Calculate Narcissistic Personality Inventory (NPI-16) results
*
* @param answers - User answer data, containing question ID and selected score
* @returns Calculation results, including total score, factor scores, severity level and other information
*/
export const calculateNPDResults = ({ answers }: NPDCalculatorProps): any => {
// NPI-16 calculation logic
let totalScore = 0;
// Subscale score initialization based on NPI factors
let leadershipAuthorityScore = 0; // Leadership/Authority subscale
let grandioseExhibitionismScore = 0; // Grandiose Exhibitionism subscale
let entitlementScore = 0; // Entitlement subscale
// Calculate total score and subscale scores
Object.entries(answers).forEach(([questionId, score]) => {
const questionNum = parseInt(questionId);
const scoreValue = parseInt(score);
totalScore += scoreValue;
// Assign to subscales based on NPI-16 factor structure
// Leadership/Authority: questions 1, 8, 10, 11, 12
if ([1, 8, 10, 11, 12].includes(questionNum)) {
leadershipAuthorityScore += scoreValue;
}
// Grandiose Exhibitionism: questions 2, 3, 7, 15
else if ([2, 3, 7, 15].includes(questionNum)) {
grandioseExhibitionismScore += scoreValue;
}
// Entitlement: questions 4, 5, 6, 9, 13, 14, 16
else if ([4, 5, 6, 9, 13, 14, 16].includes(questionNum)) {
entitlementScore += scoreValue;
}
});
// Determine interpretation level based on total score
// Based on research, average scores typically range from 2-8 in general population
let interpretation = "low";
if (totalScore >= 4 && totalScore <= 7) {
interpretation = "average";
} else if (totalScore >= 8 && totalScore <= 11) {
interpretation = "above_average";
} else if (totalScore >= 12) {
interpretation = "high";
}
// Calculate percentile approximation
let percentile = 0;
if (totalScore <= 2) percentile = 25;
else if (totalScore <= 5) percentile = 50;
else if (totalScore <= 8) percentile = 75;
else if (totalScore <= 11) percentile = 90;
else percentile = 95;
// Calculate factor scores
const factorScores: { [key: string]: number } = {
"leadership": leadershipAuthorityScore,
"exhibitionism": grandioseExhibitionismScore,
"entitlement": entitlementScore
};
// Identify dominant traits
const dominantTrait = Object.entries(factorScores).reduce((a, b) =>
factorScores[a[0]] > factorScores[b[0]] ? a : b
)[0];
// Return complete calculation results
return {
totalScore,
factorScores,
positiveItemCount: totalScore, // For NPI, each item is either 0 or 1
positiveItemAverage: totalScore / Object.keys(answers).length,
isSevere: interpretation === "high",
interpretation,
percentile,
dominantTrait,
};
};
@@ -0,0 +1,39 @@
const reverseItems = new Set([3, 4, 5, 7, 8, 9, 12, 16, 17]);
export function calculateNeedForCognitionResults(answers: string[]) {
const itemScores = answers.slice(0, 18).map((answer, index) => {
const raw = Number(answer || 1);
const item = index + 1;
return reverseItems.has(item) ? 6 - raw : raw;
});
const total = itemScores.reduce((sum, score) => sum + score, 0);
const average = total / itemScores.length;
let level: 'low' | 'moderate' | 'high' = 'moderate';
if (average < 3) {
level = 'low';
} else if (average >= 4) {
level = 'high';
}
const thinkingItems = [1, 2, 6, 10, 11, 18].map(
(item) => itemScores[item - 1],
);
const challengeItems = [3, 4, 5, 7, 8, 9, 12, 13, 14, 15, 16, 17].map(
(item) => itemScores[item - 1],
);
return {
itemScores,
total,
average,
thinkingAverage:
thinkingItems.reduce((sum, score) => sum + score, 0) /
thinkingItems.length,
challengeAverage:
challengeItems.reduce((sum, score) => sum + score, 0) /
challengeItems.length,
level,
};
}
@@ -0,0 +1,97 @@
type EnneagramType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
const directKeys: Record<number, EnneagramType> = {
1: 1, 10: 1, 19: 1, 28: 1,
2: 2, 11: 2, 20: 2, 29: 2,
3: 3, 12: 3, 21: 3, 30: 3,
4: 4, 13: 4, 22: 4, 31: 4,
5: 5, 14: 5, 23: 5, 32: 5,
6: 6, 15: 6, 24: 6, 33: 6,
7: 7, 16: 7, 25: 7, 34: 7,
8: 8, 17: 8, 26: 8, 35: 8,
9: 9, 18: 9, 27: 9, 36: 9,
};
const bipolarKeys: Record<number, { left?: EnneagramType[]; right?: EnneagramType[] }> = {
37: { left: [8], right: [6] },
38: { left: [1], right: [4, 7] },
39: { left: [9], right: [3] },
40: { left: [7], right: [5] },
41: { left: [1], right: [7] },
42: { left: [2], right: [5] },
43: { left: [7, 8], right: [9] },
44: { left: [5], right: [2, 4] },
45: { left: [8], right: [2, 9] },
46: { right: [1] },
47: { right: [2] },
48: { left: [3] },
49: { right: [4] },
50: { right: [5] },
51: { right: [6] },
52: { left: [7] },
53: { left: [8] },
54: { left: [9] },
};
export const enneagramTypeInfo: Record<EnneagramType, { name: string; shortName: string; description: string }> = {
1: { name: '1号 改革者', shortName: '改革者', description: '追求正确、负责、自律和改进。' },
2: { name: '2号 助人者', shortName: '助人者', description: '重视关系、照顾他人和被需要。' },
3: { name: '3号 成就者', shortName: '成就者', description: '重视目标、效率、表现和认可。' },
4: { name: '4号 自我型', shortName: '自我型', description: '重视真实、独特、情绪深度和个人意义。' },
5: { name: '5号 探索者', shortName: '探索者', description: '重视知识、理解、独立和能力感。' },
6: { name: '6号 忠诚者', shortName: '忠诚者', description: '重视安全、支持、可靠性和风险预判。' },
7: { name: '7号 享乐者', shortName: '享乐者', description: '重视自由、可能性、新体验和快乐。' },
8: { name: '8号 挑战者', shortName: '挑战者', description: '重视力量、直接、掌控和保护边界。' },
9: { name: '9号 调停者', shortName: '调停者', description: '重视和谐、稳定、舒适和避免冲突。' },
};
function addScore(
scores: Record<EnneagramType, { total: number; count: number }>,
type: EnneagramType,
value: number,
) {
scores[type].total += value;
scores[type].count += 1;
}
export function calculateOEPSResults(answers: string[]) {
const scores = Object.fromEntries(
([1, 2, 3, 4, 5, 6, 7, 8, 9] as EnneagramType[]).map((type) => [
type,
{ total: 0, count: 0 },
]),
) as Record<EnneagramType, { total: number; count: number }>;
answers.forEach((answer, index) => {
const questionId = index + 1;
const value = Number(answer || 0);
if (!value) return;
const directType = directKeys[questionId];
if (directType) {
addScore(scores, directType, value);
return;
}
const key = bipolarKeys[questionId];
if (!key) return;
key.left?.forEach((type) => addScore(scores, type, 6 - value));
key.right?.forEach((type) => addScore(scores, type, value));
});
const ranked = (Object.entries(scores) as [string, { total: number; count: number }][])
.map(([type, score]) => ({
type: Number(type) as EnneagramType,
total: score.total,
count: score.count,
average: score.count ? score.total / score.count : 0,
...enneagramTypeInfo[Number(type) as EnneagramType],
}))
.sort((a, b) => b.average - a.average);
return {
ranked,
top: ranked[0],
};
}
@@ -0,0 +1,64 @@
import { QuestionType } from "@/types";
interface PHQ9CalculatorProps {
answers: { [key: number]: string };
questions: QuestionType[];
}
export const calculatePHQ9Results = ({ answers }: PHQ9CalculatorProps): any => {
// PHQ-9 calculation logic
let totalScore = 0;
let suicidalIdeation = false;
// Calculate total score (simple sum)
Object.entries(answers).forEach(([questionId, score]) => {
const scoreValue = parseInt(score);
totalScore += scoreValue;
// Check question 9 (suicidal ideation)
if (parseInt(questionId) === 9 && scoreValue >= 1) {
suicidalIdeation = true;
}
});
// Determine depression severity level
let severity = "minimal";
if (totalScore >= 20) {
severity = "severe";
} else if (totalScore >= 15) {
severity = "moderately_severe";
} else if (totalScore >= 10) {
severity = "moderate";
} else if (totalScore >= 5) {
severity = "mild";
}
// Analyze item scores
const itemAnalysis = Object.entries(answers).map(([questionId, score]) => ({
questionId: parseInt(questionId),
score: parseInt(score),
isHigh: parseInt(score) >= 2 // Scores of 2 or above are considered high score items
}));
const highScoreItems = itemAnalysis.filter(item => item.isHigh);
// Determine possibility of major depressive episode (at least 5 symptoms, including at least one of the first two)
const coreSymptoms = itemAnalysis.slice(0, 2).filter(item => item.score >= 2);
const otherSymptoms = itemAnalysis.slice(2).filter(item => item.score >= 2);
const majorDepressionCriteria = coreSymptoms.length >= 1 && (coreSymptoms.length + otherSymptoms.length) >= 5;
return {
totalScore,
severity,
suicidalIdeation,
itemAnalysis,
highScoreItemCount: highScoreItems.length,
majorDepressionCriteria,
factorScores: {}, // PHQ-9 is a single-factor scale
positiveItemCount: highScoreItems.length,
positiveItemAverage: highScoreItems.length > 0
? highScoreItems.reduce((sum, item) => sum + item.score, 0) / highScoreItems.length
: 0,
isSevere: severity === "severe" || severity === "moderately_severe" || suicidalIdeation
};
};
@@ -0,0 +1,86 @@
import { QuestionType } from "@/types";
interface PSS10CalculatorProps {
answers: { [key: number]: string };
questions: QuestionType[];
}
export const calculatePSS10Results = ({ answers }: PSS10CalculatorProps): any => {
// PSS-10 calculation logic
let totalScore = 0;
// Reverse scoring items (4, 5, 7, 8)
const reverseItems = [4, 5, 7, 8];
// Calculate total score
Object.entries(answers).forEach(([questionId, score]) => {
const questionNum = parseInt(questionId);
const scoreValue = parseInt(score);
if (reverseItems.includes(questionNum)) {
// Reverse scoring: 0->4, 1->3, 2->2, 3->1, 4->0
totalScore += (4 - scoreValue);
} else {
totalScore += scoreValue;
}
});
// Determine stress level (based on reference values from research literature)
let severity = "low";
if (totalScore >= 27) {
severity = "high";
} else if (totalScore >= 14) {
severity = "moderate";
}
// Analyze item scores
const itemAnalysis = Object.entries(answers).map(([questionId, score]) => {
const questionNum = parseInt(questionId);
const scoreValue = parseInt(score);
const isReverse = reverseItems.includes(questionNum);
const actualScore = isReverse ? (4 - scoreValue) : scoreValue;
return {
questionId: questionNum,
originalScore: scoreValue,
actualScore: actualScore,
isReverse: isReverse,
isHigh: actualScore >= 3 // Scores of 3 or above are considered high score items
};
});
const highScoreItems = itemAnalysis.filter(item => item.isHigh);
// Calculate subscale scores
const stressPerceptionItems = [1, 2, 3, 6, 9, 10]; // Stress perception items
const copingAbilityItems = [4, 5, 7, 8]; // Coping ability items
let stressPerceptionScore = 0;
let copingAbilityScore = 0;
itemAnalysis.forEach(item => {
if (stressPerceptionItems.includes(item.questionId)) {
stressPerceptionScore += item.actualScore;
} else if (copingAbilityItems.includes(item.questionId)) {
copingAbilityScore += item.actualScore;
}
});
return {
totalScore,
severity,
itemAnalysis,
highScoreItemCount: highScoreItems.length,
stressPerceptionScore, // Stress perception score (0-24 points)
copingAbilityScore, // Coping ability score (0-16 points)
factorScores: {
"stress_perception": stressPerceptionScore,
"coping_ability": copingAbilityScore
},
positiveItemCount: highScoreItems.length,
positiveItemAverage: highScoreItems.length > 0
? highScoreItems.reduce((sum, item) => sum + item.actualScore, 0) / highScoreItems.length
: 0,
isSevere: severity === "high"
};
};
@@ -0,0 +1,31 @@
export const riasecTypes = {
R: { name: '现实型', items: [1, 2, 3, 4, 5, 6, 7, 8] },
I: { name: '研究型', items: [9, 10, 11, 12, 13, 14, 15, 16] },
A: { name: '艺术型', items: [17, 18, 19, 20, 21, 22, 23, 24] },
S: { name: '社会型', items: [25, 26, 27, 28, 29, 30, 31, 32] },
E: { name: '企业型', items: [33, 34, 35, 36, 37, 38, 39, 40] },
C: { name: '常规型', items: [41, 42, 43, 44, 45, 46, 47, 48] },
} as const;
export function calculateRIASECResults(answers: string[]) {
const scores = Object.fromEntries(
Object.entries(riasecTypes).map(([code, type]) => {
const score = type.items.reduce(
(sum, item) => sum + Number(answers[item - 1] || 0),
0,
);
return [code, { score, average: score / type.items.length }];
}),
);
const ranking = Object.entries(scores).sort(
([codeA, resultA], [codeB, resultB]) =>
resultB.score - resultA.score || codeA.localeCompare(codeB),
);
return {
scores,
ranking,
hollandCode: ranking.slice(0, 3).map(([code]) => code).join(''),
};
}
@@ -0,0 +1,70 @@
import { QuestionType } from "@/types";
interface SCL90CalculatorProps {
answers: { [key: number]: string };
questions: QuestionType[];
}
export const calculateSCL90Results = ({ answers }: SCL90CalculatorProps): any => {
// SCL-90 calculation logic
const totalScore = Object.values(answers).reduce((sum, score) => sum + parseInt(score), 0);
const positiveItemCount = Object.values(answers).filter(score => parseInt(score) >= 2).length;
const negativeItemCount = Object.values(answers).filter(score => parseInt(score) === 1).length;
const positiveItemAverage = positiveItemCount > 0
? (totalScore - negativeItemCount) / positiveItemCount
: 0;
// SCL-90 factor grouping (based on standard factor structure)
const factorMapping: { [key: string]: number[] } = {
"somatization": [1, 4, 12, 27, 40, 42, 48, 49, 52, 53, 56, 58], // Somatization
"obsessive": [3, 9, 10, 28, 38, 45, 46, 51, 55, 65], // Obsessive-compulsive
"interpersonal": [6, 21, 34, 36, 37, 41, 61, 69, 73], // Interpersonal sensitivity
"depression": [5, 14, 15, 20, 22, 26, 29, 30, 31, 32, 54, 71, 79], // Depression
"anxiety": [2, 17, 23, 33, 39, 57, 72, 78, 80, 86], // Anxiety
"hostility": [11, 24, 63, 67, 74, 81], // Hostility
"phobic": [13, 25, 47, 50, 70, 75, 82], // Phobic anxiety
"paranoid": [8, 18, 43, 68, 76, 83], // Paranoid ideation
"psychotic": [7, 16, 35, 62, 77, 84, 85, 87, 88, 90], // Psychoticism
"other": [19, 44, 59, 60, 64, 66, 89] // Other
};
// Calculate factor scores
const factorScores: { [key: string]: number } = {};
Object.entries(factorMapping).forEach(([factor, questionIndexes]) => {
let factorSum = 0;
let validQuestionCount = 0;
questionIndexes.forEach((index: number) => {
if (answers[index]) {
factorSum += parseInt(answers[index]);
validQuestionCount++;
}
});
factorScores[factor] = validQuestionCount > 0
? factorSum / validQuestionCount
: 0;
});
const isSevere = totalScore > 160 || positiveItemCount > 43;
// Determine overall severity level
let severity = "normal";
if (totalScore >= 160 && positiveItemCount >= 43) {
severity = "severe";
} else if (totalScore >= 120 || positiveItemCount >= 30) {
severity = "moderate";
} else if (totalScore >= 90 || positiveItemCount >= 20) {
severity = "mild";
}
return {
totalScore,
factorScores,
positiveItemCount,
positiveItemAverage,
isSevere,
severity
};
};
@@ -0,0 +1,47 @@
import { QuestionType } from "@/types";
interface SDSCalculatorProps {
answers: { [key: number]: string };
questions: QuestionType[];
}
export const calculateSDSResults = ({ answers }: SDSCalculatorProps): any => {
// SDS Depression Self-rating Scale calculation logic
const reverseItems = [2, 5, 6, 11, 12, 14, 16, 17, 18, 20]; // Reverse scoring items
let totalScore = 0;
// Calculate total score
Object.entries(answers).forEach(([questionId, score]) => {
const questionNum = parseInt(questionId);
const scoreValue = parseInt(score);
if (reverseItems.includes(questionNum)) {
// Reverse scoring: 1->4, 2->3, 3->2, 4->1
totalScore += (5 - scoreValue);
} else {
totalScore += scoreValue;
}
});
// Calculate standard score
const standardScore = Math.round(totalScore * 1.25);
// Determine depression level
let severity = "normal";
if (standardScore >= 53 && standardScore <= 62) {
severity = "mild";
} else if (standardScore >= 63 && standardScore <= 72) {
severity = "moderate";
} else if (standardScore > 72) {
severity = "severe";
}
return {
totalScore: standardScore,
factorScores: {},
positiveItemCount: 0,
positiveItemAverage: 0,
isSevere: severity === "severe",
severity: severity
};
};
@@ -0,0 +1,112 @@
export type SchwartzValue =
| "selfDirection"
| "stimulation"
| "hedonism"
| "achievement"
| "power"
| "security"
| "conformity"
| "tradition"
| "benevolence"
| "universalism";
export type SchwartzHigherOrder =
| "opennessToChange"
| "conservation"
| "selfEnhancement"
| "selfTranscendence";
const valueItems: Record<SchwartzValue, number[]> = {
selfDirection: [1, 11, 21],
stimulation: [2, 12, 22],
hedonism: [3, 13, 23],
achievement: [4, 14, 24],
power: [5, 15, 25],
security: [6, 16, 26],
conformity: [7, 17, 27],
tradition: [8, 18, 28],
benevolence: [9, 19, 29],
universalism: [10, 20, 30],
};
export const valueNames: Record<SchwartzValue, string> = {
selfDirection: "自主",
stimulation: "刺激",
hedonism: "享乐",
achievement: "成就",
power: "权力",
security: "安全",
conformity: "遵从",
tradition: "传统",
benevolence: "仁慈",
universalism: "普世主义",
};
export const higherOrderNames: Record<SchwartzHigherOrder, string> = {
opennessToChange: "开放变化",
conservation: "保守稳定",
selfEnhancement: "自我提升",
selfTranscendence: "自我超越",
};
function average(values: number[]) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function scoreItems(answers: string[], items: number[]) {
return average(items.map((item) => Number(answers[item - 1] || 1)));
}
export function calculateSchwartzResults(answers: string[]) {
const valueScores = Object.fromEntries(
Object.entries(valueItems).map(([value, items]) => [
value,
scoreItems(answers, items),
]),
) as Record<SchwartzValue, number>;
const higherOrderScores: Record<SchwartzHigherOrder, number> = {
opennessToChange: average([
valueScores.selfDirection,
valueScores.stimulation,
valueScores.hedonism,
]),
conservation: average([
valueScores.security,
valueScores.conformity,
valueScores.tradition,
]),
selfEnhancement: average([
valueScores.achievement,
valueScores.power,
valueScores.hedonism,
]),
selfTranscendence: average([
valueScores.benevolence,
valueScores.universalism,
]),
};
const rankedValues = (Object.keys(valueScores) as SchwartzValue[])
.map((id) => ({
id,
name: valueNames[id],
score: valueScores[id],
}))
.sort((a, b) => b.score - a.score);
const rankedHigherOrders = (Object.keys(higherOrderScores) as SchwartzHigherOrder[])
.map((id) => ({
id,
name: higherOrderNames[id],
score: higherOrderScores[id],
}))
.sort((a, b) => b.score - a.score);
return {
valueScores,
higherOrderScores,
rankedValues,
rankedHigherOrders,
};
}
@@ -0,0 +1,36 @@
const reverseItems = new Set([2, 3, 5, 6, 7, 8, 10, 11, 12]);
export function calculateSelfControlResults(answers: string[]) {
const itemScores = answers.slice(0, 13).map((answer, index) => {
const raw = Number(answer || 1);
const item = index + 1;
return reverseItems.has(item) ? 6 - raw : raw;
});
const total = itemScores.reduce((sum, score) => sum + score, 0);
const average = total / itemScores.length;
let level: 'low' | 'moderate' | 'high' = 'moderate';
if (average < 3) {
level = 'low';
} else if (average >= 4) {
level = 'high';
}
const impulseItems = [1, 2, 5, 10, 11].map((item) => itemScores[item - 1]);
const executionItems = [3, 4, 6, 7, 8, 9, 12, 13].map(
(item) => itemScores[item - 1],
);
return {
itemScores,
total,
average,
impulseAverage:
impulseItems.reduce((sum, score) => sum + score, 0) / impulseItems.length,
executionAverage:
executionItems.reduce((sum, score) => sum + score, 0) /
executionItems.length,
level,
};
}
@@ -0,0 +1,24 @@
const reverseItems = new Set([2, 5, 6, 8, 9]);
export function calculateSelfEsteemResults(answers: string[]) {
const itemScores = answers.slice(0, 10).map((answer, index) => {
const raw = Number(answer || 0);
const item = index + 1;
return reverseItems.has(item) ? 3 - raw : raw;
});
const total = itemScores.reduce((sum, score) => sum + score, 0);
let level: 'low' | 'moderate' | 'high' = 'moderate';
if (total <= 14) {
level = 'low';
} else if (total >= 25) {
level = 'high';
}
return {
total,
itemScores,
level,
};
}
@@ -0,0 +1,111 @@
export type VIAStrength =
| "creativity" | "curiosity" | "judgment" | "loveOfLearning" | "perspective"
| "bravery" | "perseverance" | "honesty" | "zest"
| "love" | "kindness" | "socialIntelligence"
| "teamwork" | "fairness" | "leadership"
| "forgiveness" | "humility" | "prudence" | "selfRegulation"
| "appreciation" | "gratitude" | "hope" | "humor" | "spirituality";
export type VIAVirtue = "wisdom" | "courage" | "humanity" | "justice" | "temperance" | "transcendence";
const strengthItems: Record<VIAStrength, number[]> = {
creativity: [1, 2],
curiosity: [3, 4],
judgment: [5, 6],
loveOfLearning: [7, 8],
perspective: [9, 10],
bravery: [11, 12],
perseverance: [13, 14],
honesty: [15, 16],
zest: [17, 18],
love: [19, 20],
kindness: [21, 22],
socialIntelligence: [23, 24],
teamwork: [25, 26],
fairness: [27, 28],
leadership: [29, 30],
forgiveness: [31, 32],
humility: [33, 34],
prudence: [35, 36],
selfRegulation: [37, 38],
appreciation: [39, 40],
gratitude: [41, 42],
hope: [43, 44],
humor: [45, 46],
spirituality: [47, 48],
};
const virtueStrengths: Record<VIAVirtue, VIAStrength[]> = {
wisdom: ["creativity", "curiosity", "judgment", "loveOfLearning", "perspective"],
courage: ["bravery", "perseverance", "honesty", "zest"],
humanity: ["love", "kindness", "socialIntelligence"],
justice: ["teamwork", "fairness", "leadership"],
temperance: ["forgiveness", "humility", "prudence", "selfRegulation"],
transcendence: ["appreciation", "gratitude", "hope", "humor", "spirituality"],
};
export const strengthNames: Record<VIAStrength, string> = {
creativity: "创造力",
curiosity: "好奇心",
judgment: "判断力",
loveOfLearning: "热爱学习",
perspective: "洞察力",
bravery: "勇敢",
perseverance: "坚持",
honesty: "真诚",
zest: "热情",
love: "爱",
kindness: "善良",
socialIntelligence: "社交智慧",
teamwork: "团队精神",
fairness: "公平",
leadership: "领导力",
forgiveness: "宽恕",
humility: "谦逊",
prudence: "审慎",
selfRegulation: "自我调节",
appreciation: "审美",
gratitude: "感恩",
hope: "希望",
humor: "幽默",
spirituality: "灵性",
};
export const virtueNames: Record<VIAVirtue, string> = {
wisdom: "智慧",
courage: "勇气",
humanity: "仁爱",
justice: "正义",
temperance: "节制",
transcendence: "超越",
};
function average(values: number[]) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
export function calculateVIAResults(answers: string[]) {
const strengthScores = Object.fromEntries(
Object.entries(strengthItems).map(([strength, items]) => [
strength,
average(items.map((item) => Number(answers[item - 1] || 1))),
]),
) as Record<VIAStrength, number>;
const virtueScores = Object.fromEntries(
Object.entries(virtueStrengths).map(([virtue, strengths]) => [
virtue,
average(strengths.map((strength) => strengthScores[strength])),
]),
) as Record<VIAVirtue, number>;
const rankedStrengths = (Object.keys(strengthScores) as VIAStrength[])
.map((id) => ({ id, name: strengthNames[id], score: strengthScores[id] }))
.sort((a, b) => b.score - a.score);
const rankedVirtues = (Object.keys(virtueScores) as VIAVirtue[])
.map((id) => ({ id, name: virtueNames[id], score: virtueScores[id] }))
.sort((a, b) => b.score - a.score);
return { strengthScores, virtueScores, rankedStrengths, rankedVirtues };
}
@@ -0,0 +1,32 @@
interface WHO5CalculatorProps {
answers: { [key: number]: string };
}
export const calculateWHO5Results = ({ answers }: WHO5CalculatorProps) => {
const itemScores = Array.from({ length: 5 }, (_, index) => {
const questionId = index + 1;
return Number.parseInt(answers[questionId] ?? "0", 10);
});
const rawScore = itemScores.reduce((sum, score) => sum + score, 0);
const percentageScore = rawScore * 4;
const hasVeryLowItem = itemScores.some((score) => score <= 1);
let level: "high" | "moderate" | "low" | "very_low" = "high";
if (rawScore <= 7) {
level = "very_low";
} else if (rawScore <= 12) {
level = "low";
} else if (rawScore <= 17) {
level = "moderate";
}
return {
rawScore,
percentageScore,
itemScores,
hasVeryLowItem,
level,
needsAttention: rawScore <= 12 || hasVeryLowItem,
};
};
@@ -0,0 +1,77 @@
import { QuestionType } from "@/types";
/**
* Yale-Brown Obsessive Compulsive Scale calculator parameters interface
*/
interface YBOCSCalculatorProps {
/** Answer data, key is question ID, value is selected score */
answers: { [key: number]: string };
/** Questions list */
questions: QuestionType[];
}
/**
* Calculate Yale-Brown Obsessive Compulsive Scale (Y-BOCS) results
*
* @param answers - User answer data, containing question ID and selected score
* @returns Calculation results, including total score, factor scores, severity level and other information
*/
export const calculateYBOCSResults = ({ answers }: YBOCSCalculatorProps): any => {
// Y-BOCS Obsessive Compulsive Scale calculation logic
let totalScore = 0;
// Subscale score initialization: obsessions (questions 1-5) and compulsions (questions 6-10)
let obsessionScore = 0; // Obsessions subscale score
let compulsionScore = 0; // Compulsions subscale score
// Calculate total score and subscale scores
Object.entries(answers).forEach(([questionId, score]) => {
const questionNum = parseInt(questionId); // Question number
const scoreValue = parseInt(score); // Selected score value
totalScore += scoreValue; // Accumulate total score
// Subscale score calculation: distinguish between obsessions and compulsions
if (questionNum >= 1 && questionNum <= 5) {
// Obsessions subscale (questions 1-5)
obsessionScore += scoreValue;
} else if (questionNum >= 6 && questionNum <= 10) {
// Compulsions subscale (questions 6-10)
compulsionScore += scoreValue;
}
});
// Determine severity level of obsessive-compulsive symptoms based on total score
// 0-7: Normal
// 8-15: Mild obsessive-compulsive symptoms
// 16-23: Moderate obsessive-compulsive symptoms
// 24-31: Severe obsessive-compulsive symptoms
// 32-40: Extremely severe obsessive-compulsive symptoms
let severity = "normal"; // Default to normal
if (totalScore >= 8 && totalScore <= 15) {
severity = "mild"; // Mild obsessive-compulsive symptoms
} else if (totalScore >= 16 && totalScore <= 23) {
severity = "moderate"; // Moderate obsessive-compulsive symptoms
} else if (totalScore >= 24 && totalScore <= 31) {
severity = "severe"; // Severe obsessive-compulsive symptoms
} else if (totalScore >= 32) {
severity = "extreme"; // Extremely severe obsessive-compulsive symptoms
}
// Calculate factor scores (obsessions subscale and compulsions subscale)
// These factor scores can be used to analyze symptom characteristics and intervention directions
const factorScores: { [key: string]: number } = {
"obsession": obsessionScore, // Obsessions score
"compulsion": compulsionScore // Compulsions score
};
// Return complete calculation results
return {
totalScore, // Total score
factorScores, // Factor scores (including obsessions and compulsions)
positiveItemCount: Object.values(answers).filter(score => parseInt(score) >= 1).length, // Positive item count (number of items with score ≥1)
positiveItemAverage: totalScore / Object.keys(answers).length, // Positive item average score
isSevere: severity === "severe" || severity === "extreme", // Whether it's severe or extremely severe symptoms
severity // Severity level classification
};
};
@@ -0,0 +1,47 @@
'use client';
import { useScopedI18n } from '@/locales/client';
import { Button } from '@/components/ui/button';
interface NavigationProps {
currentPage: number;
totalPages: number;
goToPage: (page: number) => void;
onSubmit: () => void;
isLastPage: boolean;
}
export function Navigation({
currentPage,
totalPages,
goToPage,
onSubmit,
isLastPage,
}: NavigationProps) {
const t = useScopedI18n('component.questionnaire.test.public.navigation');
return (
<div className="flex justify-between items-center mt-8">
<Button
variant="outline"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
>
{t('previousPage')}
</Button>
<span className="text-sm text-gray-500">
{t('pageInfo', { currentPage, totalPages })}
</span>
{isLastPage ? (
<Button onClick={onSubmit}>{t('submit')}</Button>
) : (
<Button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
{t('nextPage')}
</Button>
)}
</div>
);
}
@@ -0,0 +1,16 @@
import React from 'react';
interface ProgressBarProps {
completionPercentage: number;
}
export function ProgressBar({ completionPercentage }: ProgressBarProps) {
return (
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden mb-2">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${completionPercentage}%` }}
></div>
</div>
);
}
@@ -0,0 +1,94 @@
import { useScopedI18n } from '@/locales/client';
import { Option } from '@/types';
interface Question {
id: number;
content: string;
options: Option[];
}
interface ProgressPanelProps {
questions: Question[];
answers: { [key: number]: string };
activePanelQuestion: number | null;
goToQuestion: (questionId: number) => void;
showProgressPanel: boolean;
toggleProgressPanel: () => void;
completionPercentage: number;
}
export function ProgressPanel({
questions,
answers,
activePanelQuestion,
goToQuestion,
showProgressPanel,
toggleProgressPanel,
completionPercentage,
}: ProgressPanelProps) {
const t = useScopedI18n('component.questionnaire.test.public.progressPanel');
return (
<>
<button
className="fixed right-4 top-10 bg-blue-500 hover:bg-blue-600 text-white rounded-full p-2 z-40 transition-all duration-300"
onClick={toggleProgressPanel}
title={showProgressPanel ? t('hideNav') : t('showNav')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-6 w-6 transition-transform duration-300 ${showProgressPanel ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
<div
className={`fixed right-4 top-20 w-64 bg-white rounded-lg shadow-lg p-4 transition-transform duration-300 transform ${showProgressPanel ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="mb-4">
<div className="text-sm font-medium mb-2">
{t('completionProgress')}
{Math.round(completionPercentage)}%
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 rounded-full h-2 transition-all duration-300"
style={{ width: `${completionPercentage}%` }}
/>
</div>
</div>
<div className="grid grid-cols-5 gap-1 mb-4 max-h-[400px] overflow-y-auto">
{questions.map((_, i) => (
<button
key={i}
className={`w-9 h-9 flex items-center justify-center rounded-md text-xs
${answers[i + 1]
? 'bg-green-100 border-green-500 border-2'
: 'border'
}
${i + 1 === activePanelQuestion
? 'bg-green-300 border-green-600'
: ''
}
hover:bg-gray-100 transition-colors duration-200`}
onClick={() => goToQuestion(i + 1)}
title={t('jumpToQuestion', { questionNumber: i + 1 })}
>
{i + 1}
</button>
))}
</div>
</div>
</>
);
}
@@ -0,0 +1,41 @@
import { forwardRef } from 'react';
import { QuestionType } from '@/types';
interface QuestionProps {
question: QuestionType;
answer?: string;
onSelect: (questionId: number, option: string) => void;
}
export const Question = forwardRef<HTMLDivElement, QuestionProps>(
({ question, answer, onSelect }, ref) => {
return (
<div
ref={ref}
className="mb-8 p-6 bg-white rounded-lg shadow-sm"
id={`question-${question.id}`}
>
<h3 className="text-lg font-medium mb-4">
{question.id}. {question.content}
</h3>
<div className="space-y-2">
{question.options.map((option) => (
<button
key={option.value}
className={`w-full text-left p-3 rounded-lg transition-colors duration-200
${answer === option.value
? 'bg-blue-100 border-blue-500 border-2'
: 'border hover:bg-gray-50'
}`}
onClick={() => onSelect(question.id, option.value)}
>
{option.content}
</button>
))}
</div>
</div>
);
}
);
Question.displayName = 'Question';
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-sm border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent text-primary border border-primary [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 cursor-pointer",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+257
View File
@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { Toaster as Sonner, ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="light"
className="toaster group"
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };