Files
MindScope/components/records/RecordsDashboard.tsx
T

274 lines
15 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';
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-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>
<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 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>
);
}