feat: 增加本地测评档案与长期追踪
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ClipboardList } from 'lucide-react';
|
||||
import { ClipboardList, FolderClock } from 'lucide-react';
|
||||
import { useScopedI18n } from '@/locales/client';
|
||||
|
||||
export function Navbar() {
|
||||
@@ -36,6 +36,13 @@ export function Navbar() {
|
||||
>
|
||||
{t('questionsList')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/records"
|
||||
className={`${pathname.startsWith('/records') ? 'font-medium' : 'text-muted-foreground'} flex items-center gap-1.5 hover:text-foreground transition-colors`}
|
||||
>
|
||||
<FolderClock className="h-4 w-4" />
|
||||
测评档案
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useScopedI18n } from '@/locales/client';
|
||||
import { Copy, Download, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Questionnaire } from '@/types';
|
||||
import { AssessmentRecord } from '@/lib/assessment-types';
|
||||
import { recordToMarkdown } from '@/lib/assessment-export';
|
||||
|
||||
interface ResultContainerProps {
|
||||
title: string;
|
||||
@@ -13,9 +15,11 @@ interface ResultContainerProps {
|
||||
questionnaire?: Questionnaire;
|
||||
answers?: string[];
|
||||
questionnaireResults?: Record<string, string>;
|
||||
record?: AssessmentRecord;
|
||||
profileName?: string;
|
||||
}
|
||||
|
||||
export function ResultContainer({ title, id, children, questionnaire, answers, questionnaireResults }: ResultContainerProps) {
|
||||
export function ResultContainer({ title, id, children, questionnaire, answers, questionnaireResults, record, profileName = '未命名档案' }: ResultContainerProps) {
|
||||
const t = useScopedI18n(
|
||||
'component.questionnaire.result.public.resultContainer'
|
||||
);
|
||||
@@ -32,6 +36,9 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
|
||||
};
|
||||
|
||||
const buildResultMarkdown = () => {
|
||||
if (record) {
|
||||
return recordToMarkdown(record, profileName);
|
||||
}
|
||||
if (!questionnaire || !answers || !questionnaireResults) {
|
||||
return null;
|
||||
}
|
||||
@@ -125,6 +132,9 @@ export function ResultContainer({ title, id, children, questionnaire, answers, q
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t('downloadResultData')}
|
||||
</Button>
|
||||
<Button asChild className="w-full sm:w-auto">
|
||||
<Link href="/records">查看测评档案</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,10 @@ import { saveResult } from '@/lib/result-storage';
|
||||
import { Questionnaire as QuestionnaireType, QuestionType } from '@/types';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from "sonner"
|
||||
import { ProfilePicker } from '@/components/records/ProfilePicker';
|
||||
import { AssessmentProfile } from '@/lib/assessment-types';
|
||||
import { addAssessmentRecord, ensureActiveProfile } from '@/lib/assessment-db';
|
||||
import { buildScoreSummary } from '@/lib/score-summary';
|
||||
|
||||
interface QuestionnaireProps {
|
||||
questionnaire: QuestionnaireType;
|
||||
@@ -32,6 +36,7 @@ export function Questionnaire({
|
||||
const questionRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
|
||||
// Flag to indicate whether the questionnaire has been submitted
|
||||
const hasSubmittedRef = useRef(false);
|
||||
const [activeProfile, setActiveProfile] = useState<AssessmentProfile | null>(null);
|
||||
|
||||
// Save answers when component unmounts
|
||||
useEffect(() => {
|
||||
@@ -163,7 +168,7 @@ export function Questionnaire({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
// Check if all questions are answered first
|
||||
if (answeredCount < questions.length) {
|
||||
toast("请先完成所有题目");
|
||||
@@ -179,7 +184,32 @@ export function Questionnaire({
|
||||
|
||||
// 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);
|
||||
const profile = activeProfile || await ensureActiveProfile();
|
||||
const completedAt = new Date().toISOString();
|
||||
const recordedAnswers = questionnaire.questions.map((question, index) => {
|
||||
const value = resultAnswers[index];
|
||||
const option = questionnaire.renderOptions(question.id).find(
|
||||
(item) => String(item.value) === String(value),
|
||||
);
|
||||
return {
|
||||
questionId: question.id,
|
||||
question: question.content,
|
||||
value,
|
||||
answer: option?.content || value,
|
||||
};
|
||||
});
|
||||
const record = await addAssessmentRecord({
|
||||
profileId: profile.id,
|
||||
questionnaireId: id,
|
||||
questionnaireTitle: questionnaire.title,
|
||||
category: questionnaire.category,
|
||||
completedAt,
|
||||
answers: recordedAnswers,
|
||||
scoreSummary: buildScoreSummary(id, resultAnswers),
|
||||
retestSuitable: questionnaire.evaluation?.retestSuitable,
|
||||
recommendedInterval: questionnaire.evaluation?.recommendedInterval,
|
||||
});
|
||||
saveResult(id, resultAnswers, profile.id, record.id);
|
||||
|
||||
router.push(`/questionnaire/${id}/result`);
|
||||
}
|
||||
@@ -201,6 +231,8 @@ export function Questionnaire({
|
||||
<div className="max-w-3xl mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold mb-8">{questionnaire.title}</h1>
|
||||
|
||||
<ProfilePicker onChange={setActiveProfile} />
|
||||
|
||||
<ProgressPanel
|
||||
questions={questions}
|
||||
answers={answers}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { UserRound } from 'lucide-react';
|
||||
import { AssessmentProfile } from '@/lib/assessment-types';
|
||||
import {
|
||||
ensureActiveProfile,
|
||||
getProfiles,
|
||||
setActiveProfileId,
|
||||
} from '@/lib/assessment-db';
|
||||
|
||||
interface ProfilePickerProps {
|
||||
onChange?: (profile: AssessmentProfile) => void;
|
||||
}
|
||||
|
||||
export function ProfilePicker({ onChange }: ProfilePickerProps) {
|
||||
const [profiles, setProfiles] = useState<AssessmentProfile[]>([]);
|
||||
const [active, setActive] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
async function load() {
|
||||
const profile = await ensureActiveProfile();
|
||||
const all = await getProfiles();
|
||||
if (!mounted) return;
|
||||
setProfiles(all);
|
||||
setActive(profile.id);
|
||||
onChange?.(profile);
|
||||
}
|
||||
void load();
|
||||
return () => { mounted = false; };
|
||||
}, [onChange]);
|
||||
|
||||
const select = (profileId: string) => {
|
||||
const profile = profiles.find((item) => item.id === profileId);
|
||||
if (!profile) return;
|
||||
setActive(profileId);
|
||||
setActiveProfileId(profileId);
|
||||
onChange?.(profile);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-col gap-2 border bg-muted/30 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<UserRound className="h-4 w-4" />
|
||||
本次记录到
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
aria-label="选择测评档案"
|
||||
className="h-9 min-w-32 border bg-background px-3 text-sm"
|
||||
value={active}
|
||||
onChange={(event) => select(event.target.value)}
|
||||
>
|
||||
{profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>{profile.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<Link href="/records" className="text-sm text-primary underline-offset-4 hover:underline">
|
||||
管理档案
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Download, FileJson, KeyRound, Plus, RotateCcw, Trash2, Upload, UserRound } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AssessmentProfile, AssessmentRecord, EncryptedMindScopeBackup, MindScopeBackup, ScoreMetric } from '@/lib/assessment-types';
|
||||
import {
|
||||
clearAllAssessmentData,
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
deleteRecord,
|
||||
ensureActiveProfile,
|
||||
exportBackup,
|
||||
getProfiles,
|
||||
getRecords,
|
||||
importBackup,
|
||||
renameProfile,
|
||||
setActiveProfileId,
|
||||
} from '@/lib/assessment-db';
|
||||
import { downloadText, profileToMarkdown, recordToMarkdown } from '@/lib/assessment-export';
|
||||
import { decryptBackup, encryptBackup } from '@/lib/backup-crypto';
|
||||
|
||||
type View = 'history' | 'trends' | 'portrait';
|
||||
|
||||
const repeatable = new Set(['phq9', 'gad7', 'who5', 'dass21', 'pss10', 'bdi2', 'sds', 'isi']);
|
||||
const portraitIds = new Set(['bigfive', 'bigfive-120', 'bigfive-300', 'hexaco', 'riasec', 'schwartz']);
|
||||
|
||||
function displayDate(value: string) {
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function MetricBar({ metric }: { metric: ScoreMetric }) {
|
||||
const width = metric.max ? Math.max(0, Math.min(100, metric.value / metric.max * 100)) : 0;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between gap-3 text-sm">
|
||||
<span>{metric.label}</span>
|
||||
<span className="font-medium tabular-nums">{metric.value}{metric.max ? ` / ${metric.max}` : ''}{metric.level ? ` · ${metric.level}` : ''}</span>
|
||||
</div>
|
||||
{metric.max && <div className="h-2 overflow-hidden bg-muted"><div className="h-full bg-primary" style={{ width: `${width}%` }} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecordsDashboard() {
|
||||
const [profiles, setProfiles] = useState<AssessmentProfile[]>([]);
|
||||
const [records, setRecords] = useState<AssessmentRecord[]>([]);
|
||||
const [activeId, setActiveId] = useState('');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [view, setView] = useState<View>('history');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async (preferredId?: string) => {
|
||||
const fallback = await ensureActiveProfile();
|
||||
const allProfiles = await getProfiles();
|
||||
const selected = allProfiles.find((item) => item.id === preferredId) || allProfiles.find((item) => item.id === fallback.id) || allProfiles[0];
|
||||
setProfiles(allProfiles);
|
||||
setActiveId(selected?.id || '');
|
||||
setRecords(selected ? await getRecords(selected.id) : []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
|
||||
const activeProfile = profiles.find((profile) => profile.id === activeId);
|
||||
|
||||
const chooseProfile = async (profileId: string) => {
|
||||
setActiveId(profileId);
|
||||
setActiveProfileId(profileId);
|
||||
setRecords(await getRecords(profileId));
|
||||
};
|
||||
|
||||
const addProfile = async () => {
|
||||
if (!newName.trim()) return;
|
||||
const profile = await createProfile(newName);
|
||||
setNewName('');
|
||||
setActiveProfileId(profile.id);
|
||||
await refresh(profile.id);
|
||||
};
|
||||
|
||||
const changeName = async () => {
|
||||
if (!activeProfile) return;
|
||||
const name = window.prompt('输入新的档案名称', activeProfile.name)?.trim();
|
||||
if (!name) return;
|
||||
await renameProfile(activeProfile.id, name);
|
||||
await refresh(activeProfile.id);
|
||||
};
|
||||
|
||||
const removeProfile = async () => {
|
||||
if (!activeProfile || !window.confirm(`删除“${activeProfile.name}”及其全部测评记录?此操作无法撤销。`)) return;
|
||||
await deleteProfile(activeProfile.id);
|
||||
localStorage.removeItem('mindscope_active_profile');
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const removeRecord = async (record: AssessmentRecord) => {
|
||||
if (!window.confirm(`删除 ${record.questionnaireTitle} 的这次记录?`)) return;
|
||||
await deleteRecord(record.id);
|
||||
setRecords(await getRecords(activeId));
|
||||
};
|
||||
|
||||
const downloadProfile = () => {
|
||||
if (!activeProfile) return;
|
||||
downloadText(`${activeProfile.name}-完整测评档案.md`, profileToMarkdown(activeProfile, records), 'text/markdown;charset=utf-8');
|
||||
};
|
||||
|
||||
const downloadBackup = async () => {
|
||||
const backup = await exportBackup();
|
||||
downloadText(`mindscope-backup-${new Date().toISOString().slice(0, 10)}.json`, JSON.stringify(backup, null, 2), 'application/json;charset=utf-8');
|
||||
};
|
||||
|
||||
const downloadEncryptedBackup = async () => {
|
||||
const password = window.prompt('设置备份密码(至少 8 个字符)。密码不会被保存,遗失后无法恢复。');
|
||||
if (!password) return;
|
||||
try {
|
||||
const encrypted = await encryptBackup(await exportBackup(), password);
|
||||
downloadText(`mindscope-encrypted-${new Date().toISOString().slice(0, 10)}.json`, JSON.stringify(encrypted, null, 2), 'application/json;charset=utf-8');
|
||||
toast.success('加密备份已生成,请妥善保管密码');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '加密备份失败');
|
||||
}
|
||||
};
|
||||
|
||||
const restoreBackup = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const parsed = JSON.parse(await file.text()) as MindScopeBackup | EncryptedMindScopeBackup;
|
||||
let backup: MindScopeBackup;
|
||||
if (parsed.format === 'mindscope-encrypted-backup') {
|
||||
const password = window.prompt('输入此备份文件的密码');
|
||||
if (!password) return;
|
||||
backup = await decryptBackup(parsed, password);
|
||||
} else {
|
||||
backup = parsed;
|
||||
}
|
||||
await importBackup(backup);
|
||||
await refresh();
|
||||
toast.success('备份已导入,重复记录已按编号合并');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '备份导入失败');
|
||||
}
|
||||
};
|
||||
|
||||
const clearData = async () => {
|
||||
if (!window.confirm('清除所有人物档案、测评历史和本地草稿?此操作无法撤销。')) return;
|
||||
await clearAllAssessmentData();
|
||||
Object.keys(localStorage).filter((key) => key.startsWith('questionnaire_')).forEach((key) => localStorage.removeItem(key));
|
||||
sessionStorage.clear();
|
||||
await refresh();
|
||||
toast.success('本地测评数据已清除');
|
||||
};
|
||||
|
||||
const trendGroups = useMemo(() => {
|
||||
const groups = new Map<string, AssessmentRecord[]>();
|
||||
records.filter((record) => repeatable.has(record.questionnaireId)).forEach((record) => {
|
||||
groups.set(record.questionnaireId, [...(groups.get(record.questionnaireId) || []), record]);
|
||||
});
|
||||
return [...groups.values()].filter((group) => group.length > 0).map((group) => group.sort((a, b) => a.completedAt.localeCompare(b.completedAt)));
|
||||
}, [records]);
|
||||
|
||||
const portraitRecords = useMemo(() => {
|
||||
const latest = new Map<string, AssessmentRecord>();
|
||||
records.filter((record) => portraitIds.has(record.questionnaireId)).forEach((record) => {
|
||||
const family = record.questionnaireId.startsWith('bigfive') ? 'bigfive' : record.questionnaireId;
|
||||
if (!latest.has(family)) latest.set(family, record);
|
||||
});
|
||||
return [...latest.values()];
|
||||
}, [records]);
|
||||
|
||||
if (loading) return <div className="mx-auto max-w-6xl px-4 py-16">正在读取本地档案...</div>;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 md:py-12">
|
||||
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">测评档案</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">全部数据只保存在当前浏览器,可随时导出或彻底清除。</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={downloadProfile} disabled={!records.length}><Download className="h-4 w-4" />导出此人 MD</Button>
|
||||
<Button variant="outline" onClick={downloadBackup}><FileJson className="h-4 w-4" />备份 JSON</Button>
|
||||
<Button variant="outline" onClick={() => void downloadEncryptedBackup()}><KeyRound className="h-4 w-4" />加密备份</Button>
|
||||
<Button variant="outline" asChild><label className="cursor-pointer"><Upload className="h-4 w-4" />恢复 JSON<input type="file" accept="application/json,.json" className="sr-only" onChange={restoreBackup} /></label></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mb-8 border-y py-5">
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<UserRound className="h-4 w-4 text-muted-foreground" />
|
||||
{profiles.map((profile) => (
|
||||
<Button key={profile.id} size="sm" variant={profile.id === activeId ? 'default' : 'outline'} onClick={() => void chooseProfile(profile.id)}>{profile.name}</Button>
|
||||
))}
|
||||
<Button size="sm" variant="ghost" onClick={changeName}>重命名</Button>
|
||||
<Button size="icon" variant="ghost" title="删除当前人物档案" onClick={() => void removeProfile()}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={newName} onChange={(event) => setNewName(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') void addProfile(); }} placeholder="新档案名称" className="w-40" />
|
||||
<Button size="icon" title="新增人物档案" onClick={() => void addProfile()}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mb-6 flex border-b" role="tablist" aria-label="档案视图">
|
||||
{([['history', '历史记录'], ['trends', '变化趋势'], ['portrait', '统一画像']] as const).map(([id, label]) => (
|
||||
<button key={id} role="tab" aria-selected={view === id} onClick={() => setView(id)} className={`border-b-2 px-4 py-3 text-sm font-medium ${view === id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground'}`}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view === 'history' && (
|
||||
<section className="space-y-3">
|
||||
{!records.length && <div className="border p-8 text-center text-muted-foreground">这个档案还没有测评记录。</div>}
|
||||
{records.map((record) => (
|
||||
<details key={record.id} className="border bg-background">
|
||||
<summary className="cursor-pointer list-none p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><h2 className="font-medium">{record.questionnaireTitle}</h2><p className="mt-1 text-xs text-muted-foreground">{displayDate(record.completedAt)} · {record.answers.length} 题{record.recommendedInterval ? ` · 建议:${record.recommendedInterval === '一次即可' ? '通常一次即可' : `${record.recommendedInterval}后重测`}` : ''}</p></div>
|
||||
<div className="text-sm font-medium">{record.scoreSummary.primary ? `${record.scoreSummary.primary.label}:${record.scoreSummary.primary.value}${record.scoreSummary.primary.max ? ` / ${record.scoreSummary.primary.max}` : ''}` : `${record.scoreSummary.metrics.length} 个维度`}</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="border-t p-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">{record.scoreSummary.metrics.map((item) => <MetricBar key={item.key} metric={item} />)}</div>
|
||||
{record.analysisText && <p className="mt-5 whitespace-pre-line border-l-2 pl-4 text-sm text-muted-foreground">{record.analysisText}</p>}
|
||||
<div className="mt-5 flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => downloadText(`${record.questionnaireId}-${record.completedAt.slice(0, 10)}.md`, recordToMarkdown(record, activeProfile?.name || ''), 'text/markdown;charset=utf-8')}><Download className="h-4 w-4" />下载 MD</Button>
|
||||
<Button size="icon" variant="ghost" title="删除这次记录" onClick={() => void removeRecord(record)}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view === 'trends' && (
|
||||
<section className="space-y-8">
|
||||
{!trendGroups.length && <div className="border p-8 text-center text-muted-foreground">完成 PHQ-9、GAD-7、WHO-5、DASS-21、PSS-10、BDI-II、SDS 或 ISI 后,这里会显示长期变化。</div>}
|
||||
{trendGroups.map((group) => (
|
||||
<div key={group[0].questionnaireId}>
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2"><h2 className="text-lg font-semibold">{group[0].questionnaireTitle}</h2>{group[0].recommendedInterval && <span className="text-sm text-muted-foreground">建议间隔:{group[0].recommendedInterval}</span>}</div>
|
||||
<div className="overflow-x-auto border">
|
||||
<table className="w-full min-w-[540px] text-sm"><thead className="bg-muted/50"><tr><th className="p-3 text-left">时间</th><th className="p-3 text-left">分数</th><th className="p-3 text-left">与上次相比</th></tr></thead>
|
||||
<tbody>{group.map((record, index) => { const metrics = record.scoreSummary.primary ? [record.scoreSummary.primary] : record.scoreSummary.metrics; const previous = group[index - 1]?.scoreSummary.primary; return <tr key={record.id} className="border-t"><td className="p-3">{displayDate(record.completedAt)}</td><td className="p-3">{metrics.map((item) => <span key={item.key} className="mr-4">{item.label} {item.value}{item.max ? `/${item.max}` : ''}</span>)}</td><td className="p-3">{record.scoreSummary.primary && previous ? `${record.scoreSummary.primary.value - previous.value > 0 ? '+' : ''}${record.scoreSummary.primary.value - previous.value}` : '基线'}</td></tr>; })}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-sm text-muted-foreground">趋势只呈现分数变化,不自动判断改善或恶化;不同量表的高分方向并不相同。</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view === 'portrait' && (
|
||||
<section className="space-y-8">
|
||||
{!portraitRecords.length && <div className="border p-8 text-center text-muted-foreground">完成 Big Five、HEXACO、RIASEC 或 Schwartz 后,这里会汇总为统一画像。</div>}
|
||||
{portraitRecords.map((record) => (
|
||||
<div key={record.id}>
|
||||
<div className="mb-4"><h2 className="text-lg font-semibold">{record.questionnaireTitle}</h2><p className="text-xs text-muted-foreground">取最近一次结果:{displayDate(record.completedAt)}</p></div>
|
||||
<div className="grid gap-4 md:grid-cols-2">{record.scoreSummary.metrics.map((item) => <MetricBar key={item.key} metric={item} />)}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-l-2 border-primary pl-4 text-sm text-muted-foreground">统一画像只汇总各量表的透明分数,不使用 AI 推断,也不会将不同量表强行合并为单一人格标签。</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mt-12 border-t pt-6">
|
||||
<Button variant="destructive" onClick={() => void clearData()}><RotateCcw className="h-4 w-4" />清除全部本地数据</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user