Files
MindScope/components/records/RecordsDashboard.tsx
T

313 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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';
import { AnonymousSyncPanel } from '@/components/records/AnonymousSyncPanel';
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 directionText(direction?: ScoreMetric['direction']) {
switch (direction) {
case 'risk':
return '高分代表风险或困扰升高';
case 'protective':
return '高分代表改善或保护因素更高';
case 'ability':
return '高分代表表现更好';
case 'trait':
return '高分代表该特质更明显';
case 'mixed':
return '高分含义需按维度查看';
default:
return '高分含义需结合量表说明';
}
}
function changeText(current: ScoreMetric, previous?: ScoreMetric) {
if (!previous) return '基线';
const diff = current.value - previous.value;
if (diff === 0) return '无变化';
const signed = `${diff > 0 ? '+' : ''}${diff}`;
switch (current.direction) {
case 'risk':
return diff > 0 ? `风险升高 ${signed}` : `风险降低 ${signed}`;
case 'protective':
case 'ability':
return diff > 0 ? `改善 ${signed}` : `下降 ${signed}`;
case 'trait':
return diff > 0 ? `特质增强 ${signed}` : `特质减弱 ${signed}`;
default:
return `变化 ${signed}`;
}
}
function MetricBar({ metric }: { metric: ScoreMetric }) {
const width = metric.max ? Math.max(0, Math.min(100, metric.value / metric.max * 100)) : 0;
return (
<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>
<AnonymousSyncPanel localRecords={records} />
<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-9GAD-7WHO-5DASS-21PSS-10BDI-IISDS 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>
<p className="mb-3 text-sm text-muted-foreground">{group[0].scoreSummary.highScoreMeaning} · {directionText(group[0].scoreSummary.direction)}</p>
<div className="overflow-x-auto border">
<table className="w-full min-w-[640px] text-sm"><thead className="bg-muted/50"><tr><th className="p-3 text-left">时间</th><th className="p-3 text-left">分数</th><th className="p-3 text-left">高分方向</th><th className="p-3 text-left">与上次相比</th></tr></thead>
<tbody>{group.map((record, index) => { const metrics = record.scoreSummary.primary ? [record.scoreSummary.primary] : record.scoreSummary.metrics; const primary = record.scoreSummary.primary; const previous = group[index - 1]?.scoreSummary.primary; return <tr key={record.id} className="border-t"><td className="p-3">{displayDate(record.completedAt)}</td><td className="p-3">{metrics.map((item) => <span key={item.key} className="mr-4">{item.label} {item.value}{item.max ? `/${item.max}` : ''}</span>)}</td><td className="p-3">{directionText(primary?.direction || record.scoreSummary.direction)}</td><td className="p-3">{primary ? changeText(primary, previous) : '看各维度'}</td></tr>; })}</tbody>
</table>
</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 FiveHEXACORIASEC 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>
);
}