'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 (
{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) => (
))}
{([['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[0].scoreSummary.highScoreMeaning} · {directionText(group[0].scoreSummary.direction)}
| 时间 | 分数 | 高分方向 | 与上次相比 |
{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 | {displayDate(record.completedAt)} | {metrics.map((item) => {item.label} {item.value}{item.max ? `/${item.max}` : ''})} | {directionText(primary?.direction || record.scoreSummary.direction)} | {primary ? changeText(primary, previous) : '看各维度'} |
; })}
))}
趋势会按量表方向解释变化;敏感量表只作为自评追踪参考,不等同于诊断。
)}
{view === 'portrait' && (
{!portraitRecords.length && 完成 Big Five、HEXACO、RIASEC 或 Schwartz 后,这里会汇总为统一画像。
}
{portraitRecords.map((record) => (
{record.questionnaireTitle}
取最近一次结果:{displayDate(record.completedAt)}
{record.scoreSummary.metrics.map((item) => )}
))}
统一画像只汇总各量表的透明分数,不使用 AI 推断,也不会将不同量表强行合并为单一人格标签。
)}
);
}