'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 MetricBar({ metric }: { metric: ScoreMetric }) { const width = metric.max ? Math.max(0, Math.min(100, metric.value / metric.max * 100)) : 0; return (
{metric.label} {metric.value}{metric.max ? ` / ${metric.max}` : ''}{metric.level ? ` · ${metric.level}` : ''}
{metric.max &&
}
); } export function RecordsDashboard() { const [profiles, setProfiles] = useState([]); const [records, setRecords] = useState([]); const [activeId, setActiveId] = useState(''); const [newName, setNewName] = useState(''); const [view, setView] = useState('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) => { 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(); 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(); 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
正在读取本地档案...
; return (

测评档案

全部数据只保存在当前浏览器,可随时导出或彻底清除。

{profiles.map((profile) => ( ))}
setNewName(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') void addProfile(); }} placeholder="新档案名称" className="w-40" />
{([['history', '历史记录'], ['trends', '变化趋势'], ['portrait', '统一画像']] as const).map(([id, label]) => ( ))}
{view === 'history' && (
{!records.length &&
这个档案还没有测评记录。
} {records.map((record) => (

{record.questionnaireTitle}

{displayDate(record.completedAt)} · {record.answers.length} 题{record.recommendedInterval ? ` · 建议:${record.recommendedInterval === '一次即可' ? '通常一次即可' : `${record.recommendedInterval}后重测`}` : ''}

{record.scoreSummary.primary ? `${record.scoreSummary.primary.label}:${record.scoreSummary.primary.value}${record.scoreSummary.primary.max ? ` / ${record.scoreSummary.primary.max}` : ''}` : `${record.scoreSummary.metrics.length} 个维度`}
{record.scoreSummary.metrics.map((item) => )}
{record.analysisText &&

{record.analysisText}

}
))}
)} {view === 'trends' && (
{!trendGroups.length &&
完成 PHQ-9、GAD-7、WHO-5、DASS-21、PSS-10、BDI-II、SDS 或 ISI 后,这里会显示长期变化。
} {trendGroups.map((group) => (

{group[0].questionnaireTitle}

{group[0].recommendedInterval && 建议间隔:{group[0].recommendedInterval}}
{group.map((record, index) => { const metrics = record.scoreSummary.primary ? [record.scoreSummary.primary] : record.scoreSummary.metrics; const previous = group[index - 1]?.scoreSummary.primary; return ; })}
时间分数与上次相比
{displayDate(record.completedAt)}{metrics.map((item) => {item.label} {item.value}{item.max ? `/${item.max}` : ''})}{record.scoreSummary.primary && previous ? `${record.scoreSummary.primary.value - previous.value > 0 ? '+' : ''}${record.scoreSummary.primary.value - previous.value}` : '基线'}
))}

趋势只呈现分数变化,不自动判断改善或恶化;不同量表的高分方向并不相同。

)} {view === 'portrait' && (
{!portraitRecords.length &&
完成 Big Five、HEXACO、RIASEC 或 Schwartz 后,这里会汇总为统一画像。
} {portraitRecords.map((record) => (

{record.questionnaireTitle}

取最近一次结果:{displayDate(record.completedAt)}

{record.scoreSummary.metrics.map((item) => )}
))}
统一画像只汇总各量表的透明分数,不使用 AI 推断,也不会将不同量表强行合并为单一人格标签。
)}
); }