feat: 增加本地测评档案与长期追踪
This commit is contained in:
@@ -12,7 +12,11 @@
|
||||
- **无需账户**:没有注册、登录和个人资料系统
|
||||
- **无需数据库**:默认部署不需要 MySQL、PostgreSQL、Redis 等服务
|
||||
- **草稿保存**:未完成的答题记录保存在当前浏览器
|
||||
- **完整导出**:完成后可复制或下载包含全部题目与回答的 Markdown 文件
|
||||
- **多人档案**:可为自己、家人或朋友分别保存独立记录
|
||||
- **长期记录**:同一量表每次测评都会新增历史,不覆盖旧结果
|
||||
- **趋势与画像**:追踪适合重测的量表,并汇总透明的人格维度
|
||||
- **完整导出**:支持单次或单人全部记录的 Markdown,以及全量 JSON 备份
|
||||
- **加密备份**:可使用本地密码生成 AES-GCM 加密备份
|
||||
- **适合轻量部署**:支持 Next.js standalone 构建,可运行在普通 Linux VPS
|
||||
|
||||
## 已收录测评
|
||||
@@ -84,7 +88,9 @@
|
||||
| --- | --- | --- |
|
||||
| 未完成答题草稿 | 浏览器 `localStorage` | 用户清除草稿或浏览器站点数据前 |
|
||||
| 最近完成的答题答案 | 浏览器 `sessionStorage` | 当前标签页会话结束前 |
|
||||
| 人物档案与全部测评历史 | 浏览器 `IndexedDB` | 用户删除记录或浏览器站点数据前 |
|
||||
| 测评结果 Markdown | 用户主动下载的位置 | 由用户自行管理 |
|
||||
| JSON 或加密 JSON 备份 | 用户主动下载的位置 | 由用户自行管理 |
|
||||
|
||||
结果页 URL 不携带答案,因此答案不会因为复制页面链接而进入浏览器历史、代理日志或服务器访问日志。复制结果链接只复制页面地址,不包含测评记录;如需保留或交给其他工具分析,请使用“复制完整记录”或“下载 MD”。
|
||||
|
||||
@@ -172,6 +178,9 @@ pnpm lint
|
||||
# TypeScript 类型检查
|
||||
pnpm exec tsc --noEmit
|
||||
|
||||
# 自动化计分与加密测试
|
||||
pnpm test
|
||||
|
||||
# 生产构建
|
||||
pnpm build
|
||||
|
||||
@@ -348,6 +357,7 @@ MindScope/
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm test
|
||||
pnpm build
|
||||
pnpm audit --audit-level moderate
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { useEffect, useState, useMemo, use } from 'react';
|
||||
import { useEffect, useState, useMemo, use, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Questionnaire } from '@/types';
|
||||
import Link from 'next/link';
|
||||
@@ -11,6 +11,8 @@ import { ResultAnalysis } from '@/components/questionnaire/result/analysis/Resul
|
||||
import { useQuestionnaire } from '@/hooks/useQuestionnaire';
|
||||
import { useScopedI18n } from '@/locales/client';
|
||||
import { loadResult } from '@/lib/result-storage';
|
||||
import { AssessmentRecord } from '@/lib/assessment-types';
|
||||
import { getProfiles, getRecords, updateRecordAnalysis } from '@/lib/assessment-db';
|
||||
|
||||
export default function QuestionnaireResultPage({
|
||||
params,
|
||||
@@ -20,10 +22,14 @@ export default function QuestionnaireResultPage({
|
||||
const { id } = use(params);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [decodedAnswers, setDecodedAnswers] = useState<string[] | null>(null);
|
||||
const [record, setRecord] = useState<AssessmentRecord | null>(null);
|
||||
const [profileName, setProfileName] = useState('未命名档案');
|
||||
const analysisRef = useRef<HTMLDivElement>(null);
|
||||
const t = useScopedI18n('app.questionnaire.result');
|
||||
|
||||
// Get the questionnaire with specified id from questionnaire data
|
||||
const questionnaire = useQuestionnaire(id) as Questionnaire;
|
||||
const recordId = record?.id;
|
||||
|
||||
// Load results from tab-local storage. Answers are intentionally not kept in the URL.
|
||||
useEffect(() => {
|
||||
@@ -31,10 +37,33 @@ export default function QuestionnaireResultPage({
|
||||
return;
|
||||
}
|
||||
|
||||
setDecodedAnswers(loadResult(id));
|
||||
const stored = loadResult(id);
|
||||
setDecodedAnswers(stored?.answers || null);
|
||||
async function loadRecord() {
|
||||
if (stored?.profileId) {
|
||||
const profiles = await getProfiles();
|
||||
setProfileName(profiles.find((profile) => profile.id === stored.profileId)?.name || '未命名档案');
|
||||
}
|
||||
if (stored?.recordId) {
|
||||
const records = await getRecords(stored.profileId);
|
||||
setRecord(records.find((item) => item.id === stored.recordId) || null);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
void loadRecord();
|
||||
}, [id, questionnaire]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordId || !analysisRef.current) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
const text = analysisRef.current?.innerText.trim() || '';
|
||||
if (!text) return;
|
||||
setRecord((current) => current ? { ...current, analysisText: text } : current);
|
||||
void updateRecordAnalysis(recordId, text);
|
||||
}, 100);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [recordId]);
|
||||
|
||||
// Construct question-option text pairs for copying result data
|
||||
const questionnaireResults: Record<string, string> = useMemo(() => {
|
||||
if (!questionnaire || !decodedAnswers) return {};
|
||||
@@ -86,13 +115,17 @@ export default function QuestionnaireResultPage({
|
||||
questionnaire={questionnaire}
|
||||
answers={decodedAnswers}
|
||||
questionnaireResults={questionnaireResults}
|
||||
record={record || undefined}
|
||||
profileName={profileName}
|
||||
>
|
||||
<AnswerList
|
||||
questions={questionnaire.questions}
|
||||
answers={decodedAnswers}
|
||||
renderOptions={questionnaire.renderOptions}
|
||||
/>
|
||||
<div ref={analysisRef}>
|
||||
<ResultAnalysis questionnaireId={id} answers={decodedAnswers} />
|
||||
</div>
|
||||
</ResultContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RecordsDashboard } from '@/components/records/RecordsDashboard';
|
||||
|
||||
export default function RecordsPage() {
|
||||
return <RecordsDashboard />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
AssessmentProfile,
|
||||
AssessmentRecord,
|
||||
MindScopeBackup,
|
||||
} from '@/lib/assessment-types';
|
||||
|
||||
const DB_NAME = 'mindscope';
|
||||
const DB_VERSION = 1;
|
||||
const PROFILE_STORE = 'profiles';
|
||||
const RECORD_STORE = 'records';
|
||||
const ACTIVE_PROFILE_KEY = 'mindscope_active_profile';
|
||||
|
||||
function createId(prefix: string) {
|
||||
return `${prefix}_${Date.now()}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(PROFILE_STORE)) {
|
||||
db.createObjectStore(PROFILE_STORE, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(RECORD_STORE)) {
|
||||
const records = db.createObjectStore(RECORD_STORE, { keyPath: 'id' });
|
||||
records.createIndex('profileId', 'profileId');
|
||||
records.createIndex('questionnaireId', 'questionnaireId');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export function getActiveProfileId() {
|
||||
return localStorage.getItem(ACTIVE_PROFILE_KEY);
|
||||
}
|
||||
|
||||
export function setActiveProfileId(profileId: string) {
|
||||
localStorage.setItem(ACTIVE_PROFILE_KEY, profileId);
|
||||
window.dispatchEvent(new CustomEvent('mindscope-profile-change'));
|
||||
}
|
||||
|
||||
export async function getProfiles(): Promise<AssessmentProfile[]> {
|
||||
const db = await openDatabase();
|
||||
const profiles = await requestResult(
|
||||
db.transaction(PROFILE_STORE, 'readonly').objectStore(PROFILE_STORE).getAll(),
|
||||
);
|
||||
db.close();
|
||||
return profiles.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
}
|
||||
|
||||
export async function createProfile(name: string): Promise<AssessmentProfile> {
|
||||
const now = new Date().toISOString();
|
||||
const profile: AssessmentProfile = {
|
||||
id: createId('profile'),
|
||||
name: name.trim(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const db = await openDatabase();
|
||||
await requestResult(
|
||||
db.transaction(PROFILE_STORE, 'readwrite').objectStore(PROFILE_STORE).add(profile),
|
||||
);
|
||||
db.close();
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function ensureActiveProfile(): Promise<AssessmentProfile> {
|
||||
const profiles = await getProfiles();
|
||||
const activeId = getActiveProfileId();
|
||||
const active = profiles.find((profile) => profile.id === activeId);
|
||||
if (active) return active;
|
||||
const profile = profiles[0] || (await createProfile('我'));
|
||||
setActiveProfileId(profile.id);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function renameProfile(profileId: string, name: string) {
|
||||
const db = await openDatabase();
|
||||
const store = db.transaction(PROFILE_STORE, 'readwrite').objectStore(PROFILE_STORE);
|
||||
const profile = await requestResult(store.get(profileId));
|
||||
if (profile) {
|
||||
await requestResult(
|
||||
store.put({ ...profile, name: name.trim(), updatedAt: new Date().toISOString() }),
|
||||
);
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
export async function deleteProfile(profileId: string) {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction([PROFILE_STORE, RECORD_STORE], 'readwrite');
|
||||
transaction.objectStore(PROFILE_STORE).delete(profileId);
|
||||
const index = transaction.objectStore(RECORD_STORE).index('profileId');
|
||||
const recordKeys = await requestResult(index.getAllKeys(profileId));
|
||||
recordKeys.forEach((key) => transaction.objectStore(RECORD_STORE).delete(key));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
export async function addAssessmentRecord(
|
||||
record: Omit<AssessmentRecord, 'id'>,
|
||||
): Promise<AssessmentRecord> {
|
||||
const completeRecord = { ...record, id: createId('record') };
|
||||
const db = await openDatabase();
|
||||
await requestResult(
|
||||
db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE).add(completeRecord),
|
||||
);
|
||||
db.close();
|
||||
return completeRecord;
|
||||
}
|
||||
|
||||
export async function updateRecordAnalysis(recordId: string, analysisText: string) {
|
||||
const db = await openDatabase();
|
||||
const store = db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE);
|
||||
const record = await requestResult(store.get(recordId));
|
||||
if (record && record.analysisText !== analysisText) {
|
||||
await requestResult(store.put({ ...record, analysisText }));
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
export async function getRecords(profileId?: string): Promise<AssessmentRecord[]> {
|
||||
const db = await openDatabase();
|
||||
const store = db.transaction(RECORD_STORE, 'readonly').objectStore(RECORD_STORE);
|
||||
const records = profileId
|
||||
? await requestResult(store.index('profileId').getAll(profileId))
|
||||
: await requestResult(store.getAll());
|
||||
db.close();
|
||||
return records.sort((a, b) => b.completedAt.localeCompare(a.completedAt));
|
||||
}
|
||||
|
||||
export async function deleteRecord(recordId: string) {
|
||||
const db = await openDatabase();
|
||||
await requestResult(
|
||||
db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE).delete(recordId),
|
||||
);
|
||||
db.close();
|
||||
}
|
||||
|
||||
export async function exportBackup(): Promise<MindScopeBackup> {
|
||||
return {
|
||||
format: 'mindscope-backup',
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
profiles: await getProfiles(),
|
||||
records: await getRecords(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function importBackup(backup: MindScopeBackup) {
|
||||
if (backup.format !== 'mindscope-backup' || backup.version !== 1) {
|
||||
throw new Error('不支持的备份格式');
|
||||
}
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction([PROFILE_STORE, RECORD_STORE], 'readwrite');
|
||||
backup.profiles.forEach((profile) => transaction.objectStore(PROFILE_STORE).put(profile));
|
||||
backup.records.forEach((record) => transaction.objectStore(RECORD_STORE).put(record));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
export async function clearAllAssessmentData() {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction([PROFILE_STORE, RECORD_STORE], 'readwrite');
|
||||
transaction.objectStore(PROFILE_STORE).clear();
|
||||
transaction.objectStore(RECORD_STORE).clear();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
db.close();
|
||||
localStorage.removeItem(ACTIVE_PROFILE_KEY);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AssessmentProfile, AssessmentRecord } from '@/lib/assessment-types';
|
||||
|
||||
function date(value: string) {
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
export function recordToMarkdown(record: AssessmentRecord, profileName: string) {
|
||||
const lines = [
|
||||
`# ${record.questionnaireTitle}测评记录`, '',
|
||||
'## 基本信息',
|
||||
`- 档案:${profileName}`,
|
||||
`- 测评时间:${date(record.completedAt)}`,
|
||||
`- 量表编号:${record.questionnaireId}`,
|
||||
`- 题目数量:${record.answers.length}`, '',
|
||||
];
|
||||
if (record.scoreSummary.metrics.length) {
|
||||
lines.push('## 分数摘要');
|
||||
record.scoreSummary.metrics.forEach((item) => {
|
||||
lines.push(`- ${item.label}:${item.value}${item.max ? ` / ${item.max}` : ''}${item.level ? `(${item.level})` : ''}`);
|
||||
});
|
||||
if (record.scoreSummary.note) lines.push('', record.scoreSummary.note);
|
||||
lines.push('');
|
||||
}
|
||||
if (record.analysisText) lines.push('## 结果说明', record.analysisText, '');
|
||||
lines.push('## 完整问答');
|
||||
record.answers.forEach((item, index) => lines.push(`${index + 1}. ${item.question}`, ` 回答:${item.answer}`, ''));
|
||||
lines.push('## 使用说明', '本记录仅供自我了解、教育和研究参考,不构成医学或心理诊断。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function profileToMarkdown(profile: AssessmentProfile, records: AssessmentRecord[]) {
|
||||
const lines = [
|
||||
`# ${profile.name}的完整测评档案`, '',
|
||||
`- 导出时间:${date(new Date().toISOString())}`,
|
||||
`- 测评次数:${records.length}`, '',
|
||||
'---', '',
|
||||
];
|
||||
records
|
||||
.slice()
|
||||
.sort((a, b) => a.completedAt.localeCompare(b.completedAt))
|
||||
.forEach((record) => lines.push(recordToMarkdown(record, profile.name), '', '---', ''));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function downloadText(filename: string, content: string, type: string) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface AssessmentProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ScoreMetric {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
max?: number;
|
||||
level?: string;
|
||||
}
|
||||
|
||||
export interface ScoreSummary {
|
||||
primary?: ScoreMetric;
|
||||
metrics: ScoreMetric[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface RecordedAnswer {
|
||||
questionId: number;
|
||||
question: string;
|
||||
value: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface AssessmentRecord {
|
||||
id: string;
|
||||
profileId: string;
|
||||
questionnaireId: string;
|
||||
questionnaireTitle: string;
|
||||
category: string;
|
||||
completedAt: string;
|
||||
answers: RecordedAnswer[];
|
||||
scoreSummary: ScoreSummary;
|
||||
analysisText?: string;
|
||||
retestSuitable?: boolean;
|
||||
recommendedInterval?: '1年' | '3个月' | '一次即可';
|
||||
}
|
||||
|
||||
export interface EncryptedMindScopeBackup {
|
||||
format: 'mindscope-encrypted-backup';
|
||||
version: 1;
|
||||
algorithm: 'AES-GCM';
|
||||
iterations: number;
|
||||
salt: string;
|
||||
iv: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface MindScopeBackup {
|
||||
format: 'mindscope-backup';
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
profiles: AssessmentProfile[];
|
||||
records: AssessmentRecord[];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decryptBackup, encryptBackup } from '@/lib/backup-crypto';
|
||||
import { MindScopeBackup } from '@/lib/assessment-types';
|
||||
|
||||
const backup: MindScopeBackup = {
|
||||
format: 'mindscope-backup',
|
||||
version: 1,
|
||||
exportedAt: '2026-06-23T00:00:00.000Z',
|
||||
profiles: [],
|
||||
records: [],
|
||||
};
|
||||
|
||||
describe('encrypted backups', () => {
|
||||
it('round trips a backup with the correct password', async () => {
|
||||
const encrypted = await encryptBackup(backup, 'correct-password');
|
||||
expect(encrypted.data).not.toContain('mindscope-backup');
|
||||
await expect(decryptBackup(encrypted, 'correct-password')).resolves.toEqual(backup);
|
||||
});
|
||||
|
||||
it('rejects an incorrect password', async () => {
|
||||
const encrypted = await encryptBackup(backup, 'correct-password');
|
||||
await expect(decryptBackup(encrypted, 'wrong-password')).rejects.toThrow('密码错误');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { EncryptedMindScopeBackup, MindScopeBackup } from '@/lib/assessment-types';
|
||||
|
||||
const ITERATIONS = 250_000;
|
||||
|
||||
function toBase64(bytes: Uint8Array) {
|
||||
let binary = '';
|
||||
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(value: string) {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
async function deriveKey(password: string, salt: Uint8Array, iterations: number) {
|
||||
const material = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', hash: 'SHA-256', salt: toArrayBuffer(salt), iterations },
|
||||
material,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptBackup(backup: MindScopeBackup, password: string): Promise<EncryptedMindScopeBackup> {
|
||||
if (password.length < 8) throw new Error('备份密码至少需要 8 个字符');
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveKey(password, salt, ITERATIONS);
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: toArrayBuffer(iv) },
|
||||
key,
|
||||
new TextEncoder().encode(JSON.stringify(backup)),
|
||||
);
|
||||
return {
|
||||
format: 'mindscope-encrypted-backup',
|
||||
version: 1,
|
||||
algorithm: 'AES-GCM',
|
||||
iterations: ITERATIONS,
|
||||
salt: toBase64(salt),
|
||||
iv: toBase64(iv),
|
||||
data: toBase64(new Uint8Array(encrypted)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptBackup(backup: EncryptedMindScopeBackup, password: string): Promise<MindScopeBackup> {
|
||||
if (backup.format !== 'mindscope-encrypted-backup' || backup.version !== 1) {
|
||||
throw new Error('不支持的加密备份格式');
|
||||
}
|
||||
try {
|
||||
const salt = fromBase64(backup.salt);
|
||||
const iv = fromBase64(backup.iv);
|
||||
const key = await deriveKey(password, salt, backup.iterations);
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: toArrayBuffer(iv) },
|
||||
key,
|
||||
toArrayBuffer(fromBase64(backup.data)),
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(decrypted)) as MindScopeBackup;
|
||||
} catch {
|
||||
throw new Error('密码错误或备份文件已损坏');
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -1,19 +1,28 @@
|
||||
const RESULT_STORAGE_KEY = 'questionnaire_result';
|
||||
|
||||
interface StoredResult {
|
||||
export interface StoredResult {
|
||||
answers: string[];
|
||||
savedAt: string;
|
||||
profileId?: string;
|
||||
recordId?: string;
|
||||
}
|
||||
|
||||
function resultKey(questionnaireId: string) {
|
||||
return `${RESULT_STORAGE_KEY}_${questionnaireId}`;
|
||||
}
|
||||
|
||||
export function saveResult(questionnaireId: string, answers: string[]) {
|
||||
export function saveResult(
|
||||
questionnaireId: string,
|
||||
answers: string[],
|
||||
profileId?: string,
|
||||
recordId?: string,
|
||||
) {
|
||||
try {
|
||||
const result: StoredResult = {
|
||||
answers,
|
||||
savedAt: new Date().toISOString(),
|
||||
profileId,
|
||||
recordId,
|
||||
};
|
||||
sessionStorage.setItem(resultKey(questionnaireId), JSON.stringify(result));
|
||||
return true;
|
||||
@@ -23,7 +32,7 @@ export function saveResult(questionnaireId: string, answers: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadResult(questionnaireId: string): string[] | null {
|
||||
export function loadResult(questionnaireId: string): StoredResult | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(resultKey(questionnaireId));
|
||||
if (!raw) {
|
||||
@@ -31,7 +40,7 @@ export function loadResult(questionnaireId: string): string[] | null {
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<StoredResult>;
|
||||
return Array.isArray(parsed.answers) ? parsed.answers : null;
|
||||
return Array.isArray(parsed.answers) ? parsed as StoredResult : null;
|
||||
} catch (error) {
|
||||
console.error('Failed to load result:', error);
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildScoreSummary } from '@/lib/score-summary';
|
||||
|
||||
describe('buildScoreSummary', () => {
|
||||
it('applies PHQ-9 severity thresholds', () => {
|
||||
const summary = buildScoreSummary('phq9', Array(9).fill('2'));
|
||||
expect(summary.primary).toMatchObject({ value: 18, max: 27, level: '中重度' });
|
||||
});
|
||||
|
||||
it('converts WHO-5 raw scores to a percentage', () => {
|
||||
const summary = buildScoreSummary('who5', Array(5).fill('5'));
|
||||
expect(summary.primary).toMatchObject({ value: 100, max: 100 });
|
||||
});
|
||||
|
||||
it('reverse scores the positive PSS-10 items', () => {
|
||||
const summary = buildScoreSummary('pss10', Array(10).fill('0'));
|
||||
expect(summary.primary).toMatchObject({ value: 16, max: 40, level: '中度' });
|
||||
});
|
||||
|
||||
it('calculates all three DASS-21 dimensions independently', () => {
|
||||
const summary = buildScoreSummary('dass21', Array(21).fill('1'));
|
||||
expect(summary.metrics.map((item) => item.value)).toEqual([14, 14, 14]);
|
||||
});
|
||||
|
||||
it('keeps neutral Big Five answers neutral after reverse scoring', () => {
|
||||
const summary = buildScoreSummary('bigfive', Array(50).fill('3'));
|
||||
expect(summary.metrics).toHaveLength(5);
|
||||
expect(summary.metrics.every((item) => item.value === 30 && item.max === 50)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps neutral HEXACO answers at the midpoint', () => {
|
||||
const summary = buildScoreSummary('hexaco', Array(60).fill('3'));
|
||||
expect(summary.metrics).toHaveLength(6);
|
||||
expect(summary.metrics.every((item) => item.value === 3 && item.max === 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies SDS reverse scoring and standard-score conversion', () => {
|
||||
const summary = buildScoreSummary('sds', Array(20).fill('1'));
|
||||
expect(summary.primary).toMatchObject({ value: 63, max: 100, level: '中度' });
|
||||
});
|
||||
|
||||
it('falls back to a clearly labelled raw answer sum', () => {
|
||||
const summary = buildScoreSummary('unknown', ['1', '2', '3']);
|
||||
expect(summary.primary).toMatchObject({ label: '原始作答总和', value: 6 });
|
||||
expect(summary.note).toContain('不替代');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ScoreMetric, ScoreSummary } from '@/lib/assessment-types';
|
||||
import {
|
||||
calculateBigFiveResults,
|
||||
calculateIpipNeoResults,
|
||||
ipipNeoItemsByVersion,
|
||||
} from '@/components/questionnaire/test/private/BigFiveCalculator';
|
||||
import { calculateHEXACOResults } from '@/components/questionnaire/test/private/HEXACOCalculator';
|
||||
import { calculateRIASECResults, riasecTypes } from '@/components/questionnaire/test/private/RIASECCalculator';
|
||||
import {
|
||||
calculateSchwartzResults,
|
||||
higherOrderNames,
|
||||
} from '@/components/questionnaire/test/private/SchwartzCalculator';
|
||||
|
||||
const levels: Record<string, string> = {
|
||||
minimal: '极轻或无', mild: '轻度', moderate: '中度', moderately_severe: '中重度',
|
||||
severe: '重度', normal: '正常范围', extremely_severe: '极重度', low: '较低',
|
||||
high: '较高', subthreshold: '亚阈值', no_insomnia: '无明显失眠',
|
||||
};
|
||||
|
||||
const metric = (key: string, label: string, value: number, max?: number, level?: string): ScoreMetric => ({
|
||||
key, label, value, max, level: level ? levels[level] || level : undefined,
|
||||
});
|
||||
|
||||
function sum(answers: string[]) {
|
||||
return answers.reduce((total, answer) => total + (Number(answer) || 0), 0);
|
||||
}
|
||||
|
||||
function keyed(answers: string[]) {
|
||||
return Object.fromEntries(answers.map((answer, index) => [index + 1, answer]));
|
||||
}
|
||||
|
||||
function severity(score: number, thresholds: Array<[number, string]>) {
|
||||
return thresholds.reduce((current, [minimum, label]) => score >= minimum ? label : current, thresholds[0][1]);
|
||||
}
|
||||
|
||||
export function buildScoreSummary(questionnaireId: string, answers: string[]): ScoreSummary {
|
||||
if (questionnaireId === 'bigfive') {
|
||||
const result = calculateBigFiveResults(answers);
|
||||
const names: Record<string, string> = { extraversion: '外向性', agreeableness: '宜人性', conscientiousness: '尽责性', emotionalStability: '情绪稳定性', openness: '开放性' };
|
||||
return { metrics: Object.entries(result).map(([key, value]) => metric(key, names[key], value.score, 50)) };
|
||||
}
|
||||
if (questionnaireId === 'bigfive-120' || questionnaireId === 'bigfive-300') {
|
||||
const version = questionnaireId === 'bigfive-120' ? 120 : 300;
|
||||
const result = calculateIpipNeoResults(answers, [...ipipNeoItemsByVersion[version]]);
|
||||
const names: Record<string, string> = { neuroticism: '神经质', extraversion: '外向性', openness: '开放性', agreeableness: '宜人性', conscientiousness: '尽责性' };
|
||||
const max = version === 120 ? 120 : 300;
|
||||
return { metrics: Object.entries(result.domains).map(([key, value]) => metric(key, names[key], value.score, max)) };
|
||||
}
|
||||
if (questionnaireId === 'hexaco') {
|
||||
const names: Record<string, string> = { honestyHumility: '诚实谦逊', emotionality: '情绪性', extraversion: '外向性', agreeableness: '宜人性', conscientiousness: '尽责性', openness: '开放性' };
|
||||
return { metrics: Object.entries(calculateHEXACOResults(answers)).map(([key, value]) => metric(key, names[key], Number(value.toFixed(2)), 5)) };
|
||||
}
|
||||
if (questionnaireId === 'riasec') {
|
||||
const result = calculateRIASECResults(answers);
|
||||
return { primary: metric('holland', `霍兰德代码 ${result.hollandCode}`, result.ranking[0][1].score, 40), metrics: Object.entries(result.scores).map(([key, value]) => metric(key, riasecTypes[key as keyof typeof riasecTypes].name, value.score, 40)) };
|
||||
}
|
||||
if (questionnaireId === 'schwartz') {
|
||||
const result = calculateSchwartzResults(answers);
|
||||
return { metrics: Object.entries(result.higherOrderScores).map(([key, value]) => metric(key, higherOrderNames[key as keyof typeof higherOrderNames], Number(value.toFixed(2)), 5)) };
|
||||
}
|
||||
|
||||
const raw = sum(answers);
|
||||
const single: Record<string, { label: string; max: number; score?: number; level?: string }> = {
|
||||
phq9: { label: 'PHQ-9 总分', max: 27, level: severity(raw, [[0, 'minimal'], [5, 'mild'], [10, 'moderate'], [15, 'moderately_severe'], [20, 'severe']]) },
|
||||
gad7: { label: 'GAD-7 总分', max: 21, level: severity(raw, [[0, 'minimal'], [5, 'mild'], [10, 'moderate'], [15, 'severe']]) },
|
||||
isi: { label: 'ISI 总分', max: 28, level: severity(raw, [[0, 'no_insomnia'], [8, 'subthreshold'], [15, 'moderate'], [22, 'severe']]) },
|
||||
bdi2: { label: 'BDI-II 总分', max: 63, level: severity(raw, [[0, 'minimal'], [14, 'mild'], [20, 'moderate'], [29, 'severe']]) },
|
||||
who5: { label: 'WHO-5 百分制得分', max: 100, score: raw * 4 },
|
||||
};
|
||||
if (single[questionnaireId]) {
|
||||
const item = single[questionnaireId];
|
||||
const primary = metric('total', item.label, item.score ?? raw, item.max, item.level);
|
||||
return { primary, metrics: [primary] };
|
||||
}
|
||||
if (questionnaireId === 'dass21') {
|
||||
const groups = { depression: [3, 5, 10, 13, 16, 17, 21], anxiety: [2, 4, 7, 9, 15, 19, 20], stress: [1, 6, 8, 11, 12, 14, 18] };
|
||||
const names = { depression: '抑郁', anxiety: '焦虑', stress: '压力' };
|
||||
return { metrics: Object.entries(groups).map(([key, ids]) => metric(key, names[key as keyof typeof names], ids.reduce((total, id) => total + Number(keyed(answers)[id] || 0), 0) * 2, 42)) };
|
||||
}
|
||||
if (questionnaireId === 'pss10') {
|
||||
const reverse = new Set([4, 5, 7, 8]);
|
||||
const score = answers.reduce((total, answer, index) => total + (reverse.has(index + 1) ? 4 - Number(answer) : Number(answer)), 0);
|
||||
const primary = metric('total', 'PSS-10 总分', score, 40, severity(score, [[0, 'low'], [14, 'moderate'], [27, 'high']]));
|
||||
return { primary, metrics: [primary] };
|
||||
}
|
||||
if (questionnaireId === 'sds') {
|
||||
const reverse = new Set([2, 5, 6, 11, 12, 14, 16, 17, 18, 20]);
|
||||
const original = answers.reduce((total, answer, index) => total + (reverse.has(index + 1) ? 5 - Number(answer) : Number(answer)), 0);
|
||||
const score = Math.round(original * 1.25);
|
||||
const primary = metric('total', 'SDS 标准分', score, 100, severity(score, [[0, 'normal'], [53, 'mild'], [63, 'moderate'], [73, 'severe']]));
|
||||
return { primary, metrics: [primary] };
|
||||
}
|
||||
|
||||
const primary = metric('raw', '原始作答总和', raw);
|
||||
return { primary, metrics: [primary], note: '该数值仅用于保存和核对作答,不替代量表结果页中的正式解释。' };
|
||||
}
|
||||
+3
-1
@@ -6,6 +6,7 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint app components hooks lib locales questionairies types middleware.ts next.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -31,6 +32,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.18",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+490
@@ -75,6 +75,9 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9(@types/node@20.19.43)(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -416,6 +419,9 @@ packages:
|
||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||
engines: {node: '>=12.4.0'}
|
||||
|
||||
'@oxc-project/types@0.133.0':
|
||||
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
|
||||
|
||||
'@radix-ui/primitive@1.1.4':
|
||||
resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==}
|
||||
|
||||
@@ -682,12 +688,113 @@ packages:
|
||||
'@radix-ui/rect@1.1.2':
|
||||
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.3':
|
||||
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.3':
|
||||
resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.3':
|
||||
resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.3':
|
||||
resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.3':
|
||||
resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.3':
|
||||
resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.3':
|
||||
resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
|
||||
resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.3':
|
||||
resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.3':
|
||||
resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.3':
|
||||
resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.3':
|
||||
resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.3':
|
||||
resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.3':
|
||||
resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.3':
|
||||
resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@rtsao/scc@1.1.0':
|
||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||
|
||||
'@rushstack/eslint-patch@1.16.1':
|
||||
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
@@ -786,6 +893,12 @@ packages:
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
@@ -985,6 +1098,35 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
|
||||
|
||||
'@vitest/mocker@4.1.9':
|
||||
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
|
||||
|
||||
'@vitest/spy@4.1.9':
|
||||
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@@ -1045,6 +1187,10 @@ packages:
|
||||
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-types-flow@0.0.8:
|
||||
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
|
||||
|
||||
@@ -1101,6 +1247,10 @@ packages:
|
||||
caniuse-lite@1.0.30001799:
|
||||
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1125,6 +1275,9 @@ packages:
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1217,6 +1370,9 @@ packages:
|
||||
resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1353,10 +1509,17 @@ packages:
|
||||
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
esutils@2.0.3:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -1405,6 +1568,11 @@ packages:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
@@ -1861,6 +2029,10 @@ packages:
|
||||
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
obug@2.1.3:
|
||||
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1892,6 +2064,9 @@ packages:
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -1991,6 +2166,11 @@ packages:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rolldown@1.0.3:
|
||||
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
@@ -2061,6 +2241,9 @@ packages:
|
||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
@@ -2074,6 +2257,12 @@ packages:
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2140,10 +2329,21 @@ packages:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@1.2.4:
|
||||
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
@@ -2221,6 +2421,90 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
vite@8.0.16:
|
||||
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
'@vitejs/devtools': ^0.1.18
|
||||
esbuild: ^0.27.0 || ^0.28.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitejs/devtools':
|
||||
optional: true
|
||||
esbuild:
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.9:
|
||||
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.9
|
||||
'@vitest/browser-preview': 4.1.9
|
||||
'@vitest/browser-webdriverio': 4.1.9
|
||||
'@vitest/coverage-istanbul': 4.1.9
|
||||
'@vitest/coverage-v8': 4.1.9
|
||||
'@vitest/ui': 4.1.9
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2242,6 +2526,11 @@ packages:
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
word-wrap@1.2.5:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2521,6 +2810,8 @@ snapshots:
|
||||
|
||||
'@nolyfill/is-core-module@1.0.39': {}
|
||||
|
||||
'@oxc-project/types@0.133.0': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.4': {}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
@@ -2760,10 +3051,63 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.2': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.3':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.3':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@rtsao/scc@1.1.0': {}
|
||||
|
||||
'@rushstack/eslint-patch@1.16.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -2842,6 +3186,13 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
@@ -3021,6 +3372,47 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.9(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.16(@types/node@20.19.43)(jiti@2.7.0)
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.9
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.9': {}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.17.0):
|
||||
dependencies:
|
||||
acorn: 8.17.0
|
||||
@@ -3113,6 +3505,8 @@ snapshots:
|
||||
get-intrinsic: 1.3.0
|
||||
is-array-buffer: 3.0.5
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
|
||||
async-function@1.0.0: {}
|
||||
@@ -3163,6 +3557,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001799: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -3184,6 +3580,8 @@ snapshots:
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -3342,6 +3740,8 @@ snapshots:
|
||||
iterator.prototype: 1.1.5
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
es-module-lexer@2.1.0: {}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -3563,8 +3963,14 @@ snapshots:
|
||||
|
||||
estraverse@5.3.0: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-glob@3.3.1:
|
||||
@@ -3611,6 +4017,9 @@ snapshots:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
function.prototype.name@1.2.0:
|
||||
@@ -4060,6 +4469,8 @@ snapshots:
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
obug@2.1.3: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -4093,6 +4504,8 @@ snapshots:
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
@@ -4190,6 +4603,27 @@ snapshots:
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rolldown@1.0.3:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.133.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm64': 1.0.3
|
||||
'@rolldown/binding-darwin-arm64': 1.0.3
|
||||
'@rolldown/binding-darwin-x64': 1.0.3
|
||||
'@rolldown/binding-freebsd-x64': 1.0.3
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.3
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.0.3
|
||||
'@rolldown/binding-linux-arm64-musl': 1.0.3
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.0.3
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.0.3
|
||||
'@rolldown/binding-linux-x64-gnu': 1.0.3
|
||||
'@rolldown/binding-linux-x64-musl': 1.0.3
|
||||
'@rolldown/binding-openharmony-arm64': 1.0.3
|
||||
'@rolldown/binding-wasm32-wasi': 1.0.3
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.3
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.3
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -4309,6 +4743,8 @@ snapshots:
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
@@ -4318,6 +4754,10 @@ snapshots:
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -4395,11 +4835,17 @@ snapshots:
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.2.4: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
@@ -4513,6 +4959,45 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.10
|
||||
rolldown: 1.0.3
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
|
||||
vitest@4.1.9(@types/node@20.19.43)(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.9
|
||||
'@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0))
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/runner': 4.1.9
|
||||
'@vitest/snapshot': 4.1.9
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.3
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.16(@types/node@20.19.43)(jiti@2.7.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
dependencies:
|
||||
is-bigint: 1.1.0
|
||||
@@ -4558,6 +5043,11 @@ snapshots:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('.', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user