feat: 完善中文心理测评平台
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
story-personality.zip
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "windows-gcc-x86",
|
||||
"includePath": [
|
||||
"${workspaceFolder}/**"
|
||||
],
|
||||
"compilerPath": "C:/MinGW/bin/gcc.exe",
|
||||
"cStandard": "${default}",
|
||||
"cppStandard": "${default}",
|
||||
"intelliSenseMode": "windows-gcc-x86",
|
||||
"compilerArgs": [
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": 4
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "C/C++ Runner: Debug Session",
|
||||
"type": "cppdbg",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"stopAtEntry": false,
|
||||
"externalConsole": true,
|
||||
"cwd": "c:/Users/26931/Desktop/lx-scale/app",
|
||||
"program": "c:/Users/26931/Desktop/lx-scale/app/build/Debug/outDebug",
|
||||
"MIMode": "gdb",
|
||||
"miDebuggerPath": "gdb",
|
||||
"setupCommands": [
|
||||
{
|
||||
"description": "Enable pretty-printing for gdb",
|
||||
"text": "-enable-pretty-printing",
|
||||
"ignoreFailures": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"C_Cpp_Runner.cCompilerPath": "gcc",
|
||||
"C_Cpp_Runner.cppCompilerPath": "g++",
|
||||
"C_Cpp_Runner.debuggerPath": "gdb",
|
||||
"C_Cpp_Runner.cStandard": "",
|
||||
"C_Cpp_Runner.cppStandard": "",
|
||||
"C_Cpp_Runner.msvcBatchPath": "C:/Program Files/Microsoft Visual Studio/2022/Community/VC/Auxiliary/Build/vcvarsall.bat",
|
||||
"C_Cpp_Runner.useMsvc": false,
|
||||
"C_Cpp_Runner.warnings": [
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Wpedantic",
|
||||
"-Wshadow",
|
||||
"-Wformat=2",
|
||||
"-Wcast-align",
|
||||
"-Wconversion",
|
||||
"-Wsign-conversion",
|
||||
"-Wnull-dereference"
|
||||
],
|
||||
"C_Cpp_Runner.msvcWarnings": [
|
||||
"/W4",
|
||||
"/permissive-",
|
||||
"/w14242",
|
||||
"/w14287",
|
||||
"/w14296",
|
||||
"/w14311",
|
||||
"/w14826",
|
||||
"/w44062",
|
||||
"/w44242",
|
||||
"/w14905",
|
||||
"/w14906",
|
||||
"/w14263",
|
||||
"/w44265",
|
||||
"/w14928"
|
||||
],
|
||||
"C_Cpp_Runner.enableWarnings": true,
|
||||
"C_Cpp_Runner.warningsAsError": false,
|
||||
"C_Cpp_Runner.compilerArgs": [],
|
||||
"C_Cpp_Runner.linkerArgs": [],
|
||||
"C_Cpp_Runner.includePaths": [],
|
||||
"C_Cpp_Runner.includeSearch": [
|
||||
"*",
|
||||
"**/*"
|
||||
],
|
||||
"C_Cpp_Runner.excludeSearch": [
|
||||
"**/build",
|
||||
"**/build/**",
|
||||
"**/.*",
|
||||
"**/.*/**",
|
||||
"**/.vscode",
|
||||
"**/.vscode/**"
|
||||
],
|
||||
"C_Cpp_Runner.useAddressSanitizer": false,
|
||||
"C_Cpp_Runner.useUndefinedSanitizer": false,
|
||||
"C_Cpp_Runner.useLeakSanitizer": false,
|
||||
"C_Cpp_Runner.showCompilationTime": false,
|
||||
"C_Cpp_Runner.useLinkTimeOptimization": false,
|
||||
"C_Cpp_Runner.msvcSecureNoWarnings": false
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# 心理量表测试
|
||||
|
||||
中文心理量表自测项目。所有答题、计分和结果展示都在本地浏览器或当前 Next.js 应用内完成,不依赖 AI 分析、登录系统或数据库。
|
||||
|
||||
## 功能
|
||||
|
||||
- 浏览人格、认知、情绪、睡眠、职业和心理健康相关量表
|
||||
- 在线答题并查看本地计算结果
|
||||
- 本地保存未完成草稿
|
||||
- 复制或下载完整测评记录
|
||||
- 仅提供中文界面
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
打开 http://localhost:3000 查看项目。
|
||||
|
||||
## 生产部署
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
测评结果仅供参考,不构成医疗诊断。如有持续或严重困扰,请咨询专业医生或心理健康专业人员。
|
||||
@@ -0,0 +1,122 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Navbar } from '@/components/Navbar';
|
||||
import { I18nProviderClient } from '@/locales/client';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<I18nProviderClient locale="zh">
|
||||
<Navbar />
|
||||
<main>{children}</main>
|
||||
</I18nProviderClient>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, ClipboardList } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-3.5rem)] border-t bg-background">
|
||||
<section className="container max-w-4xl mx-auto px-4 py-16 md:py-24">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="inline-flex h-10 w-10 items-center justify-center rounded border bg-muted">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-semibold tracking-normal">
|
||||
心理量表测试
|
||||
</h1>
|
||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl">
|
||||
选择一个量表,完成答题后查看结果。数据只在当前浏览器中处理,结果仅供参考,不构成医疗诊断。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button size="lg" asChild>
|
||||
<Link href="/questionnaire" className="gap-2">
|
||||
开始测试
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-4 text-sm text-muted-foreground">
|
||||
<div className="border rounded p-4">人格、认知、情绪状态</div>
|
||||
<div className="border rounded p-4">自动计算结果</div>
|
||||
<div className="border rounded p-4">可复制完整记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import QuestionnaireDetailsPage from '@/components/questionnaire/QuestionnaireDetailsPage';
|
||||
import { questionnairesZh } from '@/questionairies/zh';
|
||||
import { Questionnaire as QuestionnaireType } from '@/types';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function QuestionnaireDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const questionnaire = questionnairesZh.find((q) => q.id === id);
|
||||
|
||||
if (!questionnaire) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const cleanQuestionnaire = {
|
||||
id: questionnaire.id,
|
||||
title: questionnaire.title,
|
||||
description: questionnaire.description,
|
||||
category: questionnaire.category,
|
||||
tags: questionnaire.tags,
|
||||
time: questionnaire.time,
|
||||
evaluation: questionnaire.evaluation,
|
||||
details: questionnaire.details,
|
||||
questions: questionnaire.questions,
|
||||
};
|
||||
|
||||
return (
|
||||
<QuestionnaireDetailsPage
|
||||
questionnaire={cleanQuestionnaire as QuestionnaireType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { use } from 'react';
|
||||
import { Questionnaire } from '@/components/questionnaire/test/QuestionnaireTest';
|
||||
import { useQuestionnaire } from '@/hooks/useQuestionnaire';
|
||||
import { Questionnaire as QuestionnaireType } from '@/types';
|
||||
|
||||
export default function QuestionnairePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
|
||||
// Get the questionnaire with specified id from questionnaire data
|
||||
const questionnaire = useQuestionnaire(id);
|
||||
|
||||
// If data not found, show 404 page
|
||||
if (!questionnaire) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <Questionnaire questionnaire={questionnaire as QuestionnaireType} id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { useEffect, useState, useMemo, use } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Questionnaire } from '@/types';
|
||||
import Link from 'next/link';
|
||||
import { ResultContainer } from '@/components/questionnaire/result/public/ResultContainer';
|
||||
import { AnswerList } from '@/components/questionnaire/result/public/AnswerList';
|
||||
import { ResultAnalysis } from '@/components/questionnaire/result/analysis/ResultAnalysis';
|
||||
import { useQuestionnaire } from '@/hooks/useQuestionnaire';
|
||||
import { useScopedI18n } from '@/locales/client';
|
||||
import { loadResult } from '@/lib/result-storage';
|
||||
|
||||
export default function QuestionnaireResultPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [decodedAnswers, setDecodedAnswers] = useState<string[] | null>(null);
|
||||
const t = useScopedI18n('app.questionnaire.result');
|
||||
|
||||
// Get the questionnaire with specified id from questionnaire data
|
||||
const questionnaire = useQuestionnaire(id) as Questionnaire;
|
||||
|
||||
// Load results from tab-local storage. Answers are intentionally not kept in the URL.
|
||||
useEffect(() => {
|
||||
if (!questionnaire || !questionnaire.details) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDecodedAnswers(loadResult(id));
|
||||
setLoading(false);
|
||||
}, [id, questionnaire]);
|
||||
|
||||
// Construct question-option text pairs for copying result data
|
||||
const questionnaireResults: Record<string, string> = useMemo(() => {
|
||||
if (!questionnaire || !decodedAnswers) return {};
|
||||
const obj: Record<string, string> = {};
|
||||
questionnaire.questions.forEach((q, idx) => {
|
||||
const val = decodedAnswers[idx];
|
||||
if (val === undefined) return;
|
||||
const option = questionnaire.renderOptions(q.id).find(
|
||||
(o) => String(o.value) === String(val)
|
||||
);
|
||||
obj[q.content] = option ? option.content : String(val);
|
||||
});
|
||||
return obj;
|
||||
}, [decodedAnswers, questionnaire]);
|
||||
|
||||
// If data not found, show 404 page
|
||||
if (!questionnaire || !questionnaire.details) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!decodedAnswers || decodedAnswers.length !== questionnaire.questions.length) {
|
||||
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">
|
||||
{questionnaire.title} - {t('resultNotFoundTitle')}
|
||||
</h1>
|
||||
<p className="text-gray-700 mb-6">{t('resultNotFoundDesc')}</p>
|
||||
<Button>
|
||||
<Link href={`/questionnaire/${id}`}>{t('retryTest')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResultContainer
|
||||
title={questionnaire.title}
|
||||
id={id}
|
||||
questionnaire={questionnaire}
|
||||
answers={decodedAnswers}
|
||||
questionnaireResults={questionnaireResults}
|
||||
>
|
||||
<AnswerList
|
||||
questions={questionnaire.questions}
|
||||
answers={decodedAnswers}
|
||||
renderOptions={questionnaire.renderOptions}
|
||||
/>
|
||||
<ResultAnalysis questionnaireId={id} answers={decodedAnswers} />
|
||||
</ResultContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import QuestionnaireList from '@/components/questionnaire/List';
|
||||
|
||||
export default function QuestionnairePage() {
|
||||
return <QuestionnaireList />;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 Five、RIASEC 和实际生活反馈综合理解。
|
||||
</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为 "{questionnaireId}" 的结果分析。
|
||||
请检查量表配置或联系开发人员。
|
||||
</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-5通常建议:原始总分低于13分,或任一题为0-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';
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,22 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
...compat.config({
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { questionnairesZh } from '@/questionairies/zh';
|
||||
|
||||
export function useQuestionnaire(id?: string) {
|
||||
if (!id) return questionnairesZh;
|
||||
return questionnairesZh.find((q) => q.id === id);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
const RESULT_STORAGE_KEY = 'questionnaire_result';
|
||||
|
||||
interface StoredResult {
|
||||
answers: string[];
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function resultKey(questionnaireId: string) {
|
||||
return `${RESULT_STORAGE_KEY}_${questionnaireId}`;
|
||||
}
|
||||
|
||||
export function saveResult(questionnaireId: string, answers: string[]) {
|
||||
try {
|
||||
const result: StoredResult = {
|
||||
answers,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
sessionStorage.setItem(resultKey(questionnaireId), JSON.stringify(result));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save result:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadResult(questionnaireId: string): string[] | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(resultKey(questionnaireId));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<StoredResult>;
|
||||
return Array.isArray(parsed.answers) ? parsed.answers : null;
|
||||
} catch (error) {
|
||||
console.error('Failed to load result:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const STORAGE_KEY = 'questionnaire';
|
||||
|
||||
function save(
|
||||
usage: string,
|
||||
questionnaireType: string,
|
||||
answers: { [key: number]: string },
|
||||
) {
|
||||
try {
|
||||
const drafts = JSON.parse(
|
||||
localStorage.getItem(`${STORAGE_KEY}_${usage}`) || '{}',
|
||||
);
|
||||
drafts[questionnaireType] = {
|
||||
answers,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(`${STORAGE_KEY}_${usage}`, JSON.stringify(drafts));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save draft:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function load(
|
||||
usage: string,
|
||||
questionnaireType: string,
|
||||
): { [key: number]: string } | null {
|
||||
try {
|
||||
const drafts = JSON.parse(
|
||||
localStorage.getItem(`${STORAGE_KEY}_${usage}`) || '{}',
|
||||
);
|
||||
return drafts[questionnaireType]?.answers || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to load draft:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clear(usage: string, questionnaireType: string) {
|
||||
try {
|
||||
const key = `${STORAGE_KEY}_${usage}`;
|
||||
const drafts = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
|
||||
if (!(questionnaireType in drafts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete drafts[questionnaireType];
|
||||
|
||||
if (Object.keys(drafts).length === 0) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(drafts));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to clear draft:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDraft(
|
||||
questionnaireType: string,
|
||||
answers: { [key: number]: string },
|
||||
) {
|
||||
return save('draft', questionnaireType, answers);
|
||||
}
|
||||
|
||||
export function loadDraft(
|
||||
questionnaireType: string,
|
||||
): { [key: number]: string } | null {
|
||||
return load('draft', questionnaireType);
|
||||
}
|
||||
|
||||
export function clearDraft(questionnaireType: string) {
|
||||
return clear('draft', questionnaireType);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
"use client"
|
||||
import { createI18nClient } from 'next-international/client';
|
||||
|
||||
export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient({
|
||||
zh: () => import('./zh/index'),
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user