Pārlūkot izejas kodu

init: hs-data MVP — 拼团漏斗端到端 + 平台框架

- 前端 apps/web:React19+Vite+TS+Tailwind v4+shadcn/ui(神策风 mint 主题)+ECharts;
  三级 IA 导航(5 个 L1)、拼团漏斗页(单日历史/近7/近30、segmented 时间控件)、
  面包屑/动态标题/暗色开关/懒加载;Vitest 37 passed。
- 后端 apps/api:FastAPI+Pydantic v2+SQLAlchemy async;POST /api/funnels/query;
  拼团漏斗两表(daily/rolling)+USE_FAKE_DATA 兜底;pytest 47 passed。
- packages/api-types:OpenAPI→TS 类型生成。
- docs/01-05 + CHANGELOG;infra docker-compose + setup.sh。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tianyu.chu 1 mēnesi atpakaļ
revīzija
5cca0f3f5e
100 mainītis faili ar 11305 papildinājumiem un 0 dzēšanām
  1. 15 0
      .editorconfig
  2. 9 0
      .gitattributes
  3. 29 0
      .gitignore
  4. 1 0
      .nvmrc
  5. 118 0
      CHANGELOG.md
  6. 11 0
      CLAUDE.md
  7. 60 0
      README.md
  8. 8 0
      apps/api/.env.example
  9. 156 0
      apps/api/README.md
  10. 44 0
      apps/api/alembic.ini
  11. 70 0
      apps/api/alembic/env.py
  12. 28 0
      apps/api/alembic/script.py.mako
  13. 66 0
      apps/api/alembic/versions/0001_create_group_funnel_tables.py
  14. 1 0
      apps/api/app/__init__.py
  15. 1 0
      apps/api/app/api/__init__.py
  16. 52 0
      apps/api/app/api/funnels.py
  17. 34 0
      apps/api/app/config.py
  18. 1 0
      apps/api/app/db/__init__.py
  19. 74 0
      apps/api/app/db/models.py
  20. 31 0
      apps/api/app/db/session.py
  21. 29 0
      apps/api/app/main.py
  22. 91 0
      apps/api/app/schemas.py
  23. 1 0
      apps/api/app/services/__init__.py
  24. 219 0
      apps/api/app/services/funnel.py
  25. 151 0
      apps/api/app/services/repository.py
  26. 275 0
      apps/api/openapi.json
  27. 39 0
      apps/api/pyproject.toml
  28. 1 0
      apps/api/scripts/__init__.py
  29. 31 0
      apps/api/scripts/export_openapi.py
  30. 110 0
      apps/api/scripts/seed.py
  31. 0 0
      apps/api/tests/__init__.py
  32. 61 0
      apps/api/tests/conftest.py
  33. 192 0
      apps/api/tests/test_api.py
  34. 237 0
      apps/api/tests/test_funnel_service.py
  35. 127 0
      apps/api/tests/test_repository_db.py
  36. 89 0
      apps/api/tests/test_validation.py
  37. 3 0
      apps/web/.env.example
  38. 25 0
      apps/web/components.json
  39. 14 0
      apps/web/index.html
  40. 48 0
      apps/web/package.json
  41. 8 0
      apps/web/public/favicon.svg
  42. 53 0
      apps/web/src/App.tsx
  43. 81 0
      apps/web/src/__tests__/routing.test.tsx
  44. 64 0
      apps/web/src/api/__tests__/funnel.mock.test.ts
  45. 200 0
      apps/web/src/api/funnel.ts
  46. 12 0
      apps/web/src/api/queryClient.ts
  47. 70 0
      apps/web/src/api/types.ts
  48. 76 0
      apps/web/src/components/ui/alert.tsx
  49. 49 0
      apps/web/src/components/ui/badge.tsx
  50. 67 0
      apps/web/src/components/ui/button.tsx
  51. 100 0
      apps/web/src/components/ui/calendar.tsx
  52. 103 0
      apps/web/src/components/ui/card.tsx
  53. 48 0
      apps/web/src/components/ui/popover.tsx
  54. 55 0
      apps/web/src/components/ui/scroll-area.tsx
  55. 26 0
      apps/web/src/components/ui/separator.tsx
  56. 147 0
      apps/web/src/components/ui/sheet.tsx
  57. 13 0
      apps/web/src/components/ui/skeleton.tsx
  58. 47 0
      apps/web/src/components/ui/sonner.tsx
  59. 116 0
      apps/web/src/components/ui/table.tsx
  60. 88 0
      apps/web/src/components/ui/tabs.tsx
  61. 142 0
      apps/web/src/index.css
  62. 73 0
      apps/web/src/layout/AppLayout.tsx
  63. 31 0
      apps/web/src/layout/Breadcrumb.tsx
  64. 149 0
      apps/web/src/layout/NavTree.tsx
  65. 39 0
      apps/web/src/layout/ThemeToggle.tsx
  66. 20 0
      apps/web/src/layout/nav-utils.ts
  67. 6 0
      apps/web/src/lib/utils.ts
  68. 24 0
      apps/web/src/main.tsx
  69. 85 0
      apps/web/src/modules/funnel/FunnelPage.tsx
  70. 260 0
      apps/web/src/modules/funnel/__tests__/FunnelPage.test.tsx
  71. 116 0
      apps/web/src/modules/funnel/__tests__/FunnelResult.test.tsx
  72. 27 0
      apps/web/src/modules/funnel/__tests__/format.test.ts
  73. 105 0
      apps/web/src/modules/funnel/components/FunnelChart.tsx
  74. 133 0
      apps/web/src/modules/funnel/components/FunnelResult.tsx
  75. 55 0
      apps/web/src/modules/funnel/components/ResultTable.tsx
  76. 94 0
      apps/web/src/modules/funnel/components/TimePeriodSelect.tsx
  77. 10 0
      apps/web/src/modules/funnel/format.ts
  78. 37 0
      apps/web/src/modules/funnel/period.ts
  79. 27 0
      apps/web/src/modules/placeholder/Placeholder.tsx
  80. 94 0
      apps/web/src/routes/domains.ts
  81. 36 0
      apps/web/src/test/setup.ts
  82. 9 0
      apps/web/src/vite-env.d.ts
  83. 30 0
      apps/web/tsconfig.app.json
  84. 13 0
      apps/web/tsconfig.json
  85. 21 0
      apps/web/tsconfig.node.json
  86. 30 0
      apps/web/vite.config.ts
  87. 173 0
      docs/01-产品需求-MVP.md
  88. 190 0
      docs/02-技术架构.md
  89. 240 0
      docs/03-数据契约.md
  90. 86 0
      docs/04-设计规范.md
  91. 90 0
      docs/05-agent协作准则.md
  92. 23 0
      infra/docker-compose.yml
  93. 63 0
      infra/setup.sh
  94. 16 0
      package.json
  95. 9 0
      packages/api-types/README.md
  96. 15 0
      packages/api-types/package.json
  97. 4 0
      packages/api-types/src/index.ts
  98. 223 0
      packages/api-types/src/schema.ts
  99. 4529 0
      pnpm-lock.yaml
  100. 3 0
      pnpm-workspace.yaml

+ 15 - 0
.editorconfig

@@ -0,0 +1,15 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 2
+
+[*.py]
+indent_size = 4
+
+[*.md]
+trim_trailing_whitespace = false

+ 9 - 0
.gitattributes

@@ -0,0 +1,9 @@
+# 跨平台统一:仓库内文本一律以 LF 存储(避免 Windows CRLF 污染 Linux 服务器,
+# 尤其 shell 脚本 CRLF 会导致 `bad interpreter` 等错误)。
+* text=auto eol=lf
+
+# 明确二进制,git 不做行尾转换。
+*.svg text eol=lf
+*.png binary
+*.jpg binary
+*.ico binary

+ 29 - 0
.gitignore

@@ -0,0 +1,29 @@
+# Node
+node_modules/
+dist/
+*.tsbuildinfo
+.vite/
+
+# Python
+__pycache__/
+*.py[cod]
+.venv/
+venv/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+*.egg-info/
+
+# Env / local
+.env
+.env.local
+*.local
+
+# OS / editor
+.DS_Store
+Thumbs.db
+.idea/
+.vscode/
+
+# Logs
+*.log

+ 1 - 0
.nvmrc

@@ -0,0 +1 @@
+20

+ 118 - 0
CHANGELOG.md

@@ -0,0 +1,118 @@
+# Changelog
+
+> 记录本项目每次改动。新条目追加到顶部。格式:`日期 — 改动概述`,正文按 `新增 / 变更 / 修复 / 移除` 分类。链接 PR / commit / 相关文档(如有)。
+
+## 2026-06-25
+
+### 新增
+- **远程开发准备**:新增 `.gitattributes`(文本统一 LF,避免 Windows CRLF 污染 Linux 服务器)与 `infra/setup.sh`(Debian/Ubuntu 一键装 Node20/pnpm/Python3.11 + 依赖 + 启动说明)。首个 git commit 落地(此前仅 `git init`,0 提交)。
+
+### 变更
+- **默认周期改为「单日(昨日)」**(`apps/web` `period.ts`):`DEFAULT_PERIOD` `last_7d` → `day`,首屏即看昨日的拼团漏斗(数据 T+1,昨日为最大可查日);测试同步更新。
+- **品牌字 `HS-Data` → `HS Data`**(空格替代连字符):顶栏品牌、`index.html` 标题、`document.title` 一并更新。
+
+### 修复
+- **时间选择选中态彻底重做**(`apps/web`):此前 近7/近30 用 shadcn Tabs、单日用 Button variant,选中高亮依赖 `data-active`/默认白底,在浅色页**几乎不可见**——用户感知不到"当前在哪个周期"。
+  - 合并 `DateField` + `PeriodSelect` → 单一 **`TimePeriodSelect`** segmented 控件(单日/近7天/近30天三选一);**选中 = mint 实色 + 白字,由 React 状态直接驱动**(不依赖任何 shadcn 变体),100% 可见、三段反馈统一。
+  - 删除 `DateField.tsx` / `PeriodSelect.tsx` 及孤儿导出(`PERIOD_OPTIONS`/`ROLLING_PERIOD_OPTIONS`/`periodLabel`);测试 role `tab` → `button`。Vitest 37 passed、build clean。`docs/04` §5 同步。
+
+## 2026-06-24
+
+### 新增
+- **品牌与 favicon**(`apps/web`):新增 `public/favicon.svg`(mint 圆角方块 + 漏斗三阶 glyph),`index.html` 接入 favicon + `theme-color` + 标题改 `HS-Data · 数据服务平台`。顶栏品牌重做:mint 方块 mark(漏斗 glyph,与 favicon 一致)+ `HS-Data`(-Data mint)+ 竖线 + 灰色副标题「数据服务平台」,替换原全小写平排文字。
+- **顶栏/页面体验五件套**(`apps/web`):
+  - **面包屑**(`Breadcrumb` + `nav-utils.findTrail`):内容区顶部显示「行为分析 / 漏斗分析 / 拼团漏斗」,末项高亮——深层导航定位。
+  - **动态页面标题**:`document.title` 随路由变为「<当前页> · HS-Data」。
+  - **ECharts 懒加载**:`FunnelChart` 改 `React.lazy` + `Suspense`,拆出独立 chunk —— 主包 **1.56MB → 510KB**(echarts 1.05MB 按需加载,非漏斗页不加载)。
+  - **暗色模式开关**(`ThemeToggle`):顶栏 Sun/Moon 切换,偏好存 `localStorage`,`main.tsx` 首屏前应用避免闪烁。
+  - 品牌图标由 lucide `Target` 换为自绘漏斗 glyph(与 favicon 统一)。
+  - Vitest **37 passed**、build clean(FunnelResult 图表断言改 `findByTestId` 适配懒加载)。
+
+### 变更
+- **加回「指标体系」L1 + 导航展开/折叠改为主流交互**(`docs/01`/`docs/02`/`docs/04` + `apps/web`):
+  - L1 由 4 → **5**,加回 **指标体系**(指标目录/指标大盘/指标监控):主流数据产品核心(统一指标口径),只取消费面、不做治理后台。
+  - **导航展开/折叠对齐主流后台**(VS Code/AntD Pro/神策):点 L1/L2 分组手动展开/折叠(不跳转)、**多个可同时展开**(非手风琴)、进入某页自动展开其所在分支且不折叠其它分支、当前分支可手动折叠。`NavTree.tsx` 改为受控 open 集合;父节点为 toggle 按钮,叶子才跳转。
+  - 同步 `docs/01` §2(5 L1 + §2.3 导航交互)/§3/§7/§8、`docs/02` §9、`docs/04` §3。Vitest **37 passed**(+折叠/多开两条)、build clean。
+- **IA 精简为内部工具版(四字对齐,多实体画像)**(`docs/01`/`docs/02`/`docs/04` + `apps/web`):
+  - L1 由 7 砍到 **4**:行为分析 / 画像体系 / 数据看板 / 营销触达(模块名统一四字)。依据:内部工具非 SaaS——**不设独立"实时"域**(实时大盘并入数据看板)、**画像改多实体体系**(用户/商家/产品画像,标签作底层不单列)、**砍掉数据管理/工作台/指标平台**门面。
+  - 导航视觉强化层级:L1 加粗 + 图标 + 展开箭头 ▸/▾;L2/L3 左侧竖引导线 + 缩进 + mint pill 激活。
+  - 同步 `docs/01` §2(4 L1 表 + L2/L3 + 取舍说明)、§3/§5/§7/§8;`docs/02` §9;`docs/04` §3;`NAV` 树 + `NavTree.tsx`。Vitest **35 passed**、build clean。
+- **前端导航对齐三级 IA**(`apps/web`,落地 `docs/01` §2):左侧导航由 5 个扁平能力域改为多级 L1 能力域 + 三级导航树(L1 能力域 → L2 分析模块 → L3 报表)。
+  - 新增 `routes/domains.ts` 的 `NAV` 树 + `layout/NavTree.tsx`(递归渲染、仅展开当前分支、按深度缩进);`App.tsx` 由树**自动生成路由**(父节点重定向到首个叶子,可用叶子 → 页面,余 → 占位)。
+  - 拼团漏斗落点 `/behavior/funnel/group`(= 行为分析 > 漏斗分析 > 拼团漏斗);`/` 重定向至此。其余 L1/L2 出"待开发"占位。
+  - `AppLayout` 抽出导航为 `NavTree`,保留既定主题/三段式布局/抗抖滚动模型;`docs/04` §3 导航说明同步更新。
+  - Vitest **35 passed**、`vite build` clean(本机实跑)。
+- **PRD 信息架构重构**(`docs/01` v2.0):由 5 个扁平能力域升级为主流数据平台通用的**三级 IA**(L1 能力域 → L2 分析模块 → L3 报表/实例),参考神策 + 网易有数/阿里 OneData/Aloudata 指标平台/CDP。
+  - 平台定位改为**多形态一站式数据平台门户**;明确数据现实约束(T+1 预聚合宽表 → 只做固定报表,自助分析需事件级数据列远期)。
+  - 7 个 L1 能力域:看板 / 行为分析 / 用户画像与标签 / 指标平台 / 实时 / 营销触达 / 数据管理(+工作台首页);**标签体系、指标体系**各立为独立 L1 预留。
+  - MVP 落点明确:`行为分析 > 漏斗分析 > 拼团漏斗(固定)` 端到端,余 L1/L2 进导航出"待开发"占位。
+  - 漏斗详规对齐已上线实况(拼团固定 5 步、单日/近7天/近30天、单日历史回溯、T+1、不补零),替换旧"任意步骤/自定义15天/bitmap"描述;演进路线、明确不做、验收标准同步更新。
+  - 同步:`docs/02` §9 路由对齐 7 个 L1 + 漏斗下 L2/L3;`docs/05` 补"导航 IA 以 docs/01 为准、视觉以 docs/04 为准"。
+  - 导航代码改造(`apps/web` 左侧导航 5→7 域 + L2 子菜单)列为后续任务,本次仅文档。
+
+### 新增
+- **`docs/04-设计规范`**:前端视觉/交互**定稿**并锁定(神策风 mint 主题色板、三段式布局、漏斗图 mint 渐变、单日日历常驻 + 滚动 Tabs 控件、快照/截至文案、抗抖滚动模型)。`docs/02` §1 加指针。设计决策以本文为唯一权威来源。
+
+### 变更
+- **前端视觉定稿(神策风)**(`apps/web`,在 shadcn 迁移基础上):
+  - 主题色板取自 sensorsdata.cn 实际 CSS:主色 mint `#04CB94`、文本 `#1F2D3D`、底 `#F9FAFC`(非纯白)、弱化 `#99A9BF`、淡 mint `#DEFFF6`、错误 `#EF4444`;默认亮色(`.dark` 备而不用)。
+  - 布局三段式:白顶栏(底线 + mint logo + mint dot/MVP)+ 浅 slate-100 侧栏(active 项淡 mint 底 + mint-700 字)+ 微蓝白主区 + 白卡轻投影。否决过的方案:全白(太平)、深 navy 侧栏(对比太狠 / 顶左同色)。
+  - 漏斗图改 mint 单色渐变 `#064E3B→#04CB94`,块宽按真实 UV 比例。
+  - 控件改版:**单日日历常驻**(`DateField` 自带「单日」标签 + 高亮 active 态)替代「单日」Tab;Tabs 仅留近 7/30 天。
+- **修复加载抖动**(`AppLayout`):根容器 `min-h-screen` → `h-screen overflow-hidden`,令 `<main>` 为唯一滚动区 + `scrollbar-gutter:stable`,横向不再因滚动条出现/消失而抖;骨架高度对齐内容,竖向不跳。
+- **前端漏斗对齐拼团契约 v3 + 单日历史日期选择器**(`apps/web`):
+  - 周期模型:`yesterday|last_7d|last_30d` → `day|last_7d|last_30d`,Tab 文案 `单日 / 近 7 天 / 近 30 天`,默认仍 `last_7d`。固定漏斗第 3 步「详情」→「拼团详情」;副标题改「拼团漏斗:启动 → 曝光 → 拼团详情 → 下单 → 成功」。
+  - 类型(`api/types.ts`):请求加可选 `snapshot_dt?`(仅 `day` 发送),响应 `snapshot_dt` 可为 `null`。
+  - 新增 `DateField`(shadcn `calendar` + `popover`,基于 react-day-picker v10):仅 `period==='day'` 渲染;默认昨日;`disabled={{ after: 昨日 }}` 禁今天/未来(T+1),历史不设下限;选日即以该 `snapshot_dt` 重查。切到 7/30 天时隐藏。
+  - 快照说明两态:`day` → 「数据快照日:YYYY-MM-DD」;`last_7d/30d` → 「数据截至 YYYY-MM-DD(近 7 天/近 30 天)」,点明滚动窗口 as-of 日不含今天。
+  - 请求形态:`day` 发 `{period, snapshot_dt}`;7/30 天发 `{period}`(api fn 与页面双重保证省略)。mock 支持 `day` 历史(按日期做确定性 ±8% 抖动,不同 `snapshot_dt` 取不同数)、`missing` 时 `snapshot_dt: null`、保留 `?missing=1`、`VITE_USE_MOCK` 默认开。
+  - 新增依赖 `react-day-picker@10` + `date-fns@4`,新增 `components/ui/{calendar,popover}.tsx`。保留既定 mint 主题、AppLayout、漏斗渐变、抗抖骨架不动。
+  - Vitest **33 passed**、`vite build` **clean**(本机实跑)。
+- **后端 MVP 漏斗升级为「拼团漏斗 + 两张真实表」(契约 v3)**(`apps/api`):
+  - 数据源由单表 `ads_trd_group_funnel` 拆为两表(`docs/03` §11):`ads_trd_group_funnel_daily`(单日、留全历史)+ `ads_trd_group_funnel_rolling`(近 7/30 天、覆盖式 1 行)。
+  - 请求契约:`period` 枚举由 `yesterday|last_7d|last_30d` 改为 `day|last_7d|last_30d`;新增可选 `snapshot_dt`(ISO `YYYY-MM-DD`,仅 `day` 有效,7/30 天忽略)。响应 `snapshot_dt` 回显实际取数行的 `dt`(yyyyMMdd)。
+  - 路由:`day` → daily 表(给 `snapshot_dt` 走 `WHERE dt=:dt`,否则最新 `dt`,列 `uv_start/show/detail/order/paid`);`last_7d`/`last_30d` → rolling 唯一行,取 `uv_*_7d` / `uv_*_30d`。
+  - 校验:`day` 的 `snapshot_dt` 上限昨日(T+1),今天/未来 → 422;非存在 `dt` → `missing`。第 3 步展示名由「详情」改为「拼团详情」。`data_status` 仍 `ready|missing`,不补零。
+  - `FakeFunnelRepository`(`USE_FAKE_DATA` 默认开)兜底:造约 10 天递减历史 daily 行(截至昨日,支持日期选择器回溯、不同 `snapshot_dt` 取不同数据)+ 1 行 as-of 昨日 rolling;无 DB 即可起。seed 脚本同步改插 10 daily + 1 rolling。
+  - 重写 Alembic 迁移(建两表,删旧单表迁移);改 `models.py`(两 ORM 模型)、`repository.py`/`funnel.py`/`schemas.py`/`api/funnels.py`;更新 `README.md`、`.env.example`。
+  - pytest **47 passed**(本机实跑);重出 `openapi.json`(Period=day/last_7d/last_30d、请求含 `snapshot_dt`)。
+- **前端 UI 栈从 Ant Design 5 全量迁移到 shadcn/ui + Tailwind CSS v4**(B 方案):
+  - `docs/02` §1 已改:`React + Vite + TS + Tailwind v4 + shadcn/ui(radix-nova/neutral)+ ECharts + TanStack Query`,Ant Design 退出技术栈。
+  - 装 Tailwind v4(`@tailwindcss/vite` 插件)、路径别名 `@/* → ./src/*`(tsconfig + vite.config)、`src/index.css` 用 v4 + neutral OKLCH 主题(light + dark)、`html/body` 默认 `class="dark"`。
+  - 跑 `shadcn@latest init`(CLI v4.11.0,style `radix-nova`,base color `neutral`,iconLibrary `lucide`)生成 `components.json` 和 `src/components/ui/`;`add` 了 button/card/tabs/table/badge/alert/skeleton/separator/scroll-area/sheet/sonner。
+  - 移除 `antd` / `@ant-design/icons` / `dayjs`(grep 0 命中)。重写 `AppLayout`(Tailwind 壳 + lucide-icon 侧栏)、`PeriodSelect`(shadcn Tabs)、`FunnelResult`(Card + Alert + Skeleton + lucide Inbox)、`ResultTable`(shadcn Table)、`Placeholder`(Card + Construction 图标)。保留 ECharts 漏斗图、新契约、5 域导航、mock 行为不变。
+  - Vitest **26/26 passed**、`vite build` **clean**(本机实跑)。dev server 起在 `:5173`。
+
+### 已知未完成
+- **shadcn 官方 Skill 未装到 `apps/web/.claude/skills/shadcn/`**。两层阻断:① 网络上 `git clone https://github.com/shadcn/ui.git`(`pnpm dlx skills add shadcn/ui` 调用的)被防火墙 RST(`raw.githubusercontent.com` 也超时,api.github.com / codeload 通);② harness 拒绝代理脚本写入 `.claude/skills/`(self-modification 防护)。需要在本地终端跑 `pnpm dlx skills add shadcn/ui`,或改走 shadcn MCP(`.mcp.json` 配置)。
+
+(前一条 v2 漏斗转向条目原文保留:)
+- **MVP 漏斗转向"固定漏斗 + 真实预聚合宽表"**(真实表 `ads_trd_group_funnel` 到位,替代原 bitmap 泛用漏斗方案):
+  - 契约 v2(`docs/02` §5/§6、`docs/03` §11):请求 `{period: yesterday|last_7d|last_30d}`;响应 `{period, snapshot_dt, results×5(event_key), data_status: ready|missing}`;固定 5 步 启动→曝光→详情→下单→成功。
+  - 后端 `apps/api`:改查 `ads_trd_group_funnel` 最新 `dt` 行选 `*_1d/7d/30d` 列;`USE_FAKE_DATA`(默认开)假数据兜底,无 DB 也能起;移除 pyroaring/bitmap 两表/OR 逻辑;Alembic 改建宽表;seed 改插快照行;pytest **25 passed**(本机实跑);重出 `openapi.json`。
+  - 前端 `apps/web`:去掉任意步骤配置 + 自定义范围 + 15天/今天校验;改 3 周期按钮(默认近7天)+ 固定 5 步;接新契约;mock 默认开;Vitest **26 passed**、构建通过(本机实跑)。
+  - `packages/api-types`:`pnpm gen:api-types` 跑通,由 `openapi.json` 生成 `schema.ts`(Period/DataStatus/event_key/snapshot_dt)。
+- 端到端联通(本机实跑):后端 `uvicorn`(假数据)`POST /api/funnels/query {period:last_7d}` 返回契约正确;前端 dev server 起于 `:5173`。
+
+### 移除
+- bitmap 相关:`app/services/bitmap.py`、`daily/period_event_bitmap` 两表与迁移、pyroaring 依赖、相关测试/seed(挪到后续"泛用漏斗"阶段)。
+- 前端:`StepConfig`、自定义 `TimeRangePicker`、15天/今天校验工具及其测试。
+
+### 环境
+- 安装 **Python 3.11.9**(用户作用域),`apps/api/.venv` 装齐依赖,后端测试本机可跑。否决"降级到 Python 2.7.5"(FastAPI/Pydantic v2 不支持)。Docker 仍未用(非管理员装不了),本地以 `USE_FAKE_DATA` 假数据替代。
+
+## 2026-06-23
+
+### 新增
+- **MVP 全栈脚手架落地**(按 `docs/01/02/03` 与计划阶段 0–4):
+  - 阶段 0 — Monorepo 脚手架:`git init`(main)、根 `package.json` + `pnpm-workspace.yaml`、`.gitignore`/`.nvmrc`/`.editorconfig`;根脚本 `dev:web`/`build:web`/`test:web`/`gen:api-types`。
+  - infra — `infra/docker-compose.yml` 起 PostgreSQL 16。
+  - 阶段 1+2 — 后端 `apps/api`(FastAPI + Pydantic v2 + SQLAlchemy async + pyroaring):`POST /api/funnels/query` 严格按 `docs/02` §5;Alembic 建 `daily_event_bitmap` / `period_event_bitmap` 两表;周期优先 / 自定义 daily OR 取数;第 1 层转化率 `null`、`data_status` 四态不补零;bitmap 计算走线程池不阻塞 event loop;seed 脚本(含缺失/损坏样本)、`export_openapi.py`、pytest 测试集(覆盖 `docs/02` §8)。
+  - 阶段 3 — 前端 `apps/web`(React 19 + Vite + TS + AntD 5 + ECharts + TanStack Query):平台壳 + 五域导航;漏斗页四区 + 校验(≤15 天、今天不可选/不可提交);其余四域共用 `Placeholder`;按 `data_status` 显式渲染、`null` 转化率显示 “—” 不补零;mock 模式(`VITE_USE_MOCK`);Vitest 37 项测试通过。
+  - 阶段 4 — `packages/api-types`:接好 `openapi-typescript` 生成链(`pnpm gen:api-types` 读 `apps/api/openapi.json`),含占位 `schema.ts`。
+- `CLAUDE.md`:项目协作守则(思考优先、简单优先、外科手术式改动、目标驱动、变更入 changelog)。
+- `CHANGELOG.md`:本文件。
+
+### 待办(环境受限,未在本机执行)
+- 后端运行/测试与端到端联调需 **Python 3.11+** 与 **Docker**(本机仅 Python 3.8、无 Docker):`apps/api` 代码与测试按 3.11 编写但未执行。
+- `gen:api-types` 待后端用 `python -m scripts.export_openapi` 导出 `apps/api/openapi.json` 后运行;在此之前前端用 `apps/web/src/api/types.ts`(契约一致)。

+ 11 - 0
CLAUDE.md

@@ -0,0 +1,11 @@
+# CLAUDE.md
+
+1. 先想清楚再动手——不臆测;不确定就问;存在多种解读时摆到台面上,不默默选;权衡讲清楚
+
+2. 简单优先——解决问题所需的最少代码;不做预判性设计;不加未被要求的灵活性;不为不可能发生的场景写错误处理
+
+3. 外科手术式改动——只碰必须碰的;不"顺手优化"周边;匹配现有风格;只清理本次改动制造的孤儿(import / 变量 / 函数),不静默删改动前就存在的死代码
+
+4. 以目标驱动执行——把任务转为可验证目标("修 bug" = "写复现测试 → 让它通过");多步任务先给简短计划(步骤 → 验证点)
+
+5. 改动入 changelog——每次落地的项目改动都追加到 `CHANGELOG.md`;新条目置顶;按日期分组,正文分 `新增 / 变更 / 修复 / 移除`;纯实验、被回滚、未提交的尝试不写

+ 60 - 0
README.md

@@ -0,0 +1,60 @@
+# hs-data 内部数据服务平台 (hs-data)
+
+数仓行为数据的可视化自助分析平台 · 五大能力域 · MVP 先交付泛用 UV 漏斗。
+
+> 完整需求见 [docs/01-产品需求.md](docs/01-产品需求-MVP.md);
+> 开发约定(技术栈、工程铁律、留痕规范)见 [docs/05-agent协作准则.md](docs/05-agent协作准则.md)(新项目根 `CLAUDE.md` 蓝本)。
+
+## Monorepo 结构
+
+```
+.
+├── apps/
+│   ├── web/               # 前端 (React + Vite · 平台框架 + 漏斗模块)
+│   └── api/               # 后端 (Python + FastAPI · 只读查询服务)
+│       └── modules/       # 按能力域分模块:funnel /(后续 retention / path / profile / realtime / touch)
+├── packages/
+│   └── api-types/         # 由后端 OpenAPI 生成的 TS 类型(前端共享,勿手改)
+├── docs/                  # 产品 / 技术 / 数据契约 / 设计 / 协作 / ADR
+├── infra/                 # 本地开发与部署配置
+└── CLAUDE.md              # 给 AI 与人的开发硬约束
+```
+
+## 技术栈
+
+| 层 | 选型 |
+|----|------|
+| 前端 | React 19 + Vite + TypeScript + Ant Design 5 + ECharts + TanStack Query |
+| 后端 | Python 3.11+ + FastAPI + Pydantic v2 |
+| 数据 | PostgreSQL 16 · roaring bitmap (`bytea` · pyroaring) |
+| 契约 | 后端 OpenAPI → 前端生成 TS 类型;DB 迁移走 Alembic |
+| 部署 | Docker |
+
+详见 [docs/02-技术架构.md](docs/02-技术架构.md)。
+
+## 快速开始
+
+前置:Node ≥ 20、pnpm 10、Python ≥ 3.11、PostgreSQL 16。
+
+```bash
+# TODO: 待 Monorepo 脚手架建立后填入
+pnpm install                 # 安装前端依赖
+pnpm dev:web                 # 启动前端
+pnpm gen:api-types           # 由后端 OpenAPI 生成 TS 类型
+# 后端
+cd apps/api && uvicorn app.main:app --reload
+alembic upgrade head         # DB 迁移
+pytest                       # 后端测试
+```
+
+## 平台能力(五大能力域)
+
+| 能力域 | MVP 状态 |
+|--------|----------|
+| 漏斗 | **可用(MVP)** |
+| 埋点完整(留存 / 路径) | 待开发 |
+| 用户画像(标签 / 人群包) | 待开发 |
+| 实时(实时大盘) | 待开发 |
+| 营销触达 | 待开发 |
+
+MVP 只开漏斗模块端到端可用,其余进导航、占位"待开发"。能力域定位与演进路线见 CLAUDE.md §1 与 docs/01。

+ 8 - 0
apps/api/.env.example

@@ -0,0 +1,8 @@
+# Funnel data source toggle.
+# true  -> serve realistic in-memory data (no DB needed). DEFAULT for now.
+# false -> read the real group-buy funnel tables from Postgres
+#          (ads_trd_group_funnel_daily + ads_trd_group_funnel_rolling).
+USE_FAKE_DATA=true
+
+# Async SQLAlchemy URL (only used when USE_FAKE_DATA=false).
+DATABASE_URL=postgresql+asyncpg://hsdata:hsdata@localhost:5432/hsdata

+ 156 - 0
apps/api/README.md

@@ -0,0 +1,156 @@
+# hs-data API
+
+FastAPI backend for the hs-data platform. MVP delivers one module: the **拼团
+(group-buy) funnel** (启动 → 曝光 → 拼团详情 → 下单 → 成功) backed by two
+pre-aggregated tables:
+
+- `ads_trd_group_funnel_daily` — single day, keeps full history (daily
+  incremental insert of a new `dt`). Backs `period=day`.
+- `ads_trd_group_funnel_rolling` — rolling 7d/30d, only one row (daily
+  overwrite, no history). Backs `period=last_7d` / `last_30d`.
+
+Data is T+1: today's data is not computed yet, so the max queryable day is
+always yesterday.
+
+## Tech stack
+
+Python 3.11+, FastAPI, Pydantic v2, SQLAlchemy 2.x (async) + asyncpg, Alembic,
+pytest.
+
+## Setup
+
+```bash
+cd apps/api
+python -m venv .venv
+. .venv/bin/activate          # Windows: .venv\Scripts\activate
+pip install -e ".[dev]"
+```
+
+## Configuration
+
+Two environment variables (see `.env.example`):
+
+```
+# true  -> serve realistic in-memory data (no DB needed). DEFAULT for now.
+# false -> read the real group-buy funnel tables from Postgres.
+USE_FAKE_DATA=true
+
+# Async SQLAlchemy URL (only used when USE_FAKE_DATA=false).
+DATABASE_URL=postgresql+asyncpg://hsdata:hsdata@localhost:5432/hsdata
+```
+
+### Fake-data fallback
+
+So the page can be seen before any Postgres exists, `USE_FAKE_DATA` defaults to
+`true`. In that mode the funnel API serves realistic data through the **same
+repository interface** as the real source, so route and service code is
+identical:
+
+- **Daily history** — ~10 descending daily rows ending yesterday, so the
+  single-day date picker has history and different `snapshot_dt` values return
+  different data. A `snapshot_dt` with no matching row → `missing`.
+- **Rolling** — one as-of-yesterday row for the 7d/30d windows.
+
+Set `USE_FAKE_DATA=false` to query the real tables.
+
+## Run the server
+
+No infrastructure (fake data, default):
+
+```bash
+uvicorn app.main:app --reload --port 8000
+```
+
+Against a real Postgres:
+
+```bash
+export USE_FAKE_DATA=false                  # Windows: $env:USE_FAKE_DATA="false"
+export DATABASE_URL=postgresql+asyncpg://hsdata:hsdata@localhost:5432/hsdata
+alembic upgrade head
+python -m scripts.seed                      # insert daily history + rolling row
+uvicorn app.main:app --reload --port 8000
+```
+
+OpenAPI docs at <http://localhost:8000/docs>. Health probe at `/health`.
+
+## Database migration
+
+```bash
+alembic upgrade head    # creates the daily + rolling tables
+```
+
+## Seed sample data
+
+Inserts ~10 historical daily rows (ending yesterday) into the daily table plus
+one rolling row as-of yesterday, all with clean descending group-buy funnels:
+
+```bash
+python -m scripts.seed
+```
+
+## Export OpenAPI schema (no DB needed)
+
+```bash
+python -m scripts.export_openapi   # writes apps/api/openapi.json
+```
+
+## API
+
+`POST /api/funnels/query`
+
+Request:
+
+```json
+{ "period": "day", "snapshot_dt": "2026-06-20" }
+```
+
+- `period` ∈ `day` | `last_7d` | `last_30d`. Any other value → HTTP 422.
+- `snapshot_dt` (optional ISO `YYYY-MM-DD`): **only meaningful for `day`**.
+  Omitted → latest daily row (yesterday); given → that historical day. Must be
+  ≤ yesterday (T+1); today/future → HTTP 422. Ignored for `last_7d` / `last_30d`.
+
+Response:
+
+```json
+{
+  "period": "day",
+  "snapshot_dt": "20260620",
+  "results": [
+    { "step_index": 1, "name": "启动",     "event_key": "start",  "uv": 10000, "conversion_rate": null, "dropoff_rate": null },
+    { "step_index": 2, "name": "曝光",     "event_key": "show",   "uv": 8200,  "conversion_rate": 0.82, "dropoff_rate": 0.18 },
+    { "step_index": 3, "name": "拼团详情", "event_key": "detail", "uv": 5100,  "conversion_rate": 0.62, "dropoff_rate": 0.38 },
+    { "step_index": 4, "name": "下单",     "event_key": "order",  "uv": 2200,  "conversion_rate": 0.43, "dropoff_rate": 0.57 },
+    { "step_index": 5, "name": "成功",     "event_key": "paid",   "uv": 1800,  "conversion_rate": 0.82, "dropoff_rate": 0.18 }
+  ],
+  "data_status": "ready"
+}
+```
+
+### Routing & rules
+
+- `period=day` → `ads_trd_group_funnel_daily`. Given `snapshot_dt` → `WHERE
+  dt=:dt`; otherwise latest `ORDER BY dt DESC LIMIT 1`. Columns
+  `uv_start/show/detail/order/paid`.
+- `period=last_7d` → `ads_trd_group_funnel_rolling` (single row), columns `uv_*_7d`.
+- `period=last_30d` → same rolling row, columns `uv_*_30d`.
+- No bitmaps, no OR, no cross-day aggregation.
+- `snapshot_dt` (response) is the `dt` (yyyyMMdd) of the row actually used: the
+  day for `day`, the rolling row's as-of dt for 7d/30d. `null` when missing.
+- `step_index` starts at 1. Step 1 has `null` conversion/dropoff rates.
+- `conversion_rate[i] = uv[i] / uv[i-1]`; `dropoff_rate[i] = 1 - conversion_rate[i]`.
+  If `uv[i-1] == 0`, both are `null` (no division by zero).
+- `data_status` ∈ {`ready`, `missing`}: `ready` when the target row exists and
+  the period's columns are non-null; `missing` when there is no row OR the period
+  columns are NULL. Missing data is never silently zero-filled.
+
+## Tests
+
+```bash
+pytest
+```
+
+Period validation, day vs rolling routing, `snapshot_dt` selection/validation
+(today/future → 422; non-existent dt → missing), conversion math, `data_status`,
+the fake data source (daily history + rolling), the SQLAlchemy repo against
+in-memory SQLite, and the exact API response shape are all tested without
+Postgres.

+ 44 - 0
apps/api/alembic.ini

@@ -0,0 +1,44 @@
+# Alembic configuration for hs-data API.
+# The database URL is injected at runtime from app settings (see alembic/env.py),
+# so sqlalchemy.url is intentionally left blank here.
+
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+version_path_separator = os
+
+sqlalchemy.url =
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 70 - 0
apps/api/alembic/env.py

@@ -0,0 +1,70 @@
+"""Alembic environment.
+
+Runs migrations against the async engine. The database URL comes from app
+settings (``DATABASE_URL`` env var with a local default). The asyncpg async URL
+is used directly via SQLAlchemy's async engine + ``connection.run_sync``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy.ext.asyncio import async_engine_from_config
+from sqlalchemy import pool
+
+from app.config import get_settings
+from app.db.models import Base
+
+config = context.config
+
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+
+# Inject the runtime database URL.
+config.set_main_option("sqlalchemy.url", get_settings().database_url)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+    """Run migrations in 'offline' mode (emit SQL, no DBAPI)."""
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url,
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+        compare_type=True,
+    )
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def do_run_migrations(connection) -> None:
+    context.configure(
+        connection=connection,
+        target_metadata=target_metadata,
+        compare_type=True,
+    )
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+async def run_migrations_online() -> None:
+    """Run migrations in 'online' mode using the async engine."""
+    connectable = async_engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+    async with connectable.connect() as connection:
+        await connection.run_sync(do_run_migrations)
+    await connectable.dispose()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    asyncio.run(run_migrations_online())

+ 28 - 0
apps/api/alembic/script.py.mako

@@ -0,0 +1,28 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+    ${downgrades if downgrades else "pass"}

+ 66 - 0
apps/api/alembic/versions/0001_create_group_funnel_tables.py

@@ -0,0 +1,66 @@
+"""create group-buy funnel tables (daily + rolling)
+
+Revision ID: 0001
+Revises:
+Create Date: 2026-06-24
+
+Two tables per docs/03 §11 (v3):
+* ads_trd_group_funnel_daily   - single day, full history.
+* ads_trd_group_funnel_rolling - rolling 7d/30d, single overwritten row.
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "0001"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "ads_trd_group_funnel_daily",
+        sa.Column("dt", sa.String(length=8), primary_key=True),
+        sa.Column("uv_start", sa.BigInteger(), nullable=True),
+        sa.Column("uv_show", sa.BigInteger(), nullable=True),
+        sa.Column("uv_detail", sa.BigInteger(), nullable=True),
+        sa.Column("uv_order", sa.BigInteger(), nullable=True),
+        sa.Column("uv_paid", sa.BigInteger(), nullable=True),
+        sa.Column(
+            "etl_time",
+            sa.DateTime(),
+            nullable=True,
+            server_default=sa.func.now(),
+        ),
+    )
+
+    op.create_table(
+        "ads_trd_group_funnel_rolling",
+        sa.Column("dt", sa.String(length=8), primary_key=True),
+        sa.Column("uv_start_7d", sa.BigInteger(), nullable=True),
+        sa.Column("uv_show_7d", sa.BigInteger(), nullable=True),
+        sa.Column("uv_detail_7d", sa.BigInteger(), nullable=True),
+        sa.Column("uv_order_7d", sa.BigInteger(), nullable=True),
+        sa.Column("uv_paid_7d", sa.BigInteger(), nullable=True),
+        sa.Column("uv_start_30d", sa.BigInteger(), nullable=True),
+        sa.Column("uv_show_30d", sa.BigInteger(), nullable=True),
+        sa.Column("uv_detail_30d", sa.BigInteger(), nullable=True),
+        sa.Column("uv_order_30d", sa.BigInteger(), nullable=True),
+        sa.Column("uv_paid_30d", sa.BigInteger(), nullable=True),
+        sa.Column(
+            "etl_time",
+            sa.DateTime(),
+            nullable=True,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("ads_trd_group_funnel_rolling")
+    op.drop_table("ads_trd_group_funnel_daily")

+ 1 - 0
apps/api/app/__init__.py

@@ -0,0 +1 @@
+"""hs-data API package."""

+ 1 - 0
apps/api/app/api/__init__.py

@@ -0,0 +1 @@
+"""API routers package."""

+ 52 - 0
apps/api/app/api/funnels.py

@@ -0,0 +1,52 @@
+"""拼团 (group-buy) funnel query route: POST /api/funnels/query."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from app.config import get_settings
+from app.db.session import get_sessionmaker
+from app.schemas import FunnelQueryRequest, FunnelQueryResponse
+from app.services.funnel import (
+    FunnelRepository,
+    SnapshotDateError,
+    run_funnel_query,
+)
+from app.services.repository import FakeFunnelRepository, SqlAlchemyFunnelRepository
+
+router = APIRouter(prefix="/api/funnels", tags=["funnels"])
+
+
+async def get_repository() -> AsyncIterator[FunnelRepository]:
+    """Provide the funnel repository.
+
+    Uses the in-memory fake source when ``USE_FAKE_DATA`` is set (default), so the
+    page works with no database. Otherwise opens an async session and reads the
+    real group-buy funnel tables. Overridden in tests with an in-memory fake.
+    """
+    settings = get_settings()
+    if settings.use_fake_data:
+        yield FakeFunnelRepository()
+        return
+    sessionmaker = get_sessionmaker()
+    async with sessionmaker() as session:
+        yield SqlAlchemyFunnelRepository(session)
+
+
+@router.post("/query", response_model=FunnelQueryResponse)
+async def query_funnel(
+    req: FunnelQueryRequest,
+    repo: FunnelRepository = Depends(get_repository),
+) -> FunnelQueryResponse:
+    """Compute UV and conversion metrics for the group-buy funnel over a period.
+
+    The ``period`` enum is validated by Pydantic (bad value -> 422). For
+    ``period=day``, a ``snapshot_dt`` later than yesterday (today/future) is
+    rejected with 422 (data is T+1).
+    """
+    try:
+        return await run_funnel_query(req.period, repo, req.snapshot_dt)
+    except SnapshotDateError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc

+ 34 - 0
apps/api/app/config.py

@@ -0,0 +1,34 @@
+"""Application settings.
+
+Configuration is read from the environment. For the MVP the two knobs are the
+database URL and the ``USE_FAKE_DATA`` flag.
+"""
+
+from __future__ import annotations
+
+from functools import lru_cache
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+    """Runtime settings, populated from environment variables.
+
+    ``DATABASE_URL`` and ``USE_FAKE_DATA`` (case-insensitive) override defaults.
+    """
+
+    model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+
+    # Async SQLAlchemy URL. asyncpg driver is required for the async engine.
+    database_url: str = "postgresql+asyncpg://hsdata:hsdata@localhost:5432/hsdata"
+
+    # When true, the funnel API serves a realistic in-memory snapshot instead of
+    # querying Postgres. Default ON so `uvicorn app.main:app` works with zero
+    # infrastructure. Set USE_FAKE_DATA=false to read the real wide table.
+    use_fake_data: bool = True
+
+
+@lru_cache
+def get_settings() -> Settings:
+    """Return a cached Settings instance."""
+    return Settings()

+ 1 - 0
apps/api/app/db/__init__.py

@@ -0,0 +1 @@
+"""Database package."""

+ 74 - 0
apps/api/app/db/models.py

@@ -0,0 +1,74 @@
+"""SQLAlchemy ORM models for the 拼团 (group-buy) funnel source tables.
+
+Source of truth: docs/03 §11 (v3). The MVP funnel data is split into two tables
+because their sync logic differs:
+
+* ``ads_trd_group_funnel_daily`` — single-day, KEEPS FULL HISTORY (daily
+  incremental insert of a new ``dt``). Backs ``period=day``.
+* ``ads_trd_group_funnel_rolling`` — rolling 7d/30d, ONLY ONE ROW (daily
+  overwrite, no history). Backs ``period=last_7d`` / ``last_30d``.
+
+Fixed funnel order (5 steps): 启动 start -> 曝光 show -> 拼团详情 detail ->
+下单 order -> 成功 paid.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, String, func
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+
+
+class Base(DeclarativeBase):
+    """Declarative base for all ORM models."""
+
+
+class AdsTrdGroupFunnelDaily(Base):
+    """Single-day group-buy funnel snapshot, full history (docs/03 §11.1).
+
+    Daily incremental insert of a new ``dt``; any historical single day can be
+    read back. Primary key: ``dt`` (varchar(8), yyyyMMdd).
+    """
+
+    __tablename__ = "ads_trd_group_funnel_daily"
+
+    dt: Mapped[str] = mapped_column(String(8), primary_key=True)
+
+    uv_start: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_show: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_detail: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_order: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_paid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+
+    etl_time: Mapped[datetime | None] = mapped_column(
+        DateTime, nullable=True, server_default=func.now()
+    )
+
+
+class AdsTrdGroupFunnelRolling(Base):
+    """Rolling 7d/30d group-buy funnel snapshot, single row (docs/03 §11.2).
+
+    Daily overwrite of the latest as-of row — no history. ``dt`` is the as-of
+    snapshot day. Only the current rolling 7d/30d windows are available.
+    """
+
+    __tablename__ = "ads_trd_group_funnel_rolling"
+
+    dt: Mapped[str] = mapped_column(String(8), primary_key=True)
+
+    uv_start_7d: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_show_7d: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_detail_7d: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_order_7d: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_paid_7d: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+
+    uv_start_30d: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_show_30d: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_detail_30d: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_order_30d: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    uv_paid_30d: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+
+    etl_time: Mapped[datetime | None] = mapped_column(
+        DateTime, nullable=True, server_default=func.now()
+    )

+ 31 - 0
apps/api/app/db/session.py

@@ -0,0 +1,31 @@
+"""Async SQLAlchemy engine and session factory.
+
+The engine is created lazily so that importing the FastAPI app (e.g. to export
+the OpenAPI schema) never requires a live database connection.
+"""
+
+from __future__ import annotations
+
+from functools import lru_cache
+
+from sqlalchemy.ext.asyncio import (
+    AsyncEngine,
+    AsyncSession,
+    async_sessionmaker,
+    create_async_engine,
+)
+
+from app.config import get_settings
+
+
+@lru_cache
+def get_engine() -> AsyncEngine:
+    """Return a lazily-created, cached async engine."""
+    settings = get_settings()
+    return create_async_engine(settings.database_url, pool_pre_ping=True)
+
+
+@lru_cache
+def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
+    """Return a cached async session factory bound to the engine."""
+    return async_sessionmaker(get_engine(), expire_on_commit=False)

+ 29 - 0
apps/api/app/main.py

@@ -0,0 +1,29 @@
+"""FastAPI application entry point.
+
+Importing this module must NOT require a live database connection (the engine is
+created lazily in :mod:`app.db.session`), so the OpenAPI schema can be exported
+offline.
+"""
+
+from __future__ import annotations
+
+from fastapi import FastAPI
+
+from app.api.funnels import router as funnels_router
+
+app = FastAPI(
+    title="hs-data API",
+    version="0.1.0",
+    description=(
+        "Group-buy (拼团) funnel service over ads_trd_group_funnel_daily "
+        "(single day, full history) + ads_trd_group_funnel_rolling (7d/30d)."
+    ),
+)
+
+app.include_router(funnels_router)
+
+
+@app.get("/health", tags=["meta"])
+async def health() -> dict[str, str]:
+    """Liveness probe. Does not touch the database."""
+    return {"status": "ok"}

+ 91 - 0
apps/api/app/schemas.py

@@ -0,0 +1,91 @@
+"""Pydantic v2 request/response models for the 拼团 (group-buy) funnel query API.
+
+Field names match the API contract in docs/02 §5 (v3) exactly:
+  request:  period, snapshot_dt (optional)
+  response: period, snapshot_dt,
+            results[].step_index, results[].name, results[].event_key,
+            results[].uv, results[].conversion_rate, results[].dropoff_rate,
+            data_status
+"""
+
+from __future__ import annotations
+
+from datetime import date
+from enum import Enum
+
+from pydantic import BaseModel, Field
+
+
+class Period(str, Enum):
+    """Supported periods (docs/02 §5 v3).
+
+    Routing:
+      day      -> table ads_trd_group_funnel_daily (single day, full history).
+      last_7d  -> table ads_trd_group_funnel_rolling, columns uv_*_7d.
+      last_30d -> table ads_trd_group_funnel_rolling, columns uv_*_30d.
+
+    Any other value is rejected with HTTP 422.
+    """
+
+    day = "day"
+    last_7d = "last_7d"
+    last_30d = "last_30d"
+
+
+class FunnelQueryRequest(BaseModel):
+    """Funnel query request body.
+
+    ``snapshot_dt`` (ISO ``YYYY-MM-DD``) is optional and only meaningful for
+    ``period=day``: omitted -> latest daily row; given -> that historical day
+    (must be <= yesterday). For ``last_7d`` / ``last_30d`` it is ignored.
+    """
+
+    period: Period
+    snapshot_dt: date | None = Field(
+        None,
+        description=(
+            "Optional ISO date (YYYY-MM-DD). Only meaningful for period=day; "
+            "ignored for last_7d/last_30d. Must be <= yesterday."
+        ),
+    )
+
+
+class DataStatus(str, Enum):
+    """Data completeness status for a query result (docs/02 §5 v3).
+
+      ready   - the target row exists and the period's columns are non-null.
+      missing - no target row, or the period columns are NULL. Never zero-filled.
+    """
+
+    ready = "ready"
+    missing = "missing"
+
+
+class FunnelStepResult(BaseModel):
+    """Computed UV and conversion metrics for one fixed step."""
+
+    step_index: int = Field(..., ge=1, description="1-based step index")
+    name: str = Field(..., description="Chinese display name of the step")
+    event_key: str = Field(..., description="Stable step key (start/show/...)")
+    uv: int = Field(..., ge=0)
+    conversion_rate: float | None = Field(
+        None, description="uv[i] / uv[i-1]; null for step 1 or when uv[i-1] == 0"
+    )
+    dropoff_rate: float | None = Field(
+        None, description="1 - conversion_rate; null when conversion_rate is null"
+    )
+
+
+class FunnelQueryResponse(BaseModel):
+    """Funnel query response body."""
+
+    period: Period
+    snapshot_dt: str | None = Field(
+        None,
+        description=(
+            "dt (yyyyMMdd) of the row actually used; for day = that day, for "
+            "7d/30d = the rolling row's as-of dt. null when missing."
+        ),
+    )
+    results: list[FunnelStepResult]
+    data_status: DataStatus

+ 1 - 0
apps/api/app/services/__init__.py

@@ -0,0 +1 @@
+"""Service layer package."""

+ 219 - 0
apps/api/app/services/funnel.py

@@ -0,0 +1,219 @@
+"""拼团 (group-buy) funnel query orchestration (docs/02 §5/§6 v3).
+
+Pipeline: validate the request -> route by ``period`` to the right table/row ->
+pick the five UV columns -> derive conversion/dropoff -> resolve data_status ->
+assemble response.
+
+Routing (docs/02 §6 v3):
+* ``day``      -> ``ads_trd_group_funnel_daily``; a given ``snapshot_dt`` selects
+                 that row, else the latest ``dt``. Columns: ``uv_{step}``.
+* ``last_7d``  -> ``ads_trd_group_funnel_rolling`` (single row). Columns: ``uv_{step}_7d``.
+* ``last_30d`` -> same rolling row. Columns: ``uv_{step}_30d``.
+
+No bitmaps, no OR, no cross-day aggregation. The repository layer is an abstract
+protocol so the service can be unit-tested without Postgres (and so a fake
+in-memory source can stand in when no DB is configured).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import date, timedelta
+from typing import Protocol
+
+from app.schemas import (
+    DataStatus,
+    FunnelQueryResponse,
+    FunnelStepResult,
+    Period,
+)
+
+# Fixed funnel order: 启动 -> 曝光 -> 拼团详情 -> 下单 -> 成功 (docs/03 §11).
+# Each entry: (event_key, Chinese display name).
+FUNNEL_STEPS: list[tuple[str, str]] = [
+    ("start", "启动"),
+    ("show", "曝光"),
+    ("detail", "拼团详情"),
+    ("order", "下单"),
+    ("paid", "成功"),
+]
+
+# period -> rolling-table column suffix (for last_7d / last_30d).
+ROLLING_SUFFIX: dict[Period, str] = {
+    Period.last_7d: "7d",
+    Period.last_30d: "30d",
+}
+
+
+def daily_columns() -> list[str]:
+    """Return the five ``uv_{step}`` daily-table column names, in funnel order."""
+    return [f"uv_{event_key}" for event_key, _ in FUNNEL_STEPS]
+
+
+def rolling_columns(period: Period) -> list[str]:
+    """Return the five ``uv_{step}_{suffix}`` rolling-table columns for a period."""
+    suffix = ROLLING_SUFFIX[period]
+    return [f"uv_{event_key}_{suffix}" for event_key, _ in FUNNEL_STEPS]
+
+
+# --------------------------------------------------------------------------- #
+# Validation                                                                  #
+# --------------------------------------------------------------------------- #
+
+
+class SnapshotDateError(ValueError):
+    """Raised when ``snapshot_dt`` is today or in the future (data is T+1)."""
+
+
+def validate_snapshot_dt(snapshot_dt: date) -> None:
+    """Reject a ``snapshot_dt`` later than yesterday.
+
+    Data is T+1: today's data is not computed yet, so the max queryable day is
+    always yesterday. today or future -> :class:`SnapshotDateError`.
+    """
+    yesterday = date.today() - timedelta(days=1)
+    if snapshot_dt > yesterday:
+        raise SnapshotDateError(
+            f"snapshot_dt {snapshot_dt.isoformat()} is later than yesterday "
+            f"({yesterday.isoformat()}); data is T+1 and today's data is not "
+            "computed yet."
+        )
+
+
+# --------------------------------------------------------------------------- #
+# Repository protocol + snapshot container                                    #
+# --------------------------------------------------------------------------- #
+
+
+@dataclass(frozen=True)
+class FunnelSnapshot:
+    """A funnel source row as returned by the repository.
+
+    ``values`` maps column name to its value (NULL columns map to ``None``).
+    ``dt`` is the row's ``dt`` (yyyyMMdd): the day for daily, the as-of day for
+    rolling.
+    """
+
+    dt: str
+    values: dict[str, int | None]
+
+
+class FunnelRepository(Protocol):
+    """Data access boundary. Implementations may hit Postgres or be in-memory."""
+
+    async def fetch_daily(self, dt: str | None) -> FunnelSnapshot | None:
+        """Return a row from the daily table.
+
+        ``dt`` (yyyyMMdd) selects that specific day; ``None`` returns the latest
+        ``dt`` row. ``None`` when no matching row exists.
+        """
+        ...
+
+    async def fetch_rolling(self) -> FunnelSnapshot | None:
+        """Return the single rolling row, or ``None`` when the table is empty."""
+        ...
+
+
+# --------------------------------------------------------------------------- #
+# Conversion math                                                             #
+# --------------------------------------------------------------------------- #
+
+
+def build_results(uvs: list[int]) -> list[FunnelStepResult]:
+    """Assemble per-step results with conversion/dropoff math.
+
+    Step 1: conversion_rate and dropoff_rate are null.
+    Step i: conversion = uv[i] / uv[i-1]; dropoff = 1 - conversion.
+            If uv[i-1] == 0, conversion (and dropoff) are null.
+    """
+    results: list[FunnelStepResult] = []
+    for idx, (uv, (event_key, name)) in enumerate(zip(uvs, FUNNEL_STEPS)):
+        if idx == 0:
+            conversion: float | None = None
+            dropoff: float | None = None
+        else:
+            prev = uvs[idx - 1]
+            if prev == 0:
+                conversion = None
+                dropoff = None
+            else:
+                conversion = uv / prev
+                dropoff = 1 - conversion
+        results.append(
+            FunnelStepResult(
+                step_index=idx + 1,
+                name=name,
+                event_key=event_key,
+                uv=uv,
+                conversion_rate=conversion,
+                dropoff_rate=dropoff,
+            )
+        )
+    return results
+
+
+# --------------------------------------------------------------------------- #
+# Orchestration                                                               #
+# --------------------------------------------------------------------------- #
+
+
+def _assemble(
+    period: Period,
+    snapshot: FunnelSnapshot | None,
+    column_names: list[str],
+) -> FunnelQueryResponse:
+    """Map a fetched snapshot + period columns to the response model.
+
+    Missing row or any NULL period column -> ``data_status = missing`` with no
+    zero-fill. ``snapshot_dt`` echoes the row used (None when no row).
+    """
+    if snapshot is None:
+        return FunnelQueryResponse(
+            period=period,
+            snapshot_dt=None,
+            results=[],
+            data_status=DataStatus.missing,
+        )
+
+    raw = [snapshot.values.get(col) for col in column_names]
+    if any(value is None for value in raw):
+        return FunnelQueryResponse(
+            period=period,
+            snapshot_dt=snapshot.dt,
+            results=[],
+            data_status=DataStatus.missing,
+        )
+
+    uvs = [int(value) for value in raw]  # narrow Optional -> int
+    return FunnelQueryResponse(
+        period=period,
+        snapshot_dt=snapshot.dt,
+        results=build_results(uvs),
+        data_status=DataStatus.ready,
+    )
+
+
+async def run_funnel_query(
+    period: Period,
+    repo: FunnelRepository,
+    snapshot_dt: date | None = None,
+) -> FunnelQueryResponse:
+    """Run the group-buy funnel query pipeline and return the response model.
+
+    Routes by ``period``:
+    * ``day`` -> daily table; validates ``snapshot_dt`` (<= yesterday) when given,
+      then selects that day or the latest row.
+    * ``last_7d`` / ``last_30d`` -> the single rolling row; ``snapshot_dt`` is
+      ignored.
+    """
+    if period is Period.day:
+        dt_str: str | None = None
+        if snapshot_dt is not None:
+            validate_snapshot_dt(snapshot_dt)  # may raise SnapshotDateError
+            dt_str = snapshot_dt.strftime("%Y%m%d")
+        snapshot = await repo.fetch_daily(dt_str)
+        return _assemble(period, snapshot, daily_columns())
+
+    # last_7d / last_30d -> rolling row; snapshot_dt ignored.
+    snapshot = await repo.fetch_rolling()
+    return _assemble(period, snapshot, rolling_columns(period))

+ 151 - 0
apps/api/app/services/repository.py

@@ -0,0 +1,151 @@
+"""Funnel repository implementations.
+
+Two concrete sources behind the same :class:`FunnelRepository` protocol so route
+and service code is identical regardless of backing store:
+
+* :class:`SqlAlchemyFunnelRepository` - reads the two group-buy funnel tables
+  (``ads_trd_group_funnel_daily`` + ``ads_trd_group_funnel_rolling``) from
+  Postgres via an :class:`AsyncSession`.
+* :class:`FakeFunnelRepository` - realistic in-memory data (daily history + one
+  rolling row) so the page works with zero infrastructure (``USE_FAKE_DATA=true``).
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime, timedelta
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import AdsTrdGroupFunnelDaily, AdsTrdGroupFunnelRolling
+from app.services.funnel import FunnelSnapshot
+
+# Daily table UV columns (funnel order).
+_DAILY_COLUMNS: tuple[str, ...] = (
+    "uv_start",
+    "uv_show",
+    "uv_detail",
+    "uv_order",
+    "uv_paid",
+)
+
+# Rolling table UV columns (7d window then 30d window).
+_ROLLING_COLUMNS: tuple[str, ...] = (
+    "uv_start_7d",
+    "uv_show_7d",
+    "uv_detail_7d",
+    "uv_order_7d",
+    "uv_paid_7d",
+    "uv_start_30d",
+    "uv_show_30d",
+    "uv_detail_30d",
+    "uv_order_30d",
+    "uv_paid_30d",
+)
+
+
+class SqlAlchemyFunnelRepository:
+    """Concrete repository backed by an :class:`AsyncSession` over Postgres."""
+
+    def __init__(self, session: AsyncSession) -> None:
+        self._session = session
+
+    async def fetch_daily(self, dt: str | None) -> FunnelSnapshot | None:
+        stmt = select(AdsTrdGroupFunnelDaily)
+        if dt is not None:
+            stmt = stmt.where(AdsTrdGroupFunnelDaily.dt == dt)
+        else:
+            # Latest day. dt is yyyyMMdd so lexical == chronological order.
+            stmt = stmt.order_by(AdsTrdGroupFunnelDaily.dt.desc()).limit(1)
+        row = (await self._session.execute(stmt)).scalars().first()
+        if row is None:
+            return None
+        values = {col: getattr(row, col) for col in _DAILY_COLUMNS}
+        return FunnelSnapshot(dt=row.dt, values=values)
+
+    async def fetch_rolling(self) -> FunnelSnapshot | None:
+        # Single-row table; order by dt desc + limit 1 is defensive.
+        stmt = (
+            select(AdsTrdGroupFunnelRolling)
+            .order_by(AdsTrdGroupFunnelRolling.dt.desc())
+            .limit(1)
+        )
+        row = (await self._session.execute(stmt)).scalars().first()
+        if row is None:
+            return None
+        values = {col: getattr(row, col) for col in _ROLLING_COLUMNS}
+        return FunnelSnapshot(dt=row.dt, values=values)
+
+
+# Cumulative keep-rate down the group-buy funnel: start, show, detail, order, paid.
+_FUNNEL_KEEP = [1.0, 0.82, 0.51, 0.22, 0.18]
+_STEP_KEYS = ["start", "show", "detail", "order", "paid"]
+
+
+def _funnel(top: int) -> list[int]:
+    """Descending UVs for the five steps from the top-step UV."""
+    return [max(0, int(top * keep)) for keep in _FUNNEL_KEEP]
+
+
+class FakeFunnelRepository:
+    """In-memory repository with realistic group-buy funnel data, no DB needed.
+
+    Used when no database is configured (``USE_FAKE_DATA=true``). Provides:
+
+    * a span of ``HISTORY_DAYS`` daily rows ending yesterday, so the single-day
+      date picker has history and different ``snapshot_dt`` values yield
+      different data;
+    * one rolling row as-of yesterday for the 7d/30d windows.
+
+    A ``snapshot_dt`` with no matching daily row -> ``fetch_daily`` returns None,
+    so the service reports ``missing``.
+    """
+
+    # Number of historical daily rows (ending yesterday).
+    HISTORY_DAYS = 10
+
+    # Top-step (start) daily UV for the most recent day; older days scale down.
+    DAILY_TOP = 12000
+
+    # Top-step UV for the rolling windows.
+    ROLLING_TOP_7D = 74000
+    ROLLING_TOP_30D = 295000
+
+    def __init__(self) -> None:
+        today = date.today()
+        yesterday = today - timedelta(days=1)
+        now = datetime.utcnow()
+
+        # Build daily history: yyyyMMdd -> value map. Newest day = full size,
+        # older days a touch smaller so rows differ.
+        self._daily: dict[str, dict[str, int | None]] = {}
+        for offset in range(self.HISTORY_DAYS):
+            day = yesterday - timedelta(days=offset)
+            dt = day.strftime("%Y%m%d")
+            scale = 1.0 - 0.04 * offset
+            top = int(self.DAILY_TOP * scale)
+            self._daily[dt] = {
+                col: uv for col, uv in zip(_DAILY_COLUMNS, _funnel(top))
+            }
+        self._latest_daily_dt = yesterday.strftime("%Y%m%d")
+
+        # Rolling row, as-of yesterday.
+        self._rolling_dt = yesterday.strftime("%Y%m%d")
+        self._rolling: dict[str, int | None] = {}
+        for col, uv in zip(_ROLLING_COLUMNS[:5], _funnel(self.ROLLING_TOP_7D)):
+            self._rolling[col] = uv
+        for col, uv in zip(_ROLLING_COLUMNS[5:], _funnel(self.ROLLING_TOP_30D)):
+            self._rolling[col] = uv
+
+        self._etl_time = now
+
+    async def fetch_daily(self, dt: str | None) -> FunnelSnapshot | None:
+        if dt is None:
+            dt = self._latest_daily_dt
+        values = self._daily.get(dt)
+        if values is None:
+            return None
+        return FunnelSnapshot(dt=dt, values=dict(values))
+
+    async def fetch_rolling(self) -> FunnelSnapshot | None:
+        return FunnelSnapshot(dt=self._rolling_dt, values=dict(self._rolling))

+ 275 - 0
apps/api/openapi.json

@@ -0,0 +1,275 @@
+{
+  "openapi": "3.1.0",
+  "info": {
+    "title": "hs-data API",
+    "description": "Group-buy (拼团) funnel service over ads_trd_group_funnel_daily (single day, full history) + ads_trd_group_funnel_rolling (7d/30d).",
+    "version": "0.1.0"
+  },
+  "paths": {
+    "/api/funnels/query": {
+      "post": {
+        "tags": [
+          "funnels"
+        ],
+        "summary": "Query Funnel",
+        "description": "Compute UV and conversion metrics for the group-buy funnel over a period.\n\nThe ``period`` enum is validated by Pydantic (bad value -> 422). For\n``period=day``, a ``snapshot_dt`` later than yesterday (today/future) is\nrejected with 422 (data is T+1).",
+        "operationId": "query_funnel_api_funnels_query_post",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/FunnelQueryRequest"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "Successful Response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/FunnelQueryResponse"
+                }
+              }
+            }
+          },
+          "422": {
+            "description": "Validation Error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/HTTPValidationError"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/health": {
+      "get": {
+        "tags": [
+          "meta"
+        ],
+        "summary": "Health",
+        "description": "Liveness probe. Does not touch the database.",
+        "operationId": "health_health_get",
+        "responses": {
+          "200": {
+            "description": "Successful Response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "additionalProperties": {
+                    "type": "string"
+                  },
+                  "type": "object",
+                  "title": "Response Health Health Get"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "DataStatus": {
+        "type": "string",
+        "enum": [
+          "ready",
+          "missing"
+        ],
+        "title": "DataStatus",
+        "description": "Data completeness status for a query result (docs/02 §5 v3).\n\nready   - the target row exists and the period's columns are non-null.\nmissing - no target row, or the period columns are NULL. Never zero-filled."
+      },
+      "FunnelQueryRequest": {
+        "properties": {
+          "period": {
+            "$ref": "#/components/schemas/Period"
+          },
+          "snapshot_dt": {
+            "anyOf": [
+              {
+                "type": "string",
+                "format": "date"
+              },
+              {
+                "type": "null"
+              }
+            ],
+            "title": "Snapshot Dt",
+            "description": "Optional ISO date (YYYY-MM-DD). Only meaningful for period=day; ignored for last_7d/last_30d. Must be <= yesterday."
+          }
+        },
+        "type": "object",
+        "required": [
+          "period"
+        ],
+        "title": "FunnelQueryRequest",
+        "description": "Funnel query request body.\n\n``snapshot_dt`` (ISO ``YYYY-MM-DD``) is optional and only meaningful for\n``period=day``: omitted -> latest daily row; given -> that historical day\n(must be <= yesterday). For ``last_7d`` / ``last_30d`` it is ignored."
+      },
+      "FunnelQueryResponse": {
+        "properties": {
+          "period": {
+            "$ref": "#/components/schemas/Period"
+          },
+          "snapshot_dt": {
+            "anyOf": [
+              {
+                "type": "string"
+              },
+              {
+                "type": "null"
+              }
+            ],
+            "title": "Snapshot Dt",
+            "description": "dt (yyyyMMdd) of the row actually used; for day = that day, for 7d/30d = the rolling row's as-of dt. null when missing."
+          },
+          "results": {
+            "items": {
+              "$ref": "#/components/schemas/FunnelStepResult"
+            },
+            "type": "array",
+            "title": "Results"
+          },
+          "data_status": {
+            "$ref": "#/components/schemas/DataStatus"
+          }
+        },
+        "type": "object",
+        "required": [
+          "period",
+          "results",
+          "data_status"
+        ],
+        "title": "FunnelQueryResponse",
+        "description": "Funnel query response body."
+      },
+      "FunnelStepResult": {
+        "properties": {
+          "step_index": {
+            "type": "integer",
+            "minimum": 1.0,
+            "title": "Step Index",
+            "description": "1-based step index"
+          },
+          "name": {
+            "type": "string",
+            "title": "Name",
+            "description": "Chinese display name of the step"
+          },
+          "event_key": {
+            "type": "string",
+            "title": "Event Key",
+            "description": "Stable step key (start/show/...)"
+          },
+          "uv": {
+            "type": "integer",
+            "minimum": 0.0,
+            "title": "Uv"
+          },
+          "conversion_rate": {
+            "anyOf": [
+              {
+                "type": "number"
+              },
+              {
+                "type": "null"
+              }
+            ],
+            "title": "Conversion Rate",
+            "description": "uv[i] / uv[i-1]; null for step 1 or when uv[i-1] == 0"
+          },
+          "dropoff_rate": {
+            "anyOf": [
+              {
+                "type": "number"
+              },
+              {
+                "type": "null"
+              }
+            ],
+            "title": "Dropoff Rate",
+            "description": "1 - conversion_rate; null when conversion_rate is null"
+          }
+        },
+        "type": "object",
+        "required": [
+          "step_index",
+          "name",
+          "event_key",
+          "uv"
+        ],
+        "title": "FunnelStepResult",
+        "description": "Computed UV and conversion metrics for one fixed step."
+      },
+      "HTTPValidationError": {
+        "properties": {
+          "detail": {
+            "items": {
+              "$ref": "#/components/schemas/ValidationError"
+            },
+            "type": "array",
+            "title": "Detail"
+          }
+        },
+        "type": "object",
+        "title": "HTTPValidationError"
+      },
+      "Period": {
+        "type": "string",
+        "enum": [
+          "day",
+          "last_7d",
+          "last_30d"
+        ],
+        "title": "Period",
+        "description": "Supported periods (docs/02 §5 v3).\n\nRouting:\n  day      -> table ads_trd_group_funnel_daily (single day, full history).\n  last_7d  -> table ads_trd_group_funnel_rolling, columns uv_*_7d.\n  last_30d -> table ads_trd_group_funnel_rolling, columns uv_*_30d.\n\nAny other value is rejected with HTTP 422."
+      },
+      "ValidationError": {
+        "properties": {
+          "loc": {
+            "items": {
+              "anyOf": [
+                {
+                  "type": "string"
+                },
+                {
+                  "type": "integer"
+                }
+              ]
+            },
+            "type": "array",
+            "title": "Location"
+          },
+          "msg": {
+            "type": "string",
+            "title": "Message"
+          },
+          "type": {
+            "type": "string",
+            "title": "Error Type"
+          },
+          "input": {
+            "title": "Input"
+          },
+          "ctx": {
+            "type": "object",
+            "title": "Context"
+          }
+        },
+        "type": "object",
+        "required": [
+          "loc",
+          "msg",
+          "type"
+        ],
+        "title": "ValidationError"
+      }
+    }
+  }
+}

+ 39 - 0
apps/api/pyproject.toml

@@ -0,0 +1,39 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "hs-data-api"
+version = "0.1.0"
+description = "hs-data FastAPI backend: fixed-funnel service over the ads_trd_group_funnel wide table in PostgreSQL"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+    "fastapi>=0.111",
+    "uvicorn[standard]>=0.30",
+    "pydantic>=2.7",
+    "pydantic-settings>=2.3",
+    "sqlalchemy[asyncio]>=2.0.30",
+    "asyncpg>=0.29",
+    "alembic>=1.13",
+]
+
+[project.optional-dependencies]
+dev = [
+    "pytest>=8.2",
+    "pytest-asyncio>=0.23",
+    "httpx>=0.27",
+    "anyio>=4.4",
+    "aiosqlite>=0.20",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["app"]
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
+pythonpath = ["."]
+
+[tool.alembic]
+script_location = "alembic"

+ 1 - 0
apps/api/scripts/__init__.py

@@ -0,0 +1 @@
+"""Operational scripts (seed, openapi export)."""

+ 31 - 0
apps/api/scripts/export_openapi.py

@@ -0,0 +1,31 @@
+"""Dump the FastAPI OpenAPI schema to apps/api/openapi.json.
+
+This must NOT require a database connection: importing ``app.main`` only builds
+the app and routes; the DB engine is created lazily. Run from apps/api:
+
+    python -m scripts.export_openapi
+    # or
+    python scripts/export_openapi.py
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+# Allow running as a bare script (python scripts/export_openapi.py).
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from app.main import app  # noqa: E402
+
+
+def main() -> None:
+    schema = app.openapi()
+    out_path = Path(__file__).resolve().parent.parent / "openapi.json"
+    out_path.write_text(json.dumps(schema, indent=2, ensure_ascii=False), encoding="utf-8")
+    print(f"wrote {out_path}")
+
+
+if __name__ == "__main__":
+    main()

+ 110 - 0
apps/api/scripts/seed.py

@@ -0,0 +1,110 @@
+"""Seed realistic rows into the group-buy funnel tables for local dev.
+
+Inserts:
+* ~10 historical daily rows (distinct ``dt``) into ``ads_trd_group_funnel_daily``,
+  ending yesterday, so the single-day date picker has history;
+* one rolling row as-of yesterday into ``ads_trd_group_funnel_rolling`` for the
+  7d/30d windows.
+
+Each row carries a clean descending group-buy funnel
+(start > show > detail > order > paid).
+
+Run from apps/api (needs a live database; set USE_FAKE_DATA=false to read it):
+
+    python -m scripts.seed
+    # or
+    python scripts/seed.py
+"""
+
+from __future__ import annotations
+
+import asyncio
+import sys
+from datetime import date, datetime, timedelta
+from pathlib import Path
+
+# Allow running as a bare script.
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from sqlalchemy import delete  # noqa: E402
+
+from app.db.models import (  # noqa: E402
+    AdsTrdGroupFunnelDaily,
+    AdsTrdGroupFunnelRolling,
+)
+from app.db.session import get_engine, get_sessionmaker  # noqa: E402
+
+# Number of historical daily rows to insert (ending yesterday).
+HISTORY_DAYS = 10
+
+# Top-step (start) UV for the most recent daily row and the rolling windows.
+DAILY_TOP = 12000
+ROLLING_TOP_7D = 74000
+ROLLING_TOP_30D = 295000
+
+# Cumulative keep-rate down the fixed funnel: start, show, detail, order, paid.
+FUNNEL_KEEP = [1.0, 0.82, 0.51, 0.22, 0.18]
+STEP_KEYS = ["start", "show", "detail", "order", "paid"]
+
+
+def _funnel_for(top: int) -> list[int]:
+    """Return descending UVs for the five steps given the top-step UV."""
+    return [max(0, int(top * keep)) for keep in FUNNEL_KEEP]
+
+
+async def seed() -> None:
+    today = date.today()
+    yesterday = today - timedelta(days=1)
+    now = datetime.utcnow()
+
+    sessionmaker = get_sessionmaker()
+    async with sessionmaker() as session:
+        # Idempotent: clear previous seed data in both tables.
+        await session.execute(delete(AdsTrdGroupFunnelDaily))
+        await session.execute(delete(AdsTrdGroupFunnelRolling))
+
+        # Daily history rows, ending yesterday; older days a touch smaller.
+        for day_offset in range(HISTORY_DAYS):
+            snapshot_day = yesterday - timedelta(days=day_offset)
+            dt = snapshot_day.strftime("%Y%m%d")
+            scale = 1.0 - 0.04 * day_offset
+            uvs = _funnel_for(int(DAILY_TOP * scale))
+            session.add(
+                AdsTrdGroupFunnelDaily(
+                    dt=dt,
+                    etl_time=now,
+                    **{f"uv_{key}": uv for key, uv in zip(STEP_KEYS, uvs)},
+                )
+            )
+
+        # Single rolling row, as-of yesterday.
+        uvs_7d = _funnel_for(ROLLING_TOP_7D)
+        uvs_30d = _funnel_for(ROLLING_TOP_30D)
+        rolling_values: dict[str, int] = {}
+        for key, uv in zip(STEP_KEYS, uvs_7d):
+            rolling_values[f"uv_{key}_7d"] = uv
+        for key, uv in zip(STEP_KEYS, uvs_30d):
+            rolling_values[f"uv_{key}_30d"] = uv
+        session.add(
+            AdsTrdGroupFunnelRolling(
+                dt=yesterday.strftime("%Y%m%d"),
+                etl_time=now,
+                **rolling_values,
+            )
+        )
+
+        await session.commit()
+
+    await get_engine().dispose()
+    print(
+        f"seed complete: inserted {HISTORY_DAYS} daily rows + 1 rolling row "
+        f"(as-of {yesterday.strftime('%Y%m%d')})"
+    )
+
+
+def main() -> None:
+    asyncio.run(seed())
+
+
+if __name__ == "__main__":
+    main()

+ 0 - 0
apps/api/tests/__init__.py


+ 61 - 0
apps/api/tests/conftest.py

@@ -0,0 +1,61 @@
+"""Shared test fixtures.
+
+Provides a configurable in-memory repository (daily history + a single rolling
+row) so the funnel service and API can be tested without Postgres.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from app.services.funnel import FunnelSnapshot
+
+
+class FakeRepository:
+    """In-memory repository implementing the FunnelRepository protocol.
+
+    * ``set_daily(dt, values)`` installs a daily row keyed by ``dt`` (yyyyMMdd).
+    * ``set_latest_daily(dt)`` marks which daily ``dt`` is returned when
+      ``fetch_daily(None)`` is called (defaults to the most recently added dt).
+    * ``set_rolling(dt, values)`` installs the single rolling row.
+
+    With nothing installed both fetches return ``None``.
+    """
+
+    def __init__(self) -> None:
+        self._daily: dict[str, dict[str, int | None]] = {}
+        self._latest_daily_dt: str | None = None
+        self._rolling: FunnelSnapshot | None = None
+
+    def set_daily(self, dt: str, values: dict[str, int | None]) -> None:
+        self._daily[dt] = dict(values)
+        self._latest_daily_dt = dt
+
+    def set_latest_daily(self, dt: str) -> None:
+        self._latest_daily_dt = dt
+
+    def set_rolling(self, dt: str, values: dict[str, int | None]) -> None:
+        self._rolling = FunnelSnapshot(dt=dt, values=dict(values))
+
+    def clear(self) -> None:
+        self._daily.clear()
+        self._latest_daily_dt = None
+        self._rolling = None
+
+    async def fetch_daily(self, dt: str | None) -> FunnelSnapshot | None:
+        if dt is None:
+            dt = self._latest_daily_dt
+        if dt is None:
+            return None
+        values = self._daily.get(dt)
+        if values is None:
+            return None
+        return FunnelSnapshot(dt=dt, values=dict(values))
+
+    async def fetch_rolling(self) -> FunnelSnapshot | None:
+        return self._rolling
+
+
+@pytest.fixture
+def repo() -> FakeRepository:
+    return FakeRepository()

+ 192 - 0
apps/api/tests/test_api.py

@@ -0,0 +1,192 @@
+"""API-level tests for POST /api/funnels/query.
+
+The repository dependency is overridden with an in-memory fake, so these run
+without Postgres.
+"""
+
+from __future__ import annotations
+
+from datetime import date, timedelta
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+from app.api.funnels import get_repository
+from app.main import app
+from tests.conftest import FakeRepository
+
+
+def _daily(uvs: list[int | None]) -> dict[str, int | None]:
+    keys = ["start", "show", "detail", "order", "paid"]
+    return {f"uv_{k}": uv for k, uv in zip(keys, uvs)}
+
+
+def _rolling_7d(uvs: list[int | None]) -> dict[str, int | None]:
+    keys = ["start", "show", "detail", "order", "paid"]
+    values: dict[str, int | None] = {f"uv_{k}_7d": uv for k, uv in zip(keys, uvs)}
+    for k in keys:
+        values[f"uv_{k}_30d"] = None
+    return values
+
+
+@pytest.fixture
+def client_and_repo():
+    repo = FakeRepository()
+    app.dependency_overrides[get_repository] = lambda: repo
+    transport = ASGITransport(app=app)
+    client = AsyncClient(transport=transport, base_url="http://test")
+    yield client, repo
+    app.dependency_overrides.clear()
+
+
+async def test_health(client_and_repo) -> None:
+    client, _ = client_and_repo
+    async with client:
+        resp = await client.get("/health")
+    assert resp.status_code == 200
+    assert resp.json() == {"status": "ok"}
+
+
+async def test_query_ready_exact_shape(client_and_repo) -> None:
+    client, repo = client_and_repo
+    repo.set_rolling("20260623", _rolling_7d([10000, 8200, 5100, 2200, 1800]))
+    async with client:
+        resp = await client.post("/api/funnels/query", json={"period": "last_7d"})
+    assert resp.status_code == 200
+    data = resp.json()
+    assert data["period"] == "last_7d"
+    assert data["snapshot_dt"] == "20260623"
+    assert data["data_status"] == "ready"
+    assert data["results"][0] == {
+        "step_index": 1,
+        "name": "启动",
+        "event_key": "start",
+        "uv": 10000,
+        "conversion_rate": None,
+        "dropoff_rate": None,
+    }
+    # Step 3: event_key=detail, name=拼团详情.
+    assert data["results"][2]["event_key"] == "detail"
+    assert data["results"][2]["name"] == "拼团详情"
+    assert data["results"][1]["conversion_rate"] == pytest.approx(0.82)
+    assert data["results"][1]["dropoff_rate"] == pytest.approx(0.18)
+    assert [r["event_key"] for r in data["results"]] == [
+        "start",
+        "show",
+        "detail",
+        "order",
+        "paid",
+    ]
+    assert [r["name"] for r in data["results"]] == [
+        "启动",
+        "曝光",
+        "拼团详情",
+        "下单",
+        "成功",
+    ]
+
+
+async def test_query_day_latest(client_and_repo) -> None:
+    client, repo = client_and_repo
+    repo.set_daily("20260623", _daily([100, 80, 50, 20, 10]))
+    async with client:
+        resp = await client.post("/api/funnels/query", json={"period": "day"})
+    assert resp.status_code == 200
+    data = resp.json()
+    assert data["snapshot_dt"] == "20260623"
+    assert [r["uv"] for r in data["results"]] == [100, 80, 50, 20, 10]
+
+
+async def test_query_day_specific_dt(client_and_repo) -> None:
+    client, repo = client_and_repo
+    yesterday = date.today() - timedelta(days=1)
+    older = yesterday - timedelta(days=2)
+    repo.set_daily(yesterday.strftime("%Y%m%d"), _daily([100, 80, 50, 20, 10]))
+    repo.set_daily(older.strftime("%Y%m%d"), _daily([90, 70, 40, 15, 8]))
+    async with client:
+        resp = await client.post(
+            "/api/funnels/query",
+            json={"period": "day", "snapshot_dt": older.isoformat()},
+        )
+    assert resp.status_code == 200
+    data = resp.json()
+    assert data["snapshot_dt"] == older.strftime("%Y%m%d")
+    assert [r["uv"] for r in data["results"]] == [90, 70, 40, 15, 8]
+
+
+async def test_query_day_nonexistent_dt_missing(client_and_repo) -> None:
+    client, repo = client_and_repo
+    yesterday = date.today() - timedelta(days=1)
+    repo.set_daily(yesterday.strftime("%Y%m%d"), _daily([100, 80, 50, 20, 10]))
+    far_past = yesterday - timedelta(days=300)
+    async with client:
+        resp = await client.post(
+            "/api/funnels/query",
+            json={"period": "day", "snapshot_dt": far_past.isoformat()},
+        )
+    assert resp.status_code == 200
+    data = resp.json()
+    assert data["data_status"] == "missing"
+    assert data["snapshot_dt"] is None
+
+
+async def test_query_day_future_dt_rejected(client_and_repo) -> None:
+    client, _ = client_and_repo
+    future = (date.today() + timedelta(days=1)).isoformat()
+    async with client:
+        resp = await client.post(
+            "/api/funnels/query",
+            json={"period": "day", "snapshot_dt": future},
+        )
+    assert resp.status_code == 422
+
+
+async def test_query_day_today_dt_rejected(client_and_repo) -> None:
+    client, _ = client_and_repo
+    async with client:
+        resp = await client.post(
+            "/api/funnels/query",
+            json={"period": "day", "snapshot_dt": date.today().isoformat()},
+        )
+    assert resp.status_code == 422
+
+
+async def test_query_rolling_ignores_snapshot_dt(client_and_repo) -> None:
+    client, repo = client_and_repo
+    repo.set_rolling("20260623", _rolling_7d([700, 560, 350, 140, 70]))
+    future = (date.today() + timedelta(days=5)).isoformat()
+    async with client:
+        resp = await client.post(
+            "/api/funnels/query",
+            json={"period": "last_7d", "snapshot_dt": future},
+        )
+    # snapshot_dt ignored for rolling -> not a 422, normal ready response.
+    assert resp.status_code == 200
+    data = resp.json()
+    assert data["data_status"] == "ready"
+    assert data["snapshot_dt"] == "20260623"
+
+
+async def test_query_missing_no_row(client_and_repo) -> None:
+    client, _repo = client_and_repo
+    async with client:
+        resp = await client.post("/api/funnels/query", json={"period": "last_30d"})
+    assert resp.status_code == 200
+    data = resp.json()
+    assert data["data_status"] == "missing"
+    assert data["snapshot_dt"] is None
+    assert data["results"] == []
+
+
+async def test_query_bad_period_rejected(client_and_repo) -> None:
+    client, _ = client_and_repo
+    async with client:
+        resp = await client.post("/api/funnels/query", json={"period": "yesterday"})
+    assert resp.status_code == 422
+
+
+async def test_query_missing_period_field_rejected(client_and_repo) -> None:
+    client, _ = client_and_repo
+    async with client:
+        resp = await client.post("/api/funnels/query", json={})
+    assert resp.status_code == 422

+ 237 - 0
apps/api/tests/test_funnel_service.py

@@ -0,0 +1,237 @@
+"""Tests for the group-buy funnel service: routing, conversion math,
+data_status, snapshot_dt validation, and the fake data source."""
+
+from __future__ import annotations
+
+from datetime import date, timedelta
+
+import pytest
+
+from app.schemas import DataStatus, Period
+from app.services.funnel import build_results, run_funnel_query
+from app.services.repository import FakeFunnelRepository
+from tests.conftest import FakeRepository
+
+
+def _daily(uvs: list[int | None]) -> dict[str, int | None]:
+    keys = ["start", "show", "detail", "order", "paid"]
+    return {f"uv_{k}": uv for k, uv in zip(keys, uvs)}
+
+
+def _rolling(
+    uvs_7d: list[int | None] | None = None,
+    uvs_30d: list[int | None] | None = None,
+) -> dict[str, int | None]:
+    keys = ["start", "show", "detail", "order", "paid"]
+    uvs_7d = uvs_7d if uvs_7d is not None else [None] * 5
+    uvs_30d = uvs_30d if uvs_30d is not None else [None] * 5
+    values: dict[str, int | None] = {}
+    for k, uv in zip(keys, uvs_7d):
+        values[f"uv_{k}_7d"] = uv
+    for k, uv in zip(keys, uvs_30d):
+        values[f"uv_{k}_30d"] = uv
+    return values
+
+
+# --------------------------------------------------------------------------- #
+# Conversion math                                                             #
+# --------------------------------------------------------------------------- #
+
+
+def test_build_results_step1_null_rates() -> None:
+    results = build_results([10000, 8200, 5100, 2200, 1800])
+    assert results[0].step_index == 1
+    assert results[0].conversion_rate is None
+    assert results[0].dropoff_rate is None
+
+
+def test_build_results_descending_funnel() -> None:
+    results = build_results([10000, 8200, 5100, 2200, 1800])
+    assert [r.uv for r in results] == [10000, 8200, 5100, 2200, 1800]
+    assert [r.event_key for r in results] == [
+        "start",
+        "show",
+        "detail",
+        "order",
+        "paid",
+    ]
+    # Step 3 display name is "拼团详情" (was "详情").
+    assert [r.name for r in results] == ["启动", "曝光", "拼团详情", "下单", "成功"]
+    assert results[1].conversion_rate == pytest.approx(0.82)
+    assert results[1].dropoff_rate == pytest.approx(0.18)
+    assert results[2].conversion_rate == pytest.approx(5100 / 8200)
+    assert results[3].conversion_rate == pytest.approx(2200 / 5100)
+
+
+def test_build_results_prev_zero_yields_null() -> None:
+    results = build_results([0, 5, 3, 1, 0])
+    assert results[1].conversion_rate is None
+    assert results[1].dropoff_rate is None
+    assert results[2].conversion_rate == pytest.approx(0.6)
+
+
+# --------------------------------------------------------------------------- #
+# day routing                                                                 #
+# --------------------------------------------------------------------------- #
+
+
+async def test_day_latest_when_no_snapshot_dt(repo: FakeRepository) -> None:
+    repo.set_daily("20260620", _daily([1, 1, 1, 1, 1]))
+    repo.set_daily("20260623", _daily([100, 80, 50, 20, 10]))  # latest
+    resp = await run_funnel_query(Period.day, repo, snapshot_dt=None)
+    assert resp.data_status == DataStatus.ready
+    assert resp.snapshot_dt == "20260623"
+    assert [r.uv for r in resp.results] == [100, 80, 50, 20, 10]
+
+
+async def test_day_specific_dt(repo: FakeRepository) -> None:
+    yesterday = date.today() - timedelta(days=1)
+    older = yesterday - timedelta(days=3)
+    repo.set_daily(yesterday.strftime("%Y%m%d"), _daily([100, 80, 50, 20, 10]))
+    repo.set_daily(older.strftime("%Y%m%d"), _daily([90, 70, 40, 15, 8]))
+    resp = await run_funnel_query(Period.day, repo, snapshot_dt=older)
+    assert resp.snapshot_dt == older.strftime("%Y%m%d")
+    assert [r.uv for r in resp.results] == [90, 70, 40, 15, 8]
+
+
+async def test_day_nonexistent_dt_is_missing(repo: FakeRepository) -> None:
+    yesterday = date.today() - timedelta(days=1)
+    repo.set_daily(yesterday.strftime("%Y%m%d"), _daily([100, 80, 50, 20, 10]))
+    # A valid (<= yesterday) date with no row installed.
+    missing_day = yesterday - timedelta(days=400)
+    resp = await run_funnel_query(Period.day, repo, snapshot_dt=missing_day)
+    assert resp.data_status == DataStatus.missing
+    assert resp.snapshot_dt is None
+    assert resp.results == []
+
+
+async def test_day_no_rows_at_all_is_missing(repo: FakeRepository) -> None:
+    resp = await run_funnel_query(Period.day, repo, snapshot_dt=None)
+    assert resp.data_status == DataStatus.missing
+    assert resp.snapshot_dt is None
+
+
+async def test_day_null_column_is_missing(repo: FakeRepository) -> None:
+    repo.set_daily("20260623", _daily([100, 80, None, 20, 10]))
+    resp = await run_funnel_query(Period.day, repo, snapshot_dt=None)
+    assert resp.data_status == DataStatus.missing
+    assert resp.snapshot_dt == "20260623"
+
+
+async def test_day_future_snapshot_dt_raises(repo: FakeRepository) -> None:
+    from app.services.funnel import SnapshotDateError
+
+    with pytest.raises(SnapshotDateError):
+        await run_funnel_query(
+            Period.day, repo, snapshot_dt=date.today() + timedelta(days=1)
+        )
+
+
+async def test_day_today_snapshot_dt_raises(repo: FakeRepository) -> None:
+    from app.services.funnel import SnapshotDateError
+
+    with pytest.raises(SnapshotDateError):
+        await run_funnel_query(Period.day, repo, snapshot_dt=date.today())
+
+
+# --------------------------------------------------------------------------- #
+# rolling routing                                                             #
+# --------------------------------------------------------------------------- #
+
+
+async def test_rolling_7d_picks_7d_columns(repo: FakeRepository) -> None:
+    repo.set_rolling(
+        "20260623",
+        _rolling(
+            uvs_7d=[700, 560, 350, 140, 70],
+            uvs_30d=[3000, 2400, 1500, 600, 300],
+        ),
+    )
+    resp = await run_funnel_query(Period.last_7d, repo)
+    assert resp.data_status == DataStatus.ready
+    assert resp.snapshot_dt == "20260623"
+    assert [r.uv for r in resp.results] == [700, 560, 350, 140, 70]
+
+
+async def test_rolling_30d_picks_30d_columns(repo: FakeRepository) -> None:
+    repo.set_rolling(
+        "20260623",
+        _rolling(
+            uvs_7d=[700, 560, 350, 140, 70],
+            uvs_30d=[3000, 2400, 1500, 600, 300],
+        ),
+    )
+    resp = await run_funnel_query(Period.last_30d, repo)
+    assert [r.uv for r in resp.results] == [3000, 2400, 1500, 600, 300]
+
+
+async def test_rolling_ignores_snapshot_dt(repo: FakeRepository) -> None:
+    repo.set_rolling("20260623", _rolling(uvs_7d=[700, 560, 350, 140, 70]))
+    # A future snapshot_dt would be rejected for period=day, but must be
+    # silently ignored for rolling periods.
+    resp = await run_funnel_query(
+        Period.last_7d, repo, snapshot_dt=date.today() + timedelta(days=5)
+    )
+    assert resp.data_status == DataStatus.ready
+    assert resp.snapshot_dt == "20260623"
+
+
+async def test_rolling_missing_when_no_row(repo: FakeRepository) -> None:
+    resp = await run_funnel_query(Period.last_7d, repo)
+    assert resp.data_status == DataStatus.missing
+    assert resp.snapshot_dt is None
+    assert resp.results == []
+
+
+async def test_rolling_missing_when_columns_null(repo: FakeRepository) -> None:
+    # Row present, 30d columns NULL.
+    repo.set_rolling("20260623", _rolling(uvs_7d=[700, 560, 350, 140, 70]))
+    resp = await run_funnel_query(Period.last_30d, repo)
+    assert resp.data_status == DataStatus.missing
+    assert resp.snapshot_dt == "20260623"
+
+
+# --------------------------------------------------------------------------- #
+# Fake data source                                                            #
+# --------------------------------------------------------------------------- #
+
+
+async def test_fake_source_ready_for_all_periods() -> None:
+    repo = FakeFunnelRepository()
+    yesterday = date.today() - timedelta(days=1)
+    for period in (Period.day, Period.last_7d, Period.last_30d):
+        resp = await run_funnel_query(period, repo)
+        assert resp.data_status == DataStatus.ready
+        assert resp.snapshot_dt == yesterday.strftime("%Y%m%d")
+        assert len(resp.results) == 5
+        uvs = [r.uv for r in resp.results]
+        assert uvs == sorted(uvs, reverse=True)
+        assert resp.results[0].conversion_rate is None
+
+
+async def test_fake_source_historical_day_ready() -> None:
+    repo = FakeFunnelRepository()
+    yesterday = date.today() - timedelta(days=1)
+    older = yesterday - timedelta(days=4)
+    resp = await run_funnel_query(Period.day, repo, snapshot_dt=older)
+    assert resp.data_status == DataStatus.ready
+    assert resp.snapshot_dt == older.strftime("%Y%m%d")
+    assert len(resp.results) == 5
+
+
+async def test_fake_source_history_differs_by_day() -> None:
+    repo = FakeFunnelRepository()
+    yesterday = date.today() - timedelta(days=1)
+    older = yesterday - timedelta(days=5)
+    resp_latest = await run_funnel_query(Period.day, repo, snapshot_dt=yesterday)
+    resp_older = await run_funnel_query(Period.day, repo, snapshot_dt=older)
+    # Older day scaled down, so the top UVs differ.
+    assert resp_latest.results[0].uv != resp_older.results[0].uv
+
+
+async def test_fake_source_nonexistent_day_missing() -> None:
+    repo = FakeFunnelRepository()
+    yesterday = date.today() - timedelta(days=1)
+    far_past = yesterday - timedelta(days=500)
+    resp = await run_funnel_query(Period.day, repo, snapshot_dt=far_past)
+    assert resp.data_status == DataStatus.missing

+ 127 - 0
apps/api/tests/test_repository_db.py

@@ -0,0 +1,127 @@
+"""SQLAlchemy repository tests against an in-memory SQLite database.
+
+Verifies day routing (latest dt + specific dt + missing) and rolling-row column
+mapping against a real engine, with no Postgres needed.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from app.db.models import (
+    AdsTrdGroupFunnelDaily,
+    AdsTrdGroupFunnelRolling,
+    Base,
+)
+from app.schemas import DataStatus, Period
+from app.services.funnel import run_funnel_query
+from app.services.repository import SqlAlchemyFunnelRepository
+
+
+def _daily_row(dt: str, top: int) -> AdsTrdGroupFunnelDaily:
+    return AdsTrdGroupFunnelDaily(
+        dt=dt,
+        uv_start=top,
+        uv_show=int(top * 0.8),
+        uv_detail=int(top * 0.5),
+        uv_order=int(top * 0.2),
+        uv_paid=int(top * 0.16),
+    )
+
+
+def _rolling_row(dt: str, top7: int, top30: int) -> AdsTrdGroupFunnelRolling:
+    return AdsTrdGroupFunnelRolling(
+        dt=dt,
+        uv_start_7d=top7,
+        uv_show_7d=int(top7 * 0.8),
+        uv_detail_7d=int(top7 * 0.5),
+        uv_order_7d=int(top7 * 0.2),
+        uv_paid_7d=int(top7 * 0.16),
+        uv_start_30d=top30,
+        uv_show_30d=int(top30 * 0.8),
+        uv_detail_30d=int(top30 * 0.5),
+        uv_order_30d=int(top30 * 0.2),
+        uv_paid_30d=int(top30 * 0.16),
+    )
+
+
+@pytest.fixture
+async def sqlite_sessionmaker():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    maker = async_sessionmaker(engine, expire_on_commit=False)
+    yield maker
+    await engine.dispose()
+
+
+async def test_empty_daily_returns_none(sqlite_sessionmaker) -> None:
+    async with sqlite_sessionmaker() as session:
+        repo = SqlAlchemyFunnelRepository(session)
+        assert await repo.fetch_daily(None) is None
+        assert await repo.fetch_daily("20260623") is None
+
+
+async def test_empty_rolling_returns_none(sqlite_sessionmaker) -> None:
+    async with sqlite_sessionmaker() as session:
+        repo = SqlAlchemyFunnelRepository(session)
+        assert await repo.fetch_rolling() is None
+
+
+async def test_daily_latest_dt_is_selected(sqlite_sessionmaker) -> None:
+    async with sqlite_sessionmaker() as session:
+        session.add_all(
+            [
+                _daily_row("20260621", 100),
+                _daily_row("20260623", 300),
+                _daily_row("20260622", 200),
+            ]
+        )
+        await session.commit()
+
+    async with sqlite_sessionmaker() as session:
+        repo = SqlAlchemyFunnelRepository(session)
+        snapshot = await repo.fetch_daily(None)
+        assert snapshot is not None
+        assert snapshot.dt == "20260623"
+        assert snapshot.values["uv_start"] == 300
+
+
+async def test_daily_specific_dt(sqlite_sessionmaker) -> None:
+    async with sqlite_sessionmaker() as session:
+        session.add_all([_daily_row("20260621", 100), _daily_row("20260623", 300)])
+        await session.commit()
+
+    async with sqlite_sessionmaker() as session:
+        repo = SqlAlchemyFunnelRepository(session)
+        snapshot = await repo.fetch_daily("20260621")
+        assert snapshot is not None
+        assert snapshot.dt == "20260621"
+        assert snapshot.values["uv_start"] == 100
+
+
+async def test_daily_specific_dt_missing(sqlite_sessionmaker) -> None:
+    async with sqlite_sessionmaker() as session:
+        session.add(_daily_row("20260623", 300))
+        await session.commit()
+
+    async with sqlite_sessionmaker() as session:
+        repo = SqlAlchemyFunnelRepository(session)
+        assert await repo.fetch_daily("20200101") is None
+
+
+async def test_rolling_row_columns(sqlite_sessionmaker) -> None:
+    async with sqlite_sessionmaker() as session:
+        session.add(_rolling_row("20260623", top7=700, top30=3000))
+        await session.commit()
+
+    async with sqlite_sessionmaker() as session:
+        repo = SqlAlchemyFunnelRepository(session)
+        resp7 = await run_funnel_query(Period.last_7d, repo)
+        assert resp7.data_status == DataStatus.ready
+        assert resp7.snapshot_dt == "20260623"
+        assert resp7.results[0].uv == 700
+
+        resp30 = await run_funnel_query(Period.last_30d, repo)
+        assert resp30.results[0].uv == 3000

+ 89 - 0
apps/api/tests/test_validation.py

@@ -0,0 +1,89 @@
+"""Tests for period validation, period -> column mapping, and snapshot_dt rules."""
+
+from __future__ import annotations
+
+from datetime import date, timedelta
+
+import pytest
+
+from app.schemas import FunnelQueryRequest, Period
+from app.services.funnel import (
+    ROLLING_SUFFIX,
+    SnapshotDateError,
+    daily_columns,
+    rolling_columns,
+    validate_snapshot_dt,
+)
+
+
+def test_period_accepts_three_valid_values() -> None:
+    for value in ("day", "last_7d", "last_30d"):
+        req = FunnelQueryRequest(period=value)
+        assert req.period.value == value
+
+
+def test_period_rejects_bad_value() -> None:
+    with pytest.raises(ValueError):
+        FunnelQueryRequest(period="yesterday")  # old v2 value, now invalid
+
+
+def test_request_snapshot_dt_optional() -> None:
+    req = FunnelQueryRequest(period="day")
+    assert req.snapshot_dt is None
+
+    req2 = FunnelQueryRequest(period="day", snapshot_dt="2026-06-20")
+    assert req2.snapshot_dt == date(2026, 6, 20)
+
+
+def test_rolling_suffix_mapping() -> None:
+    assert ROLLING_SUFFIX[Period.last_7d] == "7d"
+    assert ROLLING_SUFFIX[Period.last_30d] == "30d"
+
+
+def test_daily_columns() -> None:
+    assert daily_columns() == [
+        "uv_start",
+        "uv_show",
+        "uv_detail",
+        "uv_order",
+        "uv_paid",
+    ]
+
+
+def test_rolling_columns_last_7d() -> None:
+    assert rolling_columns(Period.last_7d) == [
+        "uv_start_7d",
+        "uv_show_7d",
+        "uv_detail_7d",
+        "uv_order_7d",
+        "uv_paid_7d",
+    ]
+
+
+def test_rolling_columns_last_30d() -> None:
+    assert rolling_columns(Period.last_30d) == [
+        "uv_start_30d",
+        "uv_show_30d",
+        "uv_detail_30d",
+        "uv_order_30d",
+        "uv_paid_30d",
+    ]
+
+
+def test_validate_snapshot_dt_accepts_yesterday() -> None:
+    yesterday = date.today() - timedelta(days=1)
+    validate_snapshot_dt(yesterday)  # no raise
+
+
+def test_validate_snapshot_dt_accepts_old_date() -> None:
+    validate_snapshot_dt(date.today() - timedelta(days=30))  # no raise
+
+
+def test_validate_snapshot_dt_rejects_today() -> None:
+    with pytest.raises(SnapshotDateError):
+        validate_snapshot_dt(date.today())
+
+
+def test_validate_snapshot_dt_rejects_future() -> None:
+    with pytest.raises(SnapshotDateError):
+        validate_snapshot_dt(date.today() + timedelta(days=1))

+ 3 - 0
apps/web/.env.example

@@ -0,0 +1,3 @@
+# Copy to .env. Mock mode returns realistic funnel responses without a backend.
+# Set to false once the FastAPI backend is available (proxied at /api -> http://localhost:8000).
+VITE_USE_MOCK=true

+ 25 - 0
apps/web/components.json

@@ -0,0 +1,25 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "radix-nova",
+  "rsc": false,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "src/index.css",
+    "baseColor": "neutral",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "iconLibrary": "lucide",
+  "rtl": false,
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  },
+  "menuColor": "default",
+  "menuAccent": "subtle",
+  "registries": {}
+}

+ 14 - 0
apps/web/index.html

@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="theme-color" content="#04CB94" />
+    <title>HS Data · 数据服务平台</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 48 - 0
apps/web/package.json

@@ -0,0 +1,48 @@
+{
+  "name": "@hs-data/web",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc -b && vite build",
+    "preview": "vite preview",
+    "test": "vitest run",
+    "test:watch": "vitest",
+    "typecheck": "tsc -b --noEmit"
+  },
+  "dependencies": {
+    "@fontsource-variable/geist": "^5.2.9",
+    "@tanstack/react-query": "^5.62.7",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "date-fns": "^4.4.0",
+    "echarts": "^5.5.1",
+    "echarts-for-react": "^3.0.2",
+    "lucide-react": "^1.21.0",
+    "next-themes": "^0.4.6",
+    "radix-ui": "^1.6.0",
+    "react": "^19.0.0",
+    "react-day-picker": "^10.0.1",
+    "react-dom": "^19.0.0",
+    "react-router-dom": "^7.1.1",
+    "sonner": "^2.0.7",
+    "tailwind-merge": "^3.6.0",
+    "tw-animate-css": "^1.4.0"
+  },
+  "devDependencies": {
+    "@tailwindcss/vite": "^4.3.1",
+    "@testing-library/jest-dom": "^6.6.3",
+    "@testing-library/react": "^16.1.0",
+    "@testing-library/user-event": "^14.5.2",
+    "@types/node": "^26.0.0",
+    "@types/react": "^19.0.2",
+    "@types/react-dom": "^19.0.2",
+    "@vitejs/plugin-react": "^4.3.4",
+    "jsdom": "^25.0.1",
+    "tailwindcss": "^4.3.1",
+    "typescript": "^5.7.2",
+    "vite": "^6.0.5",
+    "vitest": "^3.0.0"
+  }
+}

+ 8 - 0
apps/web/public/favicon.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
+  <rect width="32" height="32" rx="7" fill="#04CB94"/>
+  <g fill="#ffffff">
+    <rect x="7" y="9" width="18" height="3.6" rx="1.8"/>
+    <rect x="9.5" y="14.6" width="13" height="3.6" rx="1.8"/>
+    <rect x="12" y="20.2" width="8" height="3.6" rx="1.8"/>
+  </g>
+</svg>

+ 53 - 0
apps/web/src/App.tsx

@@ -0,0 +1,53 @@
+import type { ReactNode } from 'react';
+import { Navigate, Route, Routes } from 'react-router-dom';
+import { AppLayout } from './layout/AppLayout';
+import { FunnelPage } from './modules/funnel/FunnelPage';
+import { Placeholder } from './modules/placeholder/Placeholder';
+import { HOME_PATH, NAV, type NavNode } from './routes/domains';
+
+/** 深度优先取节点下首个叶子路径(父节点 → 子节点重定向用)。 */
+function firstLeafPath(node: NavNode): string {
+  return node.children?.length ? firstLeafPath(node.children[0]) : node.path;
+}
+
+interface RouteDef {
+  path: string;
+  element: ReactNode;
+}
+
+/**
+ * 由导航树(docs/01 §2)生成路由:
+ *  - 父节点 → 重定向到首个叶子(/behavior → /behavior/funnel → /behavior/funnel/group)
+ *  - 可用叶子(working) → 真实页面(目前唯一:拼团漏斗 = FunnelPage)
+ *  - 其余叶子 → "待开发"占位
+ */
+function collectRoutes(nodes: NavNode[], acc: RouteDef[] = []): RouteDef[] {
+  for (const n of nodes) {
+    if (n.children?.length) {
+      acc.push({ path: n.path, element: <Navigate to={firstLeafPath(n)} replace /> });
+      collectRoutes(n.children, acc);
+    } else if (n.working) {
+      acc.push({ path: n.path, element: <FunnelPage /> });
+    } else {
+      acc.push({ path: n.path, element: <Placeholder title={n.label} /> });
+    }
+  }
+  return acc;
+}
+
+export function App() {
+  const routes = collectRoutes(NAV);
+  return (
+    <AppLayout>
+      <Routes>
+        <Route path="/" element={<Navigate to={HOME_PATH} replace />} />
+        {routes.map((r) => (
+          <Route key={r.path} path={r.path} element={r.element} />
+        ))}
+        <Route path="*" element={<Navigate to={HOME_PATH} replace />} />
+      </Routes>
+    </AppLayout>
+  );
+}
+
+export default App;

+ 81 - 0
apps/web/src/__tests__/routing.test.tsx

@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { App } from '../App';
+
+const FUNNEL_PATH = '/behavior/funnel/group';
+
+function renderAt(path: string) {
+  const client = new QueryClient();
+  return render(
+    <QueryClientProvider client={client}>
+      <MemoryRouter initialEntries={[path]}>
+        <App />
+      </MemoryRouter>
+    </QueryClientProvider>,
+  );
+}
+
+describe('routing + layout (三级 IA)', () => {
+  it('left nav lists the 5 L1 capability domains', () => {
+    renderAt(FUNNEL_PATH);
+    for (const label of [
+      '行为分析',
+      '指标体系',
+      '画像体系',
+      '数据看板',
+      '营销触达',
+    ]) {
+      expect(screen.getAllByText(label).length).toBeGreaterThan(0);
+    }
+  });
+
+  it('active branch auto-expands to show 漏斗分析 → 拼团漏斗', () => {
+    renderAt(FUNNEL_PATH);
+    expect(screen.getAllByText('漏斗分析').length).toBeGreaterThan(0);
+    expect(screen.getAllByText('拼团漏斗').length).toBeGreaterThan(0);
+  });
+
+  it('clicking an expanded L1 group collapses it (manual toggle)', async () => {
+    const user = userEvent.setup();
+    renderAt(FUNNEL_PATH);
+    // 留存分析 only appears in the nav (sibling L2), never on the funnel page.
+    expect(screen.getByText('留存分析')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /行为分析/ }));
+    expect(screen.queryByText('留存分析')).not.toBeInTheDocument();
+  });
+
+  it('opening another L1 keeps the active one open (multi-open, not accordion)', async () => {
+    const user = userEvent.setup();
+    renderAt(FUNNEL_PATH);
+    expect(screen.getAllByText('漏斗分析').length).toBeGreaterThan(0); // 行为分析 open
+    await user.click(screen.getByRole('button', { name: /画像体系/ }));
+    expect(screen.getByText('用户画像')).toBeInTheDocument(); // 画像体系 now open
+    expect(screen.getAllByText('漏斗分析').length).toBeGreaterThan(0); // 行为分析 STILL open
+  });
+
+  it('the 拼团漏斗 route renders the funnel page', () => {
+    renderAt(FUNNEL_PATH);
+    // Period tabs are unique to the funnel page.
+    expect(screen.getByRole('button', { name: '近 7 天' })).toBeInTheDocument();
+  });
+
+  it('a non-working domain renders the shared 待开发 placeholder', () => {
+    renderAt('/profile');
+    expect(screen.getByText('待开发')).toBeInTheDocument();
+    // funnel controls must NOT be present
+    expect(screen.queryByRole('button', { name: '近 7 天' })).not.toBeInTheDocument();
+  });
+
+  it('parent route /behavior redirects down to the funnel leaf', () => {
+    renderAt('/behavior');
+    expect(screen.getByRole('button', { name: '近 7 天' })).toBeInTheDocument();
+  });
+
+  it('/ redirects to the funnel home', () => {
+    renderAt('/');
+    expect(screen.getByRole('button', { name: '近 7 天' })).toBeInTheDocument();
+  });
+});

+ 64 - 0
apps/web/src/api/__tests__/funnel.mock.test.ts

@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest';
+import { queryFunnel } from '../funnel';
+import type { FunnelPeriod } from '../types';
+
+// These tests assume mock mode (default ON; VITE_USE_MOCK !== 'false') and the
+// default "ready" status (no ?missing query param in the jsdom location).
+
+describe('mock queryFunnel — fixed 5-step funnel', () => {
+  it('returns the fixed 5 steps in order with the right event keys', async () => {
+    const res = await queryFunnel({ period: 'last_7d' });
+    expect(res.data_status).toBe('ready');
+    expect(res.period).toBe('last_7d');
+    expect(res.results).toHaveLength(5);
+    expect(res.results.map((r) => r.event_key)).toEqual([
+      'start',
+      'show',
+      'detail',
+      'order',
+      'paid',
+    ]);
+    expect(res.results.map((r) => r.name)).toEqual([
+      '启动',
+      '曝光',
+      '拼团详情',
+      '下单',
+      '成功',
+    ]);
+  });
+
+  it('step 1 conversion/dropoff are null; UVs descend', async () => {
+    const res = await queryFunnel({ period: 'last_7d' });
+    expect(res.results[0].conversion_rate).toBeNull();
+    expect(res.results[0].dropoff_rate).toBeNull();
+    expect(res.results[1].conversion_rate).not.toBeNull();
+    for (let i = 1; i < res.results.length; i++) {
+      expect(res.results[i].uv).toBeLessThanOrEqual(res.results[i - 1].uv);
+    }
+  });
+
+  it('returns a yyyyMMdd snapshot_dt', async () => {
+    const res = await queryFunnel({ period: 'day' });
+    expect(res.snapshot_dt).toMatch(/^\d{8}$/);
+  });
+
+  it('day with snapshot_dt echoes that date (dashes stripped) as snapshot_dt', async () => {
+    const res = await queryFunnel({ period: 'day', snapshot_dt: '2026-05-10' });
+    expect(res.snapshot_dt).toBe('20260510');
+  });
+
+  it('day numbers vary by the requested historical date', async () => {
+    const a = await queryFunnel({ period: 'day', snapshot_dt: '2026-05-10' });
+    const b = await queryFunnel({ period: 'day', snapshot_dt: '2026-05-11' });
+    expect(a.results[0].uv).not.toBe(b.results[0].uv);
+  });
+
+  it('each period yields a different step-1 UV scale', async () => {
+    const periods: FunnelPeriod[] = ['day', 'last_7d', 'last_30d'];
+    const uvs = await Promise.all(
+      periods.map(async (p) => (await queryFunnel({ period: p })).results[0].uv),
+    );
+    expect(uvs[0]).toBeLessThan(uvs[1]);
+    expect(uvs[1]).toBeLessThan(uvs[2]);
+  });
+});

+ 200 - 0
apps/web/src/api/funnel.ts

@@ -0,0 +1,200 @@
+import type {
+  DataStatus,
+  FunnelPeriod,
+  FunnelQueryRequest,
+  FunnelQueryResponse,
+  FunnelResultRow,
+} from './types';
+import { FIXED_FUNNEL_STEPS } from '../modules/funnel/period';
+
+/** Endpoint per docs/02 §5. Proxied (Vite) to http://localhost:8000. */
+const FUNNEL_QUERY_ENDPOINT = '/api/funnels/query';
+
+/** Mock mode defaults ON until the backend is wired. Set VITE_USE_MOCK=false to hit the real API. */
+export const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false';
+
+/** Raised for non-2xx responses so the UI can show an explicit error state. */
+export class FunnelApiError extends Error {
+  constructor(
+    message: string,
+    readonly status?: number,
+  ) {
+    super(message);
+    this.name = 'FunnelApiError';
+  }
+}
+
+/**
+ * Query the fixed 5-step funnel. In mock mode this returns a deterministic,
+ * realistic response without a network call.
+ */
+export async function queryFunnel(
+  req: FunnelQueryRequest,
+): Promise<FunnelQueryResponse> {
+  if (USE_MOCK) {
+    return mockQueryFunnel(req);
+  }
+
+  // Per docs/02 §5 v3: send snapshot_dt ONLY for `day`; omit for 7d/30d.
+  const body: FunnelQueryRequest =
+    req.period === 'day' && req.snapshot_dt
+      ? { period: req.period, snapshot_dt: req.snapshot_dt }
+      : { period: req.period };
+
+  let res: Response;
+  try {
+    res = await fetch(FUNNEL_QUERY_ENDPOINT, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+    });
+  } catch (e) {
+    throw new FunnelApiError(
+      `网络请求失败:${e instanceof Error ? e.message : String(e)}`,
+    );
+  }
+
+  if (!res.ok) {
+    let detail = '';
+    try {
+      const body = (await res.json()) as { detail?: string; message?: string };
+      detail = body.detail ?? body.message ?? '';
+    } catch {
+      /* ignore non-JSON error bodies */
+    }
+    throw new FunnelApiError(
+      detail || `查询失败(HTTP ${res.status})`,
+      res.status,
+    );
+  }
+
+  return (await res.json()) as FunnelQueryResponse;
+}
+
+// ---------------------------------------------------------------------------
+// Mock implementation
+// ---------------------------------------------------------------------------
+
+/**
+ * Base UV for step 1, per period. Realistic, descending across the funnel.
+ * Longer windows accumulate more users.
+ */
+const PERIOD_BASE_UV: Record<FunnelPeriod, number> = {
+  day: 12_000,
+  last_7d: 68_000,
+  last_30d: 240_000,
+};
+
+/** Fixed step-to-step retention used to derive a descending UV curve. */
+const STEP_RETENTION = [1, 0.82, 0.62, 0.43, 0.82];
+
+/**
+ * Deterministic small per-date wobble (~±8%) so historical single-day queries
+ * return visibly different numbers and history "feels real". Keyed on the
+ * requested snapshot_dt ("yyyy-MM-dd"); falls back to 1 when absent.
+ */
+function dateVariance(snapshotDt?: string): number {
+  if (!snapshotDt) return 1;
+  let hash = 0;
+  for (const ch of snapshotDt) hash = (hash * 31 + ch.charCodeAt(0)) | 0;
+  // Map hash to roughly [0.92, 1.08].
+  return 0.92 + (Math.abs(hash) % 161) / 1000;
+}
+
+/**
+ * Exercise the `missing` branch at runtime without code changes:
+ *   - URL query param `?missing=1` (or `?data_status=missing`) -> "missing"
+ *   - otherwise -> "ready"
+ * Falls back to "ready" outside a browser (tests cover missing explicitly).
+ */
+function deriveMockStatus(): DataStatus {
+  if (typeof window === 'undefined' || !window.location) return 'ready';
+  const params = new URLSearchParams(window.location.search);
+  if (params.get('missing') === '1' || params.get('data_status') === 'missing') {
+    return 'missing';
+  }
+  return 'ready';
+}
+
+/** Build the fixed 5-step funnel with descending UV + computed rates. */
+function buildMockRows(
+  period: FunnelPeriod,
+  snapshotDt?: string,
+): FunnelResultRow[] {
+  const base = PERIOD_BASE_UV[period] * dateVariance(snapshotDt);
+  let prevUv: number | null = null;
+  let cumulative = 1;
+
+  return FIXED_FUNNEL_STEPS.map((step, idx) => {
+    cumulative *= STEP_RETENTION[idx];
+    const uv = Math.round(base * cumulative);
+
+    let conversion_rate: number | null = null;
+    let dropoff_rate: number | null = null;
+    if (idx > 0 && prevUv && prevUv > 0) {
+      conversion_rate = Number((uv / prevUv).toFixed(4));
+      dropoff_rate = Number((1 - conversion_rate).toFixed(4));
+    }
+    prevUv = uv;
+
+    return {
+      step_index: idx + 1,
+      name: step.name,
+      event_key: step.event_key,
+      uv,
+      conversion_rate,
+      dropoff_rate,
+    };
+  });
+}
+
+/** Latest snapshot day (yesterday) in "yyyyMMdd". */
+function yesterdayDt(): string {
+  const d = new Date();
+  d.setDate(d.getDate() - 1);
+  const y = d.getFullYear();
+  const m = String(d.getMonth() + 1).padStart(2, '0');
+  const day = String(d.getDate()).padStart(2, '0');
+  return `${y}${m}${day}`;
+}
+
+/**
+ * The `dt` (yyyyMMdd) the mock "read":
+ *  - day with snapshot_dt → that day (strip dashes from yyyy-MM-dd).
+ *  - day without snapshot_dt → yesterday (latest).
+ *  - 7d/30d → yesterday (rolling as-of).
+ */
+function mockSnapshotDt(req: FunnelQueryRequest): string {
+  if (req.period === 'day' && req.snapshot_dt) {
+    return req.snapshot_dt.replace(/-/g, '');
+  }
+  return yesterdayDt();
+}
+
+async function mockQueryFunnel(
+  req: FunnelQueryRequest,
+): Promise<FunnelQueryResponse> {
+  // Small delay so loading states are observable in the UI.
+  await new Promise((r) => setTimeout(r, 350));
+
+  const status = deriveMockStatus();
+  const snapshot_dt = mockSnapshotDt(req);
+  // Vary single-day numbers by the requested date so history feels real.
+  const varyKey = req.period === 'day' ? req.snapshot_dt : undefined;
+
+  if (status === 'missing') {
+    return {
+      period: req.period,
+      snapshot_dt: null,
+      results: [],
+      data_status: 'missing',
+    };
+  }
+
+  return {
+    period: req.period,
+    snapshot_dt,
+    results: buildMockRows(req.period, varyKey),
+    data_status: 'ready',
+  };
+}

+ 12 - 0
apps/web/src/api/queryClient.ts

@@ -0,0 +1,12 @@
+import { QueryClient } from '@tanstack/react-query';
+
+/** Shared TanStack Query client. Funnel queries are explicit (no refetch churn). */
+export const queryClient = new QueryClient({
+  defaultOptions: {
+    queries: {
+      retry: 1,
+      refetchOnWindowFocus: false,
+      staleTime: 60_000,
+    },
+  },
+});

+ 70 - 0
apps/web/src/api/types.ts

@@ -0,0 +1,70 @@
+/**
+ * Funnel query API contract — docs/02-技术架构 §5 (v3, 拼团 funnel).
+ *
+ * These types mirror the backend contract EXACTLY. They are intentionally
+ * isolated in this file so they can later be swapped for generated types from
+ * packages/api-types with minimal churn. Import from here, not inline.
+ */
+
+/**
+ * The three supported standard periods.
+ * - day:      single calendar day (daily table; supports historical dates).
+ * - last_7d:  rolling 7-day window (rolling table; latest as-of only).
+ * - last_30d: rolling 30-day window (rolling table; latest as-of only).
+ */
+export type FunnelPeriod = 'day' | 'last_7d' | 'last_30d';
+
+/** POST /api/funnels/query request body. */
+export interface FunnelQueryRequest {
+  /** Standard period; the funnel itself is fixed (5 steps, fixed order). */
+  period: FunnelPeriod;
+  /**
+   * Historical day as `yyyy-MM-dd`. Meaningful ONLY for `period: 'day'`
+   * (omit = latest/yesterday). Ignored by the backend for 7d/30d; the page
+   * omits it for those periods.
+   */
+  snapshot_dt?: string;
+}
+
+/** One computed result row in the response. */
+export interface FunnelResultRow {
+  /** 1-based position in the fixed funnel (1..5). */
+  step_index: number;
+  /** Display name, e.g. 启动 / 曝光 / 详情 / 下单 / 成功. */
+  name: string;
+  /** Underlying event key: start / show / detail / order / paid. */
+  event_key: string;
+  /** Independent user count for this step. */
+  uv: number;
+  /**
+   * Conversion vs. previous step (uv / prevUv). `null` for step 1.
+   * Render null as "—", never 0%.
+   */
+  conversion_rate: number | null;
+  /**
+   * Drop-off vs. previous step (1 - conversion). `null` for step 1.
+   * Render null as "—", never 0%.
+   */
+  dropoff_rate: number | null;
+}
+
+/**
+ * Data availability for the query.
+ * - ready:   show funnel chart + table.
+ * - missing: explicit "数据缺失" empty state, no zero-fill.
+ */
+export type DataStatus = 'ready' | 'missing';
+
+/** POST /api/funnels/query response body. */
+export interface FunnelQueryResponse {
+  period: FunnelPeriod;
+  /**
+   * The `dt` of the row actually read, as "yyyyMMdd".
+   * - day:        the queried day.
+   * - last_7d/30d: the rolling window's as-of (end) date.
+   * `null` when no row was found (e.g. data_status = missing).
+   */
+  snapshot_dt: string | null;
+  results: FunnelResultRow[];
+  data_status: DataStatus;
+}

+ 76 - 0
apps/web/src/components/ui/alert.tsx

@@ -0,0 +1,76 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+  "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
+  {
+    variants: {
+      variant: {
+        default: "bg-card text-card-foreground",
+        destructive:
+          "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+function Alert({
+  className,
+  variant,
+  ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
+  return (
+    <div
+      data-slot="alert"
+      role="alert"
+      className={cn(alertVariants({ variant }), className)}
+      {...props}
+    />
+  )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-title"
+      className={cn(
+        "font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDescription({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-description"
+      className={cn(
+        "text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-action"
+      className={cn("absolute top-2 right-2", className)}
+      {...props}
+    />
+  )
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction }

+ 49 - 0
apps/web/src/components/ui/badge.tsx

@@ -0,0 +1,49 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+  "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+        secondary:
+          "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+        destructive:
+          "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+        outline:
+          "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+        ghost:
+          "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+function Badge({
+  className,
+  variant = "default",
+  asChild = false,
+  ...props
+}: React.ComponentProps<"span"> &
+  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+  const Comp = asChild ? Slot.Root : "span"
+
+  return (
+    <Comp
+      data-slot="badge"
+      data-variant={variant}
+      className={cn(badgeVariants({ variant }), className)}
+      {...props}
+    />
+  )
+}
+
+export { Badge, badgeVariants }

+ 67 - 0
apps/web/src/components/ui/button.tsx

@@ -0,0 +1,67 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+  "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground hover:bg-primary/80",
+        outline:
+          "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+        ghost:
+          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+        destructive:
+          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default:
+          "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+        xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+        sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+        lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+        icon: "size-8",
+        "icon-xs":
+          "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+        "icon-sm":
+          "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+        "icon-lg": "size-9",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  }
+)
+
+function Button({
+  className,
+  variant = "default",
+  size = "default",
+  asChild = false,
+  ...props
+}: React.ComponentProps<"button"> &
+  VariantProps<typeof buttonVariants> & {
+    asChild?: boolean
+  }) {
+  const Comp = asChild ? Slot.Root : "button"
+
+  return (
+    <Comp
+      data-slot="button"
+      data-variant={variant}
+      data-size={size}
+      className={cn(buttonVariants({ variant, size, className }))}
+      {...props}
+    />
+  )
+}
+
+export { Button, buttonVariants }

+ 100 - 0
apps/web/src/components/ui/calendar.tsx

@@ -0,0 +1,100 @@
+"use client"
+
+import { ChevronLeft, ChevronRight } from "lucide-react"
+import {
+  DayPicker,
+  getDefaultClassNames,
+  type DayButtonProps,
+  type DayPickerProps,
+} from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+/**
+ * shadcn-style calendar built on react-day-picker v10, themed with the mint
+ * tokens. Single-month, used by TimePeriodSelect's 单日 popover.
+ */
+export type CalendarProps = DayPickerProps
+
+function Calendar({ className, classNames, ...props }: CalendarProps) {
+  const defaults = getDefaultClassNames()
+
+  return (
+    <DayPicker
+      showOutsideDays
+      className={cn("p-1", className)}
+      classNames={{
+        root: cn(defaults.root, "w-fit"),
+        months: cn(defaults.months, "flex flex-col gap-2"),
+        month: cn(defaults.month, "flex flex-col gap-3"),
+        month_caption: cn(
+          defaults.month_caption,
+          "flex h-8 items-center justify-center px-8",
+        ),
+        caption_label: cn(
+          defaults.caption_label,
+          "text-sm font-medium select-none",
+        ),
+        nav: cn(defaults.nav, "absolute inset-x-0 top-0 flex justify-between"),
+        button_previous: cn(
+          buttonVariants({ variant: "ghost", size: "icon-sm" }),
+          "text-muted-foreground",
+        ),
+        button_next: cn(
+          buttonVariants({ variant: "ghost", size: "icon-sm" }),
+          "text-muted-foreground",
+        ),
+        month_grid: cn(defaults.month_grid, "border-collapse"),
+        weekdays: cn(defaults.weekdays, "flex"),
+        weekday: cn(
+          defaults.weekday,
+          "text-muted-foreground w-8 text-[0.8rem] font-normal",
+        ),
+        week: cn(defaults.week, "mt-1 flex w-full"),
+        day: cn(
+          defaults.day,
+          "relative size-8 p-0 text-center text-sm focus-within:relative focus-within:z-20",
+        ),
+        today: cn(defaults.today, "[&>button]:font-semibold"),
+        outside: cn(defaults.outside, "[&>button]:text-muted-foreground/50"),
+        disabled: cn(defaults.disabled, "[&>button]:opacity-40"),
+        selected: defaults.selected,
+        ...classNames,
+      }}
+      components={{
+        DayButton: CalendarDayButton,
+        Chevron: ({ orientation, className: chevronClassName }) => {
+          const Icon = orientation === "left" ? ChevronLeft : ChevronRight
+          return <Icon className={cn("size-4", chevronClassName)} />
+        },
+      }}
+      {...props}
+    />
+  )
+}
+
+function CalendarDayButton({
+  className,
+  day: _day,
+  modifiers,
+  ...props
+}: DayButtonProps) {
+  return (
+    <button
+      type="button"
+      data-selected={modifiers.selected || undefined}
+      data-today={modifiers.today || undefined}
+      className={cn(
+        buttonVariants({ variant: "ghost", size: "icon-sm" }),
+        "size-8 rounded-md font-normal",
+        "data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary/90",
+        "data-[today=true]:not-data-[selected=true]:bg-accent data-[today=true]:not-data-[selected=true]:text-accent-foreground",
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Calendar }

+ 103 - 0
apps/web/src/components/ui/card.tsx

@@ -0,0 +1,103 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({
+  className,
+  size = "default",
+  ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+  return (
+    <div
+      data-slot="card"
+      data-size={size}
+      className={cn(
+        "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-header"
+      className={cn(
+        "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-title"
+      className={cn(
+        "text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-description"
+      className={cn("text-sm text-muted-foreground", className)}
+      {...props}
+    />
+  )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-action"
+      className={cn(
+        "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-content"
+      className={cn("px-(--card-spacing)", className)}
+      {...props}
+    />
+  )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-footer"
+      className={cn(
+        "flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  Card,
+  CardHeader,
+  CardFooter,
+  CardTitle,
+  CardAction,
+  CardDescription,
+  CardContent,
+}

+ 48 - 0
apps/web/src/components/ui/popover.tsx

@@ -0,0 +1,48 @@
+"use client"
+
+import * as React from "react"
+import { Popover as PopoverPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Popover({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+  return <PopoverPrimitive.Root data-slot="popover" {...props} />
+}
+
+function PopoverTrigger({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
+  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
+}
+
+function PopoverContent({
+  className,
+  align = "center",
+  sideOffset = 4,
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
+  return (
+    <PopoverPrimitive.Portal>
+      <PopoverPrimitive.Content
+        data-slot="popover-content"
+        align={align}
+        sideOffset={sideOffset}
+        className={cn(
+          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border p-4 shadow-md outline-hidden",
+          className,
+        )}
+        {...props}
+      />
+    </PopoverPrimitive.Portal>
+  )
+}
+
+function PopoverAnchor({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
+  return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

+ 55 - 0
apps/web/src/components/ui/scroll-area.tsx

@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
+  return (
+    <ScrollAreaPrimitive.Root
+      data-slot="scroll-area"
+      className={cn("relative", className)}
+      {...props}
+    >
+      <ScrollAreaPrimitive.Viewport
+        data-slot="scroll-area-viewport"
+        className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
+      >
+        {children}
+      </ScrollAreaPrimitive.Viewport>
+      <ScrollBar />
+      <ScrollAreaPrimitive.Corner />
+    </ScrollAreaPrimitive.Root>
+  )
+}
+
+function ScrollBar({
+  className,
+  orientation = "vertical",
+  ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
+  return (
+    <ScrollAreaPrimitive.ScrollAreaScrollbar
+      data-slot="scroll-area-scrollbar"
+      data-orientation={orientation}
+      orientation={orientation}
+      className={cn(
+        "flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
+        className
+      )}
+      {...props}
+    >
+      <ScrollAreaPrimitive.ScrollAreaThumb
+        data-slot="scroll-area-thumb"
+        className="relative flex-1 rounded-full bg-border"
+      />
+    </ScrollAreaPrimitive.ScrollAreaScrollbar>
+  )
+}
+
+export { ScrollArea, ScrollBar }

+ 26 - 0
apps/web/src/components/ui/separator.tsx

@@ -0,0 +1,26 @@
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+  className,
+  orientation = "horizontal",
+  decorative = true,
+  ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+  return (
+    <SeparatorPrimitive.Root
+      data-slot="separator"
+      decorative={decorative}
+      orientation={orientation}
+      className={cn(
+        "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Separator }

+ 147 - 0
apps/web/src/components/ui/sheet.tsx

@@ -0,0 +1,147 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
+  return <SheetPrimitive.Root data-slot="sheet" {...props} />
+}
+
+function SheetTrigger({
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
+  return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
+}
+
+function SheetClose({
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Close>) {
+  return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
+}
+
+function SheetPortal({
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
+  return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
+}
+
+function SheetOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
+  return (
+    <SheetPrimitive.Overlay
+      data-slot="sheet-overlay"
+      className={cn(
+        "fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function SheetContent({
+  className,
+  children,
+  side = "right",
+  showCloseButton = true,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Content> & {
+  side?: "top" | "right" | "bottom" | "left"
+  showCloseButton?: boolean
+}) {
+  return (
+    <SheetPortal>
+      <SheetOverlay />
+      <SheetPrimitive.Content
+        data-slot="sheet-content"
+        data-side={side}
+        className={cn(
+          "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
+          className
+        )}
+        {...props}
+      >
+        {children}
+        {showCloseButton && (
+          <SheetPrimitive.Close data-slot="sheet-close" asChild>
+            <Button
+              variant="ghost"
+              className="absolute top-3 right-3"
+              size="icon-sm"
+            >
+              <XIcon
+              />
+              <span className="sr-only">Close</span>
+            </Button>
+          </SheetPrimitive.Close>
+        )}
+      </SheetPrimitive.Content>
+    </SheetPortal>
+  )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="sheet-header"
+      className={cn("flex flex-col gap-0.5 p-4", className)}
+      {...props}
+    />
+  )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="sheet-footer"
+      className={cn("mt-auto flex flex-col gap-2 p-4", className)}
+      {...props}
+    />
+  )
+}
+
+function SheetTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Title>) {
+  return (
+    <SheetPrimitive.Title
+      data-slot="sheet-title"
+      className={cn(
+        "text-base font-medium text-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function SheetDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Description>) {
+  return (
+    <SheetPrimitive.Description
+      data-slot="sheet-description"
+      className={cn("text-sm text-muted-foreground", className)}
+      {...props}
+    />
+  )
+}
+
+export {
+  Sheet,
+  SheetTrigger,
+  SheetClose,
+  SheetContent,
+  SheetHeader,
+  SheetFooter,
+  SheetTitle,
+  SheetDescription,
+}

+ 13 - 0
apps/web/src/components/ui/skeleton.tsx

@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="skeleton"
+      className={cn("animate-pulse rounded-md bg-muted", className)}
+      {...props}
+    />
+  )
+}
+
+export { Skeleton }

+ 47 - 0
apps/web/src/components/ui/sonner.tsx

@@ -0,0 +1,47 @@
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+  const { theme = "system" } = useTheme()
+
+  return (
+    <Sonner
+      theme={theme as ToasterProps["theme"]}
+      className="toaster group"
+      icons={{
+        success: (
+          <CircleCheckIcon className="size-4" />
+        ),
+        info: (
+          <InfoIcon className="size-4" />
+        ),
+        warning: (
+          <TriangleAlertIcon className="size-4" />
+        ),
+        error: (
+          <OctagonXIcon className="size-4" />
+        ),
+        loading: (
+          <Loader2Icon className="size-4 animate-spin" />
+        ),
+      }}
+      style={
+        {
+          "--normal-bg": "var(--popover)",
+          "--normal-text": "var(--popover-foreground)",
+          "--normal-border": "var(--border)",
+          "--border-radius": "var(--radius)",
+        } as React.CSSProperties
+      }
+      toastOptions={{
+        classNames: {
+          toast: "cn-toast",
+        },
+      }}
+      {...props}
+    />
+  )
+}
+
+export { Toaster }

+ 116 - 0
apps/web/src/components/ui/table.tsx

@@ -0,0 +1,116 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+  return (
+    <div
+      data-slot="table-container"
+      className="relative w-full overflow-x-auto"
+    >
+      <table
+        data-slot="table"
+        className={cn("w-full caption-bottom text-sm", className)}
+        {...props}
+      />
+    </div>
+  )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+  return (
+    <thead
+      data-slot="table-header"
+      className={cn("[&_tr]:border-b", className)}
+      {...props}
+    />
+  )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+  return (
+    <tbody
+      data-slot="table-body"
+      className={cn("[&_tr:last-child]:border-0", className)}
+      {...props}
+    />
+  )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+  return (
+    <tfoot
+      data-slot="table-footer"
+      className={cn(
+        "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+  return (
+    <tr
+      data-slot="table-row"
+      className={cn(
+        "border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+  return (
+    <th
+      data-slot="table-head"
+      className={cn(
+        "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+  return (
+    <td
+      data-slot="table-cell"
+      className={cn(
+        "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableCaption({
+  className,
+  ...props
+}: React.ComponentProps<"caption">) {
+  return (
+    <caption
+      data-slot="table-caption"
+      className={cn("mt-4 text-sm text-muted-foreground", className)}
+      {...props}
+    />
+  )
+}
+
+export {
+  Table,
+  TableHeader,
+  TableBody,
+  TableFooter,
+  TableHead,
+  TableRow,
+  TableCell,
+  TableCaption,
+}

+ 88 - 0
apps/web/src/components/ui/tabs.tsx

@@ -0,0 +1,88 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Tabs as TabsPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+  className,
+  orientation = "horizontal",
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+  return (
+    <TabsPrimitive.Root
+      data-slot="tabs"
+      data-orientation={orientation}
+      className={cn(
+        "group/tabs flex gap-2 data-horizontal:flex-col",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+const tabsListVariants = cva(
+  "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+  {
+    variants: {
+      variant: {
+        default: "bg-muted",
+        line: "gap-1 bg-transparent",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+function TabsList({
+  className,
+  variant = "default",
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.List> &
+  VariantProps<typeof tabsListVariants>) {
+  return (
+    <TabsPrimitive.List
+      data-slot="tabs-list"
+      data-variant={variant}
+      className={cn(tabsListVariants({ variant }), className)}
+      {...props}
+    />
+  )
+}
+
+function TabsTrigger({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+  return (
+    <TabsPrimitive.Trigger
+      data-slot="tabs-trigger"
+      className={cn(
+        "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
+        "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
+        "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TabsContent({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+  return (
+    <TabsPrimitive.Content
+      data-slot="tabs-content"
+      className={cn("flex-1 text-sm outline-none", className)}
+      {...props}
+    />
+  )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

+ 142 - 0
apps/web/src/index.css

@@ -0,0 +1,142 @@
+@import "tailwindcss";
+
+@custom-variant dark (&:is(.dark *));
+
+/*
+ * 主题:**神策数据 风** —— 取自 sensorsdata.cn 的实际配色。
+ *   primary  #04CB94  mint teal(主按钮 / Tabs / focus ring)
+ *   hover    #36D5A9
+ *   text     #1F2D3D  slate-navy
+ *   bg       #F9FAFC  极浅 slate(非纯白)
+ *   muted    #99A9BF
+ *   tint     #DEFFF6  mint 极浅(高亮 / 选中)
+ *   error    #EF4444
+ *
+ * 改色调:调下面 :root 的 --primary 即可(连同 --ring/--accent 一起换)。
+ */
+
+:root {
+  --background: oklch(0.985 0.003 247);          /* #F9FAFC */
+  --foreground: oklch(0.27 0.035 261);           /* #1F2D3D / #2C2C45 之间 */
+  --card: oklch(1 0 0);                          /* 卡片纯白,浮在底色上 */
+  --card-foreground: oklch(0.27 0.035 261);
+  --popover: oklch(1 0 0);
+  --popover-foreground: oklch(0.27 0.035 261);
+  --primary: oklch(0.71 0.16 168);               /* #04CB94 mint */
+  --primary-foreground: oklch(1 0 0);
+  --secondary: oklch(0.967 0.006 247);
+  --secondary-foreground: oklch(0.32 0.035 261);
+  --muted: oklch(0.967 0.006 247);
+  --muted-foreground: oklch(0.68 0.025 247);     /* #99A9BF */
+  --accent: oklch(0.96 0.045 162);               /* #DEFFF6 mint 极浅 */
+  --accent-foreground: oklch(0.45 0.16 168);     /* 深 mint 文本 */
+  --destructive: oklch(0.63 0.22 27);            /* #EF4444 */
+  --destructive-foreground: oklch(1 0 0);
+  --border: oklch(0.92 0.008 247);
+  --input: oklch(0.92 0.008 247);
+  --ring: oklch(0.71 0.16 168 / 60%);            /* mint focus ring */
+  --chart-1: oklch(0.71 0.16 168);               /* mint(品牌) */
+  --chart-2: oklch(0.55 0.14 232);               /* blue */
+  --chart-3: oklch(0.58 0.20 282);               /* violet */
+  --chart-4: oklch(0.72 0.16 70);                /* amber */
+  --chart-5: oklch(0.62 0.22 27);                /* coral */
+  --radius: 0.625rem;
+  /* 侧栏 — 浅 slate(有色不刺眼,比白主区沉一档,顶 ≠ 左) */
+  --sidebar: oklch(0.965 0.008 247);             /* slate-100 微蓝灰 */
+  --sidebar-foreground: oklch(0.40 0.03 261);    /* slate-600/700 正文 */
+  --sidebar-primary: oklch(0.71 0.16 168);
+  --sidebar-primary-foreground: oklch(1 0 0);
+  --sidebar-accent: oklch(0.95 0.035 168);       /* 选中/hover:淡 mint 底 */
+  --sidebar-accent-foreground: oklch(0.50 0.15 168); /* mint-700 文字 */
+  --sidebar-border: oklch(0.91 0.008 247);
+  --sidebar-ring: oklch(0.71 0.16 168 / 60%);
+}
+
+.dark {
+  --background: oklch(0.18 0.02 261);
+  --foreground: oklch(0.97 0.003 247);
+  --card: oklch(0.23 0.02 261);
+  --card-foreground: oklch(0.97 0.003 247);
+  --popover: oklch(0.23 0.02 261);
+  --popover-foreground: oklch(0.97 0.003 247);
+  --primary: oklch(0.76 0.14 165);
+  --primary-foreground: oklch(0.18 0.02 261);
+  --secondary: oklch(0.28 0.025 261);
+  --secondary-foreground: oklch(0.97 0.003 247);
+  --muted: oklch(0.28 0.025 261);
+  --muted-foreground: oklch(0.70 0.018 247);
+  --accent: oklch(0.32 0.05 168);
+  --accent-foreground: oklch(0.85 0.10 168);
+  --destructive: oklch(0.65 0.22 27);
+  --destructive-foreground: oklch(0.97 0 0);
+  --border: oklch(1 0 0 / 10%);
+  --input: oklch(1 0 0 / 15%);
+  --ring: oklch(0.76 0.14 165 / 60%);
+  --chart-1: oklch(0.76 0.14 165);
+  --chart-2: oklch(0.65 0.16 232);
+  --chart-3: oklch(0.72 0.18 282);
+  --chart-4: oklch(0.78 0.16 70);
+  --chart-5: oklch(0.70 0.22 27);
+  --sidebar: oklch(0.21 0.022 261);
+  --sidebar-foreground: oklch(0.97 0.003 247);
+  --sidebar-primary: oklch(0.76 0.14 165);
+  --sidebar-primary-foreground: oklch(0.18 0.02 261);
+  --sidebar-accent: oklch(0.32 0.05 168);
+  --sidebar-accent-foreground: oklch(0.97 0.003 247);
+  --sidebar-border: oklch(1 0 0 / 10%);
+  --sidebar-ring: oklch(0.76 0.14 165 / 60%);
+}
+
+@theme inline {
+  --color-background: var(--background);
+  --color-foreground: var(--foreground);
+  --color-card: var(--card);
+  --color-card-foreground: var(--card-foreground);
+  --color-popover: var(--popover);
+  --color-popover-foreground: var(--popover-foreground);
+  --color-primary: var(--primary);
+  --color-primary-foreground: var(--primary-foreground);
+  --color-secondary: var(--secondary);
+  --color-secondary-foreground: var(--secondary-foreground);
+  --color-muted: var(--muted);
+  --color-muted-foreground: var(--muted-foreground);
+  --color-accent: var(--accent);
+  --color-accent-foreground: var(--accent-foreground);
+  --color-destructive: var(--destructive);
+  --color-destructive-foreground: var(--destructive-foreground);
+  --color-border: var(--border);
+  --color-input: var(--input);
+  --color-ring: var(--ring);
+  --color-chart-1: var(--chart-1);
+  --color-chart-2: var(--chart-2);
+  --color-chart-3: var(--chart-3);
+  --color-chart-4: var(--chart-4);
+  --color-chart-5: var(--chart-5);
+  --radius-sm: calc(var(--radius) - 4px);
+  --radius-md: calc(var(--radius) - 2px);
+  --radius-lg: var(--radius);
+  --radius-xl: calc(var(--radius) + 4px);
+  --color-sidebar: var(--sidebar);
+  --color-sidebar-foreground: var(--sidebar-foreground);
+  --color-sidebar-primary: var(--sidebar-primary);
+  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+  --color-sidebar-accent: var(--sidebar-accent);
+  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+  --color-sidebar-border: var(--sidebar-border);
+  --color-sidebar-ring: var(--sidebar-ring);
+}
+
+@layer base {
+  * {
+    @apply border-border;
+  }
+  body {
+    @apply bg-background text-foreground antialiased;
+  }
+  /* 卡片浮起的轻阴影,匹配神策站点 dashboard 风。 */
+  [data-slot="card"] {
+    box-shadow:
+      0 1px 2px oklch(0.27 0.035 261 / 0.04),
+      0 4px 12px oklch(0.27 0.035 261 / 0.04);
+  }
+}

+ 73 - 0
apps/web/src/layout/AppLayout.tsx

@@ -0,0 +1,73 @@
+import { useEffect } from 'react';
+import type { ReactNode } from 'react';
+import { useLocation } from 'react-router-dom';
+import { NavTree } from './NavTree';
+import { Breadcrumb } from './Breadcrumb';
+import { ThemeToggle } from './ThemeToggle';
+import { findTrail } from './nav-utils';
+
+interface Props {
+  children: ReactNode;
+}
+
+/** 品牌 mark:漏斗三阶 glyph(与 favicon 一致),mint 方块内白色。 */
+function BrandGlyph() {
+  return (
+    <svg viewBox="0 0 32 32" className="size-4" fill="currentColor" aria-hidden>
+      <rect x="7" y="9" width="18" height="3.6" rx="1.8" />
+      <rect x="9.5" y="14.6" width="13" height="3.6" rx="1.8" />
+      <rect x="12" y="20.2" width="8" height="3.6" rx="1.8" />
+    </svg>
+  );
+}
+
+/** Shell: top bar + left nav (L1/L2/L3 tree) + content area (docs/02 §9). */
+export function AppLayout({ children }: Props) {
+  const { pathname } = useLocation();
+
+  // 页面标题随路由变:「<当前页> · HS-Data」。
+  useEffect(() => {
+    const trail = findTrail(pathname);
+    const leaf = trail[trail.length - 1];
+    document.title = leaf
+      ? `${leaf.label} · HS Data`
+      : 'HS Data · 数据服务平台';
+  }, [pathname]);
+
+  return (
+    <div className="h-screen overflow-hidden flex flex-col bg-background text-foreground">
+      {/* 顶栏白、侧栏浅 slate —— 低反差,顶 ≠ 左 */}
+      <header className="h-14 flex items-center gap-2.5 px-5 border-b border-border bg-card">
+        <span className="grid size-7 shrink-0 place-items-center rounded-md bg-primary text-primary-foreground shadow-sm">
+          <BrandGlyph />
+        </span>
+        <span className="text-[15px] font-bold tracking-tight">
+          HS <span className="text-primary">Data</span>
+        </span>
+        <span className="h-4 w-px bg-border" />
+        <span className="text-xs text-muted-foreground">数据服务平台</span>
+        <div className="ml-auto flex items-center gap-2">
+          <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
+            <span className="size-1.5 rounded-full bg-primary" />
+            <span>MVP</span>
+          </span>
+          <ThemeToggle />
+        </div>
+      </header>
+      <div className="flex flex-1 min-h-0">
+        <aside className="w-56 shrink-0 overflow-y-auto border-r border-border bg-sidebar text-sidebar-foreground">
+          <NavTree />
+        </aside>
+        {/* scrollbar-gutter: stable —— 始终预留滚动条宽度,内容增高出现滚动条时不再横向抖动 */}
+        <main className="flex-1 min-w-0 overflow-auto [scrollbar-gutter:stable]">
+          <div className="p-6 flex flex-col gap-4">
+            <Breadcrumb />
+            {children}
+          </div>
+        </main>
+      </div>
+    </div>
+  );
+}
+
+export default AppLayout;

+ 31 - 0
apps/web/src/layout/Breadcrumb.tsx

@@ -0,0 +1,31 @@
+import { ChevronRight } from 'lucide-react';
+import { useLocation } from 'react-router-dom';
+import { findTrail } from './nav-utils';
+
+/** 内容区顶部面包屑:L1 / L2 / L3,末项高亮(docs/01 §2.3)。 */
+export function Breadcrumb() {
+  const { pathname } = useLocation();
+  const trail = findTrail(pathname);
+  if (!trail.length) return null;
+
+  return (
+    <nav
+      aria-label="breadcrumb"
+      className="flex items-center gap-1 text-xs text-muted-foreground"
+    >
+      {trail.map((n, i) => {
+        const last = i === trail.length - 1;
+        return (
+          <span key={n.key} className="flex items-center gap-1">
+            {i > 0 && <ChevronRight className="size-3 opacity-60" />}
+            <span className={last ? 'text-foreground font-medium' : ''}>
+              {n.label}
+            </span>
+          </span>
+        );
+      })}
+    </nav>
+  );
+}
+
+export default Breadcrumb;

+ 149 - 0
apps/web/src/layout/NavTree.tsx

@@ -0,0 +1,149 @@
+import { useEffect, useState } from 'react';
+import { ChevronDown, ChevronRight } from 'lucide-react';
+import { NavLink, useLocation } from 'react-router-dom';
+import { NAV, type NavNode } from '../routes/domains';
+import { cn } from '@/lib/utils';
+
+/** current 是否落在 base 子树内(精确 base 或 base/ 开头)。 */
+function inSubtree(current: string, base: string): boolean {
+  return current === base || current.startsWith(base + '/');
+}
+
+/** 当前路径命中的所有祖先父节点 key(用于自动展开当前分支)。 */
+function activeKeys(
+  nodes: NavNode[],
+  pathname: string,
+  acc: Set<string> = new Set(),
+): Set<string> {
+  for (const n of nodes) {
+    if (n.children?.length && inSubtree(pathname, n.path)) {
+      acc.add(n.key);
+      activeKeys(n.children, pathname, acc);
+    }
+  }
+  return acc;
+}
+
+interface ItemProps {
+  node: NavNode;
+  depth: number;
+  pathname: string;
+  openSet: Set<string>;
+  onToggle: (key: string) => void;
+}
+
+function NavItem({ node, depth, pathname, openSet, onToggle }: ItemProps) {
+  const hasChildren = !!node.children?.length;
+  const isOpen = hasChildren && openSet.has(node.key);
+  const inActive = inSubtree(pathname, node.path);
+  const Icon = node.icon;
+  const isL1 = depth === 0;
+
+  const row = cn(
+    'flex items-center gap-2 w-full rounded-md px-2.5 text-left transition-colors',
+    isL1 ? 'py-2 text-sm font-semibold' : 'py-1.5 text-[13px]',
+    'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
+  );
+
+  return (
+    <div>
+      {hasChildren ? (
+        // 父节点 = 手动展开/折叠按钮(不跳转)。
+        <button
+          type="button"
+          onClick={() => onToggle(node.key)}
+          aria-expanded={isOpen}
+          className={cn(row, inActive && 'text-sidebar-accent-foreground')}
+        >
+          {Icon && <Icon className="size-4 shrink-0" />}
+          <span className="truncate">{node.label}</span>
+          <span className="ml-auto text-sidebar-foreground/40">
+            {isOpen ? (
+              <ChevronDown className="size-3.5" />
+            ) : (
+              <ChevronRight className="size-3.5" />
+            )}
+          </span>
+        </button>
+      ) : (
+        // 叶子 = 跳转链接,精确激活 mint pill。
+        <NavLink
+          to={node.path}
+          end
+          className={({ isActive }) =>
+            cn(
+              row,
+              isActive &&
+                'bg-sidebar-accent text-sidebar-accent-foreground font-medium',
+            )
+          }
+        >
+          {Icon && <Icon className="size-4 shrink-0" />}
+          <span className="truncate">{node.label}</span>
+        </NavLink>
+      )}
+      {isOpen && (
+        // 左侧竖引导线 —— 三级层级一眼可分
+        <div className="ml-4 mt-1 flex flex-col gap-1 border-l border-sidebar-border pl-2">
+          {node.children!.map((c) => (
+            <NavItem
+              key={c.key}
+              node={c}
+              depth={depth + 1}
+              pathname={pathname}
+              openSet={openSet}
+              onToggle={onToggle}
+            />
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
+/**
+ * 左侧三级导航树(docs/01 §2)。展开行为对齐主流后台:
+ *  - 点 L1/L2 分组手动展开/折叠;叶子才跳转。
+ *  - 多个分组可同时展开(非手风琴)。
+ *  - 进入某页自动展开其所在分支,但不折叠用户已手动展开的其它分支。
+ */
+export function NavTree() {
+  const { pathname } = useLocation();
+  const [openSet, setOpenSet] = useState<Set<string>>(() =>
+    activeKeys(NAV, pathname),
+  );
+
+  // 路由变化时把当前分支并入展开集合(保留用户手动展开的其它分支)。
+  useEffect(() => {
+    setOpenSet((prev) => {
+      const next = new Set(prev);
+      for (const k of activeKeys(NAV, pathname)) next.add(k);
+      return next;
+    });
+  }, [pathname]);
+
+  const toggle = (key: string) =>
+    setOpenSet((prev) => {
+      const next = new Set(prev);
+      if (next.has(key)) next.delete(key);
+      else next.add(key);
+      return next;
+    });
+
+  return (
+    <nav className="p-3 flex flex-col gap-1">
+      {NAV.map((n) => (
+        <NavItem
+          key={n.key}
+          node={n}
+          depth={0}
+          pathname={pathname}
+          openSet={openSet}
+          onToggle={toggle}
+        />
+      ))}
+    </nav>
+  );
+}
+
+export default NavTree;

+ 39 - 0
apps/web/src/layout/ThemeToggle.tsx

@@ -0,0 +1,39 @@
+import { useEffect, useState } from 'react';
+import { Moon, Sun } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+type Theme = 'light' | 'dark';
+const KEY = 'hs-theme';
+
+function getInitial(): Theme {
+  const saved = localStorage.getItem(KEY);
+  return saved === 'dark' || saved === 'light' ? saved : 'light';
+}
+
+/** 暗色/亮色切换。主题变量已在 index.css 备好;偏好存 localStorage。 */
+export function ThemeToggle() {
+  const [theme, setTheme] = useState<Theme>(getInitial);
+
+  useEffect(() => {
+    document.documentElement.classList.toggle('dark', theme === 'dark');
+    localStorage.setItem(KEY, theme);
+  }, [theme]);
+
+  return (
+    <Button
+      variant="ghost"
+      size="icon"
+      className="size-8"
+      aria-label={theme === 'dark' ? '切换到亮色' : '切换到暗色'}
+      onClick={() => setTheme((t) => (t === 'dark' ? 'light' : 'dark'))}
+    >
+      {theme === 'dark' ? (
+        <Sun className="size-4" />
+      ) : (
+        <Moon className="size-4" />
+      )}
+    </Button>
+  );
+}
+
+export default ThemeToggle;

+ 20 - 0
apps/web/src/layout/nav-utils.ts

@@ -0,0 +1,20 @@
+import { NAV, type NavNode } from '../routes/domains';
+
+function inSubtree(current: string, base: string): boolean {
+  return current === base || current.startsWith(base + '/');
+}
+
+/**
+ * 当前路径对应的节点链 L1 → … → 叶子(面包屑 / 标题用)。
+ * 命中父节点但无精确子节点时,返回到父节点为止(如重定向落点)。
+ */
+export function findTrail(pathname: string, nodes: NavNode[] = NAV): NavNode[] {
+  for (const n of nodes) {
+    if (n.path === pathname) return [n];
+    if (n.children?.length && inSubtree(pathname, n.path)) {
+      const deeper = findTrail(pathname, n.children);
+      return deeper.length ? [n, ...deeper] : [n];
+    }
+  }
+  return [];
+}

+ 6 - 0
apps/web/src/lib/utils.ts

@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}

+ 24 - 0
apps/web/src/main.tsx

@@ -0,0 +1,24 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { App } from './App';
+import { queryClient } from './api/queryClient';
+import { Toaster } from '@/components/ui/sonner';
+import './index.css';
+
+// 在首屏渲染前应用主题偏好,避免亮/暗闪烁。
+if (localStorage.getItem('hs-theme') === 'dark') {
+  document.documentElement.classList.add('dark');
+}
+
+createRoot(document.getElementById('root')!).render(
+  <StrictMode>
+    <QueryClientProvider client={queryClient}>
+      <BrowserRouter>
+        <App />
+        <Toaster />
+      </BrowserRouter>
+    </QueryClientProvider>
+  </StrictMode>,
+);

+ 85 - 0
apps/web/src/modules/funnel/FunnelPage.tsx

@@ -0,0 +1,85 @@
+import { useEffect, useState } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { queryFunnel, USE_MOCK } from '../../api/funnel';
+import type {
+  FunnelPeriod,
+  FunnelQueryRequest,
+  FunnelQueryResponse,
+} from '../../api/types';
+import { DEFAULT_PERIOD, toSnapshotParam, yesterday } from './period';
+import { TimePeriodSelect } from './components/TimePeriodSelect';
+import { FunnelResult } from './components/FunnelResult';
+import { Badge } from '@/components/ui/badge';
+
+/**
+ * 拼团 5-step funnel page (docs/02 §5 v3). The funnel is fixed. Inputs are the
+ * standard period plus — for `day` only — a historical date. Selecting a period
+ * (incl. the default on first load) or, in single-day mode, a date immediately
+ * fires a query.
+ */
+export function FunnelPage() {
+  const [period, setPeriod] = useState<FunnelPeriod>(DEFAULT_PERIOD);
+  // Selected calendar day for `period: 'day'`; defaults to yesterday (max).
+  const [snapshotDt, setSnapshotDt] = useState<Date>(() => yesterday());
+
+  const mutation = useMutation<FunnelQueryResponse, Error, FunnelQueryRequest>({
+    mutationFn: queryFunnel,
+  });
+
+  const { mutate } = mutation;
+
+  // Query on mount and whenever the period — or, for single-day, the chosen
+  // date — changes. Send snapshot_dt ONLY for `day`; omit it for 7d/30d.
+  const dayParam = period === 'day' ? toSnapshotParam(snapshotDt) : undefined;
+  useEffect(() => {
+    mutate(
+      period === 'day'
+        ? { period, snapshot_dt: dayParam }
+        : { period },
+    );
+  }, [period, dayParam, mutate]);
+
+  return (
+    <div className="flex flex-col gap-5 w-full">
+      {/* 标题行:标题 + Mock 徽章 在左,控件 ml-auto 落右 */}
+      <div className="flex items-center gap-3 flex-wrap">
+        <h2 className="text-xl font-semibold tracking-tight m-0">漏斗分析</h2>
+        {USE_MOCK && (
+          <Badge
+            variant="outline"
+            className="text-xs font-normal text-muted-foreground border-dashed gap-1.5 pl-2"
+          >
+            <span className="size-1.5 rounded-full bg-amber-500" />
+            Mock 模式
+          </Badge>
+        )}
+        <div className="ml-auto">
+          {/* 单日 / 近7天 / 近30天 三选一,选中 mint 高亮;单日可选历史。 */}
+          <TimePeriodSelect
+            period={period}
+            snapshotDt={snapshotDt}
+            onPickDate={(d) => {
+              setSnapshotDt(d);
+              setPeriod('day');
+            }}
+            onPickRolling={(p) => setPeriod(p)}
+          />
+        </div>
+      </div>
+
+      <p className="text-sm text-muted-foreground m-0">
+        拼团漏斗:启动 → 曝光 → 拼团详情 → 下单 → 成功
+      </p>
+
+      <FunnelResult
+        period={period}
+        data={mutation.data}
+        isLoading={mutation.isPending}
+        isError={mutation.isError}
+        errorMessage={mutation.error?.message}
+      />
+    </div>
+  );
+}
+
+export default FunnelPage;

+ 260 - 0
apps/web/src/modules/funnel/__tests__/FunnelPage.test.tsx

@@ -0,0 +1,260 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { FunnelPage } from '../FunnelPage';
+import type { FunnelQueryResponse } from '../../../api/types';
+
+// Force mock mode regardless of env and make it controllable per test.
+const queryFunnelMock = vi.fn();
+vi.mock('../../../api/funnel', () => ({
+  USE_MOCK: true,
+  queryFunnel: (...args: unknown[]) => queryFunnelMock(...args),
+}));
+
+function readyResponse(): FunnelQueryResponse {
+  return {
+    period: 'last_7d',
+    snapshot_dt: '20260623',
+    data_status: 'ready',
+    results: [
+      {
+        step_index: 1,
+        name: '启动',
+        event_key: 'start',
+        uv: 10000,
+        conversion_rate: null,
+        dropoff_rate: null,
+      },
+      {
+        step_index: 2,
+        name: '曝光',
+        event_key: 'show',
+        uv: 8200,
+        conversion_rate: 0.82,
+        dropoff_rate: 0.18,
+      },
+      {
+        step_index: 3,
+        name: '拼团详情',
+        event_key: 'detail',
+        uv: 5100,
+        conversion_rate: 0.62,
+        dropoff_rate: 0.38,
+      },
+      {
+        step_index: 4,
+        name: '下单',
+        event_key: 'order',
+        uv: 2200,
+        conversion_rate: 0.43,
+        dropoff_rate: 0.57,
+      },
+      {
+        step_index: 5,
+        name: '成功',
+        event_key: 'paid',
+        uv: 1800,
+        conversion_rate: 0.82,
+        dropoff_rate: 0.18,
+      },
+    ],
+  };
+}
+
+function renderPage() {
+  const client = new QueryClient({
+    defaultOptions: { queries: { retry: false } },
+  });
+  return render(
+    <QueryClientProvider client={client}>
+      <FunnelPage />
+    </QueryClientProvider>,
+  );
+}
+
+describe('FunnelPage — fixed funnel + period selection', () => {
+  beforeEach(() => {
+    queryFunnelMock.mockReset();
+  });
+
+  it('renders 单日 / 近7天 / 近30天 segmented time control', () => {
+    queryFunnelMock.mockResolvedValue(readyResponse());
+    renderPage();
+    for (const label of ['近 7 天', '近 30 天']) {
+      expect(screen.getByRole('button', { name: label })).toBeInTheDocument();
+    }
+    // 单日 segment opens the calendar (aria-label) and shows "单日".
+    const picker = screen.getByRole('button', { name: '选择历史日期' });
+    expect(picker).toBeInTheDocument();
+    expect(picker).toHaveTextContent('单日');
+  });
+
+  /** Last request body the spy was invoked with. */
+  function lastReq(): { period?: string; snapshot_dt?: string } | undefined {
+    const calls = queryFunnelMock.mock.calls;
+    return calls[calls.length - 1]?.[0];
+  }
+
+  /** Last period the spy was invoked with. */
+  function lastPeriod(): string | undefined {
+    return lastReq()?.period;
+  }
+
+  /** Yesterday as "yyyy-MM-dd" (the picker default + request snapshot_dt). */
+  function yesterdayParam(): string {
+    const d = new Date();
+    d.setDate(d.getDate() - 1);
+    const y = d.getFullYear();
+    const m = String(d.getMonth() + 1).padStart(2, '0');
+    const day = String(d.getDate()).padStart(2, '0');
+    return `${y}-${m}-${day}`;
+  }
+
+  it('auto-queries with default 单日 (day = yesterday) on first render', async () => {
+    queryFunnelMock.mockResolvedValue(readyResponse());
+    renderPage();
+
+    await waitFor(() => expect(lastPeriod()).toBe('day'));
+    expect(lastReq()).toEqual({ period: 'day', snapshot_dt: yesterdayParam() });
+    expect(queryFunnelMock).toHaveBeenCalledTimes(1);
+  });
+
+  it('default ready response renders chart and a 5-row table', async () => {
+    queryFunnelMock.mockResolvedValue(readyResponse());
+    renderPage();
+
+    await waitFor(() =>
+      expect(screen.getByTestId('funnel-chart')).toBeInTheDocument(),
+    );
+    // 5 fixed steps shown in the table
+    for (const name of ['启动', '曝光', '拼团详情', '下单', '成功']) {
+      expect(screen.getAllByText(new RegExp(name)).length).toBeGreaterThan(0);
+    }
+  });
+
+  /** Open the always-on 单日 calendar popover and return its dialog. */
+  async function openCalendar(user: ReturnType<typeof userEvent.setup>) {
+    await user.click(screen.getByRole('button', { name: '选择历史日期' }));
+    return screen.findByRole('dialog');
+  }
+
+  /** Click the first enabled past day-cell (the 1st of the current month). */
+  async function pickFirstOfMonth(
+    user: ReturnType<typeof userEvent.setup>,
+    dialog: HTMLElement,
+  ) {
+    const dayButtons = within(dialog)
+      .getAllByRole('button')
+      .filter((b) => /^\d+$/.test(b.textContent?.trim() ?? ''));
+    const firstBtn = dayButtons.find(
+      (b) => b.textContent?.trim() === '1' && !b.hasAttribute('disabled'),
+    );
+    expect(firstBtn).toBeTruthy();
+    await user.click(firstBtn!);
+  }
+
+  it('rolling segment / picking a date triggers a query with the right period', async () => {
+    queryFunnelMock.mockResolvedValue(readyResponse());
+    const user = userEvent.setup();
+    renderPage();
+
+    await waitFor(() => expect(lastPeriod()).toBe('day'));
+
+    await user.click(screen.getByRole('button', { name: '近 30 天' }));
+    await waitFor(() => expect(lastPeriod()).toBe('last_30d'));
+
+    // picking a date switches the active period back to `day`
+    await pickFirstOfMonth(user, await openCalendar(user));
+    await waitFor(() => expect(lastPeriod()).toBe('day'));
+  });
+
+  it('单日 segment defaults to yesterday', async () => {
+    queryFunnelMock.mockResolvedValue(readyResponse());
+    renderPage();
+
+    await waitFor(() => expect(lastPeriod()).toBe('day'));
+    const picker = screen.getByRole('button', { name: '选择历史日期' });
+    expect(picker).toBeInTheDocument();
+    expect(picker).toHaveTextContent(yesterdayParam());
+  });
+
+  it('default day sends snapshot_dt; rolling periods omit it', async () => {
+    queryFunnelMock.mockResolvedValue(readyResponse());
+    const user = userEvent.setup();
+    renderPage();
+
+    // default 单日 sends snapshot_dt = yesterday
+    await waitFor(() => expect(lastPeriod()).toBe('day'));
+    expect(lastReq()).toEqual({ period: 'day', snapshot_dt: yesterdayParam() });
+
+    // rolling periods omit snapshot_dt
+    await user.click(screen.getByRole('button', { name: '近 7 天' }));
+    await waitFor(() => expect(lastPeriod()).toBe('last_7d'));
+    expect(lastReq()).toEqual({ period: 'last_7d' });
+
+    await user.click(screen.getByRole('button', { name: '近 30 天' }));
+    await waitFor(() => expect(lastPeriod()).toBe('last_30d'));
+    expect(lastReq()).toEqual({ period: 'last_30d' });
+  });
+
+  it('today + future are disabled in the calendar; a past date re-queries with that snapshot_dt', async () => {
+    queryFunnelMock.mockResolvedValue(readyResponse());
+    const user = userEvent.setup();
+    renderPage();
+
+    await waitFor(() => expect(lastPeriod()).toBe('day'));
+
+    // Open the 单日 calendar popover.
+    const dialog = await openCalendar(user);
+
+    // All day-cell buttons in the current month grid.
+    const dayButtons = within(dialog)
+      .getAllByRole('button')
+      .filter((b) => /^\d+$/.test(b.textContent?.trim() ?? ''));
+
+    // Today's day cell must be disabled (data is T+1).
+    const today = new Date();
+    const todayBtn = dayButtons.find(
+      (b) => b.textContent?.trim() === String(today.getDate()),
+    );
+    if (todayBtn) {
+      expect(todayBtn).toBeDisabled();
+    }
+
+    // Pick a clearly-past, enabled day (the 1st of the current month).
+    const firstBtn = dayButtons.find(
+      (b) => b.textContent?.trim() === '1' && !b.hasAttribute('disabled'),
+    );
+    expect(firstBtn).toBeTruthy();
+    await user.click(firstBtn!);
+
+    await waitFor(() => {
+      const req = lastReq();
+      expect(req?.period).toBe('day');
+      expect(req?.snapshot_dt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+    });
+  });
+
+  it('missing data_status renders 数据缺失 without zero-fill', async () => {
+    queryFunnelMock.mockResolvedValue({
+      period: 'last_7d',
+      snapshot_dt: null,
+      data_status: 'missing',
+      results: [],
+    });
+    renderPage();
+    await waitFor(() =>
+      expect(screen.getByText(/数据缺失/)).toBeInTheDocument(),
+    );
+    expect(screen.queryByTestId('funnel-chart')).not.toBeInTheDocument();
+  });
+
+  it('transport error renders the error state', async () => {
+    queryFunnelMock.mockRejectedValue(new Error('网络请求失败'));
+    renderPage();
+    await waitFor(() =>
+      expect(screen.getByText('查询失败')).toBeInTheDocument(),
+    );
+  });
+});

+ 116 - 0
apps/web/src/modules/funnel/__tests__/FunnelResult.test.tsx

@@ -0,0 +1,116 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import type { FunnelQueryResponse } from '../../../api/types';
+import { FunnelResult } from '../components/FunnelResult';
+
+function renderResult(props: Parameters<typeof FunnelResult>[0]) {
+  return render(<FunnelResult {...props} />);
+}
+
+const readyData: FunnelQueryResponse = {
+  period: 'last_7d',
+  snapshot_dt: '20260623',
+  data_status: 'ready',
+  results: [
+    {
+      step_index: 1,
+      name: '启动',
+      event_key: 'start',
+      uv: 10000,
+      conversion_rate: null,
+      dropoff_rate: null,
+    },
+    {
+      step_index: 2,
+      name: '曝光',
+      event_key: 'show',
+      uv: 8200,
+      conversion_rate: 0.82,
+      dropoff_rate: 0.18,
+    },
+  ],
+};
+
+const baseProps = {
+  period: 'last_7d' as const,
+  isLoading: false,
+  isError: false,
+};
+
+describe('FunnelResult states', () => {
+  it('ready: renders funnel chart and result table', async () => {
+    renderResult({ ...baseProps, data: readyData });
+    // FunnelChart is lazy-loaded — await its appearance.
+    expect(await screen.findByTestId('funnel-chart')).toBeInTheDocument();
+    expect(screen.getByText('UV')).toBeInTheDocument();
+    expect(screen.getByText('转化率')).toBeInTheDocument();
+    expect(screen.getByText('流失率')).toBeInTheDocument();
+    expect(screen.getByText(/启动/)).toBeInTheDocument();
+    expect(screen.getByText(/曝光/)).toBeInTheDocument();
+  });
+
+  it('ready (7d): shows the rolling "数据截至 … (近 7 天)" caption', () => {
+    renderResult({ ...baseProps, data: readyData });
+    expect(
+      screen.getByText(/数据截至 2026-06-23(近 7 天)/),
+    ).toBeInTheDocument();
+    expect(screen.queryByText(/数据快照日/)).not.toBeInTheDocument();
+  });
+
+  it('ready (30d): caption says (近 30 天)', () => {
+    renderResult({
+      ...baseProps,
+      period: 'last_30d',
+      data: { ...readyData, period: 'last_30d' },
+    });
+    expect(
+      screen.getByText(/数据截至 2026-06-23(近 30 天)/),
+    ).toBeInTheDocument();
+  });
+
+  it('ready (day): shows the "数据快照日" caption', () => {
+    renderResult({
+      ...baseProps,
+      period: 'day',
+      data: { ...readyData, period: 'day' },
+    });
+    expect(screen.getByText(/数据快照日:2026-06-23/)).toBeInTheDocument();
+    expect(screen.queryByText(/数据截至/)).not.toBeInTheDocument();
+  });
+
+  it('ready: step 1 null conversion renders as "—", not 0%', () => {
+    renderResult({ ...baseProps, data: readyData });
+    const dashes = screen.getAllByText('—');
+    // step 1 conversion + dropoff = two em-dashes
+    expect(dashes.length).toBeGreaterThanOrEqual(2);
+    expect(screen.queryByText('0%')).not.toBeInTheDocument();
+    expect(screen.queryByText('0.00%')).not.toBeInTheDocument();
+    // the real value still shows
+    expect(screen.getByText('82.00%')).toBeInTheDocument();
+  });
+
+  it('missing: explicit 数据缺失 empty state, no zeros / no chart', () => {
+    renderResult({
+      ...baseProps,
+      data: { ...readyData, results: [], data_status: 'missing' },
+    });
+    expect(screen.getByText(/数据缺失/)).toBeInTheDocument();
+    expect(screen.queryByTestId('funnel-chart')).not.toBeInTheDocument();
+    expect(screen.queryByText('0')).not.toBeInTheDocument();
+  });
+
+  it('request error: shows error state with message', () => {
+    renderResult({
+      ...baseProps,
+      isError: true,
+      errorMessage: '网络请求失败',
+    });
+    expect(screen.getByText('查询失败')).toBeInTheDocument();
+    expect(screen.getByText('网络请求失败')).toBeInTheDocument();
+  });
+
+  it('loading / no data yet: shows loading card', () => {
+    renderResult({ ...baseProps, isLoading: true });
+    expect(screen.getByTestId('funnel-loading')).toBeInTheDocument();
+  });
+});

+ 27 - 0
apps/web/src/modules/funnel/__tests__/format.test.ts

@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { formatRate, formatUv } from '../format';
+
+describe('formatRate — null rendered as "—", never 0%', () => {
+  it('renders null as em-dash', () => {
+    expect(formatRate(null)).toBe('—');
+  });
+  it('renders undefined as em-dash', () => {
+    expect(formatRate(undefined)).toBe('—');
+  });
+  it('does NOT render null as 0%', () => {
+    expect(formatRate(null)).not.toBe('0%');
+    expect(formatRate(null)).not.toBe('0.00%');
+  });
+  it('renders 0 (real zero) as 0.00%, distinct from null', () => {
+    expect(formatRate(0)).toBe('0.00%');
+  });
+  it('renders a fraction as a percentage', () => {
+    expect(formatRate(0.42)).toBe('42.00%');
+  });
+});
+
+describe('formatUv', () => {
+  it('adds thousands separators', () => {
+    expect(formatUv(10000)).toBe('10,000');
+  });
+});

+ 105 - 0
apps/web/src/modules/funnel/components/FunnelChart.tsx

@@ -0,0 +1,105 @@
+import ReactECharts from 'echarts-for-react';
+import type { EChartsOption } from 'echarts';
+import type { FunnelResultRow } from '../../../api/types';
+import { formatRate, formatUv } from '../format';
+
+interface Props {
+  results: FunnelResultRow[];
+}
+
+// Mint 单色渐变(对齐神策品牌 #04CB94,5 步从深到浅,数据工具感)。
+const STEP_COLORS = ['#064E3B', '#065F46', '#047857', '#10A37F', '#04CB94'];
+
+/**
+ * 结果图表区 — ECharts 漏斗图。每块显示步骤名 + UV,块右侧标注相邻步骤转化率,
+ * tooltip 给出完整数值(null 渲染为 “—”)。
+ */
+export function FunnelChart({ results }: Props) {
+  const option: EChartsOption = {
+    backgroundColor: 'transparent',
+    textStyle: {
+      fontFamily:
+        'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif',
+      color: '#334155',
+    },
+    tooltip: {
+      trigger: 'item',
+      backgroundColor: 'rgba(15, 23, 42, 0.95)',
+      borderWidth: 0,
+      padding: [10, 12],
+      textStyle: { color: '#f8fafc', fontSize: 12, lineHeight: 18 },
+      extraCssText: 'border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.18);',
+      formatter: (params) => {
+        const p = params as { dataIndex: number; name: string };
+        const row = results[p.dataIndex];
+        if (!row) return p.name;
+        return [
+          `<div style="font-weight:600;margin-bottom:4px">${row.name}</div>`,
+          `<div style="opacity:.75">事件 ${row.event_key}</div>`,
+          `<div>UV <b>${formatUv(row.uv)}</b></div>`,
+          `<div>转化率 <b>${formatRate(row.conversion_rate)}</b></div>`,
+          `<div>流失率 <b>${formatRate(row.dropoff_rate)}</b></div>`,
+        ].join('');
+      },
+    },
+    series: [
+      {
+        name: '漏斗',
+        type: 'funnel',
+        // 用真实 UV 比例驱动块宽,但保留 12% 最小尾巴避免最末步细到不可读。
+        sort: 'none',
+        funnelAlign: 'center',
+        gap: 4,
+        left: '12%',
+        right: '12%',
+        top: 12,
+        bottom: 12,
+        min: 0,
+        minSize: '12%',
+        maxSize: '100%',
+        label: {
+          show: true,
+          position: 'inside',
+          color: '#ffffff',
+          fontSize: 13,
+          fontWeight: 600,
+          formatter: (params) => {
+            const p = params as { dataIndex: number };
+            const row = results[p.dataIndex];
+            if (!row) return '';
+            return `${row.name}  ${formatUv(row.uv)}`;
+          },
+        },
+        labelLine: { show: false },
+        itemStyle: {
+          borderColor: '#ffffff',
+          borderWidth: 1,
+          borderRadius: 4,
+        },
+        emphasis: {
+          label: { fontSize: 14 },
+          itemStyle: {
+            shadowBlur: 8,
+            shadowColor: 'rgba(15, 23, 42, 0.25)',
+          },
+        },
+        data: results.map((r, i) => ({
+          value: r.uv,
+          name: r.name,
+          itemStyle: { color: STEP_COLORS[i] ?? STEP_COLORS[STEP_COLORS.length - 1] },
+        })),
+      },
+    ],
+  };
+
+  return (
+    <div className="px-2 py-3">
+      <ReactECharts
+        option={option}
+        style={{ height: 380, width: '100%' }}
+        notMerge
+        data-testid="funnel-chart"
+      />
+    </div>
+  );
+}

+ 133 - 0
apps/web/src/modules/funnel/components/FunnelResult.tsx

@@ -0,0 +1,133 @@
+import { lazy, Suspense } from 'react';
+import { Inbox, AlertCircle } from 'lucide-react';
+import type { FunnelPeriod, FunnelQueryResponse } from '../../../api/types';
+import { formatSnapshotDt } from '../period';
+import { ResultTable } from './ResultTable';
+import { Card, CardContent } from '@/components/ui/card';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Separator } from '@/components/ui/separator';
+
+// ECharts 较重,按需懒加载 —— 拆出独立 chunk,壳先渲染、图表随后填入。
+const FunnelChart = lazy(() =>
+  import('./FunnelChart').then((m) => ({ default: m.FunnelChart })),
+);
+
+interface Props {
+  /** Active period — drives the caption wording (快照日 vs 数据截至). */
+  period: FunnelPeriod;
+  /** Undefined before any query has run. */
+  data?: FunnelQueryResponse;
+  isLoading: boolean;
+  isError: boolean;
+  /** Error message from a failed request (network / HTTP). */
+  errorMessage?: string;
+}
+
+/**
+ * 结果区 — renders each data_status explicitly:
+ *   ready   -> chart + table + snapshot caption
+ *   missing -> "数据缺失" empty state (NO zero-fill)
+ * Plus a generic transport/HTTP error state and an initial loading state.
+ */
+export function FunnelResult({
+  period,
+  data,
+  isLoading,
+  isError,
+  errorMessage,
+}: Props) {
+  // Transport-level failure (network / non-2xx) takes priority.
+  if (isError) {
+    return (
+      <Alert variant="destructive">
+        <AlertCircle className="size-4" />
+        <AlertTitle>查询失败</AlertTitle>
+        <AlertDescription>
+          {errorMessage ?? '请求漏斗接口时发生错误,请稍后重试。'}
+        </AlertDescription>
+      </Alert>
+    );
+  }
+
+  if (isLoading || !data) {
+    // 骨架高度对齐真实内容(漏斗图 ~380 + 表格),避免加载完竖向跳动。
+    return (
+      <Card data-testid="funnel-loading">
+        <CardContent className="flex flex-col gap-6 pt-6">
+          <Skeleton className="h-[360px] w-full" />
+          <Separator />
+          <div className="flex flex-col gap-3">
+            <Skeleton className="h-8 w-full" />
+            {Array.from({ length: 5 }).map((_, i) => (
+              <Skeleton key={i} className="h-6 w-full" />
+            ))}
+          </div>
+          <Skeleton className="h-4 w-40" />
+        </CardContent>
+      </Card>
+    );
+  }
+
+  // Explicit missing — never zero-fill.
+  if (data.data_status === 'missing' || data.results.length === 0) {
+    return (
+      <Card>
+        <CardContent className="flex flex-col items-center justify-center gap-3 py-12 text-center">
+          <Inbox className="size-10 text-muted-foreground/60" />
+          <p className="text-sm text-muted-foreground m-0">
+            数据缺失:所选周期暂无产出数据,未做补零处理。
+          </p>
+          <SnapshotCaption period={period} snapshotDt={data.snapshot_dt} />
+        </CardContent>
+      </Card>
+    );
+  }
+
+  // ready — 单卡:漏斗图 + 分隔线 + 结果明细 + 快照说明。
+  return (
+    <Card>
+      <CardContent className="flex flex-col gap-6 pt-6">
+        <Suspense fallback={<Skeleton className="h-[360px] w-full" />}>
+          <FunnelChart results={data.results} />
+        </Suspense>
+        <Separator />
+        <ResultTable results={data.results} />
+        <SnapshotCaption period={period} snapshotDt={data.snapshot_dt} />
+      </CardContent>
+    </Card>
+  );
+}
+
+/**
+ * Two caption variants from the response snapshot_dt:
+ *  - day:        “数据快照日:YYYY-MM-DD” (the queried day).
+ *  - 7d/30d:     “数据截至 YYYY-MM-DD(近 7 天 / 近 30 天)” — the rolling
+ *                window's as-of/end date, making clear it excludes today.
+ * Renders nothing when the backend returned no row (snapshot_dt === null).
+ */
+function SnapshotCaption({
+  period,
+  snapshotDt,
+}: {
+  period: FunnelPeriod;
+  snapshotDt: string | null;
+}) {
+  if (!snapshotDt) return null;
+  const dt = formatSnapshotDt(snapshotDt);
+
+  if (period === 'day') {
+    return (
+      <p className="text-sm text-muted-foreground mt-3 mb-0">
+        数据快照日:{dt}
+      </p>
+    );
+  }
+
+  const window = period === 'last_7d' ? '近 7 天' : '近 30 天';
+  return (
+    <p className="text-sm text-muted-foreground mt-3 mb-0">
+      数据截至 {dt}({window})
+    </p>
+  );
+}

+ 55 - 0
apps/web/src/modules/funnel/components/ResultTable.tsx

@@ -0,0 +1,55 @@
+import type { FunnelResultRow } from '../../../api/types';
+import { formatRate, formatUv } from '../format';
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table';
+
+interface Props {
+  results: FunnelResultRow[];
+}
+
+/** 结果表格区 — 步骤名 / UV / 转化率 / 流失率. */
+export function ResultTable({ results }: Props) {
+  return (
+    <Table>
+      <TableHeader>
+        <TableRow>
+          <TableHead>步骤名</TableHead>
+          <TableHead className="text-right">UV</TableHead>
+          <TableHead className="text-right">转化率</TableHead>
+          <TableHead className="text-right">流失率</TableHead>
+        </TableRow>
+      </TableHeader>
+      <TableBody>
+        {results.map((row) => (
+          <TableRow key={row.step_index}>
+            <TableCell>
+              <div className="flex flex-col">
+                <strong className="font-medium">
+                  {row.step_index}. {row.name}
+                </strong>
+                <span className="text-xs text-muted-foreground">
+                  {row.event_key}
+                </span>
+              </div>
+            </TableCell>
+            <TableCell className="text-right tabular-nums">
+              {formatUv(row.uv)}
+            </TableCell>
+            <TableCell className="text-right tabular-nums">
+              {formatRate(row.conversion_rate)}
+            </TableCell>
+            <TableCell className="text-right tabular-nums">
+              {formatRate(row.dropoff_rate)}
+            </TableCell>
+          </TableRow>
+        ))}
+      </TableBody>
+    </Table>
+  );
+}

+ 94 - 0
apps/web/src/modules/funnel/components/TimePeriodSelect.tsx

@@ -0,0 +1,94 @@
+import { CalendarIcon } from 'lucide-react';
+import type { FunnelPeriod } from '../../../api/types';
+import { toSnapshotParam, yesterday } from '../period';
+import { Calendar } from '@/components/ui/calendar';
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/popover';
+import { cn } from '@/lib/utils';
+
+interface Props {
+  period: FunnelPeriod;
+  /** Selected day for `period: 'day'`. */
+  snapshotDt: Date;
+  /** Pick a historical day → switch to single-day mode. */
+  onPickDate: (d: Date) => void;
+  /** Pick a rolling window. */
+  onPickRolling: (p: 'last_7d' | 'last_30d') => void;
+}
+
+// 统一 segmented:选中态由 React 状态直接驱动 mint 实色,不依赖 shadcn 变体。
+const SEG =
+  'h-8 px-3 inline-flex items-center gap-1.5 rounded-md text-sm font-medium transition-colors cursor-pointer';
+const ON = 'bg-primary text-primary-foreground shadow-sm';
+const OFF = 'text-muted-foreground hover:text-foreground hover:bg-background';
+
+/**
+ * 时间选择 — 单日 / 近 7 天 / 近 30 天 三选一的 segmented 控件(docs/04)。
+ * 「单日」段点开日历可选历史日(上限昨日,T+1);任一选中 = mint 高亮,
+ * 给明确的"当前在此周期"反馈。
+ */
+export function TimePeriodSelect({
+  period,
+  snapshotDt,
+  onPickDate,
+  onPickRolling,
+}: Props) {
+  const max = yesterday();
+
+  return (
+    <div className="inline-flex items-center gap-1 rounded-lg border border-border bg-muted/60 p-1">
+      {/* 单日 —— 日历 popover */}
+      <Popover>
+        <PopoverTrigger asChild>
+          <button
+            type="button"
+            aria-label="选择历史日期"
+            aria-pressed={period === 'day'}
+            className={cn(SEG, period === 'day' ? ON : OFF)}
+          >
+            <CalendarIcon className="size-4" />
+            <span>单日</span>
+            <span className="tabular-nums opacity-90">
+              {toSnapshotParam(snapshotDt)}
+            </span>
+          </button>
+        </PopoverTrigger>
+        <PopoverContent className="w-auto p-0" align="start">
+          <Calendar
+            mode="single"
+            selected={snapshotDt}
+            defaultMonth={snapshotDt}
+            disabled={{ after: max }}
+            required
+            onSelect={(d) => {
+              if (d) onPickDate(d);
+            }}
+          />
+        </PopoverContent>
+      </Popover>
+
+      {/* 近 7 天 / 近 30 天 */}
+      <button
+        type="button"
+        aria-pressed={period === 'last_7d'}
+        onClick={() => onPickRolling('last_7d')}
+        className={cn(SEG, period === 'last_7d' ? ON : OFF)}
+      >
+        近 7 天
+      </button>
+      <button
+        type="button"
+        aria-pressed={period === 'last_30d'}
+        onClick={() => onPickRolling('last_30d')}
+        className={cn(SEG, period === 'last_30d' ? ON : OFF)}
+      >
+        近 30 天
+      </button>
+    </div>
+  );
+}
+
+export default TimePeriodSelect;

+ 10 - 0
apps/web/src/modules/funnel/format.ts

@@ -0,0 +1,10 @@
+/** Render a rate (0..1) or null. Null -> "—" (NEVER 0% / 100%). */
+export function formatRate(rate: number | null | undefined): string {
+  if (rate === null || rate === undefined) return '—';
+  return `${(rate * 100).toFixed(2)}%`;
+}
+
+/** Render a UV count with thousands separators. */
+export function formatUv(uv: number): string {
+  return uv.toLocaleString('en-US');
+}

+ 37 - 0
apps/web/src/modules/funnel/period.ts

@@ -0,0 +1,37 @@
+import type { FunnelPeriod } from '../../api/types';
+
+/** Default period selected on first load: 单日(默认昨日,T+1)。 */
+export const DEFAULT_PERIOD: FunnelPeriod = 'day';
+
+/** The fixed 5-step funnel (display name + event key), in fixed order. */
+export const FIXED_FUNNEL_STEPS: { name: string; event_key: string }[] = [
+  { name: '启动', event_key: 'start' },
+  { name: '曝光', event_key: 'show' },
+  { name: '拼团详情', event_key: 'detail' },
+  { name: '下单', event_key: 'order' },
+  { name: '成功', event_key: 'paid' },
+];
+
+/** Format a "yyyyMMdd" snapshot day as "yyyy-MM-dd" for display. */
+export function formatSnapshotDt(dt: string): string {
+  if (/^\d{8}$/.test(dt)) {
+    return `${dt.slice(0, 4)}-${dt.slice(4, 6)}-${dt.slice(6, 8)}`;
+  }
+  return dt;
+}
+
+/** Local-midnight `Date` for yesterday — the latest selectable / queryable day (data is T+1). */
+export function yesterday(): Date {
+  const d = new Date();
+  d.setHours(0, 0, 0, 0);
+  d.setDate(d.getDate() - 1);
+  return d;
+}
+
+/** Format a local `Date` as request `snapshot_dt` ("yyyy-MM-dd"). */
+export function toSnapshotParam(d: Date): string {
+  const y = d.getFullYear();
+  const m = String(d.getMonth() + 1).padStart(2, '0');
+  const day = String(d.getDate()).padStart(2, '0');
+  return `${y}-${m}-${day}`;
+}

+ 27 - 0
apps/web/src/modules/placeholder/Placeholder.tsx

@@ -0,0 +1,27 @@
+import { Construction } from 'lucide-react';
+import { Card, CardContent } from '@/components/ui/card';
+
+interface Props {
+  /** Domain name to show in the heading, e.g. "用户画像能力". */
+  title?: string;
+}
+
+/**
+ * Shared "待开发" placeholder reused by all non-funnel domains (docs/01 §6).
+ * Intentionally minimal: NO page skeleton, NO filter panel, NO chart slot.
+ */
+export function Placeholder({ title }: Props) {
+  return (
+    <Card>
+      <CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center">
+        {title && (
+          <h2 className="text-lg font-semibold m-0 text-foreground">{title}</h2>
+        )}
+        <Construction className="size-10 text-muted-foreground/60" />
+        <p className="text-sm text-muted-foreground m-0">待开发</p>
+      </CardContent>
+    </Card>
+  );
+}
+
+export default Placeholder;

+ 94 - 0
apps/web/src/routes/domains.ts

@@ -0,0 +1,94 @@
+import type { ComponentType } from 'react';
+import { Activity, Gauge, LayoutDashboard, Megaphone, Users } from 'lucide-react';
+
+/**
+ * 导航信息架构(三级,docs/01 §2)。内部工具,精简为 4 个 L1 能力域;
+ * 模块名统一四字。当前仅 `行为分析 > 漏斗分析 > 拼团漏斗` 可用,其余出"待开发"占位。
+ */
+export interface NavNode {
+  key: string;
+  /** 路由路径。父节点路径会重定向到其首个叶子。 */
+  path: string;
+  label: string;
+  /** 仅 L1 带图标。 */
+  icon?: ComponentType<{ className?: string }>;
+  /** 叶子节点指向真实可用页面(目前唯一:拼团漏斗)。 */
+  working?: boolean;
+  children?: NavNode[];
+}
+
+export const NAV: NavNode[] = [
+  {
+    key: 'behavior',
+    path: '/behavior',
+    label: '行为分析',
+    icon: Activity,
+    children: [
+      {
+        key: 'funnel',
+        path: '/behavior/funnel',
+        label: '漏斗分析',
+        children: [
+          {
+            key: 'group-funnel',
+            path: '/behavior/funnel/group',
+            label: '拼团漏斗',
+            working: true,
+          },
+        ],
+      },
+      { key: 'retention', path: '/behavior/retention', label: '留存分析' },
+      { key: 'path', path: '/behavior/path', label: '路径分析' },
+      { key: 'event', path: '/behavior/event', label: '事件分析' },
+      { key: 'distribution', path: '/behavior/distribution', label: '分布分析' },
+      { key: 'attribution', path: '/behavior/attribution', label: '归因分析' },
+    ],
+  },
+  {
+    key: 'metrics',
+    path: '/metrics',
+    label: '指标体系',
+    icon: Gauge,
+    children: [
+      { key: 'metric-catalog', path: '/metrics/catalog', label: '指标目录' },
+      { key: 'metric-board', path: '/metrics/board', label: '指标大盘' },
+      { key: 'metric-monitor', path: '/metrics/monitor', label: '指标监控' },
+    ],
+  },
+  {
+    key: 'profile',
+    path: '/profile',
+    label: '画像体系',
+    icon: Users,
+    children: [
+      { key: 'user-profile', path: '/profile/user', label: '用户画像' },
+      { key: 'merchant-profile', path: '/profile/merchant', label: '商家画像' },
+      { key: 'product-profile', path: '/profile/product', label: '产品画像' },
+    ],
+  },
+  {
+    key: 'dashboards',
+    path: '/dashboards',
+    label: '数据看板',
+    icon: LayoutDashboard,
+    children: [
+      { key: 'overview', path: '/dashboards/overview', label: '业务大盘' },
+      { key: 'realtime', path: '/dashboards/realtime', label: '实时大盘' },
+      { key: 'reports', path: '/dashboards/reports', label: '关键报表' },
+    ],
+  },
+  {
+    key: 'marketing',
+    path: '/marketing',
+    label: '营销触达',
+    icon: Megaphone,
+    children: [
+      { key: 'rules', path: '/marketing/rules', label: '触达规则' },
+      { key: 'records', path: '/marketing/records', label: '触达记录' },
+      { key: 'effect', path: '/marketing/effect', label: '触达效果' },
+    ],
+  },
+];
+
+/** 首页落点:目前唯一可用页面(拼团漏斗)。 */
+export const HOME_PATH = '/behavior/funnel/group';

+ 36 - 0
apps/web/src/test/setup.ts

@@ -0,0 +1,36 @@
+import '@testing-library/jest-dom/vitest';
+import { vi } from 'vitest';
+import { createElement } from 'react';
+
+// Radix UI / responsive components may query matchMedia; jsdom lacks it.
+Object.defineProperty(window, 'matchMedia', {
+  writable: true,
+  value: (query: string) => ({
+    matches: false,
+    media: query,
+    onchange: null,
+    addListener: vi.fn(),
+    removeListener: vi.fn(),
+    addEventListener: vi.fn(),
+    removeEventListener: vi.fn(),
+    dispatchEvent: vi.fn(),
+  }),
+});
+
+// jsdom has no layout; stub ResizeObserver used by ECharts / Radix.
+class ResizeObserverStub {
+  observe() {}
+  unobserve() {}
+  disconnect() {}
+}
+window.ResizeObserver =
+  window.ResizeObserver ?? (ResizeObserverStub as unknown as typeof ResizeObserver);
+
+// echarts-for-react renders a canvas; mock it to a simple element so we can
+// assert "chart rendered" without a real rendering backend in jsdom.
+vi.mock('echarts-for-react', () => ({
+  default: (props: { 'data-testid'?: string }) =>
+    createElement('div', {
+      'data-testid': props['data-testid'] ?? 'funnel-chart',
+    }),
+}));

+ 9 - 0
apps/web/src/vite-env.d.ts

@@ -0,0 +1,9 @@
+/// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+  readonly VITE_USE_MOCK?: string;
+}
+
+interface ImportMeta {
+  readonly env: ImportMetaEnv;
+}

+ 30 - 0
apps/web/tsconfig.app.json

@@ -0,0 +1,30 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "react-jsx",
+
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true,
+
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["./src/*"]
+    },
+
+    "types": ["vitest/globals", "@testing-library/jest-dom"]
+  },
+  "include": ["src"]
+}

+ 13 - 0
apps/web/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "files": [],
+  "references": [
+    { "path": "./tsconfig.app.json" },
+    { "path": "./tsconfig.node.json" }
+  ],
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  }
+}

+ 21 - 0
apps/web/tsconfig.node.json

@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": ["ES2023"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 30 - 0
apps/web/vite.config.ts

@@ -0,0 +1,30 @@
+import path from 'node:path';
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [react(), tailwindcss()],
+  resolve: {
+    alias: {
+      '@': path.resolve(__dirname, './src'),
+    },
+  },
+  server: {
+    port: 5173,
+    proxy: {
+      // Forward API calls to the FastAPI backend (default port 8000).
+      '/api': {
+        target: 'http://localhost:8000',
+        changeOrigin: true,
+      },
+    },
+  },
+  test: {
+    globals: true,
+    environment: 'jsdom',
+    setupFiles: ['./src/test/setup.ts'],
+    css: false,
+  },
+});

+ 173 - 0
docs/01-产品需求-MVP.md

@@ -0,0 +1,173 @@
+# 产品需求
+
+> hs-data 内部数据服务平台的产品需求与信息架构(模块与层级)。
+
+| 项 | 内容 |
+|----|----|
+| 文档版本 | v2.0 |
+| 文档状态 | 评审中 |
+| 更新日期 | 2026-06-24 |
+
+## 修订记录
+
+| 版本 | 日期 | 修订内容 |
+|------|------|----------|
+| v1.0 | 2026-06-21 | 初版:5 扁平能力域 + 泛用 UV 漏斗 MVP |
+| v2.0 | 2026-06-24 | 重构为多形态一站式平台的**三级信息架构**;MVP 改为拼团漏斗(固定);漏斗详规对齐已上线实况 |
+
+---
+
+## 1. 平台定位
+
+hs-data 是一个**面向内部的一站式数据平台门户**:把数仓产出的行为数据、画像、看板与触达能力,沉淀为统一入口下的可视化分析与运营工具。面向内部使用者(分析、运营、产品、业务)。
+
+平台不是单一的埋点分析工具,而是一个**门户 + 多能力域**的综合体——行为分析(神策式)只是其中一块,平台还覆盖指标体系(统一口径)、画像体系(多实体画像)、数据看板(含实时大盘)、营销触达(见 §2)。**这是内部工具,不堆砌中台/治理门面**(精简取舍见 §2)。
+
+平台分阶段建设,按能力域逐步上线(见 §5 路线)。**本期(MVP)** 先搭好门户框架(各能力域进导航),只把**一个**报表做到端到端可用——**拼团漏斗**(`行为分析 > 漏斗分析 > 拼团漏斗`,见 §3、§4);其余能力域出"待开发"占位。
+
+### 1.1 数据现实约束(决定能做什么)
+
+当前数据是数仓 **T+1 预聚合宽表**(非事件级明细)。因此:
+
+- 能做的是**固定预制报表**(口径预先算好,如拼团漏斗)。
+- 神策式**自助分析**(用户自己拖事件/维度建漏斗、留存等)需要事件级 / bitmap 数据,**列为远期**,本期不做。
+- PRD 描述完整愿景,但每个模块的"可用 / 待开发"以本文标注为准。
+
+## 2. 信息架构(模块与层级)
+
+平台采用主流数据平台通用的**三级结构**(参考神策、网易有数/数帆、阿里 OneData/OneEntity、CDP 多实体画像):
+
+- **L1 能力域**:左侧一级导航。
+- **L2 子能力 / 分析模型**:能力域下的具体模块(如漏斗分析、用户画像)。
+- **L3 报表 / 实例 / 视图**:某个具体报表或实例(如**拼团漏斗**),挂在 L2 下,**不是独立模块**。
+
+设计取舍(**这是内部工具,不是 SaaS**):
+
+- **精简到 5 个 L1**,只保留内部用数者真正会用的业务能力;不堆砌"中台/治理"门面。
+- **指标体系是核心** —— 主流数据产品(网易有数、阿里 DataWorks、火山 DataWind、Aloudata)都有;它是"数据平台"区别于"画图工具"的关键(统一指标口径)。只取**消费面**(指标目录/大盘/监控),重治理的口径定义后台不进主导航。
+- **不设独立"实时"域** —— 实时是看板的一种(实时大盘),并入"数据看板"。
+- **画像是多实体体系** —— 用户/商家/产品画像并列;标签是画像的底层支撑,不单列为域。
+- **不设"数据管理 / 工作台"门户域** —— 元数据治理是后台,内部看数者不需要在主导航里看到。
+- **模块名统一四字**,视觉对齐。
+
+状态标注:**可用(MVP)** = 本期端到端交付;**待开发** = 进导航、出统一占位页。
+
+### 2.1 顶层能力域(L1)
+
+| # | L1 能力域 | 一句话定位 | 本期状态 |
+|---|---|---|---|
+| 1 | **行为分析** | 漏斗/留存/路径/事件/分布/归因等分析模型 | **部分可用(漏斗)** |
+| 2 | 指标体系 | 统一指标口径:指标目录、指标大盘、指标监控 | 待开发 |
+| 3 | 画像体系 | 多实体画像:用户画像、商家画像、产品画像(标签为底层支撑) | 待开发 |
+| 4 | 数据看板 | 业务大盘、实时大盘、关键报表(实时并入此域) | 待开发 |
+| 5 | 营销触达 | 触达规则、触达记录、触达效果 | 待开发 |
+
+### 2.2 L2 / L3 展开(模块名四字)
+
+**L1-1 行为分析**(本期唯一有可用模块的域)
+- L2 漏斗分析 **[可用]**
+  - L3 **拼团漏斗(固定)[可用,MVP]** — 见 §4 详规
+  - L3 其他固定漏斗(下单漏斗…)[待开发]
+  - L3 自助漏斗(用户选事件)[远期,需事件级数据]
+- L2 留存分析 / 路径分析 / 事件分析 / 分布分析 / 归因分析(均 [待开发])
+
+**L1-2 指标体系**:指标目录 / 指标大盘 / 指标监控(均 [待开发];重治理的口径定义后台不进主导航)
+**L1-3 画像体系**:用户画像 / 商家画像 / 产品画像(均 [待开发];标签管理作为底层支撑,后续按需补)
+**L1-4 数据看板**:业务大盘 / 实时大盘 / 关键报表(均 [待开发])
+**L1-5 营销触达**:触达规则 / 触达记录 / 触达效果(均 [待开发])
+
+> 扩展规则:新增分析能力 → 在对应 L1 加 L2 模型;新增具体报表 → 在 L2 下加 L3 实例。顶层 5 个 L1 保持稳定。
+
+### 2.3 导航交互(展开/折叠)
+
+对齐主流后台(VS Code / Ant Design Pro / 神策):
+
+- 点 L1/L2 分组**手动展开/折叠**(点该行,不跳转);只有叶子(报表)跳转。
+- **多个分组可同时展开**,非手风琴。
+- 进入某页**自动展开其所在分支**,但不折叠用户已手动展开的其它分支;当前分支可手动折叠。
+
+## 3. 本期 MVP 范围
+
+本期交付两部分:
+
+**A. 平台门户框架(占位)**
+- 顶栏 + 左侧 L1 导航(5 个能力域全部进入)+ 内容区。
+- 除"拼团漏斗"外,所有 L1/L2 进入后展示统一"待开发"占位页(见 §6)。
+
+**B. 拼团漏斗(端到端可用)** — 落点 `行为分析 > 漏斗分析 > 拼团漏斗`
+- 固定 5 步拼团转化漏斗,展示每层 UV、相邻层转化率/流失率。
+- 时间维度:单日(可回溯历史)、近 7 天、近 30 天。
+- 数据来自数据服务层 PostgreSQL 的预聚合宽表(见 `docs/03` §11)。
+
+## 4. 拼团漏斗模块详规
+
+### 4.1 漏斗定义
+
+固定的**拼团转化漏斗**,5 步固定顺序:
+
+**启动 → 曝光 → 拼团详情 → 下单 → 成功**(`start / show / detail / order / paid`)。
+
+- 每层 UV = 该步骤事件在所选周期内的独立用户数。
+- 非递进、不要求按序完成;步骤顺序只影响展示与相邻转化率口径。
+- 相邻层转化率 = 当前层 UV / 上一层 UV;相邻层流失率 = 1 − 转化率。
+- 第 1 层转化率/流失率为空(展示"—",不为 100%/0)。
+
+### 4.2 时间能力
+
+三种周期(数据 **T+1**,最大可查日**始终为昨日**;今天不可查):
+
+- **单日**:看某一天的当日漏斗。配**常驻日历**,默认昨日,可回溯任意历史日;上限昨日(今天/未来禁选)。
+- **近 7 天 / 近 30 天**:滚动窗口,取最新 as-of 快照(截至昨日);**无历史**(数据源只保留最新一行)。
+
+取数路由与字段见 `docs/02` §5(v3)与 `docs/03` §11。
+
+### 4.3 页面说明(视觉/交互定稿见 `docs/04`)
+
+- **时间控件**(标题行右):单日日历常驻 + 近 7 天/近 30 天 Tabs。
+- **结果图表区**:ECharts 漏斗图,每层 UV + 块比例。
+- **结果表格区**:步骤名 / UV / 转化率 / 流失率。
+- **快照说明**:单日"数据快照日:YYYY-MM-DD";近 7/30 天"数据截至 YYYY-MM-DD(近 N 天)"。
+- **状态**:`ready` 出图表+表格;`missing` 出"数据缺失"空态(**不补零**);传输错误出错误态。
+
+## 5. 演进路线图
+
+路线描述长期愿景与大致先后,不是本期范围(本期以 §3 为准)。各 L1 能力域按依赖与价值排序逐步落地:
+
+| 阶段 | 能力域 / 模块 | 依赖 |
+|---|---|---|
+| 已交付 | 行为分析 > 漏斗分析 > 拼团漏斗(固定) | 预聚合两表(`docs/03` §11) |
+| 近期 | 行为分析:更多固定漏斗、留存/路径分析(固定报表) | 对应 ADS 预聚合表 |
+| 近期 | 数据看板:业务大盘 / 关键报表 | 复用各域报表 |
+| 中期 | 画像体系:用户/商家/产品画像 + 人群圈选 | 标签宽/长表、人群包表 |
+| 中期 | 行为分析:自助分析(任意步骤漏斗等) | **事件级 / bitmap 数据**到位 |
+| 远期 | 数据看板:实时大盘 | 实时入仓链路 |
+| 远期 | 营销触达:规则/记录/效果 | 触达记录表 |
+
+## 6. 占位模块说明(空架子)
+
+除拼团漏斗外,所有 L1/L2 本期只进导航、只出占位页:
+
+- 占位文案统一"待开发"。
+- 不做页面骨架(无筛选区/图表占位),仅一个明确占位状态。
+- 点击导航可正常进入,不报错、不空白。
+- 复用同一占位组件。
+
+## 7. 明确不做(本期)
+
+- **自助分析**:任意步骤漏斗、自定义事件/维度组合(需事件级数据,远期)。
+- 严格顺序漏斗、转化窗口、用户/事件属性筛选、复杂人群圈选。
+- 自定义日期范围(本期周期固定为 单日/近7天/近30天)。
+- 实时今天数据(T+1)。
+- 指标体系、画像体系、数据看板、营销触达的**任何实际功能**(仅占位)。
+- 指标口径定义后台 / 数据治理 / 工作台等中台门面(内部工具不做主导航域)。
+- 报表保存/分享/订阅;多数据源接入;Redis 等额外加速层。
+
+## 8. 验收标准
+
+- 平台导航包含 5 个 L1 能力域(行为分析/指标体系/画像体系/数据看板/营销触达);拼团漏斗可用,其余进入后展示"待开发"占位。
+- 导航分组可手动展开/折叠、多个同时展开;进入某页自动展开其所在分支。
+- 拼团漏斗展示固定 5 步(启动→曝光→拼团详情→下单→成功)的每层 UV、相邻层转化率/流失率;第 1 层转化率展示"—"。
+- 时间可选 单日(含历史回溯)/ 近 7 天 / 近 30 天;**最大可选日为昨日**,今天及未来不可选/不可查(前端禁选,后端拒绝)。
+- 单日可选历史日;近 7/30 天展示 as-of 截至日。
+- 数据缺失时**不静默补零**,展示明确的"数据缺失"状态。
+- 信息架构可承载未来扩展:新分析模型落 L2、新报表落 L3,不破坏顶层 L1。

+ 190 - 0
docs/02-技术架构.md

@@ -0,0 +1,190 @@
+# 技术架构
+
+> hs-data 平台的技术栈、目录结构、接口契约、计算与非功能性需求。
+
+| 项 | 内容 |
+|----|------|
+| 文档版本 | v1.0 |
+| 文档状态 | 待评审 |
+| 更新日期 | 2026-06-21 |
+
+## 修订记录
+
+| 版本 | 日期 | 修订内容 |
+|------|------|----------|
+| v1.0 | 2026-06-21 | 初版 |
+
+---
+
+## 1. 技术栈
+
+MVP 使用前后端分离架构:
+
+- 前端:React 19 + Vite + TypeScript + **Tailwind CSS v4 + shadcn/ui**(Radix-based)+ ECharts(漏斗图)+ TanStack Query。
+- 后端:FastAPI(Python 3.11+)+ Pydantic v2 + SQLAlchemy(async)。
+- 存储:PostgreSQL 16(MVP 漏斗读 `ads_trd_group_funnel` 预聚合宽表,见 `docs/03` §11)。
+
+本项目不使用 Next.js。原因是内部数据后台不需要 SEO/SSR;前端用 React/Vite 更轻,UI 走 shadcn/ui 拷贝式组件 + Tailwind 工具类。
+
+> **2026-06-24 修订**:前端 UI 库由 Ant Design 5 改为 **shadcn/ui + Tailwind v4**(参见 CHANGELOG)。ECharts 漏斗图保留。
+> 原 Ant Design 方案因观感不达预期被替换。bitmap 相关存储格式(原 `bytea` + roaring bitmap)随漏斗方案转向预聚合宽表已退出 MVP,挪到后续"泛用漏斗"阶段(见 `docs/03` v2 头注)。
+>
+> **视觉/交互定稿见 `docs/04-设计规范`**(神策风 mint 主题、布局、漏斗图、控件、抗抖滚动模型),已锁定。
+
+## 2. 仓库结构
+
+采用 monorepo:
+
+```text
+apps/
+  web/        # 前端应用
+  api/        # FastAPI 后端服务
+docs/         # 产品、技术、数据契约文档
+infra/        # 本地开发和部署配置
+```
+
+MVP 阶段不拆分多个 Git 仓库。前后端、文档和本地基础设施放在同一个仓库,方便联调、交付和 AI 协作。
+
+## 3. 前端职责
+
+前端负责产品交互和展示:
+
+- 漏斗步骤选择。
+- 时间范围选择。
+- 自定义时间最大 15 天限制。
+- 今天不可选或不可提交。
+- 请求后端漏斗查询接口。
+- 使用 ECharts 展示漏斗图。
+- 使用 Ant Design 展示表格、表单、空状态和错误提示。
+
+前端不负责 bitmap 解析或计算。
+
+## 4. 后端职责
+
+后端负责数据服务 API 和 bitmap 计算:
+
+- 提供漏斗查询接口。
+- 校验时间范围和漏斗步骤参数。
+- 从 PostgreSQL 读取 `bytea` bitmap。
+- 对自定义时间范围内的 daily bitmap 做 OR 计算。
+- 对标准周期优先读取预计算 period bitmap 或结果。
+- 返回每层 UV、转化率和数据状态。
+
+后端不存储埋点明细,不负责数仓产出逻辑。
+
+## 5. 查询 API
+
+> **v3(2026-06-24 修订)**:MVP = **拼团漏斗**,数据源拆为两张表(`ads_trd_group_funnel_daily` + `ads_trd_group_funnel_rolling`,见 `docs/03` §11)。
+> 固定 5 步:启动 `start` → 曝光 `show` → **拼团详情** `detail` → 下单 `order` → 成功 `paid`。
+> 单日支持历史日期(daily 表留全历史);近 7/30 天只有最新 as-of(rolling 表覆盖式 1 行)。数据 T+1,最大可查日始终昨日。
+> 原 v1"泛用漏斗"(任意步骤 + bitmap)仍挪后续,详见 §6。
+
+接口:
+
+```text
+POST /api/funnels/query
+```
+
+请求字段:
+
+```json
+{ "period": "day", "snapshot_dt": "2026-06-20" }
+```
+
+- `period` ∈ `day`(单日) | `last_7d` | `last_30d`。
+- `snapshot_dt`(可选,`yyyy-MM-dd`):**仅对 `day` 有意义**。省略=最新(昨日),给值=该历史日;上限昨日(T+1),今天/未来 → 422。`last_7d`/`last_30d` 忽略该字段。
+- MVP 不接受任意 `steps`、不接受自定义日期范围。
+
+响应字段:
+
+```json
+{
+  "period": "day",
+  "snapshot_dt": "20260620",
+  "results": [
+    { "step_index": 1, "name": "启动",     "event_key": "start",  "uv": 10000, "conversion_rate": null, "dropoff_rate": null },
+    { "step_index": 2, "name": "曝光",     "event_key": "show",   "uv": 8200,  "conversion_rate": 0.82, "dropoff_rate": 0.18 },
+    { "step_index": 3, "name": "拼团详情", "event_key": "detail", "uv": 5100,  "conversion_rate": 0.62, "dropoff_rate": 0.38 },
+    { "step_index": 4, "name": "下单",     "event_key": "order",  "uv": 2200,  "conversion_rate": 0.43, "dropoff_rate": 0.57 },
+    { "step_index": 5, "name": "成功",     "event_key": "paid",   "uv": 1800,  "conversion_rate": 0.82, "dropoff_rate": 0.18 }
+  ],
+  "data_status": "ready"
+}
+```
+
+- `snapshot_dt`:实际取数那行的 `dt`(`yyyyMMdd`)。单日=该天;近 7/30 天=rolling 的 as-of 日(即"数据截至"日)。
+- `step_index` 从 1 开始;第 1 步 `conversion_rate`/`dropoff_rate` 为 `null`。
+- 相邻转化率 = `uv[i]/uv[i-1]`,流失率 = `1-转化率`;`uv[i-1]==0` 时为 `null`。
+- `data_status` ∈ `ready` | `missing`(目标行不存在或对应列为空时 `missing`,不补零)。
+
+## 6. 计算策略
+
+### MVP(拼团漏斗,v3)
+
+数据源两张表(`docs/03` §11),按 `period` 路由:
+
+- `period = day` → 读 `ads_trd_group_funnel_daily`;给 `snapshot_dt` → `WHERE dt=:dt`,否则 `ORDER BY dt DESC LIMIT 1`;取 `uv_start/show/detail/order/paid` 五列。
+- `period = last_7d` → 读 `ads_trd_group_funnel_rolling`(唯一行),取 `uv_*_7d`。
+- `period = last_30d` → 读 `ads_trd_group_funnel_rolling`(唯一行),取 `uv_*_30d`。
+
+UV 直接取列值;相邻转化率由 UV 计算。**不读 bitmap、不做 OR、不做跨天聚合。** 单日可回溯历史,近 7/30 天仅最新 as-of。
+
+### 后续(泛用漏斗,v1 设计,暂不实现)
+
+- 任意步骤事件 + 自定义范围最大 15 天。
+- 标准周期优先读预计算结果;自定义范围读每日 bitmap,逐步骤跨天 OR 后取 cardinality。
+- 依赖 `daily_event_bitmap` / `period_event_bitmap`(`docs/03` §4、§5),这两表 MVP 暂不落地。
+
+## 7. 性能策略
+
+系统是内部低并发数据服务平台,MVP 不引入额外缓存层。
+
+后端性能策略:
+
+- FastAPI 使用多 worker 部署。
+- 大 bitmap 的 CPU 计算不直接阻塞 async event loop。
+- 典型查询按 `漏斗层数 x 日期天数` 读取 bitmap。
+- 自定义范围最大 15 天,用产品规则控制计算上限。
+
+MVP 不引入:
+
+- Redis。
+- Celery。
+- ClickHouse。
+- PostgreSQL bitmap 扩展。
+
+## 8. 测试策略
+
+后端测试:
+
+- 单日查询。
+- 多日查询。
+- 标准周期查询。
+- 空 bitmap 查询。
+- 缺失数据查询。
+- 重复用户在 bitmap 中只计一次。
+- 多步骤 UV 和转化率计算。
+
+前端测试:
+
+- 快捷时间选择。
+- 自定义时间最大 15 天限制。
+- 今天不可选或不可提交。
+- 图表渲染。
+- 表格渲染。
+- 空状态和错误状态展示。
+
+## 9. 前端模块结构与路由
+
+> **导航信息架构(L1/L2/L3)以 `01-产品需求` §2 为准**。本节只讲路由落地。
+
+前端是多模块单页应用,左侧导航按 **5 个 L1 能力域**组织(行为分析 / 指标体系 / 画像体系 / 数据看板 / 营销触达,见 `docs/01` §2.1):
+
+> 导航分组手动展开/折叠、多个同时展开、进入某页自动展开其所在分支(`NavTree.tsx`,交互见 `docs/01` §2.3)。
+
+- 布局壳:统一外框(顶栏 + 左侧 L1 导航 + 内容区),各模块挂在内容区。
+- **三级路由**:L1 能力域一级路由;有可用模块的域下挂 L2(分析模块)、L3(报表/实例)。
+  - MVP 仅 `行为分析 > 漏斗分析 > 拼团漏斗` 一条 L1→L2→L3 链渲染完整分析页(当前实现路由 `/funnel` 即此 L3,后续可规整为 `/behavior/funnel/group`)。
+  - 其余 L1/L2 路由渲染统一的"待开发"占位组件。
+- 占位机制:非可用模块复用同一占位组件(`src/modules/placeholder/`),文案统一"待开发",可正常进入、不报错、不空白。
+- L2 子菜单(域内多模块)与 L3 报表列表后续按路线图补;当前 MVP 导航可先平铺 7 个 L1 + 漏斗页,不强求展开全部 L2。

+ 240 - 0
docs/03-数据契约.md

@@ -0,0 +1,240 @@
+# 数据契约
+
+> 数仓与数据服务平台之间的数据接口与表结构约定。
+
+| 项 | 内容 |
+|----|------|
+| 文档版本 | v1.0 |
+| 文档状态 | 待评审 |
+| 更新日期 | 2026-06-21 |
+
+## 修订记录
+
+| 版本 | 日期 | 修订内容 |
+|------|------|----------|
+| v1.0 | 2026-06-21 | 初版 |
+
+---
+
+> **v2(2026-06-24)**:MVP 数据源改为预聚合宽表 `ads_trd_group_funnel`(见 §11)。
+> §3–§9 描述的 bitmap 方案(`daily_event_bitmap` / `period_event_bitmap`)对应"泛用漏斗",MVP **暂不落地**,留作后续阶段。
+
+## 1. 边界说明
+
+本文档定义数仓与数据服务平台之间的数据契约。
+
+职责边界:
+
+- 数仓负责产出 bitmap 数据。
+- PostgreSQL 是数据服务层存储。
+- FastAPI 从 PostgreSQL 读取 bitmap 并计算查询结果。
+- 数据服务平台不存储埋点明细。
+- 数据服务平台不负责数仓侧的更新、重算和明细治理。
+
+## 2. 用户 ID 前提
+
+bitmap 中的用户 ID 已经满足以下条件:
+
+- `user_id` 是自增数值。
+- `user_id` 适合写入 roaring bitmap。
+- 数据服务层不负责字符串用户 ID 到数值 ID 的映射。
+
+## 3. bitmap 存储约定
+
+bitmap 使用 PostgreSQL `bytea` 存储。
+
+格式约定:
+
+- `bitmap_payload` 存储 roaring bitmap 的二进制序列化结果。
+- `bitmap_format` 标识 bitmap 格式,例如 `roaring32` 或 `roaring64`。
+- 不使用 text/base64 存储 bitmap。
+- 不使用 PostgreSQL bitmap 扩展。
+
+## 4. 每日 bitmap 表
+
+每日 bitmap 用于支持自定义时间范围查询,自定义范围最大 15 天。
+
+建议表名:
+
+```text
+daily_event_bitmap
+```
+
+字段:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| event_date | date | 事件日期 |
+| event_name | text | 事件名称 |
+| bitmap_payload | bytea | 该日期、该事件对应的用户 bitmap |
+| bitmap_format | text | bitmap 格式,例如 `roaring32` |
+| uv_count | integer | 该日期、该事件的 UV 冗余值 |
+| updated_at | timestamp | 数据更新时间 |
+
+唯一键:
+
+```text
+event_date + event_name
+```
+
+## 5. 标准周期 bitmap 表
+
+标准周期 bitmap 用于支持快捷时间查询。
+
+支持周期:
+
+- 昨日。
+- 近 7 天。
+- 近 30 天。
+
+建议表名:
+
+```text
+period_event_bitmap
+```
+
+字段:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| period_type | text | 周期类型:`yesterday`、`last_7d`、`last_30d` |
+| period_start | date | 周期开始日期 |
+| period_end | date | 周期结束日期 |
+| event_name | text | 事件名称 |
+| bitmap_payload | bytea | 该周期、该事件对应的用户 bitmap |
+| bitmap_format | text | bitmap 格式,例如 `roaring32` |
+| uv_count | integer | 该周期、该事件的 UV 冗余值 |
+| updated_at | timestamp | 数据更新时间 |
+
+唯一键:
+
+```text
+period_type + period_start + period_end + event_name
+```
+
+## 6. 日期规则
+
+日期规则:
+
+- 默认最大可选日期为昨日。
+- 今天不纳入 MVP 查询范围。
+- 自定义时间范围最大 15 天。
+- 快捷时间范围由标准周期表提供。
+
+周期定义:
+
+- `yesterday`:昨日。
+- `last_7d`:截至昨日的近 7 天。
+- `last_30d`:截至昨日的近 30 天。
+
+## 7. 缺失数据规则
+
+数据缺失时,接口必须返回明确状态,不静默补零。
+
+缺失场景包括:
+
+- 某日期没有对应事件 bitmap。
+- 某标准周期没有对应事件 bitmap。
+- bitmap 格式不被服务端支持。
+- bitmap payload 无法解析。
+
+建议状态:
+
+```text
+ready       # 数据完整
+partial     # 部分数据缺失
+missing     # 查询范围数据缺失
+invalid     # 数据格式错误
+```
+
+## 8. 查询使用规则
+
+标准周期查询:
+
+- 如果请求时间范围命中昨日、近 7 天、近 30 天,优先读取 `period_event_bitmap`。
+- 直接使用周期 bitmap 的 cardinality 或 `uv_count` 返回 UV。
+
+自定义范围查询:
+
+- 读取 `daily_event_bitmap` 中对应日期和事件的 bitmap。
+- 每个事件在时间范围内做 bitmap OR。
+- OR 后取 cardinality 得到该事件 UV。
+
+## 9. 验收标准
+
+- PostgreSQL 表中 bitmap 字段类型为 `bytea`。
+- 数据服务层能区分 daily bitmap 和 period bitmap。
+- 快捷时间查询可以命中标准周期数据。
+- 自定义查询最大只需要组合 15 天 daily bitmap。
+- 缺失数据不会被静默补零。
+- 文档中不引入 Redis、PG 明细事件表或 PostgreSQL bitmap 扩展作为 MVP 依赖。
+
+## 10. 后续能力域产出表(非 MVP 数据源)
+
+MVP 仅依赖 §4 `daily_event_bitmap` 与 §5 `period_event_bitmap`。以下为其余能力域对应的数仓产出表,此处登记备查,本期**不接入**,字段以后续各域设计为准:
+
+| 表 | 能力域 | 用途 |
+| --- | --- | --- |
+| `ads_funnel_result_d` | 漏斗 | 固定漏斗的预计算结果 |
+| `ads_retention_matrix_d` | 埋点完整 | 留存矩阵 |
+| `ads_path_d` | 埋点完整 | 行为路径 |
+| `dws_user_tag_wide_d` | 用户画像 | 标签宽表 |
+| `dws_user_tag_long_d` | 用户画像 | 标签长表(按更新频率分层) |
+| `dim_user_segment_d` | 用户画像 | 人群包 |
+| `dwd_touch_record_d` | 营销触达 | 触达记录 |
+
+> `ads_funnel_result_d` 是固定漏斗的预计算结果,与 MVP 的事件级 bitmap 组合是**两种不同供给路径**:MVP 泛用漏斗(步骤用户自选、非递进)用 bitmap 两表,不依赖该表。两者关系(是否后续作为固定漏斗的加速层)留待后续定。
+
+## 11. MVP 数据源:拼团漏斗两张表(v3,2026-06-24)
+
+MVP 漏斗 = **拼团漏斗**,固定 5 步(漏斗顺序):
+启动 `start` → 曝光 `show` → 拼团详情 `detail` → 下单 `order` → 成功 `paid`。
+
+数据拆成**两张表**,因同步逻辑不同。两表 ADS↔PG 结构一致。数据为 **T+1**:今天的数据次日才产出,故**最大可查日始终为昨日**。
+
+### 11.1 `ads_trd_group_funnel_daily` —— 单日,留全历史
+
+每日**增量 insert 新 `dt`**,逐日累积,可回溯任意历史单日。
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `dt` | varchar(8) | 快照日 `yyyyMMdd`(主键) |
+| `uv_start` | bigint | 启动 UV |
+| `uv_show` | bigint | 曝光 UV |
+| `uv_detail` | bigint | 拼团详情 UV |
+| `uv_order` | bigint | 下单 UV |
+| `uv_paid` | bigint | 成功 UV |
+| `etl_time` | timestamp | ETL 处理时间 |
+
+### 11.2 `ads_trd_group_funnel_rolling` —— 近 7/30 天,只保留最新一行
+
+每日**覆盖最新 1 行**(`as-of` 快照),**无历史**——只能取当前的近 7/30 天。
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `dt` | varchar(8) | as-of 快照日 `yyyyMMdd` |
+| `uv_start_7d` | bigint | 近 7 天 启动 UV |
+| `uv_show_7d` | bigint | 近 7 天 曝光 UV |
+| `uv_detail_7d` | bigint | 近 7 天 拼团详情 UV |
+| `uv_order_7d` | bigint | 近 7 天 下单 UV |
+| `uv_paid_7d` | bigint | 近 7 天 成功 UV |
+| `uv_start_30d` | bigint | 近 30 天 启动 UV |
+| `uv_show_30d` | bigint | 近 30 天 曝光 UV |
+| `uv_detail_30d` | bigint | 近 30 天 拼团详情 UV |
+| `uv_order_30d` | bigint | 近 30 天 下单 UV |
+| `uv_paid_30d` | bigint | 近 30 天 成功 UV |
+| `etl_time` | timestamp | ETL 处理时间 |
+
+### 11.3 周期 → 取数路由
+
+| period | 表 | 取行 | 列 |
+| --- | --- | --- | --- |
+| `day`(单日) | `..._daily` | 给 `snapshot_dt` → `WHERE dt=:dt`;否则 `ORDER BY dt DESC LIMIT 1` | `uv_start/show/detail/order/paid` |
+| `last_7d` | `..._rolling` | 唯一行 | `uv_*_7d` |
+| `last_30d` | `..._rolling` | 唯一行 | `uv_*_30d` |
+
+- **单日**支持历史:`snapshot_dt` 可选,省略=最新(昨日),给值=该天;上限昨日(T+1),今天及未来拒绝。
+- **近 7/30 天**只有最新 as-of 行,不接受历史 `snapshot_dt`(忽略)。
+- 响应 `snapshot_dt` 回显实际取数那行的 `dt`(单日=该天;7/30 天=rolling 的 as-of 日,即"数据截至"日)。
+
+缺失规则:目标行不存在或对应列为 `NULL` → `data_status = missing`,不补零。

+ 86 - 0
docs/04-设计规范.md

@@ -0,0 +1,86 @@
+# 设计规范(UI / 视觉)
+
+> hs-data 前端的视觉与交互定稿。**本文是设计决策的唯一权威来源**;`apps/web/src/index.css` 是实现来源(CSS 变量),改样式先看本文、再动 CSS,二者保持一致。
+> 风格已锁定,非经讨论不随意更改(`CLAUDE.md` 第 1、3 条)。
+
+| 项 | 内容 |
+|----|----|
+| 文档版本 | v1.0 |
+| 文档状态 | 已定稿 |
+| 更新日期 | 2026-06-24 |
+
+## 修订记录
+
+| 版本 | 日期 | 修订内容 |
+|------|------|----------|
+| v1.0 | 2026-06-24 | 初版:锁定 shadcn + Tailwind v4 + 神策风 mint 主题、布局、漏斗图、控件、抗抖 |
+
+---
+
+## 1. 技术与来源
+
+- UI 栈:**shadcn/ui(radix-nova/neutral 起手)+ Tailwind CSS v4**,图表用 **ECharts**(见 `docs/02` §1)。
+- 配色取自**神策数据**官网(sensorsdata.cn)实际 CSS:主色 mint `#04CB94`、文本 `#1F2D3D`、底 `#F9FAFC`、弱化文本 `#99A9BF`、强调淡 mint `#DEFFF6`、错误 `#EF4444`。
+- 组件源码拷贝在 `apps/web/src/components/ui/`,可改;主题变量在 `apps/web/src/index.css`。
+
+## 2. 色板(关键 token)
+
+> 完整值见 `src/index.css` 的 `:root`。以下为决策锚点(OKLCH / 对应 hex)。
+
+| 角色 | token | 值 | 用途 |
+|----|----|----|----|
+| 页底色 | `--background` | `oklch(0.985 0.003 247)` ≈ `#F9FAFC` | 主内容区,微蓝白(**非纯白**) |
+| 文本 | `--foreground` | `oklch(0.27 0.035 261)` ≈ `#1F2D3D` | 标题 / 正文 |
+| 卡片 | `--card` | 纯白 | 浮在底色上 |
+| 主色 | `--primary` | `oklch(0.71 0.16 168)` = `#04CB94` | 按钮 / 选中 / focus / logo / 漏斗末层 |
+| 弱化文本 | `--muted-foreground` | `oklch(0.68 0.025 247)` ≈ `#99A9BF` | caption / 辅助 |
+| 强调淡底 | `--accent` | `oklch(0.96 0.045 162)` ≈ `#DEFFF6` | 选中 / hover 淡 mint |
+| 错误 | `--destructive` | `oklch(0.63 0.22 27)` ≈ `#EF4444` | 错误态 |
+| 描边 | `--border` | `oklch(0.92 0.008 247)` | 分隔线 / 边框 |
+| 圆角 | `--radius` | `0.625rem` | 全局基准 |
+
+暗色(`.dark`)变量已备但**默认不启用**(`index.html` 不挂 `dark` 类)。
+
+## 3. 布局(三段式 + 单一滚动区)
+
+- **顶栏**:白(`bg-card`)+ 底部细线;左 mint logo 图标 + 标题「hs-data · 数据服务平台」,右 mint dot + `MVP`。
+- **侧栏**:浅 slate-100(`--sidebar`,**有色不刺眼**,比主区沉一档);宽 `w-56`,可纵向滚动。
+  - **三级导航树**(`NavTree.tsx`,IA 见 `docs/01` §2):L1 能力域(加粗 + 图标 + 展开箭头 ▸/▾)→ L2 分析模块 → L3 报表;L2/L3 用**左侧竖引导线**(`border-l border-sidebar-border`)+ 缩进,层级一眼可分。
+  - **展开/折叠**(对齐主流后台):点 L1/L2 分组手动展开/折叠(不跳转);多个可同时展开;进入某页自动展开其所在分支,不折叠其它已展开分支。叶子才跳转。
+  - hover/激活:淡 mint 底(`--sidebar-accent`)+ mint-700 文字;**叶子精确激活强高亮(mint pill)**,父分组在路径内仅文字变 mint。
+  - **5 个 L1(模块名统一四字)**:行为分析 `/behavior`、指标体系 `/metrics`、画像体系 `/profile`、数据看板 `/dashboards`、营销触达 `/marketing`。MVP 可用页:`/behavior/funnel/group`(拼团漏斗);`/` 重定向至此。
+  - 设计取舍(内部工具非 SaaS):指标体系只取消费面;不设独立"实时"域(并入数据看板);画像为多实体(用户/商家/产品);不设数据治理/工作台门面。
+- **主区**:`--background` 微蓝白,`p-6`;白卡浮起,卡片带轻双层投影(`index.css` 的 `[data-slot="card"]`)。
+- **反差原则**:顶(白)≠ 左(浅 slate),全程浅色低反差。**不用**深色侧栏(试过,弃:对比太狠、顶左同色死板)。
+
+### 滚动模型(防加载抖动)— 不可回退
+- 根容器 **`h-screen overflow-hidden`**:让 `<main>` 成为**唯一滚动区**,窗口(html)不滚动。
+- `<main>` 带 **`overflow-auto [scrollbar-gutter:stable]`**:始终预留滚动条宽度,内容增高时滚动条不出现/消失 → **无横向抖动**。
+- 加载骨架高度对齐真实内容(漏斗图 ~380 + 表格) → **无竖向跳动**。
+
+## 4. 漏斗图(ECharts)
+
+- 类型 funnel,块宽按**真实 UV 比例**(`minSize:'12%'` 保底尾巴)。
+- 配色:**mint 单色渐变**,深→浅 5 阶 `#064E3B → #065F46 → #047857 → #10A37F → #04CB94`(对齐主色)。
+- 块内白字加粗显示「步骤名 + UV」;转化率不在图上重复(右侧表格已有,避免拥挤)。
+- tooltip:深底圆角 + 阴影,列出 UV / 转化率 / 流失率(`null` 显示 “—”)。
+
+## 5. 控件与文案
+
+- **时间控件**(漏斗页标题行右侧)—— **统一 segmented 控件** `TimePeriodSelect`,三选一:`📅 单日 YYYY-MM-DD | 近 7 天 | 近 30 天`。
+  - **选中态 = mint 实色 + 白字**(`bg-primary`),由 React 状态直接驱动(`cn(SEG, on?ON:OFF)`),**不依赖 shadcn Tabs/Button 的 `data-active` 变体**——避免选中态在浅页上隐形。任一选中都同款 mint,给明确"当前在此周期"反馈。
+  - 「单日」段点开日历(popover)可选历史日:默认昨日、**上限昨日**(T+1,今天/未来禁选)、历史不设下限;选日即进单日模式。
+  - 教训:此前用 shadcn Tabs 默认选中态(白底+微阴影)+ Button variant,选中高亮在浅色页几乎不可见;改为自绘 segmented + 状态驱动后彻底解决。
+- **快照/截至说明**(结果区底部小灰字):
+  - 单日 → 「数据快照日:`YYYY-MM-DD`」。
+  - 近 7/30 天 → 「数据截至 `YYYY-MM-DD`(近 7 天 / 近 30 天)」,点明窗口不含今天。
+- **数据状态**:`ready` 出图表+表格;`missing` 出「数据缺失」空态(**不补零**);传输错误出 destructive Alert。
+- **Mock 徽章**:虚线灰边 + amber 小 dot「Mock 模式」(低饱和,不抢主色)。
+- 漏斗页副标题:「拼团漏斗:启动 → 曝光 → 拼团详情 → 下单 → 成功」。
+
+## 6. 改样式的规矩
+
+- 调色:改 `src/index.css` 的 `:root`(主色改 `--primary` + `--ring` + `--accent` 一组)。
+- 漏斗图色:改 `FunnelChart.tsx` 的 `STEP_COLORS`。
+- 布局滚动模型(§3 末)与抗抖骨架**不要回退**。
+- 任何视觉大改先按 `docs/05` §7 讨论再动,落地后更新本文 + `CHANGELOG.md`。

+ 90 - 0
docs/05-agent协作准则.md

@@ -0,0 +1,90 @@
+# Agent 角色与职责
+
+> 描述 AI agent 在本项目里的角色定位与职责边界。协作进度由人把控,本文不立工作流规矩。
+> 本文由原 05(协作准则,已砍掉技术栈/铁律/CI/工作流)与原 06(subagent 角色分工)合并而来。
+
+| 项 | 内容 |
+|----|------|
+| 文档版本 | v1.0 |
+| 文档状态 | 待评审 |
+| 更新日期 | 2026-06-21 |
+
+## 修订记录
+
+| 版本 | 日期 | 修订内容 |
+|------|------|----------|
+| v1.0 | 2026-06-21 | 初版:合并原 05 与 06 |
+
+---
+
+## 1. 项目背景
+
+内部数据服务平台,MVP 先交付泛用 UV 漏斗一个可用模块,其余能力域占位"待开发"。完整需求见 `docs/01-产品需求`。
+
+## 2. 角色模型
+
+- **主会话(orchestrator,人主导)**:拆任务、定契约、做集成与评审,是协作的把控者。
+- **AI agent(实现执行者)**:接受需求与方案,产出代码、文档、SQL 等具体产物。
+
+具体实现按目录边界委派给两个 **subagent**:**前端 agent**(在 `apps/web/`)、**后端 agent**(在 `apps/api/`)。按目录分两个 subagent,各自上下文干净、互不越界,主会话只在契约与集成处介入。
+
+## 3. Agent 总体职责范围
+
+- 按既定的产品需求(`docs/01`)、技术架构(`docs/02`)、数据契约(`docs/03`)、设计规范(`docs/04`)做实现。
+- **导航信息架构(模块与层级,L1/L2/L3)以 `docs/01` §2 为准**;视觉/交互定稿以 `docs/04` 为准。
+- 跟随既定技术栈,不擅自更换;新增依赖前先与人确认。
+- 不清楚的事实以代码与 `docs/` 为准,查不到先问,不臆测。
+- 严格的"做"与"不做"边界以 `docs/01` §7"明确不做"为准。
+
+## 4. Agent 不负责
+
+- **不负责协作进度安排、需求决策、架构定型** —— 由人(主会话)把控。
+- 不擅自扩大需求范围、不"顺手优化"周边、不替假想场景写防御性代码。
+- 不替用户拍板未决议事项(鉴权方案、事件清单等),见 `docs/01` §10。
+
+## 5. 前端 agent
+
+- **目录**:只在 `apps/web/`。
+- **职责**:
+  - 平台导航/布局壳,五大能力域全部进入导航。
+  - 漏斗模块:时间选择区、漏斗配置区、结果图表区(ECharts 漏斗图)、结果表格区、空/错误状态。
+  - 其余四域:"待开发"占位页。
+  - 前端校验:自定义范围最大 15 天、今天不可选/不可提交。
+  - 调用后端 `POST /api/funnels/query`,消费 UV/转化率/流失率/数据状态。
+- **不碰**:bitmap 字节解析与计算、后端代码、数据契约的服务端实现。
+- **产出**:可运行前端 + 组件;契约未就绪时用 **mock 响应** 对齐字段并行开发。
+- **测试**:快捷时间选择、15 天限制、今天不可提交、图表渲染、表格渲染、空/错误状态。
+
+## 6. 后端 agent
+
+- **目录**:只在 `apps/api/`。
+- **职责**:
+  - 提供 `POST /api/funnels/query`。
+  - 校验时间范围与漏斗步骤参数(15 天上限、今天不可查)。
+  - 从 PostgreSQL 读取 `bytea` bitmap;自定义范围对 daily bitmap 做 OR 后取 cardinality;标准周期优先读 period 表。
+  - 返回每层 UV、转化率、流失率、数据状态。
+  - bitmap 的 CPU 计算不阻塞 async event loop(放线程/进程池)。
+- **不碰**:前端代码、埋点明细存储、数仓侧产出逻辑(只消费 `docs/03` 约定的表)。
+- **产出**:可运行 API + 单测;真数据未就绪时基于 **seed 假数据** 开发。
+- **测试**:单日/多日/标准周期/空 bitmap/缺失数据查询、重复用户只计一次、多步骤 UV 与转化率计算。
+
+## 7. 契约交接点
+
+两角色唯一耦合在接口契约:
+
+- **唯一来源**:`docs/02-技术架构` §5 的请求/响应字段。
+- **并行机制**:前端按契约 mock、后端按契约 + seed 实现,各自先跑通,再联调。
+- **改契约协议**:任一侧需要改字段,先改 `docs/02` 并同步对端,不在代码里单方面偏离。
+- **数据状态**:`ready`/`partial`/`missing`/`invalid` 由后端判定并返回,前端按状态渲染,**两侧都不静默补零**。
+
+## 8. 主会话职责(不下放给 agent)
+
+- 任务拆分与排期、契约定义与变更裁决。
+- 跨端集成与联调、端到端验收(对照 `docs/01` §8)。
+- 代码评审、依赖增减裁决(新依赖按 `docs/02` §1 技术栈把关)。
+
+## 9. 委派纪律
+
+- 一个 subagent 只在自己的目录内改动;跨目录需求回主会话重新拆分。
+- 委派任务时带齐:目标、相关契约/文档、验证点。
+- subagent 返回后,主会话核对产出是否落在边界内、是否满足验证点,再决定集成。

+ 23 - 0
infra/docker-compose.yml

@@ -0,0 +1,23 @@
+# 本地开发用 PostgreSQL 16(数据服务层存储)。
+# 启动:docker compose -f infra/docker-compose.yml up -d
+# 连接:postgresql://hsdata:hsdata@localhost:5432/hsdata
+services:
+  postgres:
+    image: postgres:16
+    container_name: hs-data-pg
+    environment:
+      POSTGRES_USER: hsdata
+      POSTGRES_PASSWORD: hsdata
+      POSTGRES_DB: hsdata
+    ports:
+      - "5432:5432"
+    volumes:
+      - hs_data_pg:/var/lib/postgresql/data
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U hsdata -d hsdata"]
+      interval: 5s
+      timeout: 3s
+      retries: 10
+
+volumes:
+  hs_data_pg:

+ 63 - 0
infra/setup.sh

@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# hs-data 服务器一键环境准备(Debian/Ubuntu)。幂等,可重复执行。
+# 用法:在仓库根目录执行  bash infra/setup.sh
+set -euo pipefail
+
+cd "$(dirname "$0")/.."   # 切到仓库根
+echo "==> 仓库根: $(pwd)"
+
+# --- Node 20 + pnpm(corepack) ---
+if ! command -v node >/dev/null 2>&1 || [ "$(node -v | cut -dv -f2 | cut -d. -f1)" -lt 20 ]; then
+  echo "==> 安装 Node 20 (nvm)"
+  export NVM_DIR="$HOME/.nvm"
+  if [ ! -s "$NVM_DIR/nvm.sh" ]; then
+    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
+  fi
+  # shellcheck disable=SC1091
+  . "$NVM_DIR/nvm.sh"
+  nvm install 20
+  nvm use 20
+fi
+corepack enable
+echo "==> Node $(node -v) / pnpm $(corepack pnpm@10 -v)"
+
+# --- 前端依赖 ---
+echo "==> pnpm install(workspace)"
+corepack pnpm@10 install
+
+# --- Python 3.11 + 后端依赖 ---
+if ! command -v python3.11 >/dev/null 2>&1; then
+  echo "==> 安装 Python 3.11"
+  sudo apt-get update
+  sudo apt-get install -y python3.11 python3.11-venv
+fi
+echo "==> 后端 venv + 依赖"
+cd apps/api
+python3.11 -m venv .venv
+.venv/bin/pip install -q --upgrade pip
+.venv/bin/pip install -q -e ".[dev]"
+cd ../..
+
+# --- 本地 env ---
+[ -f apps/web/.env ] || cp apps/web/.env.example apps/web/.env
+[ -f apps/api/.env ] || cp apps/api/.env.example apps/api/.env 2>/dev/null || true
+
+cat <<'EOF'
+
+==> 完成。启动方式:
+
+  # 后端(默认 USE_FAKE_DATA=true,无需 DB 即可起)
+  cd apps/api && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
+
+  # 前端(--host 暴露到局域网/远程访问)
+  corepack pnpm@10 --filter @hs-data/web dev --host
+
+  # 接真实 Postgres(可选):
+  docker compose -f infra/docker-compose.yml up -d
+  cd apps/api && .venv/bin/alembic upgrade head && .venv/bin/python -m scripts.seed
+  # 然后把 apps/api/.env 的 USE_FAKE_DATA 设为 false 再起后端
+
+  # 测试
+  corepack pnpm@10 --filter @hs-data/web test
+  cd apps/api && .venv/bin/pytest -q
+EOF

+ 16 - 0
package.json

@@ -0,0 +1,16 @@
+{
+  "name": "hs-data",
+  "version": "0.0.0",
+  "private": true,
+  "description": "内部数据服务平台 — 五大能力域 · MVP 泛用 UV 漏斗",
+  "packageManager": "pnpm@10.34.4",
+  "engines": {
+    "node": ">=20"
+  },
+  "scripts": {
+    "dev:web": "pnpm --filter @hs-data/web dev",
+    "build:web": "pnpm --filter @hs-data/web build",
+    "test:web": "pnpm --filter @hs-data/web test",
+    "gen:api-types": "pnpm --filter @hs-data/api-types gen"
+  }
+}

+ 9 - 0
packages/api-types/README.md

@@ -0,0 +1,9 @@
+# @hs-data/api-types
+
+由后端 FastAPI 的 OpenAPI 生成的 TypeScript 类型,供前端共享。
+
+- **唯一来源**:`apps/api` 暴露的 OpenAPI(`apps/api/openapi.json`,由 `python scripts/export_openapi.py` 导出)。
+- **生成**:在仓库根运行 `pnpm gen:api-types`(等价 `openapi-typescript ../../apps/api/openapi.json -o ./src/schema.ts`)。
+- **勿手改** `src/schema.ts` —— 它是生成产物,改契约请先改 `docs/02-技术架构.md` §5,再重生成(见 `docs/05` §7)。
+
+`src/index.ts` 从 `schema.ts` 再导出常用契约类型,供前端 `import` 使用。

+ 15 - 0
packages/api-types/package.json

@@ -0,0 +1,15 @@
+{
+  "name": "@hs-data/api-types",
+  "version": "0.0.0",
+  "private": true,
+  "description": "由后端 FastAPI OpenAPI 生成的 TS 类型 — 前端共享,勿手改 src/schema.ts",
+  "type": "module",
+  "main": "src/index.ts",
+  "types": "src/index.ts",
+  "scripts": {
+    "gen": "openapi-typescript ../../apps/api/openapi.json -o ./src/schema.ts"
+  },
+  "devDependencies": {
+    "openapi-typescript": "^7.4.0"
+  }
+}

+ 4 - 0
packages/api-types/src/index.ts

@@ -0,0 +1,4 @@
+// 由后端 OpenAPI 生成的类型出口。
+// `schema.ts` 由 `pnpm gen:api-types` 生成(读 apps/api/openapi.json),勿手改。
+// 生成前此文件为占位,生成后从 schema 再导出常用契约类型。
+export type {} from "./schema";

+ 223 - 0
packages/api-types/src/schema.ts

@@ -0,0 +1,223 @@
+/**
+ * This file was auto-generated by openapi-typescript.
+ * Do not make direct changes to the file.
+ */
+
+export interface paths {
+    "/api/funnels/query": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        /**
+         * Query Funnel
+         * @description Compute UV and conversion metrics for the group-buy funnel over a period.
+         *
+         *     The ``period`` enum is validated by Pydantic (bad value -> 422). For
+         *     ``period=day``, a ``snapshot_dt`` later than yesterday (today/future) is
+         *     rejected with 422 (data is T+1).
+         */
+        post: operations["query_funnel_api_funnels_query_post"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/health": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * Health
+         * @description Liveness probe. Does not touch the database.
+         */
+        get: operations["health_health_get"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+}
+export type webhooks = Record<string, never>;
+export interface components {
+    schemas: {
+        /**
+         * DataStatus
+         * @description Data completeness status for a query result (docs/02 §5 v3).
+         *
+         *     ready   - the target row exists and the period's columns are non-null.
+         *     missing - no target row, or the period columns are NULL. Never zero-filled.
+         * @enum {string}
+         */
+        DataStatus: "ready" | "missing";
+        /**
+         * FunnelQueryRequest
+         * @description Funnel query request body.
+         *
+         *     ``snapshot_dt`` (ISO ``YYYY-MM-DD``) is optional and only meaningful for
+         *     ``period=day``: omitted -> latest daily row; given -> that historical day
+         *     (must be <= yesterday). For ``last_7d`` / ``last_30d`` it is ignored.
+         */
+        FunnelQueryRequest: {
+            period: components["schemas"]["Period"];
+            /**
+             * Snapshot Dt
+             * @description Optional ISO date (YYYY-MM-DD). Only meaningful for period=day; ignored for last_7d/last_30d. Must be <= yesterday.
+             */
+            snapshot_dt?: string | null;
+        };
+        /**
+         * FunnelQueryResponse
+         * @description Funnel query response body.
+         */
+        FunnelQueryResponse: {
+            period: components["schemas"]["Period"];
+            /**
+             * Snapshot Dt
+             * @description dt (yyyyMMdd) of the row actually used; for day = that day, for 7d/30d = the rolling row's as-of dt. null when missing.
+             */
+            snapshot_dt?: string | null;
+            /** Results */
+            results: components["schemas"]["FunnelStepResult"][];
+            data_status: components["schemas"]["DataStatus"];
+        };
+        /**
+         * FunnelStepResult
+         * @description Computed UV and conversion metrics for one fixed step.
+         */
+        FunnelStepResult: {
+            /**
+             * Step Index
+             * @description 1-based step index
+             */
+            step_index: number;
+            /**
+             * Name
+             * @description Chinese display name of the step
+             */
+            name: string;
+            /**
+             * Event Key
+             * @description Stable step key (start/show/...)
+             */
+            event_key: string;
+            /** Uv */
+            uv: number;
+            /**
+             * Conversion Rate
+             * @description uv[i] / uv[i-1]; null for step 1 or when uv[i-1] == 0
+             */
+            conversion_rate?: number | null;
+            /**
+             * Dropoff Rate
+             * @description 1 - conversion_rate; null when conversion_rate is null
+             */
+            dropoff_rate?: number | null;
+        };
+        /** HTTPValidationError */
+        HTTPValidationError: {
+            /** Detail */
+            detail?: components["schemas"]["ValidationError"][];
+        };
+        /**
+         * Period
+         * @description Supported periods (docs/02 §5 v3).
+         *
+         *     Routing:
+         *       day      -> table ads_trd_group_funnel_daily (single day, full history).
+         *       last_7d  -> table ads_trd_group_funnel_rolling, columns uv_*_7d.
+         *       last_30d -> table ads_trd_group_funnel_rolling, columns uv_*_30d.
+         *
+         *     Any other value is rejected with HTTP 422.
+         * @enum {string}
+         */
+        Period: "day" | "last_7d" | "last_30d";
+        /** ValidationError */
+        ValidationError: {
+            /** Location */
+            loc: (string | number)[];
+            /** Message */
+            msg: string;
+            /** Error Type */
+            type: string;
+            /** Input */
+            input?: unknown;
+            /** Context */
+            ctx?: Record<string, never>;
+        };
+    };
+    responses: never;
+    parameters: never;
+    requestBodies: never;
+    headers: never;
+    pathItems: never;
+}
+export type $defs = Record<string, never>;
+export interface operations {
+    query_funnel_api_funnels_query_post: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": components["schemas"]["FunnelQueryRequest"];
+            };
+        };
+        responses: {
+            /** @description Successful Response */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": components["schemas"]["FunnelQueryResponse"];
+                };
+            };
+            /** @description Validation Error */
+            422: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": components["schemas"]["HTTPValidationError"];
+                };
+            };
+        };
+    };
+    health_health_get: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description Successful Response */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        [key: string]: string;
+                    };
+                };
+            };
+        };
+    };
+}

+ 4529 - 0
pnpm-lock.yaml

@@ -0,0 +1,4529 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .: {}
+
+  apps/web:
+    dependencies:
+      '@fontsource-variable/geist':
+        specifier: ^5.2.9
+        version: 5.2.9
+      '@tanstack/react-query':
+        specifier: ^5.62.7
+        version: 5.101.1(react@19.2.7)
+      class-variance-authority:
+        specifier: ^0.7.1
+        version: 0.7.1
+      clsx:
+        specifier: ^2.1.1
+        version: 2.1.1
+      date-fns:
+        specifier: ^4.4.0
+        version: 4.4.0
+      echarts:
+        specifier: ^5.5.1
+        version: 5.6.0
+      echarts-for-react:
+        specifier: ^3.0.2
+        version: 3.0.6(echarts@5.6.0)(react@19.2.7)
+      lucide-react:
+        specifier: ^1.21.0
+        version: 1.21.0(react@19.2.7)
+      next-themes:
+        specifier: ^0.4.6
+        version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+      radix-ui:
+        specifier: ^1.6.0
+        version: 1.6.0(@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)
+      react:
+        specifier: ^19.0.0
+        version: 19.2.7
+      react-day-picker:
+        specifier: ^10.0.1
+        version: 10.0.1(@types/react@19.2.17)(react@19.2.7)
+      react-dom:
+        specifier: ^19.0.0
+        version: 19.2.7(react@19.2.7)
+      react-router-dom:
+        specifier: ^7.1.1
+        version: 7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+      sonner:
+        specifier: ^2.0.7
+        version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+      tailwind-merge:
+        specifier: ^3.6.0
+        version: 3.6.0
+      tw-animate-css:
+        specifier: ^1.4.0
+        version: 1.4.0
+    devDependencies:
+      '@tailwindcss/vite':
+        specifier: ^4.3.1
+        version: 4.3.1(vite@6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0))
+      '@testing-library/jest-dom':
+        specifier: ^6.6.3
+        version: 6.9.1
+      '@testing-library/react':
+        specifier: ^16.1.0
+        version: 16.3.2(@testing-library/dom@10.4.1)(@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)
+      '@testing-library/user-event':
+        specifier: ^14.5.2
+        version: 14.6.1(@testing-library/dom@10.4.1)
+      '@types/node':
+        specifier: ^26.0.0
+        version: 26.0.0
+      '@types/react':
+        specifier: ^19.0.2
+        version: 19.2.17
+      '@types/react-dom':
+        specifier: ^19.0.2
+        version: 19.2.3(@types/react@19.2.17)
+      '@vitejs/plugin-react':
+        specifier: ^4.3.4
+        version: 4.7.0(vite@6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0))
+      jsdom:
+        specifier: ^25.0.1
+        version: 25.0.1
+      tailwindcss:
+        specifier: ^4.3.1
+        version: 4.3.1
+      typescript:
+        specifier: ^5.7.2
+        version: 5.9.3
+      vite:
+        specifier: ^6.0.5
+        version: 6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)
+      vitest:
+        specifier: ^3.0.0
+        version: 3.2.6(@types/node@26.0.0)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)
+
+  packages/api-types:
+    devDependencies:
+      openapi-typescript:
+        specifier: ^7.4.0
+        version: 7.13.0(typescript@5.9.3)
+
+packages:
+
+  '@adobe/css-tools@4.5.0':
+    resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
+
+  '@asamuzakjp/css-color@3.2.0':
+    resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
+
+  '@babel/code-frame@7.29.7':
+    resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/compat-data@7.29.7':
+    resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/core@7.29.7':
+    resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/generator@7.29.7':
+    resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-compilation-targets@7.29.7':
+    resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-globals@7.29.7':
+    resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-module-imports@7.29.7':
+    resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-module-transforms@7.29.7':
+    resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+    engines: {node: '>=6.9.0'}
+    peerDependencies:
+      '@babel/core': ^7.0.0
+
+  '@babel/helper-plugin-utils@7.29.7':
+    resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-string-parser@7.29.7':
+    resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-identifier@7.29.7':
+    resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-option@7.29.7':
+    resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helpers@7.29.7':
+    resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/parser@7.29.7':
+    resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+    engines: {node: '>=6.0.0'}
+    hasBin: true
+
+  '@babel/plugin-transform-react-jsx-self@7.29.7':
+    resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
+    engines: {node: '>=6.9.0'}
+    peerDependencies:
+      '@babel/core': ^7.0.0-0
+
+  '@babel/plugin-transform-react-jsx-source@7.29.7':
+    resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
+    engines: {node: '>=6.9.0'}
+    peerDependencies:
+      '@babel/core': ^7.0.0-0
+
+  '@babel/runtime@7.29.7':
+    resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/template@7.29.7':
+    resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/traverse@7.29.7':
+    resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/types@7.29.7':
+    resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+    engines: {node: '>=6.9.0'}
+
+  '@csstools/color-helpers@5.1.0':
+    resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
+    engines: {node: '>=18'}
+
+  '@csstools/css-calc@2.1.4':
+    resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@csstools/css-parser-algorithms': ^3.0.5
+      '@csstools/css-tokenizer': ^3.0.4
+
+  '@csstools/css-color-parser@3.1.0':
+    resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@csstools/css-parser-algorithms': ^3.0.5
+      '@csstools/css-tokenizer': ^3.0.4
+
+  '@csstools/css-parser-algorithms@3.0.5':
+    resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@csstools/css-tokenizer': ^3.0.4
+
+  '@csstools/css-tokenizer@3.0.4':
+    resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
+    engines: {node: '>=18'}
+
+  '@date-fns/tz@1.5.0':
+    resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
+
+  '@esbuild/aix-ppc64@0.25.12':
+    resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.25.12':
+    resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.25.12':
+    resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.25.12':
+    resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.25.12':
+    resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.25.12':
+    resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.25.12':
+    resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.25.12':
+    resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.25.12':
+    resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.25.12':
+    resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.25.12':
+    resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.25.12':
+    resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.25.12':
+    resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.25.12':
+    resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.25.12':
+    resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.25.12':
+    resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.25.12':
+    resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-arm64@0.25.12':
+    resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.25.12':
+    resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-arm64@0.25.12':
+    resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.25.12':
+    resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openharmony-arm64@0.25.12':
+    resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.25.12':
+    resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.25.12':
+    resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.25.12':
+    resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.25.12':
+    resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@floating-ui/core@1.7.5':
+    resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
+
+  '@floating-ui/dom@1.7.6':
+    resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
+
+  '@floating-ui/react-dom@2.1.8':
+    resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
+    peerDependencies:
+      react: '>=16.8.0'
+      react-dom: '>=16.8.0'
+
+  '@floating-ui/utils@0.2.11':
+    resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+
+  '@fontsource-variable/geist@5.2.9':
+    resolution: {integrity: sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==}
+
+  '@jridgewell/gen-mapping@0.3.13':
+    resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+  '@jridgewell/remapping@2.3.5':
+    resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+  '@jridgewell/resolve-uri@3.1.2':
+    resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/sourcemap-codec@1.5.5':
+    resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+  '@jridgewell/trace-mapping@0.3.31':
+    resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+  '@radix-ui/number@1.1.2':
+    resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
+
+  '@radix-ui/primitive@1.1.4':
+    resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==}
+
+  '@radix-ui/react-accessible-icon@1.1.10':
+    resolution: {integrity: sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-accordion@1.2.14':
+    resolution: {integrity: sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-alert-dialog@1.1.17':
+    resolution: {integrity: sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-arrow@1.1.10':
+    resolution: {integrity: sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-aspect-ratio@1.1.10':
+    resolution: {integrity: sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-avatar@1.2.0':
+    resolution: {integrity: sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-checkbox@1.3.5':
+    resolution: {integrity: sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-collapsible@1.1.14':
+    resolution: {integrity: sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-collection@1.1.10':
+    resolution: {integrity: sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-compose-refs@1.1.3':
+    resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-context-menu@2.3.1':
+    resolution: {integrity: sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-context@1.1.4':
+    resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-dialog@1.1.17':
+    resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-direction@1.1.2':
+    resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-dismissable-layer@1.1.13':
+    resolution: {integrity: sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-dropdown-menu@2.1.18':
+    resolution: {integrity: sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-focus-guards@1.1.4':
+    resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-focus-scope@1.1.10':
+    resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-form@0.1.10':
+    resolution: {integrity: sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-hover-card@1.1.17':
+    resolution: {integrity: sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-id@1.1.2':
+    resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-label@2.1.10':
+    resolution: {integrity: sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-menu@2.1.18':
+    resolution: {integrity: sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-menubar@1.1.18':
+    resolution: {integrity: sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-navigation-menu@1.2.16':
+    resolution: {integrity: sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-one-time-password-field@0.1.10':
+    resolution: {integrity: sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-password-toggle-field@0.1.5':
+    resolution: {integrity: sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-popover@1.1.17':
+    resolution: {integrity: sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-popper@1.3.1':
+    resolution: {integrity: sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-portal@1.1.12':
+    resolution: {integrity: sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-presence@1.1.6':
+    resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-primitive@2.1.6':
+    resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-progress@1.1.10':
+    resolution: {integrity: sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-radio-group@1.4.1':
+    resolution: {integrity: sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-roving-focus@1.1.13':
+    resolution: {integrity: sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-scroll-area@1.2.12':
+    resolution: {integrity: sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-select@2.3.1':
+    resolution: {integrity: sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-separator@1.1.10':
+    resolution: {integrity: sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-slider@1.4.1':
+    resolution: {integrity: sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-slot@1.3.0':
+    resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-switch@1.3.1':
+    resolution: {integrity: sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-tabs@1.1.15':
+    resolution: {integrity: sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-toast@1.2.17':
+    resolution: {integrity: sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-toggle-group@1.1.13':
+    resolution: {integrity: sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-toggle@1.1.12':
+    resolution: {integrity: sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-toolbar@1.1.13':
+    resolution: {integrity: sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-tooltip@1.2.10':
+    resolution: {integrity: sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/react-use-callback-ref@1.1.2':
+    resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-use-controllable-state@1.2.3':
+    resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-use-effect-event@0.0.3':
+    resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-use-escape-keydown@1.1.2':
+    resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-use-is-hydrated@0.1.1':
+    resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-use-layout-effect@1.1.2':
+    resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-use-previous@1.1.2':
+    resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-use-rect@1.1.2':
+    resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-use-size@1.1.2':
+    resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-visually-hidden@1.2.6':
+    resolution: {integrity: sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@radix-ui/rect@1.1.2':
+    resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
+
+  '@redocly/ajv@8.11.2':
+    resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
+
+  '@redocly/config@0.22.0':
+    resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
+
+  '@redocly/openapi-core@1.34.15':
+    resolution: {integrity: sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==}
+    engines: {node: '>=18.17.0', npm: '>=9.5.0'}
+
+  '@rolldown/pluginutils@1.0.0-beta.27':
+    resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+  '@rollup/rollup-android-arm-eabi@4.62.2':
+    resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==}
+    cpu: [arm]
+    os: [android]
+
+  '@rollup/rollup-android-arm64@4.62.2':
+    resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==}
+    cpu: [arm64]
+    os: [android]
+
+  '@rollup/rollup-darwin-arm64@4.62.2':
+    resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rollup/rollup-darwin-x64@4.62.2':
+    resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rollup/rollup-freebsd-arm64@4.62.2':
+    resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@rollup/rollup-freebsd-x64@4.62.2':
+    resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.62.2':
+    resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.62.2':
+    resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-arm64-gnu@4.62.2':
+    resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm64-musl@4.62.2':
+    resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-loong64-gnu@4.62.2':
+    resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-loong64-musl@4.62.2':
+    resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-ppc64-gnu@4.62.2':
+    resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-ppc64-musl@4.62.2':
+    resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.62.2':
+    resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-musl@4.62.2':
+    resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-s390x-gnu@4.62.2':
+    resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-gnu@4.62.2':
+    resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-musl@4.62.2':
+    resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-openbsd-x64@4.62.2':
+    resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@rollup/rollup-openharmony-arm64@4.62.2':
+    resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@rollup/rollup-win32-arm64-msvc@4.62.2':
+    resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rollup/rollup-win32-ia32-msvc@4.62.2':
+    resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==}
+    cpu: [ia32]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-gnu@4.62.2':
+    resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==}
+    cpu: [x64]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-msvc@4.62.2':
+    resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==}
+    cpu: [x64]
+    os: [win32]
+
+  '@tailwindcss/node@4.3.1':
+    resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==}
+
+  '@tailwindcss/oxide-android-arm64@4.3.1':
+    resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==}
+    engines: {node: '>= 20'}
+    cpu: [arm64]
+    os: [android]
+
+  '@tailwindcss/oxide-darwin-arm64@4.3.1':
+    resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==}
+    engines: {node: '>= 20'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@tailwindcss/oxide-darwin-x64@4.3.1':
+    resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==}
+    engines: {node: '>= 20'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@tailwindcss/oxide-freebsd-x64@4.3.1':
+    resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==}
+    engines: {node: '>= 20'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1':
+    resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==}
+    engines: {node: '>= 20'}
+    cpu: [arm]
+    os: [linux]
+
+  '@tailwindcss/oxide-linux-arm64-gnu@4.3.1':
+    resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==}
+    engines: {node: '>= 20'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@tailwindcss/oxide-linux-arm64-musl@4.3.1':
+    resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==}
+    engines: {node: '>= 20'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@tailwindcss/oxide-linux-x64-gnu@4.3.1':
+    resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==}
+    engines: {node: '>= 20'}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@tailwindcss/oxide-linux-x64-musl@4.3.1':
+    resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==}
+    engines: {node: '>= 20'}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@tailwindcss/oxide-wasm32-wasi@4.3.1':
+    resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==}
+    engines: {node: '>=14.0.0'}
+    cpu: [wasm32]
+    bundledDependencies:
+      - '@napi-rs/wasm-runtime'
+      - '@emnapi/core'
+      - '@emnapi/runtime'
+      - '@tybys/wasm-util'
+      - '@emnapi/wasi-threads'
+      - tslib
+
+  '@tailwindcss/oxide-win32-arm64-msvc@4.3.1':
+    resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==}
+    engines: {node: '>= 20'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@tailwindcss/oxide-win32-x64-msvc@4.3.1':
+    resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==}
+    engines: {node: '>= 20'}
+    cpu: [x64]
+    os: [win32]
+
+  '@tailwindcss/oxide@4.3.1':
+    resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==}
+    engines: {node: '>= 20'}
+
+  '@tailwindcss/vite@4.3.1':
+    resolution: {integrity: sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==}
+    peerDependencies:
+      vite: ^5.2.0 || ^6 || ^7 || ^8
+
+  '@tanstack/query-core@5.101.1':
+    resolution: {integrity: sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==}
+
+  '@tanstack/react-query@5.101.1':
+    resolution: {integrity: sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==}
+    peerDependencies:
+      react: ^18 || ^19
+
+  '@testing-library/dom@10.4.1':
+    resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+    engines: {node: '>=18'}
+
+  '@testing-library/jest-dom@6.9.1':
+    resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+    engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+  '@testing-library/react@16.3.2':
+    resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@testing-library/dom': ^10.0.0
+      '@types/react': ^18.0.0 || ^19.0.0
+      '@types/react-dom': ^18.0.0 || ^19.0.0
+      react: ^18.0.0 || ^19.0.0
+      react-dom: ^18.0.0 || ^19.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@testing-library/user-event@14.6.1':
+    resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+    engines: {node: '>=12', npm: '>=6'}
+    peerDependencies:
+      '@testing-library/dom': '>=7.21.4'
+
+  '@types/aria-query@5.0.4':
+    resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
+  '@types/babel__core@7.20.5':
+    resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+  '@types/babel__generator@7.27.0':
+    resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+  '@types/babel__template@7.4.4':
+    resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+  '@types/babel__traverse@7.28.0':
+    resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+  '@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==}
+
+  '@types/node@26.0.0':
+    resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==}
+
+  '@types/react-dom@19.2.3':
+    resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+    peerDependencies:
+      '@types/react': ^19.2.0
+
+  '@types/react@19.2.17':
+    resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
+
+  '@vitejs/plugin-react@4.7.0':
+    resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+    peerDependencies:
+      vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+  '@vitest/expect@3.2.6':
+    resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==}
+
+  '@vitest/mocker@3.2.6':
+    resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==}
+    peerDependencies:
+      msw: ^2.4.9
+      vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
+    peerDependenciesMeta:
+      msw:
+        optional: true
+      vite:
+        optional: true
+
+  '@vitest/pretty-format@3.2.6':
+    resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==}
+
+  '@vitest/runner@3.2.6':
+    resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==}
+
+  '@vitest/snapshot@3.2.6':
+    resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==}
+
+  '@vitest/spy@3.2.6':
+    resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==}
+
+  '@vitest/utils@3.2.6':
+    resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
+
+  agent-base@7.1.4:
+    resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
+    engines: {node: '>= 14'}
+
+  ansi-colors@4.1.3:
+    resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
+    engines: {node: '>=6'}
+
+  ansi-regex@5.0.1:
+    resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+    engines: {node: '>=8'}
+
+  ansi-styles@5.2.0:
+    resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+    engines: {node: '>=10'}
+
+  argparse@2.0.1:
+    resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+  aria-hidden@1.2.6:
+    resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
+    engines: {node: '>=10'}
+
+  aria-query@5.3.0:
+    resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
+  aria-query@5.3.2:
+    resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+    engines: {node: '>= 0.4'}
+
+  assertion-error@2.0.1:
+    resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+    engines: {node: '>=12'}
+
+  asynckit@0.4.0:
+    resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
+  balanced-match@1.0.2:
+    resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+  baseline-browser-mapping@2.10.38:
+    resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==}
+    engines: {node: '>=6.0.0'}
+    hasBin: true
+
+  brace-expansion@2.1.1:
+    resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
+
+  browserslist@4.28.4:
+    resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==}
+    engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+    hasBin: true
+
+  cac@6.7.14:
+    resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
+    engines: {node: '>=8'}
+
+  call-bind-apply-helpers@1.0.2:
+    resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+    engines: {node: '>= 0.4'}
+
+  caniuse-lite@1.0.30001799:
+    resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
+
+  chai@5.3.3:
+    resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
+    engines: {node: '>=18'}
+
+  change-case@5.4.4:
+    resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
+
+  check-error@2.1.3:
+    resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
+    engines: {node: '>= 16'}
+
+  class-variance-authority@0.7.1:
+    resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+
+  clsx@2.1.1:
+    resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+    engines: {node: '>=6'}
+
+  colorette@1.4.0:
+    resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
+
+  combined-stream@1.0.8:
+    resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+    engines: {node: '>= 0.8'}
+
+  convert-source-map@2.0.0:
+    resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+  cookie@1.1.1:
+    resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+    engines: {node: '>=18'}
+
+  css.escape@1.5.1:
+    resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
+  cssstyle@4.6.0:
+    resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
+    engines: {node: '>=18'}
+
+  csstype@3.2.3:
+    resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+  data-urls@5.0.0:
+    resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
+    engines: {node: '>=18'}
+
+  date-fns@4.4.0:
+    resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
+
+  debug@4.4.3:
+    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  decimal.js@10.6.0:
+    resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
+  deep-eql@5.0.2:
+    resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
+    engines: {node: '>=6'}
+
+  delayed-stream@1.0.0:
+    resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+    engines: {node: '>=0.4.0'}
+
+  dequal@2.0.3:
+    resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+    engines: {node: '>=6'}
+
+  detect-libc@2.1.2:
+    resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+    engines: {node: '>=8'}
+
+  detect-node-es@1.1.0:
+    resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+
+  dom-accessibility-api@0.5.16:
+    resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+  dom-accessibility-api@0.6.3:
+    resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
+  dunder-proto@1.0.1:
+    resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+    engines: {node: '>= 0.4'}
+
+  echarts-for-react@3.0.6:
+    resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
+    peerDependencies:
+      echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
+      react: ^15.0.0 || >=16.0.0
+
+  echarts@5.6.0:
+    resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
+
+  electron-to-chromium@1.5.377:
+    resolution: {integrity: sha512-cH1jZgJHoezfTnKfKwnScpHywTFVnJUNITDPREFdhNjiuD502+QFpG0Qk7G8jhsV/f+CEAFlIrzP1fT+IMb92g==}
+
+  enhanced-resolve@5.21.6:
+    resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
+    engines: {node: '>=10.13.0'}
+
+  entities@6.0.1:
+    resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+    engines: {node: '>=0.12'}
+
+  es-define-property@1.0.1:
+    resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+    engines: {node: '>= 0.4'}
+
+  es-errors@1.3.0:
+    resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+    engines: {node: '>= 0.4'}
+
+  es-module-lexer@1.7.0:
+    resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
+  es-object-atoms@1.1.2:
+    resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+    engines: {node: '>= 0.4'}
+
+  es-set-tostringtag@2.1.0:
+    resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+    engines: {node: '>= 0.4'}
+
+  esbuild@0.25.12:
+    resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  escalade@3.2.0:
+    resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+    engines: {node: '>=6'}
+
+  estree-walker@3.0.3:
+    resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+  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==}
+
+  fdir@6.5.0:
+    resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+    engines: {node: '>=12.0.0'}
+    peerDependencies:
+      picomatch: ^3 || ^4
+    peerDependenciesMeta:
+      picomatch:
+        optional: true
+
+  form-data@4.0.6:
+    resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
+    engines: {node: '>= 6'}
+
+  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==}
+
+  gensync@1.0.0-beta.2:
+    resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+    engines: {node: '>=6.9.0'}
+
+  get-intrinsic@1.3.0:
+    resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+    engines: {node: '>= 0.4'}
+
+  get-nonce@1.0.1:
+    resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
+    engines: {node: '>=6'}
+
+  get-proto@1.0.1:
+    resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+    engines: {node: '>= 0.4'}
+
+  gopd@1.2.0:
+    resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+    engines: {node: '>= 0.4'}
+
+  graceful-fs@4.2.11:
+    resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+  has-symbols@1.1.0:
+    resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+    engines: {node: '>= 0.4'}
+
+  has-tostringtag@1.0.2:
+    resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+    engines: {node: '>= 0.4'}
+
+  hasown@2.0.4:
+    resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+    engines: {node: '>= 0.4'}
+
+  html-encoding-sniffer@4.0.0:
+    resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
+    engines: {node: '>=18'}
+
+  http-proxy-agent@7.0.2:
+    resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
+    engines: {node: '>= 14'}
+
+  https-proxy-agent@7.0.6:
+    resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+    engines: {node: '>= 14'}
+
+  iconv-lite@0.6.3:
+    resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+    engines: {node: '>=0.10.0'}
+
+  indent-string@4.0.0:
+    resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+    engines: {node: '>=8'}
+
+  index-to-position@1.2.0:
+    resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
+    engines: {node: '>=18'}
+
+  is-potential-custom-element-name@1.0.1:
+    resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
+  jiti@2.7.0:
+    resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
+    hasBin: true
+
+  js-levenshtein@1.1.6:
+    resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
+    engines: {node: '>=0.10.0'}
+
+  js-tokens@4.0.0:
+    resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+  js-tokens@9.0.1:
+    resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
+
+  js-yaml@4.1.1:
+    resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+    hasBin: true
+
+  jsdom@25.0.1:
+    resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      canvas: ^2.11.2
+    peerDependenciesMeta:
+      canvas:
+        optional: true
+
+  jsesc@3.1.0:
+    resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+    engines: {node: '>=6'}
+    hasBin: true
+
+  json-schema-traverse@1.0.0:
+    resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
+  json5@2.2.3:
+    resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+    engines: {node: '>=6'}
+    hasBin: true
+
+  lightningcss-android-arm64@1.32.0:
+    resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm64]
+    os: [android]
+
+  lightningcss-darwin-arm64@1.32.0:
+    resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm64]
+    os: [darwin]
+
+  lightningcss-darwin-x64@1.32.0:
+    resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [x64]
+    os: [darwin]
+
+  lightningcss-freebsd-x64@1.32.0:
+    resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [x64]
+    os: [freebsd]
+
+  lightningcss-linux-arm-gnueabihf@1.32.0:
+    resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm]
+    os: [linux]
+
+  lightningcss-linux-arm64-gnu@1.32.0:
+    resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  lightningcss-linux-arm64-musl@1.32.0:
+    resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  lightningcss-linux-x64-gnu@1.32.0:
+    resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  lightningcss-linux-x64-musl@1.32.0:
+    resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  lightningcss-win32-arm64-msvc@1.32.0:
+    resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm64]
+    os: [win32]
+
+  lightningcss-win32-x64-msvc@1.32.0:
+    resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [x64]
+    os: [win32]
+
+  lightningcss@1.32.0:
+    resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+    engines: {node: '>= 12.0.0'}
+
+  loupe@3.2.1:
+    resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+
+  lru-cache@10.4.3:
+    resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+
+  lru-cache@5.1.1:
+    resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+  lucide-react@1.21.0:
+    resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==}
+    peerDependencies:
+      react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+  lz-string@1.5.0:
+    resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+    hasBin: true
+
+  magic-string@0.30.21:
+    resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+  math-intrinsics@1.1.0:
+    resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+    engines: {node: '>= 0.4'}
+
+  mime-db@1.52.0:
+    resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@2.1.35:
+    resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+    engines: {node: '>= 0.6'}
+
+  min-indent@1.0.1:
+    resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+    engines: {node: '>=4'}
+
+  minimatch@5.1.9:
+    resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
+    engines: {node: '>=10'}
+
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+  nanoid@3.3.15:
+    resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
+    engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+    hasBin: true
+
+  next-themes@0.4.6:
+    resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
+    peerDependencies:
+      react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+
+  node-releases@2.0.48:
+    resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==}
+    engines: {node: '>=18'}
+
+  nwsapi@2.2.24:
+    resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==}
+
+  openapi-typescript@7.13.0:
+    resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
+    hasBin: true
+    peerDependencies:
+      typescript: ^5.x
+
+  parse-json@8.3.0:
+    resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
+    engines: {node: '>=18'}
+
+  parse5@7.3.0:
+    resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+
+  pathe@2.0.3:
+    resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+  pathval@2.0.1:
+    resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
+    engines: {node: '>= 14.16'}
+
+  picocolors@1.1.1:
+    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+  picomatch@4.0.4:
+    resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+    engines: {node: '>=12'}
+
+  pluralize@8.0.0:
+    resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
+    engines: {node: '>=4'}
+
+  postcss@8.5.15:
+    resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+    engines: {node: ^10 || ^12 || >=14}
+
+  pretty-format@27.5.1:
+    resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+    engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
+  punycode@2.3.1:
+    resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+    engines: {node: '>=6'}
+
+  radix-ui@1.6.0:
+    resolution: {integrity: sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  react-day-picker@10.0.1:
+    resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@types/react': '>=16.8.0'
+      react: '>=16.8.0'
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  react-dom@19.2.7:
+    resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
+    peerDependencies:
+      react: ^19.2.7
+
+  react-is@17.0.2:
+    resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
+  react-refresh@0.17.0:
+    resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+    engines: {node: '>=0.10.0'}
+
+  react-remove-scroll-bar@2.3.8:
+    resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  react-remove-scroll@2.7.2:
+    resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  react-router-dom@7.18.0:
+    resolution: {integrity: sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: '>=18'
+      react-dom: '>=18'
+
+  react-router@7.18.0:
+    resolution: {integrity: sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==}
+    engines: {node: '>=20.0.0'}
+    peerDependencies:
+      react: '>=18'
+      react-dom: '>=18'
+    peerDependenciesMeta:
+      react-dom:
+        optional: true
+
+  react-style-singleton@2.2.3:
+    resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  react@19.2.7:
+    resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
+    engines: {node: '>=0.10.0'}
+
+  redent@3.0.0:
+    resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+    engines: {node: '>=8'}
+
+  require-from-string@2.0.2:
+    resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+    engines: {node: '>=0.10.0'}
+
+  rollup@4.62.2:
+    resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==}
+    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+    hasBin: true
+
+  rrweb-cssom@0.7.1:
+    resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==}
+
+  rrweb-cssom@0.8.0:
+    resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
+
+  safer-buffer@2.1.2:
+    resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+  saxes@6.0.0:
+    resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+    engines: {node: '>=v12.22.7'}
+
+  scheduler@0.27.0:
+    resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+  semver@6.3.1:
+    resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+    hasBin: true
+
+  set-cookie-parser@2.7.2:
+    resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+  siginfo@2.0.0:
+    resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
+  size-sensor@1.0.3:
+    resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
+
+  sonner@2.0.7:
+    resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
+    peerDependencies:
+      react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+      react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+
+  source-map-js@1.2.1:
+    resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+    engines: {node: '>=0.10.0'}
+
+  stackback@0.0.2:
+    resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+  std-env@3.10.0:
+    resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
+  strip-indent@3.0.0:
+    resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+    engines: {node: '>=8'}
+
+  strip-literal@3.1.0:
+    resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
+
+  supports-color@10.2.2:
+    resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
+    engines: {node: '>=18'}
+
+  symbol-tree@3.2.4:
+    resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
+  tailwind-merge@3.6.0:
+    resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
+
+  tailwindcss@4.3.1:
+    resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==}
+
+  tapable@2.3.3:
+    resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
+    engines: {node: '>=6'}
+
+  tinybench@2.9.0:
+    resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+  tinyexec@0.3.2:
+    resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+
+  tinyglobby@0.2.17:
+    resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+    engines: {node: '>=12.0.0'}
+
+  tinypool@1.1.1:
+    resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+
+  tinyrainbow@2.0.0:
+    resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
+    engines: {node: '>=14.0.0'}
+
+  tinyspy@4.0.4:
+    resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
+    engines: {node: '>=14.0.0'}
+
+  tldts-core@6.1.86:
+    resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
+
+  tldts@6.1.86:
+    resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
+    hasBin: true
+
+  tough-cookie@5.1.2:
+    resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
+    engines: {node: '>=16'}
+
+  tr46@5.1.1:
+    resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
+    engines: {node: '>=18'}
+
+  tslib@2.3.0:
+    resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
+
+  tw-animate-css@1.4.0:
+    resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
+
+  type-fest@4.41.0:
+    resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
+    engines: {node: '>=16'}
+
+  typescript@5.9.3:
+    resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
+  undici-types@8.3.0:
+    resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
+
+  update-browserslist-db@1.2.3:
+    resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+    hasBin: true
+    peerDependencies:
+      browserslist: '>= 4.21.0'
+
+  uri-js-replace@1.0.1:
+    resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
+
+  use-callback-ref@1.3.3:
+    resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  use-sidecar@1.1.3:
+    resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  vite-node@3.2.4:
+    resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
+    engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+    hasBin: true
+
+  vite@6.4.3:
+    resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
+    engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+    hasBin: true
+    peerDependencies:
+      '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+      jiti: '>=1.21.0'
+      less: '*'
+      lightningcss: ^1.21.0
+      sass: '*'
+      sass-embedded: '*'
+      stylus: '*'
+      sugarss: '*'
+      terser: ^5.16.0
+      tsx: ^4.8.1
+      yaml: ^2.4.2
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+      jiti:
+        optional: true
+      less:
+        optional: true
+      lightningcss:
+        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@3.2.6:
+    resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==}
+    engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+    hasBin: true
+    peerDependencies:
+      '@edge-runtime/vm': '*'
+      '@types/debug': ^4.1.12
+      '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+      '@vitest/browser': 3.2.6
+      '@vitest/ui': 3.2.6
+      happy-dom: '*'
+      jsdom: '*'
+    peerDependenciesMeta:
+      '@edge-runtime/vm':
+        optional: true
+      '@types/debug':
+        optional: true
+      '@types/node':
+        optional: true
+      '@vitest/browser':
+        optional: true
+      '@vitest/ui':
+        optional: true
+      happy-dom:
+        optional: true
+      jsdom:
+        optional: true
+
+  w3c-xmlserializer@5.0.0:
+    resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+    engines: {node: '>=18'}
+
+  webidl-conversions@7.0.0:
+    resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+    engines: {node: '>=12'}
+
+  whatwg-encoding@3.1.1:
+    resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
+    engines: {node: '>=18'}
+    deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
+
+  whatwg-mimetype@4.0.0:
+    resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
+    engines: {node: '>=18'}
+
+  whatwg-url@14.2.0:
+    resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
+    engines: {node: '>=18'}
+
+  why-is-node-running@2.3.0:
+    resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+    engines: {node: '>=8'}
+    hasBin: true
+
+  ws@8.21.0:
+    resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: '>=5.0.2'
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
+  xml-name-validator@5.0.0:
+    resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+    engines: {node: '>=18'}
+
+  xmlchars@2.2.0:
+    resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
+  yallist@3.1.1:
+    resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+  yaml-ast-parser@0.0.43:
+    resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
+
+  yargs-parser@21.1.1:
+    resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+    engines: {node: '>=12'}
+
+  zrender@5.6.1:
+    resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
+
+snapshots:
+
+  '@adobe/css-tools@4.5.0': {}
+
+  '@asamuzakjp/css-color@3.2.0':
+    dependencies:
+      '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-tokenizer': 3.0.4
+      lru-cache: 10.4.3
+
+  '@babel/code-frame@7.29.7':
+    dependencies:
+      '@babel/helper-validator-identifier': 7.29.7
+      js-tokens: 4.0.0
+      picocolors: 1.1.1
+
+  '@babel/compat-data@7.29.7': {}
+
+  '@babel/core@7.29.7':
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      '@babel/generator': 7.29.7
+      '@babel/helper-compilation-targets': 7.29.7
+      '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+      '@babel/helpers': 7.29.7
+      '@babel/parser': 7.29.7
+      '@babel/template': 7.29.7
+      '@babel/traverse': 7.29.7
+      '@babel/types': 7.29.7
+      '@jridgewell/remapping': 2.3.5
+      convert-source-map: 2.0.0
+      debug: 4.4.3(supports-color@10.2.2)
+      gensync: 1.0.0-beta.2
+      json5: 2.2.3
+      semver: 6.3.1
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/generator@7.29.7':
+    dependencies:
+      '@babel/parser': 7.29.7
+      '@babel/types': 7.29.7
+      '@jridgewell/gen-mapping': 0.3.13
+      '@jridgewell/trace-mapping': 0.3.31
+      jsesc: 3.1.0
+
+  '@babel/helper-compilation-targets@7.29.7':
+    dependencies:
+      '@babel/compat-data': 7.29.7
+      '@babel/helper-validator-option': 7.29.7
+      browserslist: 4.28.4
+      lru-cache: 5.1.1
+      semver: 6.3.1
+
+  '@babel/helper-globals@7.29.7': {}
+
+  '@babel/helper-module-imports@7.29.7':
+    dependencies:
+      '@babel/traverse': 7.29.7
+      '@babel/types': 7.29.7
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+    dependencies:
+      '@babel/core': 7.29.7
+      '@babel/helper-module-imports': 7.29.7
+      '@babel/helper-validator-identifier': 7.29.7
+      '@babel/traverse': 7.29.7
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/helper-plugin-utils@7.29.7': {}
+
+  '@babel/helper-string-parser@7.29.7': {}
+
+  '@babel/helper-validator-identifier@7.29.7': {}
+
+  '@babel/helper-validator-option@7.29.7': {}
+
+  '@babel/helpers@7.29.7':
+    dependencies:
+      '@babel/template': 7.29.7
+      '@babel/types': 7.29.7
+
+  '@babel/parser@7.29.7':
+    dependencies:
+      '@babel/types': 7.29.7
+
+  '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)':
+    dependencies:
+      '@babel/core': 7.29.7
+      '@babel/helper-plugin-utils': 7.29.7
+
+  '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)':
+    dependencies:
+      '@babel/core': 7.29.7
+      '@babel/helper-plugin-utils': 7.29.7
+
+  '@babel/runtime@7.29.7': {}
+
+  '@babel/template@7.29.7':
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      '@babel/parser': 7.29.7
+      '@babel/types': 7.29.7
+
+  '@babel/traverse@7.29.7':
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      '@babel/generator': 7.29.7
+      '@babel/helper-globals': 7.29.7
+      '@babel/parser': 7.29.7
+      '@babel/template': 7.29.7
+      '@babel/types': 7.29.7
+      debug: 4.4.3(supports-color@10.2.2)
+    transitivePeerDependencies:
+      - supports-color
+
+  '@babel/types@7.29.7':
+    dependencies:
+      '@babel/helper-string-parser': 7.29.7
+      '@babel/helper-validator-identifier': 7.29.7
+
+  '@csstools/color-helpers@5.1.0': {}
+
+  '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
+    dependencies:
+      '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-tokenizer': 3.0.4
+
+  '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
+    dependencies:
+      '@csstools/color-helpers': 5.1.0
+      '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-tokenizer': 3.0.4
+
+  '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
+    dependencies:
+      '@csstools/css-tokenizer': 3.0.4
+
+  '@csstools/css-tokenizer@3.0.4': {}
+
+  '@date-fns/tz@1.5.0': {}
+
+  '@esbuild/aix-ppc64@0.25.12':
+    optional: true
+
+  '@esbuild/android-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/android-arm@0.25.12':
+    optional: true
+
+  '@esbuild/android-x64@0.25.12':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/darwin-x64@0.25.12':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-arm@0.25.12':
+    optional: true
+
+  '@esbuild/linux-ia32@0.25.12':
+    optional: true
+
+  '@esbuild/linux-loong64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.25.12':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-s390x@0.25.12':
+    optional: true
+
+  '@esbuild/linux-x64@0.25.12':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/sunos-x64@0.25.12':
+    optional: true
+
+  '@esbuild/win32-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/win32-ia32@0.25.12':
+    optional: true
+
+  '@esbuild/win32-x64@0.25.12':
+    optional: true
+
+  '@floating-ui/core@1.7.5':
+    dependencies:
+      '@floating-ui/utils': 0.2.11
+
+  '@floating-ui/dom@1.7.6':
+    dependencies:
+      '@floating-ui/core': 1.7.5
+      '@floating-ui/utils': 0.2.11
+
+  '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+    dependencies:
+      '@floating-ui/dom': 1.7.6
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+
+  '@floating-ui/utils@0.2.11': {}
+
+  '@fontsource-variable/geist@5.2.9': {}
+
+  '@jridgewell/gen-mapping@0.3.13':
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.5.5
+      '@jridgewell/trace-mapping': 0.3.31
+
+  '@jridgewell/remapping@2.3.5':
+    dependencies:
+      '@jridgewell/gen-mapping': 0.3.13
+      '@jridgewell/trace-mapping': 0.3.31
+
+  '@jridgewell/resolve-uri@3.1.2': {}
+
+  '@jridgewell/sourcemap-codec@1.5.5': {}
+
+  '@jridgewell/trace-mapping@0.3.31':
+    dependencies:
+      '@jridgewell/resolve-uri': 3.1.2
+      '@jridgewell/sourcemap-codec': 1.5.5
+
+  '@radix-ui/number@1.1.2': {}
+
+  '@radix-ui/primitive@1.1.4': {}
+
+  '@radix-ui/react-accessible-icon@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)':
+    dependencies:
+      '@radix-ui/react-visually-hidden': 1.2.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-accordion@1.2.14(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-collapsible': 1.1.14(@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)
+      '@radix-ui/react-collection': 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)
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-alert-dialog@1.1.17(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-dialog': 1.1.17(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@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)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-aspect-ratio@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)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-avatar@1.2.0(@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)':
+    dependencies:
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-checkbox@1.3.5(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-collapsible@1.1.14(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-collection@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)':
+    dependencies:
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-context-menu@2.3.1(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-menu': 2.1.18(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-dialog@1.1.17(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-dismissable-layer': 1.1.13(@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)
+      '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-focus-scope': 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)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-portal': 1.1.12(@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)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      aria-hidden: 1.2.6
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+      react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-dismissable-layer@1.1.13(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-dropdown-menu@2.1.18(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-menu': 2.1.18(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-focus-scope@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)':
+    dependencies:
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-form@0.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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-label': 2.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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-hover-card@1.1.17(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-dismissable-layer': 1.1.13(@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)
+      '@radix-ui/react-popper': 1.3.1(@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)
+      '@radix-ui/react-portal': 1.1.12(@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)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-label@2.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)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-menu@2.1.18(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-collection': 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)
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-dismissable-layer': 1.1.13(@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)
+      '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-focus-scope': 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)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-popper': 1.3.1(@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)
+      '@radix-ui/react-portal': 1.1.12(@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)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-roving-focus': 1.1.13(@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)
+      '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      aria-hidden: 1.2.6
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+      react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-menubar@1.1.18(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-collection': 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)
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-menu': 2.1.18(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-roving-focus': 1.1.13(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-navigation-menu@1.2.16(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-collection': 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)
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-dismissable-layer': 1.1.13(@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)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-visually-hidden': 1.2.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-one-time-password-field@0.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)':
+    dependencies:
+      '@radix-ui/number': 1.1.2
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-collection': 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)
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-roving-focus': 1.1.13(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-password-toggle-field@0.1.5(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-popover@1.1.17(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-dismissable-layer': 1.1.13(@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)
+      '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-focus-scope': 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)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-popper': 1.3.1(@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)
+      '@radix-ui/react-portal': 1.1.12(@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)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      aria-hidden: 1.2.6
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+      react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-popper@1.3.1(@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)':
+    dependencies:
+      '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+      '@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)
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/rect': 1.1.2
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-portal@1.1.12(@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)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-presence@1.1.6(@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)':
+    dependencies:
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-primitive@2.1.6(@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)':
+    dependencies:
+      '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-progress@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)':
+    dependencies:
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-radio-group@1.4.1(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-roving-focus': 1.1.13(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-roving-focus@1.1.13(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-collection': 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)
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-scroll-area@1.2.12(@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)':
+    dependencies:
+      '@radix-ui/number': 1.1.2
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-select@2.3.1(@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)':
+    dependencies:
+      '@radix-ui/number': 1.1.2
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-collection': 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)
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-dismissable-layer': 1.1.13(@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)
+      '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-focus-scope': 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)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-popper': 1.3.1(@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)
+      '@radix-ui/react-portal': 1.1.12(@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)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-visually-hidden': 1.2.6(@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)
+      aria-hidden: 1.2.6
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+      react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-separator@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)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-slider@1.4.1(@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)':
+    dependencies:
+      '@radix-ui/number': 1.1.2
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-collection': 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)
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-switch@1.3.1(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-tabs@1.1.15(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-roving-focus': 1.1.13(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-toast@1.2.17(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-collection': 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)
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-dismissable-layer': 1.1.13(@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)
+      '@radix-ui/react-portal': 1.1.12(@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)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-visually-hidden': 1.2.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-toggle-group@1.1.13(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-roving-focus': 1.1.13(@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)
+      '@radix-ui/react-toggle': 1.1.12(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-toggle@1.1.12(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-toolbar@1.1.13(@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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-roving-focus': 1.1.13(@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)
+      '@radix-ui/react-separator': 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)
+      '@radix-ui/react-toggle-group': 1.1.13(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-tooltip@1.2.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)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-dismissable-layer': 1.1.13(@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)
+      '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-popper': 1.3.1(@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)
+      '@radix-ui/react-portal': 1.1.12(@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)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-visually-hidden': 1.2.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      '@radix-ui/rect': 1.1.2
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)':
+    dependencies:
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  '@radix-ui/react-visually-hidden@1.2.6(@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)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@radix-ui/rect@1.1.2': {}
+
+  '@redocly/ajv@8.11.2':
+    dependencies:
+      fast-deep-equal: 3.1.3
+      json-schema-traverse: 1.0.0
+      require-from-string: 2.0.2
+      uri-js-replace: 1.0.1
+
+  '@redocly/config@0.22.0': {}
+
+  '@redocly/openapi-core@1.34.15(supports-color@10.2.2)':
+    dependencies:
+      '@redocly/ajv': 8.11.2
+      '@redocly/config': 0.22.0
+      colorette: 1.4.0
+      https-proxy-agent: 7.0.6(supports-color@10.2.2)
+      js-levenshtein: 1.1.6
+      js-yaml: 4.1.1
+      minimatch: 5.1.9
+      pluralize: 8.0.0
+      yaml-ast-parser: 0.0.43
+    transitivePeerDependencies:
+      - supports-color
+
+  '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+  '@rollup/rollup-android-arm-eabi@4.62.2':
+    optional: true
+
+  '@rollup/rollup-android-arm64@4.62.2':
+    optional: true
+
+  '@rollup/rollup-darwin-arm64@4.62.2':
+    optional: true
+
+  '@rollup/rollup-darwin-x64@4.62.2':
+    optional: true
+
+  '@rollup/rollup-freebsd-arm64@4.62.2':
+    optional: true
+
+  '@rollup/rollup-freebsd-x64@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm-musleabihf@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-gnu@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-musl@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-gnu@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-musl@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-gnu@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-musl@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-gnu@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-musl@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-s390x-gnu@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-x64-gnu@4.62.2':
+    optional: true
+
+  '@rollup/rollup-linux-x64-musl@4.62.2':
+    optional: true
+
+  '@rollup/rollup-openbsd-x64@4.62.2':
+    optional: true
+
+  '@rollup/rollup-openharmony-arm64@4.62.2':
+    optional: true
+
+  '@rollup/rollup-win32-arm64-msvc@4.62.2':
+    optional: true
+
+  '@rollup/rollup-win32-ia32-msvc@4.62.2':
+    optional: true
+
+  '@rollup/rollup-win32-x64-gnu@4.62.2':
+    optional: true
+
+  '@rollup/rollup-win32-x64-msvc@4.62.2':
+    optional: true
+
+  '@tailwindcss/node@4.3.1':
+    dependencies:
+      '@jridgewell/remapping': 2.3.5
+      enhanced-resolve: 5.21.6
+      jiti: 2.7.0
+      lightningcss: 1.32.0
+      magic-string: 0.30.21
+      source-map-js: 1.2.1
+      tailwindcss: 4.3.1
+
+  '@tailwindcss/oxide-android-arm64@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide-darwin-arm64@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide-darwin-x64@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide-freebsd-x64@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide-linux-arm64-gnu@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide-linux-arm64-musl@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide-linux-x64-gnu@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide-linux-x64-musl@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide-wasm32-wasi@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide-win32-arm64-msvc@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide-win32-x64-msvc@4.3.1':
+    optional: true
+
+  '@tailwindcss/oxide@4.3.1':
+    optionalDependencies:
+      '@tailwindcss/oxide-android-arm64': 4.3.1
+      '@tailwindcss/oxide-darwin-arm64': 4.3.1
+      '@tailwindcss/oxide-darwin-x64': 4.3.1
+      '@tailwindcss/oxide-freebsd-x64': 4.3.1
+      '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1
+      '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1
+      '@tailwindcss/oxide-linux-arm64-musl': 4.3.1
+      '@tailwindcss/oxide-linux-x64-gnu': 4.3.1
+      '@tailwindcss/oxide-linux-x64-musl': 4.3.1
+      '@tailwindcss/oxide-wasm32-wasi': 4.3.1
+      '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1
+      '@tailwindcss/oxide-win32-x64-msvc': 4.3.1
+
+  '@tailwindcss/vite@4.3.1(vite@6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0))':
+    dependencies:
+      '@tailwindcss/node': 4.3.1
+      '@tailwindcss/oxide': 4.3.1
+      tailwindcss: 4.3.1
+      vite: 6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)
+
+  '@tanstack/query-core@5.101.1': {}
+
+  '@tanstack/react-query@5.101.1(react@19.2.7)':
+    dependencies:
+      '@tanstack/query-core': 5.101.1
+      react: 19.2.7
+
+  '@testing-library/dom@10.4.1':
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      '@babel/runtime': 7.29.7
+      '@types/aria-query': 5.0.4
+      aria-query: 5.3.0
+      dom-accessibility-api: 0.5.16
+      lz-string: 1.5.0
+      picocolors: 1.1.1
+      pretty-format: 27.5.1
+
+  '@testing-library/jest-dom@6.9.1':
+    dependencies:
+      '@adobe/css-tools': 4.5.0
+      aria-query: 5.3.2
+      css.escape: 1.5.1
+      dom-accessibility-api: 0.6.3
+      picocolors: 1.1.1
+      redent: 3.0.0
+
+  '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@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)':
+    dependencies:
+      '@babel/runtime': 7.29.7
+      '@testing-library/dom': 10.4.1
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+    dependencies:
+      '@testing-library/dom': 10.4.1
+
+  '@types/aria-query@5.0.4': {}
+
+  '@types/babel__core@7.20.5':
+    dependencies:
+      '@babel/parser': 7.29.7
+      '@babel/types': 7.29.7
+      '@types/babel__generator': 7.27.0
+      '@types/babel__template': 7.4.4
+      '@types/babel__traverse': 7.28.0
+
+  '@types/babel__generator@7.27.0':
+    dependencies:
+      '@babel/types': 7.29.7
+
+  '@types/babel__template@7.4.4':
+    dependencies:
+      '@babel/parser': 7.29.7
+      '@babel/types': 7.29.7
+
+  '@types/babel__traverse@7.28.0':
+    dependencies:
+      '@babel/types': 7.29.7
+
+  '@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/node@26.0.0':
+    dependencies:
+      undici-types: 8.3.0
+
+  '@types/react-dom@19.2.3(@types/react@19.2.17)':
+    dependencies:
+      '@types/react': 19.2.17
+
+  '@types/react@19.2.17':
+    dependencies:
+      csstype: 3.2.3
+
+  '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0))':
+    dependencies:
+      '@babel/core': 7.29.7
+      '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
+      '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
+      '@rolldown/pluginutils': 1.0.0-beta.27
+      '@types/babel__core': 7.20.5
+      react-refresh: 0.17.0
+      vite: 6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)
+    transitivePeerDependencies:
+      - supports-color
+
+  '@vitest/expect@3.2.6':
+    dependencies:
+      '@types/chai': 5.2.3
+      '@vitest/spy': 3.2.6
+      '@vitest/utils': 3.2.6
+      chai: 5.3.3
+      tinyrainbow: 2.0.0
+
+  '@vitest/mocker@3.2.6(vite@6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0))':
+    dependencies:
+      '@vitest/spy': 3.2.6
+      estree-walker: 3.0.3
+      magic-string: 0.30.21
+    optionalDependencies:
+      vite: 6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)
+
+  '@vitest/pretty-format@3.2.6':
+    dependencies:
+      tinyrainbow: 2.0.0
+
+  '@vitest/runner@3.2.6':
+    dependencies:
+      '@vitest/utils': 3.2.6
+      pathe: 2.0.3
+      strip-literal: 3.1.0
+
+  '@vitest/snapshot@3.2.6':
+    dependencies:
+      '@vitest/pretty-format': 3.2.6
+      magic-string: 0.30.21
+      pathe: 2.0.3
+
+  '@vitest/spy@3.2.6':
+    dependencies:
+      tinyspy: 4.0.4
+
+  '@vitest/utils@3.2.6':
+    dependencies:
+      '@vitest/pretty-format': 3.2.6
+      loupe: 3.2.1
+      tinyrainbow: 2.0.0
+
+  agent-base@7.1.4: {}
+
+  ansi-colors@4.1.3: {}
+
+  ansi-regex@5.0.1: {}
+
+  ansi-styles@5.2.0: {}
+
+  argparse@2.0.1: {}
+
+  aria-hidden@1.2.6:
+    dependencies:
+      tslib: 2.3.0
+
+  aria-query@5.3.0:
+    dependencies:
+      dequal: 2.0.3
+
+  aria-query@5.3.2: {}
+
+  assertion-error@2.0.1: {}
+
+  asynckit@0.4.0: {}
+
+  balanced-match@1.0.2: {}
+
+  baseline-browser-mapping@2.10.38: {}
+
+  brace-expansion@2.1.1:
+    dependencies:
+      balanced-match: 1.0.2
+
+  browserslist@4.28.4:
+    dependencies:
+      baseline-browser-mapping: 2.10.38
+      caniuse-lite: 1.0.30001799
+      electron-to-chromium: 1.5.377
+      node-releases: 2.0.48
+      update-browserslist-db: 1.2.3(browserslist@4.28.4)
+
+  cac@6.7.14: {}
+
+  call-bind-apply-helpers@1.0.2:
+    dependencies:
+      es-errors: 1.3.0
+      function-bind: 1.1.2
+
+  caniuse-lite@1.0.30001799: {}
+
+  chai@5.3.3:
+    dependencies:
+      assertion-error: 2.0.1
+      check-error: 2.1.3
+      deep-eql: 5.0.2
+      loupe: 3.2.1
+      pathval: 2.0.1
+
+  change-case@5.4.4: {}
+
+  check-error@2.1.3: {}
+
+  class-variance-authority@0.7.1:
+    dependencies:
+      clsx: 2.1.1
+
+  clsx@2.1.1: {}
+
+  colorette@1.4.0: {}
+
+  combined-stream@1.0.8:
+    dependencies:
+      delayed-stream: 1.0.0
+
+  convert-source-map@2.0.0: {}
+
+  cookie@1.1.1: {}
+
+  css.escape@1.5.1: {}
+
+  cssstyle@4.6.0:
+    dependencies:
+      '@asamuzakjp/css-color': 3.2.0
+      rrweb-cssom: 0.8.0
+
+  csstype@3.2.3: {}
+
+  data-urls@5.0.0:
+    dependencies:
+      whatwg-mimetype: 4.0.0
+      whatwg-url: 14.2.0
+
+  date-fns@4.4.0: {}
+
+  debug@4.4.3(supports-color@10.2.2):
+    dependencies:
+      ms: 2.1.3
+    optionalDependencies:
+      supports-color: 10.2.2
+
+  decimal.js@10.6.0: {}
+
+  deep-eql@5.0.2: {}
+
+  delayed-stream@1.0.0: {}
+
+  dequal@2.0.3: {}
+
+  detect-libc@2.1.2: {}
+
+  detect-node-es@1.1.0: {}
+
+  dom-accessibility-api@0.5.16: {}
+
+  dom-accessibility-api@0.6.3: {}
+
+  dunder-proto@1.0.1:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-errors: 1.3.0
+      gopd: 1.2.0
+
+  echarts-for-react@3.0.6(echarts@5.6.0)(react@19.2.7):
+    dependencies:
+      echarts: 5.6.0
+      fast-deep-equal: 3.1.3
+      react: 19.2.7
+      size-sensor: 1.0.3
+
+  echarts@5.6.0:
+    dependencies:
+      tslib: 2.3.0
+      zrender: 5.6.1
+
+  electron-to-chromium@1.5.377: {}
+
+  enhanced-resolve@5.21.6:
+    dependencies:
+      graceful-fs: 4.2.11
+      tapable: 2.3.3
+
+  entities@6.0.1: {}
+
+  es-define-property@1.0.1: {}
+
+  es-errors@1.3.0: {}
+
+  es-module-lexer@1.7.0: {}
+
+  es-object-atoms@1.1.2:
+    dependencies:
+      es-errors: 1.3.0
+
+  es-set-tostringtag@2.1.0:
+    dependencies:
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      has-tostringtag: 1.0.2
+      hasown: 2.0.4
+
+  esbuild@0.25.12:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.25.12
+      '@esbuild/android-arm': 0.25.12
+      '@esbuild/android-arm64': 0.25.12
+      '@esbuild/android-x64': 0.25.12
+      '@esbuild/darwin-arm64': 0.25.12
+      '@esbuild/darwin-x64': 0.25.12
+      '@esbuild/freebsd-arm64': 0.25.12
+      '@esbuild/freebsd-x64': 0.25.12
+      '@esbuild/linux-arm': 0.25.12
+      '@esbuild/linux-arm64': 0.25.12
+      '@esbuild/linux-ia32': 0.25.12
+      '@esbuild/linux-loong64': 0.25.12
+      '@esbuild/linux-mips64el': 0.25.12
+      '@esbuild/linux-ppc64': 0.25.12
+      '@esbuild/linux-riscv64': 0.25.12
+      '@esbuild/linux-s390x': 0.25.12
+      '@esbuild/linux-x64': 0.25.12
+      '@esbuild/netbsd-arm64': 0.25.12
+      '@esbuild/netbsd-x64': 0.25.12
+      '@esbuild/openbsd-arm64': 0.25.12
+      '@esbuild/openbsd-x64': 0.25.12
+      '@esbuild/openharmony-arm64': 0.25.12
+      '@esbuild/sunos-x64': 0.25.12
+      '@esbuild/win32-arm64': 0.25.12
+      '@esbuild/win32-ia32': 0.25.12
+      '@esbuild/win32-x64': 0.25.12
+
+  escalade@3.2.0: {}
+
+  estree-walker@3.0.3:
+    dependencies:
+      '@types/estree': 1.0.9
+
+  expect-type@1.3.0: {}
+
+  fast-deep-equal@3.1.3: {}
+
+  fdir@6.5.0(picomatch@4.0.4):
+    optionalDependencies:
+      picomatch: 4.0.4
+
+  form-data@4.0.6:
+    dependencies:
+      asynckit: 0.4.0
+      combined-stream: 1.0.8
+      es-set-tostringtag: 2.1.0
+      hasown: 2.0.4
+      mime-types: 2.1.35
+
+  fsevents@2.3.3:
+    optional: true
+
+  function-bind@1.1.2: {}
+
+  gensync@1.0.0-beta.2: {}
+
+  get-intrinsic@1.3.0:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-define-property: 1.0.1
+      es-errors: 1.3.0
+      es-object-atoms: 1.1.2
+      function-bind: 1.1.2
+      get-proto: 1.0.1
+      gopd: 1.2.0
+      has-symbols: 1.1.0
+      hasown: 2.0.4
+      math-intrinsics: 1.1.0
+
+  get-nonce@1.0.1: {}
+
+  get-proto@1.0.1:
+    dependencies:
+      dunder-proto: 1.0.1
+      es-object-atoms: 1.1.2
+
+  gopd@1.2.0: {}
+
+  graceful-fs@4.2.11: {}
+
+  has-symbols@1.1.0: {}
+
+  has-tostringtag@1.0.2:
+    dependencies:
+      has-symbols: 1.1.0
+
+  hasown@2.0.4:
+    dependencies:
+      function-bind: 1.1.2
+
+  html-encoding-sniffer@4.0.0:
+    dependencies:
+      whatwg-encoding: 3.1.1
+
+  http-proxy-agent@7.0.2:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.3(supports-color@10.2.2)
+    transitivePeerDependencies:
+      - supports-color
+
+  https-proxy-agent@7.0.6(supports-color@10.2.2):
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.3(supports-color@10.2.2)
+    transitivePeerDependencies:
+      - supports-color
+
+  iconv-lite@0.6.3:
+    dependencies:
+      safer-buffer: 2.1.2
+
+  indent-string@4.0.0: {}
+
+  index-to-position@1.2.0: {}
+
+  is-potential-custom-element-name@1.0.1: {}
+
+  jiti@2.7.0: {}
+
+  js-levenshtein@1.1.6: {}
+
+  js-tokens@4.0.0: {}
+
+  js-tokens@9.0.1: {}
+
+  js-yaml@4.1.1:
+    dependencies:
+      argparse: 2.0.1
+
+  jsdom@25.0.1:
+    dependencies:
+      cssstyle: 4.6.0
+      data-urls: 5.0.0
+      decimal.js: 10.6.0
+      form-data: 4.0.6
+      html-encoding-sniffer: 4.0.0
+      http-proxy-agent: 7.0.2
+      https-proxy-agent: 7.0.6(supports-color@10.2.2)
+      is-potential-custom-element-name: 1.0.1
+      nwsapi: 2.2.24
+      parse5: 7.3.0
+      rrweb-cssom: 0.7.1
+      saxes: 6.0.0
+      symbol-tree: 3.2.4
+      tough-cookie: 5.1.2
+      w3c-xmlserializer: 5.0.0
+      webidl-conversions: 7.0.0
+      whatwg-encoding: 3.1.1
+      whatwg-mimetype: 4.0.0
+      whatwg-url: 14.2.0
+      ws: 8.21.0
+      xml-name-validator: 5.0.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  jsesc@3.1.0: {}
+
+  json-schema-traverse@1.0.0: {}
+
+  json5@2.2.3: {}
+
+  lightningcss-android-arm64@1.32.0:
+    optional: true
+
+  lightningcss-darwin-arm64@1.32.0:
+    optional: true
+
+  lightningcss-darwin-x64@1.32.0:
+    optional: true
+
+  lightningcss-freebsd-x64@1.32.0:
+    optional: true
+
+  lightningcss-linux-arm-gnueabihf@1.32.0:
+    optional: true
+
+  lightningcss-linux-arm64-gnu@1.32.0:
+    optional: true
+
+  lightningcss-linux-arm64-musl@1.32.0:
+    optional: true
+
+  lightningcss-linux-x64-gnu@1.32.0:
+    optional: true
+
+  lightningcss-linux-x64-musl@1.32.0:
+    optional: true
+
+  lightningcss-win32-arm64-msvc@1.32.0:
+    optional: true
+
+  lightningcss-win32-x64-msvc@1.32.0:
+    optional: true
+
+  lightningcss@1.32.0:
+    dependencies:
+      detect-libc: 2.1.2
+    optionalDependencies:
+      lightningcss-android-arm64: 1.32.0
+      lightningcss-darwin-arm64: 1.32.0
+      lightningcss-darwin-x64: 1.32.0
+      lightningcss-freebsd-x64: 1.32.0
+      lightningcss-linux-arm-gnueabihf: 1.32.0
+      lightningcss-linux-arm64-gnu: 1.32.0
+      lightningcss-linux-arm64-musl: 1.32.0
+      lightningcss-linux-x64-gnu: 1.32.0
+      lightningcss-linux-x64-musl: 1.32.0
+      lightningcss-win32-arm64-msvc: 1.32.0
+      lightningcss-win32-x64-msvc: 1.32.0
+
+  loupe@3.2.1: {}
+
+  lru-cache@10.4.3: {}
+
+  lru-cache@5.1.1:
+    dependencies:
+      yallist: 3.1.1
+
+  lucide-react@1.21.0(react@19.2.7):
+    dependencies:
+      react: 19.2.7
+
+  lz-string@1.5.0: {}
+
+  magic-string@0.30.21:
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.5.5
+
+  math-intrinsics@1.1.0: {}
+
+  mime-db@1.52.0: {}
+
+  mime-types@2.1.35:
+    dependencies:
+      mime-db: 1.52.0
+
+  min-indent@1.0.1: {}
+
+  minimatch@5.1.9:
+    dependencies:
+      brace-expansion: 2.1.1
+
+  ms@2.1.3: {}
+
+  nanoid@3.3.15: {}
+
+  next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+    dependencies:
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+
+  node-releases@2.0.48: {}
+
+  nwsapi@2.2.24: {}
+
+  openapi-typescript@7.13.0(typescript@5.9.3):
+    dependencies:
+      '@redocly/openapi-core': 1.34.15(supports-color@10.2.2)
+      ansi-colors: 4.1.3
+      change-case: 5.4.4
+      parse-json: 8.3.0
+      supports-color: 10.2.2
+      typescript: 5.9.3
+      yargs-parser: 21.1.1
+
+  parse-json@8.3.0:
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      index-to-position: 1.2.0
+      type-fest: 4.41.0
+
+  parse5@7.3.0:
+    dependencies:
+      entities: 6.0.1
+
+  pathe@2.0.3: {}
+
+  pathval@2.0.1: {}
+
+  picocolors@1.1.1: {}
+
+  picomatch@4.0.4: {}
+
+  pluralize@8.0.0: {}
+
+  postcss@8.5.15:
+    dependencies:
+      nanoid: 3.3.15
+      picocolors: 1.1.1
+      source-map-js: 1.2.1
+
+  pretty-format@27.5.1:
+    dependencies:
+      ansi-regex: 5.0.1
+      ansi-styles: 5.2.0
+      react-is: 17.0.2
+
+  punycode@2.3.1: {}
+
+  radix-ui@1.6.0(@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):
+    dependencies:
+      '@radix-ui/primitive': 1.1.4
+      '@radix-ui/react-accessible-icon': 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)
+      '@radix-ui/react-accordion': 1.2.14(@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)
+      '@radix-ui/react-alert-dialog': 1.1.17(@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)
+      '@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)
+      '@radix-ui/react-aspect-ratio': 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)
+      '@radix-ui/react-avatar': 1.2.0(@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)
+      '@radix-ui/react-checkbox': 1.3.5(@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)
+      '@radix-ui/react-collapsible': 1.1.14(@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)
+      '@radix-ui/react-collection': 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)
+      '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-context-menu': 2.3.1(@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)
+      '@radix-ui/react-dialog': 1.1.17(@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)
+      '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-dismissable-layer': 1.1.13(@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)
+      '@radix-ui/react-dropdown-menu': 2.1.18(@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)
+      '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-focus-scope': 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)
+      '@radix-ui/react-form': 0.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)
+      '@radix-ui/react-hover-card': 1.1.17(@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)
+      '@radix-ui/react-label': 2.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)
+      '@radix-ui/react-menu': 2.1.18(@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)
+      '@radix-ui/react-menubar': 1.1.18(@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)
+      '@radix-ui/react-navigation-menu': 1.2.16(@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)
+      '@radix-ui/react-one-time-password-field': 0.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)
+      '@radix-ui/react-password-toggle-field': 0.1.5(@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)
+      '@radix-ui/react-popover': 1.1.17(@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)
+      '@radix-ui/react-popper': 1.3.1(@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)
+      '@radix-ui/react-portal': 1.1.12(@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)
+      '@radix-ui/react-presence': 1.1.6(@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)
+      '@radix-ui/react-primitive': 2.1.6(@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)
+      '@radix-ui/react-progress': 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)
+      '@radix-ui/react-radio-group': 1.4.1(@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)
+      '@radix-ui/react-roving-focus': 1.1.13(@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)
+      '@radix-ui/react-scroll-area': 1.2.12(@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)
+      '@radix-ui/react-select': 2.3.1(@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)
+      '@radix-ui/react-separator': 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)
+      '@radix-ui/react-slider': 1.4.1(@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)
+      '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-switch': 1.3.1(@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)
+      '@radix-ui/react-tabs': 1.1.15(@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)
+      '@radix-ui/react-toast': 1.2.17(@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)
+      '@radix-ui/react-toggle': 1.1.12(@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)
+      '@radix-ui/react-toggle-group': 1.1.13(@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)
+      '@radix-ui/react-toolbar': 1.1.13(@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)
+      '@radix-ui/react-tooltip': 1.2.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)
+      '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+      '@radix-ui/react-visually-hidden': 1.2.6(@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)
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+      '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+  react-day-picker@10.0.1(@types/react@19.2.17)(react@19.2.7):
+    dependencies:
+      '@date-fns/tz': 1.5.0
+      date-fns: 4.4.0
+      react: 19.2.7
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  react-dom@19.2.7(react@19.2.7):
+    dependencies:
+      react: 19.2.7
+      scheduler: 0.27.0
+
+  react-is@17.0.2: {}
+
+  react-refresh@0.17.0: {}
+
+  react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
+    dependencies:
+      react: 19.2.7
+      react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7)
+      tslib: 2.3.0
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7):
+    dependencies:
+      react: 19.2.7
+      react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7)
+      react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7)
+      tslib: 2.3.0
+      use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7)
+      use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7)
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  react-router-dom@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+    dependencies:
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+      react-router: 7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+
+  react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+    dependencies:
+      cookie: 1.1.1
+      react: 19.2.7
+      set-cookie-parser: 2.7.2
+    optionalDependencies:
+      react-dom: 19.2.7(react@19.2.7)
+
+  react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7):
+    dependencies:
+      get-nonce: 1.0.1
+      react: 19.2.7
+      tslib: 2.3.0
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  react@19.2.7: {}
+
+  redent@3.0.0:
+    dependencies:
+      indent-string: 4.0.0
+      strip-indent: 3.0.0
+
+  require-from-string@2.0.2: {}
+
+  rollup@4.62.2:
+    dependencies:
+      '@types/estree': 1.0.9
+    optionalDependencies:
+      '@rollup/rollup-android-arm-eabi': 4.62.2
+      '@rollup/rollup-android-arm64': 4.62.2
+      '@rollup/rollup-darwin-arm64': 4.62.2
+      '@rollup/rollup-darwin-x64': 4.62.2
+      '@rollup/rollup-freebsd-arm64': 4.62.2
+      '@rollup/rollup-freebsd-x64': 4.62.2
+      '@rollup/rollup-linux-arm-gnueabihf': 4.62.2
+      '@rollup/rollup-linux-arm-musleabihf': 4.62.2
+      '@rollup/rollup-linux-arm64-gnu': 4.62.2
+      '@rollup/rollup-linux-arm64-musl': 4.62.2
+      '@rollup/rollup-linux-loong64-gnu': 4.62.2
+      '@rollup/rollup-linux-loong64-musl': 4.62.2
+      '@rollup/rollup-linux-ppc64-gnu': 4.62.2
+      '@rollup/rollup-linux-ppc64-musl': 4.62.2
+      '@rollup/rollup-linux-riscv64-gnu': 4.62.2
+      '@rollup/rollup-linux-riscv64-musl': 4.62.2
+      '@rollup/rollup-linux-s390x-gnu': 4.62.2
+      '@rollup/rollup-linux-x64-gnu': 4.62.2
+      '@rollup/rollup-linux-x64-musl': 4.62.2
+      '@rollup/rollup-openbsd-x64': 4.62.2
+      '@rollup/rollup-openharmony-arm64': 4.62.2
+      '@rollup/rollup-win32-arm64-msvc': 4.62.2
+      '@rollup/rollup-win32-ia32-msvc': 4.62.2
+      '@rollup/rollup-win32-x64-gnu': 4.62.2
+      '@rollup/rollup-win32-x64-msvc': 4.62.2
+      fsevents: 2.3.3
+
+  rrweb-cssom@0.7.1: {}
+
+  rrweb-cssom@0.8.0: {}
+
+  safer-buffer@2.1.2: {}
+
+  saxes@6.0.0:
+    dependencies:
+      xmlchars: 2.2.0
+
+  scheduler@0.27.0: {}
+
+  semver@6.3.1: {}
+
+  set-cookie-parser@2.7.2: {}
+
+  siginfo@2.0.0: {}
+
+  size-sensor@1.0.3: {}
+
+  sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+    dependencies:
+      react: 19.2.7
+      react-dom: 19.2.7(react@19.2.7)
+
+  source-map-js@1.2.1: {}
+
+  stackback@0.0.2: {}
+
+  std-env@3.10.0: {}
+
+  strip-indent@3.0.0:
+    dependencies:
+      min-indent: 1.0.1
+
+  strip-literal@3.1.0:
+    dependencies:
+      js-tokens: 9.0.1
+
+  supports-color@10.2.2: {}
+
+  symbol-tree@3.2.4: {}
+
+  tailwind-merge@3.6.0: {}
+
+  tailwindcss@4.3.1: {}
+
+  tapable@2.3.3: {}
+
+  tinybench@2.9.0: {}
+
+  tinyexec@0.3.2: {}
+
+  tinyglobby@0.2.17:
+    dependencies:
+      fdir: 6.5.0(picomatch@4.0.4)
+      picomatch: 4.0.4
+
+  tinypool@1.1.1: {}
+
+  tinyrainbow@2.0.0: {}
+
+  tinyspy@4.0.4: {}
+
+  tldts-core@6.1.86: {}
+
+  tldts@6.1.86:
+    dependencies:
+      tldts-core: 6.1.86
+
+  tough-cookie@5.1.2:
+    dependencies:
+      tldts: 6.1.86
+
+  tr46@5.1.1:
+    dependencies:
+      punycode: 2.3.1
+
+  tslib@2.3.0: {}
+
+  tw-animate-css@1.4.0: {}
+
+  type-fest@4.41.0: {}
+
+  typescript@5.9.3: {}
+
+  undici-types@8.3.0: {}
+
+  update-browserslist-db@1.2.3(browserslist@4.28.4):
+    dependencies:
+      browserslist: 4.28.4
+      escalade: 3.2.0
+      picocolors: 1.1.1
+
+  uri-js-replace@1.0.1: {}
+
+  use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7):
+    dependencies:
+      react: 19.2.7
+      tslib: 2.3.0
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7):
+    dependencies:
+      detect-node-es: 1.1.0
+      react: 19.2.7
+      tslib: 2.3.0
+    optionalDependencies:
+      '@types/react': 19.2.17
+
+  vite-node@3.2.4(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0):
+    dependencies:
+      cac: 6.7.14
+      debug: 4.4.3(supports-color@10.2.2)
+      es-module-lexer: 1.7.0
+      pathe: 2.0.3
+      vite: 6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)
+    transitivePeerDependencies:
+      - '@types/node'
+      - jiti
+      - less
+      - lightningcss
+      - sass
+      - sass-embedded
+      - stylus
+      - sugarss
+      - supports-color
+      - terser
+      - tsx
+      - yaml
+
+  vite@6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0):
+    dependencies:
+      esbuild: 0.25.12
+      fdir: 6.5.0(picomatch@4.0.4)
+      picomatch: 4.0.4
+      postcss: 8.5.15
+      rollup: 4.62.2
+      tinyglobby: 0.2.17
+    optionalDependencies:
+      '@types/node': 26.0.0
+      fsevents: 2.3.3
+      jiti: 2.7.0
+      lightningcss: 1.32.0
+
+  vitest@3.2.6(@types/node@26.0.0)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0):
+    dependencies:
+      '@types/chai': 5.2.3
+      '@vitest/expect': 3.2.6
+      '@vitest/mocker': 3.2.6(vite@6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0))
+      '@vitest/pretty-format': 3.2.6
+      '@vitest/runner': 3.2.6
+      '@vitest/snapshot': 3.2.6
+      '@vitest/spy': 3.2.6
+      '@vitest/utils': 3.2.6
+      chai: 5.3.3
+      debug: 4.4.3(supports-color@10.2.2)
+      expect-type: 1.3.0
+      magic-string: 0.30.21
+      pathe: 2.0.3
+      picomatch: 4.0.4
+      std-env: 3.10.0
+      tinybench: 2.9.0
+      tinyexec: 0.3.2
+      tinyglobby: 0.2.17
+      tinypool: 1.1.1
+      tinyrainbow: 2.0.0
+      vite: 6.4.3(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)
+      vite-node: 3.2.4(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)
+      why-is-node-running: 2.3.0
+    optionalDependencies:
+      '@types/node': 26.0.0
+      jsdom: 25.0.1
+    transitivePeerDependencies:
+      - jiti
+      - less
+      - lightningcss
+      - msw
+      - sass
+      - sass-embedded
+      - stylus
+      - sugarss
+      - supports-color
+      - terser
+      - tsx
+      - yaml
+
+  w3c-xmlserializer@5.0.0:
+    dependencies:
+      xml-name-validator: 5.0.0
+
+  webidl-conversions@7.0.0: {}
+
+  whatwg-encoding@3.1.1:
+    dependencies:
+      iconv-lite: 0.6.3
+
+  whatwg-mimetype@4.0.0: {}
+
+  whatwg-url@14.2.0:
+    dependencies:
+      tr46: 5.1.1
+      webidl-conversions: 7.0.0
+
+  why-is-node-running@2.3.0:
+    dependencies:
+      siginfo: 2.0.0
+      stackback: 0.0.2
+
+  ws@8.21.0: {}
+
+  xml-name-validator@5.0.0: {}
+
+  xmlchars@2.2.0: {}
+
+  yallist@3.1.1: {}
+
+  yaml-ast-parser@0.0.43: {}
+
+  yargs-parser@21.1.1: {}
+
+  zrender@5.6.1:
+    dependencies:
+      tslib: 2.3.0

+ 3 - 0
pnpm-workspace.yaml

@@ -0,0 +1,3 @@
+packages:
+  - "apps/web"
+  - "packages/*"