From c8d5a918cf8b00504a435274dd3e2502eaea70ed Mon Sep 17 00:00:00 2001 From: mikemoi Date: Tue, 23 Jun 2026 00:21:07 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=B5=8B=E8=AF=84=E6=A1=A3=E6=A1=88=E4=B8=8E=E9=95=BF=E6=9C=9F?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +- .../questionnaire/[id]/result/page.tsx | 41 +- app/[locale]/records/page.tsx | 5 + components/Navbar.tsx | 9 +- .../result/public/ResultContainer.tsx | 12 +- .../questionnaire/test/QuestionnaireTest.tsx | 36 +- components/records/ProfilePicker.tsx | 66 +++ components/records/RecordsDashboard.tsx | 273 ++++++++++ lib/assessment-db.ts | 189 +++++++ lib/assessment-export.ts | 53 ++ lib/assessment-types.ts | 59 +++ lib/backup-crypto.test.ts | 24 + lib/backup-crypto.ts | 77 +++ lib/result-storage.ts | 17 +- lib/score-summary.test.ts | 47 ++ lib/score-summary.ts | 96 ++++ package.json | 4 +- pnpm-lock.yaml | 490 ++++++++++++++++++ vitest.config.ts | 13 + 19 files changed, 1509 insertions(+), 14 deletions(-) create mode 100644 app/[locale]/records/page.tsx create mode 100644 components/records/ProfilePicker.tsx create mode 100644 components/records/RecordsDashboard.tsx create mode 100644 lib/assessment-db.ts create mode 100644 lib/assessment-export.ts create mode 100644 lib/assessment-types.ts create mode 100644 lib/backup-crypto.test.ts create mode 100644 lib/backup-crypto.ts create mode 100644 lib/score-summary.test.ts create mode 100644 lib/score-summary.ts create mode 100644 vitest.config.ts diff --git a/README.md b/README.md index 7136473..a191635 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/app/[locale]/questionnaire/[id]/result/page.tsx b/app/[locale]/questionnaire/[id]/result/page.tsx index 5064413..2f20939 100644 --- a/app/[locale]/questionnaire/[id]/result/page.tsx +++ b/app/[locale]/questionnaire/[id]/result/page.tsx @@ -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(null); + const [record, setRecord] = useState(null); + const [profileName, setProfileName] = useState('未命名档案'); + const analysisRef = useRef(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)); - setLoading(false); + 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 = useMemo(() => { if (!questionnaire || !decodedAnswers) return {}; @@ -86,13 +115,17 @@ export default function QuestionnaireResultPage({ questionnaire={questionnaire} answers={decodedAnswers} questionnaireResults={questionnaireResults} + record={record || undefined} + profileName={profileName} > - +
+ +
); } diff --git a/app/[locale]/records/page.tsx b/app/[locale]/records/page.tsx new file mode 100644 index 0000000..c3ced60 --- /dev/null +++ b/app/[locale]/records/page.tsx @@ -0,0 +1,5 @@ +import { RecordsDashboard } from '@/components/records/RecordsDashboard'; + +export default function RecordsPage() { + return ; +} diff --git a/components/Navbar.tsx b/components/Navbar.tsx index e072ad4..6df582a 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -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')} + + + 测评档案 + diff --git a/components/questionnaire/result/public/ResultContainer.tsx b/components/questionnaire/result/public/ResultContainer.tsx index 793d20f..70b58e4 100644 --- a/components/questionnaire/result/public/ResultContainer.tsx +++ b/components/questionnaire/result/public/ResultContainer.tsx @@ -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; + 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 {t('downloadResultData')} + diff --git a/components/questionnaire/test/QuestionnaireTest.tsx b/components/questionnaire/test/QuestionnaireTest.tsx index c3826eb..a307445 100644 --- a/components/questionnaire/test/QuestionnaireTest.tsx +++ b/components/questionnaire/test/QuestionnaireTest.tsx @@ -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(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({

{questionnaire.title}

+ + void; +} + +export function ProfilePicker({ onChange }: ProfilePickerProps) { + const [profiles, setProfiles] = useState([]); + 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 ( +
+
+ + 本次记录到 +
+
+ + + 管理档案 + +
+
+ ); +} diff --git a/components/records/RecordsDashboard.tsx b/components/records/RecordsDashboard.tsx new file mode 100644 index 0000000..e6ca976 --- /dev/null +++ b/components/records/RecordsDashboard.tsx @@ -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 ( +
+
+ {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 推断,也不会将不同量表强行合并为单一人格标签。
+
+ )} + +
+ +
+
+ ); +} diff --git a/lib/assessment-db.ts b/lib/assessment-db.ts new file mode 100644 index 0000000..177d9f0 --- /dev/null +++ b/lib/assessment-db.ts @@ -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 { + 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(request: IDBRequest): Promise { + 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 { + 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 { + 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 { + 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((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); + db.close(); +} + +export async function addAssessmentRecord( + record: Omit, +): Promise { + 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 { + 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 { + 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((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((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); + db.close(); + localStorage.removeItem(ACTIVE_PROFILE_KEY); +} diff --git a/lib/assessment-export.ts b/lib/assessment-export.ts new file mode 100644 index 0000000..78b92e2 --- /dev/null +++ b/lib/assessment-export.ts @@ -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); +} diff --git a/lib/assessment-types.ts b/lib/assessment-types.ts new file mode 100644 index 0000000..38e691c --- /dev/null +++ b/lib/assessment-types.ts @@ -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[]; +} diff --git a/lib/backup-crypto.test.ts b/lib/backup-crypto.test.ts new file mode 100644 index 0000000..d086193 --- /dev/null +++ b/lib/backup-crypto.test.ts @@ -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('密码错误'); + }); +}); diff --git a/lib/backup-crypto.ts b/lib/backup-crypto.ts new file mode 100644 index 0000000..514c1f7 --- /dev/null +++ b/lib/backup-crypto.ts @@ -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 { + 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 { + 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('密码错误或备份文件已损坏'); + } +} diff --git a/lib/result-storage.ts b/lib/result-storage.ts index 96e07fe..43c924a 100644 --- a/lib/result-storage.ts +++ b/lib/result-storage.ts @@ -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; - 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; diff --git a/lib/score-summary.test.ts b/lib/score-summary.test.ts new file mode 100644 index 0000000..2397254 --- /dev/null +++ b/lib/score-summary.test.ts @@ -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('不替代'); + }); +}); diff --git a/lib/score-summary.ts b/lib/score-summary.ts new file mode 100644 index 0000000..aab4c2a --- /dev/null +++ b/lib/score-summary.ts @@ -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 = { + 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 = { 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 = { 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 = { 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 = { + 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: '该数值仅用于保存和核对作答,不替代量表结果页中的正式解释。' }; +} diff --git a/package.json b/package.json index f95ef56..041ff80 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5ccd99..c362953 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5988827 --- /dev/null +++ b/vitest.config.ts @@ -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', + }, +});