commit 23e5cb4006f5a70fe8f9a4c22b278ffbc11f131c Author: qichi.liang Date: Wed May 20 17:50:37 2026 +0800 feat: AI 驱动的配方研发智能平台 v0.1 核心功能: - M3 配方记录: 创建/编辑/详情/可视化编辑/AI提取/版本历史/版本对比 - M1 颜色引擎: D3.js 色相环/滑条微调/ΔE计算/取色棒/AI配色推荐 - M2 可视化编辑器: ECharts饼图/成分滑条/AI预测/雷达图/仪表盘 - M4 配方推演: 约束设置/SSE推演/方案对比/散点图 - 平台: NL智能搜索/项目管理/CSV导出/JWT认证/全局搜索 技术栈: - 前端: React + Vite + Tailwind CSS 4 + Zustand + TanStack Query - 后端: Fastify 5 + Prisma 7 + PostgreSQL + pgvector - AI: OpenAI/DeepSeek API 调用 + Prompt模板 + 缓存/降级/限流 - 测试: Vitest 42 tests (26 API集成 + 16 色彩模块) diff --git a/.scratch/formula-rd-platform/PRD.md b/.scratch/formula-rd-platform/PRD.md new file mode 100644 index 0000000..518f48c --- /dev/null +++ b/.scratch/formula-rd-platform/PRD.md @@ -0,0 +1,204 @@ +# PRD:AI 驱动的配方研发智能平台 + +> **Status:** `ready-for-agent` +> **Feature slug:** `formula-rd-platform` +> **Created:** 2026-05-20 + +--- + +## Problem Statement + +化妆品研发人员当前面临以下痛点: + +1. **配方研发依赖经验试错**:手动调整成分比例后难以预测量化效果(肤感、稳定性、成本),需要反复打样验证,周期长、成本高。 +2. **颜色调配效率低下**:人工调配色差难以量化(通常 ΔE > 2.0),颜色记忆与复现依赖纸质记录,无法快速匹配目标色号。 +3. **配方数据管理混乱**:配方记录散落在 Excel 或纸质文档中,版本管控缺失,历史数据难以检索与复用。 +4. **假设验证能力不足**:"如果…会怎样"的配方探索依赖逐一实验,无法并行对比多个候选方案,缺乏从目标功效反推成分组合的能力。 + +研发人员需要一个**集成 AI、具备可视化交互能力**的 Web 端辅助工具,将隐性经验转化为可检索、可复用的显性数据资产。 + +--- + +## Solution + +构建一个纯 Web 端的化妆品配方研发智能平台,包含四大核心功能模块: + +1. **颜色引擎**:支持广色域(Display P3)的交互式色相环,AI 驱动的色号匹配与自动配色推荐,取色棒从参考图取色并匹配历史配方。 +2. **配方可视化编辑器**:拖拽式成分比例调整,AI 实时预测肤感指数、稳定性评分、成本变化,通过雷达图、饼图、仪表盘联动展现。 +3. **配方记录管理**:AI 自动提取并结构化配方数据,支持自然语言搜索、版本历史对比、成分智能目录。 +4. **配方推演引擎**:基于约束条件 AI 生成多候选配方,支持并行推演与逆向推理,以散点图、对比表可视化呈现。 + +平台为纯 Web 端,支持跨设备访问与云端数据同步。所有 AI 模型基于企业内部历史配方数据库训练,形成专属知识资产。 + +--- + +## User Stories + +### 颜色引擎(M1) + +1. As a 配方工程师,I want 通过交互式色相环选择目标颜色,so that 直观地指定配方目标色号。 +2. As a 配方工程师,I want 输入潘通色号/Lab 值/RGB 值来定义目标颜色,so that 精确复现品牌标准色或客户指定色。 +3. As a 配方工程师,I want AI 根据目标颜色自动推荐基础色浆组合与比例,so that 减少手动试配色浆的时间。 +4. As a 配方工程师,I want 使用取色棒从参考图片上吸取颜色,so that 快速将视觉参考转化为可量化的色号。 +5. As a 配方工程师,I want 取色后 AI 自动匹配历史配方中最接近的颜色配方,so that 复用已有经验减少重复调配。 +6. As a 配方工程师,I want 实时查看当前调配颜色与目标颜色的色差(ΔE)数值,so that 量化判断颜色是否达标(ΔE ≤ 1.0)。 +7. As a 配方工程师,I want 在虚拟皮肤材质上预览颜色涂抹效果,so that 模拟实际使用场景下的颜色呈现。 +8. As a 配方工程师,I want 保存当前调色配方到配方库,so that 后续可复用或作为推演起点。 +9. As a 配方工程师,I want 通过滑块精细调节 RGB/Lab 各通道值,so that 对颜色进行微调。 + +### 配方可视化编辑器(M2) + +10. As a 配方工程师,I want 拖拽调整配方中各成分的比例(饼图交互),so that 直观地探索成分比例变化的影响。 +11. As a 配方工程师,I want 调整成分比例后 AI 实时预测并更新肤感指数、稳定性评分、成本估算,so that 无需等待打样即可评估配方方向。 +12. As a 配方工程师,I want 通过雷达图查看配方的多维度指标(肤感、稳定性、铺展性、吸收速度等),so that 宏观把握配方综合性能。 +13. As a 配方工程师,I want 通过仪表盘查看关键指标是否在目标范围内,so that 快速判断配方是否满足产品规格。 +14. As a 配方工程师,I want 查看配方成分的结构树状图(按相/功能分类),so that 理解配方的层次化组成。 +15. As a 配方工程师,I want 添加/删除/替换成分,并即时看到预测指标变化,so that 快速迭代配方方案。 +16. As a 配方工程师,I want 设置指标目标范围(如成本 ≤ X 元/kg),配方超出范围时获得警告提示,so that 确保配方满足商业约束。 + +### 配方记录管理(M3) + +17. As a 配方工程师,I want 新建配方记录时 AI 自动提取关键成分、比例、工艺参数并结构化存储,so that 减少手动录入工作量。 +18. As a 配方工程师,I want 通过自然语言搜索配方(如"不含酒精的高保湿精华"),so that 在海量配方中快速定位目标。 +19. As a 配方工程师,I want 以卡片视图浏览配方列表(含关键指标摘要),so that 快速扫描和筛选配方。 +20. As a 配方工程师,I want 查看配方的完整版本历史时间线,so that 追溯配方的演变过程。 +21. As a 配方工程师,I want 选择配方的两个版本进行差异对比(高亮变化),so that 清晰了解每次修改的内容。 +22. As a 配方工程师,I want 浏览成分智能目录(按功能分类、INCI 名称索引),so that 快速查找和了解可用成分。 +23. As a 配方工程师,I want 对配方添加自定义标签和备注,so that 按自己的方式组织和检索配方。 +24. As a 配方工程师,I want 导出配方为 PDF/Excel 格式,so that 用于报告或与供应商沟通。 + +### 配方推演引擎(M4) + +25. As a 配方工程师,I want 设置约束条件(如"成本降低 20%,保持肤感不变"),AI 生成多个候选配方方案,so that 并行探索配方优化方向。 +26. As a 配方工程师,I want 在多方案结果对比表中查看各候选方案的指标与差异,so that 快速比较和筛选方案。 +27. As a 配方工程师,I want 通过成本-功效散点图查看候选方案的 Pareto 前沿分布,so that 识别性价比最优方案。 +28. As a 配方工程师,I want 使用逆向推演功能(输入目标功效,反推成分组合),so that 从产品需求出发设计配方。 +29. As a 配方工程师,I want 查看配方路径示意图(从起点到各候选方案的演化路径),so that 理解配方优化的逻辑链条。 +30. As a 配方工程师,I want 将推演结果中的方案一键保存为正式配方记录,so that 将探索成果沉淀到配方库。 + +### 平台基础(M5、M6) + +31. As a 配方工程师,I want 创建和管理项目空间,so that 按产品或项目组织配方工作。 +32. As a 配方工程师,I want 系统记住我的历史配方数据和偏好,so that AI 推荐越来越精准。 +33. As a 管理员,I want 管理用户权限和团队协作,so that 团队成员可以安全地共享配方数据。 +34. As a 配方工程师,I want 导入已有的 Excel 配方数据,so that 将历史数据迁移到平台中。 + +--- + +## Implementation Decisions + +### 整体架构 + +- **前端**:React + TypeScript,SPA 架构,使用 Vite 构建 +- **状态管理**:Zustand(轻量、TS 友好、避免 Redux 模板代码) +- **路由**:React Router v7(支持 layout routes 嵌套布局) +- **样式方案**:Tailwind CSS + CSS Modules(Tailwind 处理布局/间距,CSS Modules 处理组件级复杂样式) +- **UI 组件库**:Radix UI(无样式行为组件)+ 自定义样式 +- **图表**:ECharts(echarts-for-react)作为主图表库(雷达图/饼图/散点图/仪表盘全覆盖,支持拖拽交互),D3.js 用于颜色盘等高度定制化场景 +- **色彩科学**:color.js(Color Science library)处理色空间转换、ΔE 计算;CSS Color Level 4 的 `color(display-p3 ...)` 用于广色域渲染 +- **AI 集成**:前端通过 REST API + SSE 流式推送与后端 AI 服务通信 +- **后端**:Node.js(Fastify)作为 BFF 单体,AI 能力通过外部 LLM API 调用实现 +- **数据库**:PostgreSQL(配方结构化数据)+ pgvector(向量化搜索) +- **文件存储**:MinIO / S3 兼容存储(参考图片、导出文件) + +### 模块划分 + +| 模块 ID | 模块名称 | 前端组件 Root | 关键依赖 | +| :--- | :--- | :--- | :--- | +| M1 | 颜色引擎 | `ColorEngine/` | color.js, D3.js, Canvas API | +| M2 | 配方可视化编辑器 | `FormulaEditor/` | ECharts, DnD Kit | +| M3 | 配方记录管理 | `FormulaRecords/` | React Query, Diff library | +| M4 | 配方推演引擎 | `FormulaExplorer/` | ECharts (scatter), M5 API | +| M5 | AI 服务层 | BFF 模块 | Prompt 模板、LLM API 客户端、缓存/降级/限流 | +| M6 | 平台基础 | `Platform/` | React Router, Zustand, Radix UI | + +### 数据模型核心实体 + +- **Formula(配方)**:配方核心实体,含名称、描述、创建时间、版本号、所属项目 +- **FormulaVersion(配方版本)**:配方的某个版本快照,含完整成分列表和比例 +- **Ingredient(成分)**:成分目录实体,含 INCI 名称、中文名、功能分类、供应商信息 +- **Phase(相)**:配方中按工艺阶段划分的相(油相/水相/后添加相等) +- **FormulaIngredient(配方-成分关联)**:某版本中某成分在特定相中的比例和工艺说明 +- **ColorFormula(颜色配方)**:颜色调配记录,含目标色值、色浆组合、ΔE 结果 +- **Project(项目)**:配方所属的产品项目 +- **User(用户)**:平台用户,含角色(工程师/管理员) + +### 路由结构 + +``` +/ → Dashboard(项目总览) +/projects/:projectId → 项目主页 +/formula/:formulaId → 配方详情(可视化编辑器视图) +/formula/:formulaId/history → 配方版本历史 +/formula/:formulaId/compare → 配方版本对比 +/color-lab → 颜色引擎(独立入口) +/color-lab/:colorFormulaId → 颜色配方详情 +/formula-explorer → 配方推演(独立入口) +/ingredients → 成分智能目录 +/search → 自然语言搜索 +/settings → 用户设置 +/admin → 管理员面板 +``` + +### AI 集成策略 + +- 所有 AI 能力通过调用外部 LLM API 实现(GPT-4o / Claude / DeepSeek),无需自建模型服务 +- BFF 层(Fastify)作为 AI API 的统一网关:负责编排、缓存、降级、限流、prompt 模板管理 +- 实时预测(配方调整反馈)使用 SSE 流式推送(BFF 代理 AI API 的 streaming 响应) +- AI 推荐结果需附带置信度评分和解释 +- 高频指标预测使用缓存 + cheaper 模型降低成本 +- 详见 ADR-0002 + +### 色彩管理策略 + +- 统一内部色彩表示:CIELAB(设备无关,便于 ΔE 计算) +- 输入支持:HEX、RGB、潘通色号、Lab、LCH +- 渲染:优先使用 Display P3 色彩空间(`color(display-p3 r g b)`),降级方案为 sRGB +- ΔE 标准:CIEDE2000(最新、最精确的色差公式) +- 取色棒:使用 EyeDropper API(Chromium 系)+ Canvas getImageData 降级方案 + +--- + +## Testing Decisions + +### 测试策略 + +- **单元测试**:核心逻辑层(色空间转换、ΔE 计算、比例约束校验、AI 结果解析) +- **组件测试**:关键交互组件(颜色盘拖拽、饼图拖拽调整比例、版本差异对比渲染) +- **集成测试**:AI API 调用链路(请求→响应→前端状态更新)、配方 CRUD 全流程 +- **E2E 测试**:核心用户流程(新建配方→调整比例→查看预测→保存) + +### 测试工具 + +- Vitest(单元/组件测试) +- React Testing Library(组件交互测试) +- Playwright(E2E) +- MSW(API Mock) + +### 测试模块优先级 + +1. M1 颜色引擎:色空间转换和 ΔE 计算必须有完整单元测试(精度敏感) +2. M3 配方记录:CRUD 和版本对比逻辑 +3. M2 可视化编辑器:比例调整的边界校验 +4. M4 推演引擎:约束校验逻辑 + +--- + +## Out of Scope + +- 移动端原生 App(当前仅 Web 端,可通过 PWA 提升移动体验) +- 与 ERP/MES 系统的集成(预留 API 接口,不做实现) +- 实时协作编辑(多人同时编辑同一配方 — 后续版本) +- 3D 皮肤模拟渲染(当前为 2D 虚拟皮肤材质贴图) +- AI 模型训练工具(使用已训练好的模型,不在平台内完成训练) +- 法规合规自动检查(如成分禁用清单比对) +- 供应商管理和采购流程 + +--- + +## Further Notes + +- 优先实现 M3(配方记录)作为数据基石,M1(颜色引擎)作为差异化亮点,M2(可视化编辑器)作为核心交互,M4(推演引擎)作为高阶能力 +- AI 模型的有效性高度依赖历史数据质量,建议在初期阶段同步进行历史配方数据的清洗和结构化 +- 色彩准确性对显示器硬件有依赖,建议在用户文档中说明推荐硬件(支持 Display P3 的显示器)和校准建议 +- 考虑将颜色引擎作为可独立部署的微前端模块,未来可嵌入其他系统 diff --git a/.scratch/formula-rd-platform/issues/01-s0a-react-frontend-init.md b/.scratch/formula-rd-platform/issues/01-s0a-react-frontend-init.md new file mode 100644 index 0000000..dc20a64 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/01-s0a-react-frontend-init.md @@ -0,0 +1,29 @@ +# 01 — React 前端项目初始化 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** None — can start immediately + +## What to build + +初始化 React + TypeScript 前端项目,安装并配置所有核心依赖。 + +端到端验证:`pnpm dev` 启动开发服务器,浏览器打开显示 Vite 默认页(后续 slice 替换为实际内容)。 + +## Acceptance criteria + +- [x] `pnpm create vite` 创建 React + TypeScript 项目(项目根目录下 `frontend/`) +- [x] 安装 `react-router-dom` v7,创建基础路由配置文件 `src/router.tsx` +- [x] 安装 `zustand`、`@tanstack/react-query`,创建空 store 和 query client 初始化 +- [x] 安装并配置 Tailwind CSS 4(`@tailwindcss/vite` 插件) +- [x] 安装 Radix UI 基础包:`@radix-ui/react-dialog`、`@radix-ui/react-dropdown-menu`、`@radix-ui/react-tabs`、`@radix-ui/react-tooltip`、`@radix-ui/react-slot` +- [x] 安装 `echarts` + `echarts-for-react`、`d3`、`@dnd-kit/core` + `@dnd-kit/sortable`、`color.js`、`lucide-react`(图标) +- [x] 安装开发依赖:`eslint` + `@typescript-eslint/*` + `prettier` + `prettier-plugin-tailwindcss` +- [x] `pnpm dev` 成功启动,无编译错误 +- [x] TypeScript strict mode 启用(`tsconfig.json` 中 `strict: true`) + +## Further notes + +- 项目路径:`frontend/`(与 BFF `backend/` 并行) +- 使用 pnpm 作为包管理器 +- Tailwind CSS v4 使用 CSS-first 配置(`@import "tailwindcss"` 在全局 CSS 中) diff --git a/.scratch/formula-rd-platform/issues/02-s0b-layout-routing.md b/.scratch/formula-rd-platform/issues/02-s0b-layout-routing.md new file mode 100644 index 0000000..26e49db --- /dev/null +++ b/.scratch/formula-rd-platform/issues/02-s0b-layout-routing.md @@ -0,0 +1,27 @@ +# 02 — 基础布局与路由框架 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 01-s0a-react-frontend-init + +## What to build + +创建应用的全局布局(侧边栏导航 + 顶部 Header + 内容区)和路由骨架,为所有模块提供导航入口。 + +端到端验证:打开浏览器可见完整布局,点击侧边栏各菜单项能跳转到对应占位页面。 + +## Acceptance criteria + +- [ ] 创建 `src/layouts/AppLayout.tsx`:左侧可折叠侧边栏(240px→64px)+ 顶部 Header(用户头像/设置入口)+ 内容区 `` +- [ ] 侧边栏菜单项:仪表盘、配方记录、颜色引擎、配方推演、成分目录、项目管理、设置(使用 lucide-react 图标) +- [ ] 当前激活菜单项高亮(使用 React Router `useLocation`) +- [ ] 创建各模块占位页面(DashboardPage、FormulaListPage、ColorLabPage、FormulaExplorerPage、IngredientsPage、ProjectsPage、SettingsPage) +- [ ] 路由配置:`/` → Dashboard,`/formulas` → 配方列表,`/color-lab` → 颜色引擎,`/formula-explorer` → 推演,`/ingredients` → 成分目录,`/projects` → 项目管理,`/settings` → 设置 +- [ ] 支持深色/浅色主题切换(Tailwind `dark:` variant + Zustand theme store + `localStorage` 持久化) +- [ ] 响应式:侧边栏在小屏自动折叠为汉堡菜单 + +## Further notes + +- 侧边栏宽度参考:展开 240px,折叠 64px(仅图标) +- 不需要实际页面内容,占位文本 + 标题即可 +- 先不做认证路由守卫(S0d 实现) diff --git a/.scratch/formula-rd-platform/issues/03-s0c-bff-init.md b/.scratch/formula-rd-platform/issues/03-s0c-bff-init.md new file mode 100644 index 0000000..c9617bb --- /dev/null +++ b/.scratch/formula-rd-platform/issues/03-s0c-bff-init.md @@ -0,0 +1,30 @@ +# 03 — BFF 项目初始化 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** None — can start immediately + +## What to build + +初始化 Fastify 5 BFF 后端项目,配置基础中间件和项目结构。 + +端到端验证:`pnpm dev` 启动 BFF,`GET /api/health` 返回 `{ status: "ok" }`。 + +## Acceptance criteria + +- [ ] 创建 `backend/` 目录,初始化 pnpm + TypeScript 项目 +- [ ] 安装 `fastify` v5、`@fastify/cors`、`@fastify/env`、`@fastify/formbody`、`@fastify/multipart` +- [ ] 安装 `pino`(fastify 内置)、`jsonwebtoken` + `@types/jsonwebtoken`、`bcryptjs` + `@types/bcryptjs` +- [ ] 安装 Prisma:`prisma` + `@prisma/client` +- [ ] 安装 dev 依赖:`tsx`(开发运行)、`@types/node` +- [ ] 创建 `src/app.ts`:Fastify 实例 + CORS + 错误处理 + 日志 + `/api/health` 路由 +- [ ] 创建 `src/server.ts`:启动入口,监听 `env.PORT`(默认 3001) +- [ ] 环境变量配置:PORT、DATABASE_URL、JWT_SECRET、AI_API_KEY 等(`.env.example`) +- [ ] `pnpm dev` 启动成功,`curl http://localhost:3001/api/health` 返回 200 +- [ ] TypeScript strict mode 启用 + +## Further notes + +- 目录结构:`src/routes/`(路由)、`src/middleware/`(中间件)、`src/services/`(业务逻辑)、`src/lib/`(工具) +- 路由按模块拆分文件(`routes/formulas.ts`、`routes/ingredients.ts` 等) +- pino 日志格式:开发环境 `pino-pretty`,生产环境 JSON diff --git a/.scratch/formula-rd-platform/issues/04-s0d-auth-system.md b/.scratch/formula-rd-platform/issues/04-s0d-auth-system.md new file mode 100644 index 0000000..f1c18b8 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/04-s0d-auth-system.md @@ -0,0 +1,32 @@ +# 04 — 认证系统 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 03-s0c-bff-init + +## What to build + +实现 JWT 用户认证系统:后端提供登录/注册/Token 刷新 API,前端提供登录页面和路由守卫。 + +端到端验证:未登录访问任意页面 → 跳转登录页 → 输入凭据 → 返回原页面 → 侧边栏显示用户名。 + +## Acceptance criteria + +- [ ] 后端 `POST /api/auth/register`:用户名 + 密码 → bcrypt hash → 存入 users 表 → 返回 JWT +- [ ] 后端 `POST /api/auth/login`:用户名 + 密码 → 验证 → 返回 JWT(access + refresh token) +- [ ] 后端 `POST /api/auth/refresh`:refresh token → 新 access token +- [ ] 后端 `GET /api/auth/me`:验证 JWT → 返回当前用户信息 +- [ ] 后端 auth middleware:验证 `Authorization: Bearer `,注入 `req.user` +- [ ] 前端登录页(`/login`):用户名/密码表单 + 错误提示 + 登录成功后跳转 +- [ ] 前端注册页(`/register`):用户名/密码/确认密码 + 跳转 +- [ ] 前端路由守卫:未登录 → 重定向到 `/login`,登录后跳回原目标页 +- [ ] Zustand auth store:`user`、`token`、`login()`、`logout()`、`refreshToken()` +- [ ] token 自动刷新:access token 过期前 5 分钟自动使用 refresh token 续期 +- [ ] 侧边栏底部显示当前用户名和退出按钮 + +## Further notes + +- JWT access token 有效期 15 分钟,refresh token 7 天 +- token 存储:httpOnly cookie(优先)或 localStorage(降级) +- users 表简化:id, username, password_hash, role (engineer/admin), created_at +- 管理员角色(admin)先预留字段,UI 后续实现 diff --git a/.scratch/formula-rd-platform/issues/05-s1-db-schema.md b/.scratch/formula-rd-platform/issues/05-s1-db-schema.md new file mode 100644 index 0000000..e162741 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/05-s1-db-schema.md @@ -0,0 +1,29 @@ +# 05 — 数据库 Schema 与迁移 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 03-s0c-bff-init + +## What to build + +设计并创建 PostgreSQL 数据库 schema,启用 pgvector 扩展,配置 Prisma ORM 并生成种子数据。 + +端到端验证:执行 `prisma migrate dev` 成功建表,`prisma db seed` 插入成分种子数据,`prisma studio` 可查看数据。 + +## Acceptance criteria + +- [x] Prisma schema 定义所有表(`prisma/schema.prisma`) +- [x] pgvector 扩展启用:迁移文件 `0002_pgvector/migration.sql` +- [x] `formulas` 表添加 `embedding vector(1536)` 列 +- [x] 创建 HNSW 索引 +- [x] 种子数据:41 条化妆品常见成分(覆盖 12 个功能分类) +- [ ] `prisma migrate dev` 成功执行(需 PostgreSQL 可用后运行) +- [x] Docker Compose 配置:`postgres` 服务(`ankane/pgvector:latest`),端口 5432 +- [x] Prisma Client 已生成到 `src/generated/prisma/` + +## Further notes + +- Prisma 版本使用 v6+ +- 成分种子数据用 `prisma/seed.ts`,使用 TypeScript 脚本 +- `formula_versions.snapshot_data` 用 JSONB 存储该版本的完整快照(成分+比例),避免多次 JOIN +- 向量维度 1536 适配 OpenAI `text-embedding-3-small`,未来可迁移到其他维度 diff --git a/.scratch/formula-rd-platform/issues/06-s2a-ingredient-api.md b/.scratch/formula-rd-platform/issues/06-s2a-ingredient-api.md new file mode 100644 index 0000000..b3f689a --- /dev/null +++ b/.scratch/formula-rd-platform/issues/06-s2a-ingredient-api.md @@ -0,0 +1,27 @@ +# 06 — 成分目录 API + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 03-s0c-bff-init, 05-s1-db-schema + +## What to build + +实现成分目录的后端 CRUD API,支持搜索、筛选和分页。 + +端到端验证:`POST /api/ingredients` 创建成分 → `GET /api/ingredients?search=甘油&category=保湿剂` 返回匹配结果。 + +## Acceptance criteria + +- [ ] `GET /api/ingredients`:成分列表,支持 `?search=`(模糊匹配 INCI/中文名)、`?category=`(功能分类筛选)、`?page=&limit=`(分页) +- [ ] `GET /api/ingredients/:id`:成分详情 +- [ ] `POST /api/ingredients`:创建成分(需认证、管理员权限) +- [ ] `PUT /api/ingredients/:id`:更新成分 +- [ ] `DELETE /api/ingredients/:id`:删除成分(需检查是否被配方引用,如被引用则禁止删除并返回错误) +- [ ] 搜索参数校验:search 关键词长度 ≥ 1,category 值在允许列表中 +- [ ] 响应格式统一:`{ data, pagination: { page, limit, total, totalPages } }` +- [ ] 所有路由挂载在 `/api` 前缀下 + +## Further notes + +- 功能分类枚举:emulsifier(乳化剂)、humectant(保湿剂)、thickener(增稠剂)、preservative(防腐剂)、antioxidant(抗氧化剂)、fragrance(香精)、colorant(着色剂)、ph_adjuster(pH 调节剂)、sunscreen(防晒剂)、surfactant(表面活性剂)、emollient(润肤剂)、other +- 先不实现复杂权限,所有认证用户可查看和搜索,管理员可增删改 diff --git a/.scratch/formula-rd-platform/issues/07-s2b-ingredient-ui.md b/.scratch/formula-rd-platform/issues/07-s2b-ingredient-ui.md new file mode 100644 index 0000000..574db55 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/07-s2b-ingredient-ui.md @@ -0,0 +1,31 @@ +# 07 — 成分目录前端 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 06-s2a-ingredient-api + +## What to build + +实现成分目录的前端页面:成分列表、搜索筛选、详情弹窗、增删改表单。 + +端到端验证:打开"成分目录"页面 → 看到成分列表 → 搜索"甘油" → 筛选"保湿剂" → 点击行查看详情 → 新建成分。 + +## Acceptance criteria + +- [ ] 成分列表页(`/ingredients`):表格展示 INCI 名、中文名、功能分类、单价 +- [ ] 搜索框:实时搜索(防抖 300ms),支持 INCI 名/中文名模糊匹配 +- [ ] 功能分类下拉筛选器 +- [ ] 分页控件(上一页/下一页/页码) +- [ ] 点击成分行 → 弹出详情 Dialog(Radix Dialog):显示所有字段 + 编辑/删除按钮 +- [ ] 新建成分表单(Dialog 内):INCI 名、中文名、功能分类、供应商、单位、单价、描述 +- [ ] 编辑成分表单:预填现有数据 +- [ ] 删除确认弹窗(Radix AlertDialog) +- [ ] 表单验证:INCI 名和中文名必填,单价 ≥ 0 +- [ ] 使用 TanStack Query 管理 API 请求(自动缓存/重新获取) +- [ ] Loading 状态 + 空状态提示 + +## Further notes + +- 表格使用 Tailwind 自定义样式(不用第三方表格组件) +- 操作按钮:编辑、删除仅管理员可见 +- 移动端适配:表格在小屏改为卡片列表 diff --git a/.scratch/formula-rd-platform/issues/08-s3a-formula-api.md b/.scratch/formula-rd-platform/issues/08-s3a-formula-api.md new file mode 100644 index 0000000..0047007 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/08-s3a-formula-api.md @@ -0,0 +1,32 @@ +# 08 — 配方 API + 成分-相管理 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 03-s0c-bff-init, 05-s1-db-schema + +## What to build + +实现配方 CRUD API,包括相管理和成分-相关联,以及比例校验。 + +端到端验证:`POST /api/formulas` 创建配方(含 2 个相,各含 3 个成分,比例总和 100%)→ 返回配方详情。 + +## Acceptance criteria + +- [ ] `POST /api/formulas`:创建配方,body 含 `name, description, projectId, phases: [{ name, sortOrder, ingredients: [{ ingredientId, percentage, processNotes }] }]` +- [ ] `GET /api/formulas/:id`:返回配方详情,含 phases、ingredients(JOIN 查询) +- [ ] `PUT /api/formulas/:id`:更新配方基本信息(name, description) +- [ ] `PUT /api/formulas/:id/composition`:更新配方成分(全量替换 phases + ingredients),触发版本快照创建 +- [ ] `DELETE /api/formulas/:id`:软删除(设置 deleted_at) +- [ ] `GET /api/formulas`:配方列表,支持 `?projectId=&search=&page=&limit=&sortBy=&sortOrder=` +- [ ] 比例校验(后端): + - 单个成分比例 0 < percentage ≤ 100 + - 所有成分 percentage 总和必须在 99.5% ~ 100.5% 范围内 + - 不允许空配方(至少 1 个成分) +- [ ] Phase sortOrder 自动递增 +- [ ] 创建/更新配方时自动设置 `updated_at` + +## Further notes + +- phases 和 formula_ingredients 在请求 body 中嵌套传入,在 service 层事务处理 +- 更新成分时创建新版本快照(为 S6a 做准备,此处先创建 formula_versions 记录) +- 成分 proportion 用 DECIMAL(5,2) 类型 diff --git a/.scratch/formula-rd-platform/issues/09-s3b-formula-editor-ui.md b/.scratch/formula-rd-platform/issues/09-s3b-formula-editor-ui.md new file mode 100644 index 0000000..7f742f7 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/09-s3b-formula-editor-ui.md @@ -0,0 +1,38 @@ +# 09 — 配方创建/编辑前端 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 07-s2b-ingredient-ui, 08-s3a-formula-api + +## What to build + +实现配方创建和编辑的前端页面:配方基本信息表单、成分选择器、相管理 UI、比例输入。 + +端到端验证:点击"新建配方"→ 填写名称 → 创建相 → 在相中添加成分 → 输入比例 → 保存 → 看到配方详情。 + +## Acceptance criteria + +- [ ] 配方创建页(`/formulas/new`):配方名称、描述、项目选择 +- [ ] 配方编辑页(`/formulas/:id/edit`):预填现有数据 +- [ ] 相管理 UI: + - 添加相:输入相名称 → 自动排序 + - 删除相:确认弹窗(相内成分一并删除) + - 展开/折叠相 +- [ ] 成分选择器(弹窗式): + - 搜索框搜索成分(调用成分搜索 API) + - 点击添加按钮将成分添加到当前相 + - 已添加的成分灰显禁止重复添加 +- [ ] 成分列表(在相内): + - 显示 INCI 名、中文名、比例输入框(百分比)、删除按钮 + - 比例实时显示,自动格式化为百分比 +- [ ] 比例总和实时显示(顶部/底部固定栏):红色告警(< 99.5% 或 > 100.5%)、绿色正常(范围内) +- [ ] 保存按钮:比例不合法时禁用 + tooltip 提示原因 +- [ ] 保存成功后跳转到配方详情页(S5 实现) + +## Further notes + +- 使用 React Hook Form + zod 做表单验证 +- 相管理用 Radix Accordion 实现展开/折叠 +- 成分选择器用 Radix Popover + Command(搜索单选/多选) +- 比例输入允许小数点后 2 位 +- 先不做 AI 提取的入口,S4b 追加 diff --git a/.scratch/formula-rd-platform/issues/10-s4a-ai-client.md b/.scratch/formula-rd-platform/issues/10-s4a-ai-client.md new file mode 100644 index 0000000..1d87633 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/10-s4a-ai-client.md @@ -0,0 +1,36 @@ +# 10 — AI API 客户端封装 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 03-s0c-bff-init + +## What to build + +在 BFF 层封装统一的 AI API 调用模块,支持多 provider、Prompt 模板管理、缓存、降级和 SSE streaming 代理。 + +端到端验证:调用 `aiService.predict({ ingredients: [...] })` → 返回模拟 AI 响应(mock 模式或真实 API)。 + +## Acceptance criteria + +- [ ] 创建 `src/services/ai/` 模块目录 +- [ ] Provider 抽象层(`providers/`):`openai.ts`、`deepseek.ts`,实现统一接口 `chat()` 和 `chatStream()` +- [ ] Prompt 模板管理(`templates/`):每种 AI 能力一个模板文件,含 system prompt + user prompt 构建函数 + - `templates/predict-metrics.ts`:配方指标预测 + - `templates/parse-nl-query.ts`:NL 搜索解析 + - `templates/generate-formula.ts`:配方生成/推演 + - `templates/recommend-colorants.ts`:颜色推荐 + - `templates/extract-formula.ts`:配方结构化提取 +- [ ] 缓存层:LRU 内存缓存,按 prompt hash 缓存 AI 响应,可配置 TTL(默认:指标预测 1h,NL 解析 5min,颜色推荐 30min) +- [ ] 降级处理:API 超时(30s)/连续失败(3 次)→ 返回降级提示或缓存结果 +- [ ] 限流:令牌桶算法,默认 10 req/s +- [ ] 重试:指数退避(1s, 2s, 4s),最多 3 次 +- [ ] 审计日志:每次调用记录到 `ai_audit_logs` 表(capability, model, tokens, duration) +- [ ] SSE streaming 代理:`aiService.chatStream()` 返回 AsyncIterable,路由层转为 SSE 推送 +- [ ] 环境变量配置:`OPENAI_API_KEY`、`DEEPSEEK_API_KEY`、`AI_DEFAULT_MODEL` + +## Further notes + +- 使用 OpenAI Node SDK (`openai`) 和 DeepSeek 兼容的 OpenAI 格式 +- 缓存 key:`hash(capability + JSON.stringify(promptParams))` +- Mock 模式:环境变量 `AI_MOCK=true` 时返回预设响应,用于前端开发 +- 先实现 OpenAI 和 DeepSeek provider,架构支持后续扩展 diff --git a/.scratch/formula-rd-platform/issues/11-s4b-formula-extraction.md b/.scratch/formula-rd-platform/issues/11-s4b-formula-extraction.md new file mode 100644 index 0000000..949051d --- /dev/null +++ b/.scratch/formula-rd-platform/issues/11-s4b-formula-extraction.md @@ -0,0 +1,34 @@ +# 11 — 配方结构化提取 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 08-s3a-formula-api, 10-s4a-ai-client + +## What to build + +实现配方文本/Excel 上传后 AI 自动结构化提取,前端展示确认界面,确认后自动填充到配方表单。 + +端到端验证:在配方创建页点击"AI 提取"→ 粘贴配方文本 → 点击解析 → AI 返回结构化数据 → 确认 → 配方表单自动填充。 + +## Acceptance criteria + +- [ ] 后端 `POST /api/ai/extract-formula`:接收配方文本,调用 AI API(structured output),返回 `{ ingredients: [{ inciName, chineseName, percentage, phase, processNotes }] }` +- [ ] AI 提取结果与成分库(ingredients 表)模糊匹配校验:INCI 名相似度 ≥ 80% → 自动匹配;匹配失败 → 标记 `needsReview: true` +- [ ] 前端"AI 提取"入口(配方创建/编辑页顶部按钮) +- [ ] 输入方式: + - 文本粘贴:Textarea 粘贴配方描述文本 + - 文件上传:拖拽或点击上传 .txt / .xlsx 文件 +- [ ] 解析中:loading 动画 + "AI 正在分析配方成分..." +- [ ] 解析结果展示(确认界面): + - 表格展示:INCI 名、中文名、比例、所属相、工艺备注 + - 匹配状态标记:✅ 已匹配(绿色)/ ⚠️ 待确认(黄色,表示未在成分库中找到) + - 待确认项:允许手动选择匹配或创建新成分 +- [ ] 确认后 → 自动填充到配方表单(成分 + 相 + 比例) +- [ ] 错误处理:AI 解析失败 → 显示错误信息 + 重试按钮 +- [ ] 支持的文件格式校验(仅 .txt / .xlsx,≤ 10MB) + +## Further notes + +- 使用 OpenAI Structured Outputs(`response_format: { type: "json_schema" }`)确保返回格式稳定 +- 模糊匹配使用简单的 Levenshtein 距离或字符串包含判断 +- Excel 解析用 `xlsx` 库在前端读取后转文本再提交 diff --git a/.scratch/formula-rd-platform/issues/12-s5-formula-list.md b/.scratch/formula-rd-platform/issues/12-s5-formula-list.md new file mode 100644 index 0000000..390380a --- /dev/null +++ b/.scratch/formula-rd-platform/issues/12-s5-formula-list.md @@ -0,0 +1,31 @@ +# 12 — 配方列表与基础搜索 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 08-s3a-formula-api + +## What to build + +实现配方列表页面:卡片视图、关键词搜索、筛选、分页、排序。 + +端到端验证:打开"配方记录"页面 → 看到配方卡片列表 → 搜索关键词 → 筛选项目 → 排序 → 点击进入详情。 + +## Acceptance criteria + +- [ ] 配方列表页(`/formulas`):卡片视图,每张卡片显示配方名称、描述摘要、版本号、更新时间、项目名 +- [ ] 卡片内显示指标摘要占位区(等 S14b 接入后显示实际指标) +- [ ] 搜索框:关键词搜索配方名称和描述(后端 LIKE/ILIKE 查询) +- [ ] 项目筛选下拉框 +- [ ] 排序:按更新时间/创建时间/名称排序 +- [ ] 分页:底部页码 +- [ ] 点击卡片 → 跳转到配方详情页(`/formulas/:id`) +- [ ] 配方详情页:显示配方基本信息 + 成分列表(按相分组)+ 比例 +- [ ] 详情页操作按钮:编辑、版本历史、删除(确认弹窗) +- [ ] "新建配方"浮动按钮(FAB) +- [ ] Loading 骨架屏 + 空状态配图 + +## Further notes + +- 卡片布局:桌面 3 列,平板 2 列,手机 1 列 +- 搜索和筛选用 TanStack Query 的 `queryKey` 联动(搜索词变化自动重新请求) +- 详情页的指标面板留空(S13/S14 填充) diff --git a/.scratch/formula-rd-platform/issues/13-s6a-version-snapshot.md b/.scratch/formula-rd-platform/issues/13-s6a-version-snapshot.md new file mode 100644 index 0000000..fc38a42 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/13-s6a-version-snapshot.md @@ -0,0 +1,30 @@ +# 13 — 配方版本快照 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 03-s0c-bff-init, 08-s3a-formula-api + +## What to build + +实现配方版本快照机制:更新配方成分时自动创建新版本,支持手动创建快照和查看版本列表。 + +端到端验证:编辑配方 → 修改成分 → 保存 → 自动生成 v2 版本 → `GET /api/formulas/:id/versions` 返回版本列表。 + +## Acceptance criteria + +- [ ] 更新配方成分(`PUT /api/formulas/:id/composition`)时自动创建 `formula_versions` 记录: + - `version_number` 自动递增(当前最大版本号 + 1) + - `snapshot_data` 存储该版本的完整 JSON 快照(phases + ingredients + percentages) + - `created_by` 记录操作者 +- [ ] `formulas.current_version` 字段自动更新为最新版本号 +- [ ] `POST /api/formulas/:id/versions`:手动创建版本快照(可选 description) +- [ ] `GET /api/formulas/:id/versions`:返回版本列表(按 version_number 倒序) +- [ ] 每个版本记录最小元数据:version_number、description、created_at、created_by +- [ ] 空变更检测:成分无变化时不创建新版本(与上一个 snapshot_data 比对) +- [ ] 版本号格式:`v1`, `v2`, `v3` ... + +## Further notes + +- snapshot_data 存储整个 phases + ingredients 结构,避免后续查询需要多表 JOIN +- 使用 Prisma 事务保证一致性 +- 空变更检测:对 JSONB snapshot_data 做深度比较 diff --git a/.scratch/formula-rd-platform/issues/14-s6b-version-history-compare.md b/.scratch/formula-rd-platform/issues/14-s6b-version-history-compare.md new file mode 100644 index 0000000..ee89c91 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/14-s6b-version-history-compare.md @@ -0,0 +1,32 @@ +# 14 — 版本历史与对比 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 13-s6a-version-snapshot + +## What to build + +实现配方版本历史时间线 UI 和双版本差异对比功能。 + +端到端验证:在配方详情页点击"版本历史"→ 看到时间线 → 选择 v1 和 v3 → 差异对比高亮显示成分变化。 + +## Acceptance criteria + +- [ ] 版本历史页(`/formulas/:id/history`):时间线 UI 展示所有版本 + - 每个节点显示:版本号(v1/v2/...)、描述、创建时间、创建人 + - 最新版本高亮标记 + - 点击版本节点 → 展开该版本的成分快照 +- [ ] 差异对比页(`/formulas/:id/compare?v1=1&v2=3`): + - 顶部:两个版本选择器(下拉列表) + - 对比表格:按相分组,每行显示成分名 + 旧比例 + 新比例 + 变化量(红色减少/绿色增加) + - 新增成分:绿色高亮行 + - 删除成分:红色高亮行(strikethrough) + - 比例变化:箭头指示增减方向 + 变化量 +- [ ] 差异摘要卡片(顶部):新增 X 个成分、删除 Y 个成分、修改 Z 个成分比例 +- [ ] 空状态:两个版本相同时显示"两个版本无差异" + +## Further notes + +- 时间线用自定义 Tailwind CSS(左侧竖线 + 圆点),不依赖第三方时间线组件 +- 对比计算在前端完成(从两个 snapshot_data 做 diff) +- 对比数据用 `useMemo` 缓存 diff --git a/.scratch/formula-rd-platform/issues/15-s7-color-science-core.md b/.scratch/formula-rd-platform/issues/15-s7-color-science-core.md new file mode 100644 index 0000000..bdae90e --- /dev/null +++ b/.scratch/formula-rd-platform/issues/15-s7-color-science-core.md @@ -0,0 +1,46 @@ +# 15 — 色彩科学核心 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** None — can start immediately(纯逻辑,无依赖) + +## What to build + +集成 color.js 色彩科学库,实现全色空间转换和 CIEDE2000 ΔE 计算,并编写完整单元测试。 + +端到端验证:运行 `pnpm test`,所有色彩转换和 ΔE 计算的单元测试通过。 + +## Acceptance criteria + +- [ ] 安装 `color.js`(npm 包名 `colorjs.io`) +- [ ] 创建 `frontend/src/lib/color/` 模块: + - `convert.ts`:色空间转换函数 + - `hexToLab(hex: string): { L, a, b }` + - `labToHex(L: number, a: number, b: number): string` + - `rgbToLab(r: number, g: number, b: number): { L, a, b }` + - `labToRGB(L: number, a: number, b: number): { r, g, b }` + - `labToLCH(L: number, a: number, b: number): { L, C, h }` + - `lchToLab(L: number, C: number, h: number): { L, a, b }` + - `displayP3ToLab(r: number, g: number, b: number): { L, a, b }` + - `labToDisplayP3(L: number, a: number, b: number): { r, g, b }` +- [ ] `deltaE.ts`:色差计算 + - `deltaE2000(lab1, lab2): number`(CIEDE2000) + - `deltaE94(lab1, lab2): number`(CIE94,备选) + - `deltaE76(lab1, lab2): number`(CIE76,基础参考) +- [ ] `types.ts`:TypeScript 类型定义 + - `LABColor`, `RGBColor`, `LCHColor`, `DisplayP3Color` +- [ ] 单元测试(Vitest): + - 已知色值对的转换测试(例如 sRGB 白 #FFFFFF → Lab(100, 0, 0)) + - 往返转换测试:Lab → RGB → Lab,误差 < 0.01 + - Display P3 色值转换测试(使用已知 P3 色值) + - ΔE 基准测试:两相同颜色 ΔE = 0 + - ΔE 已知差异测试:例如纯红 vs 纯绿,ΔE > 10 + - CIEDE2000 vs CIE76 对比(CIEDE2000 应更精确) +- [ ] 所有公共函数有 JSDoc 注释 + +## Further notes + +- 内部颜色统一使用 CIELAB 表示(设备无关) +- color.js 的 `Color` 对象使用方式参考官方文档 +- 测试用例参考 color.js 官方测试数据或已知色彩学基准值 +- Display P3 转换仅在支持的环境测试(BFC 使用 color.js 的 `p3` 空间) diff --git a/.scratch/formula-rd-platform/issues/16-s8a-color-wheel-render.md b/.scratch/formula-rd-platform/issues/16-s8a-color-wheel-render.md new file mode 100644 index 0000000..a88fa94 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/16-s8a-color-wheel-render.md @@ -0,0 +1,34 @@ +# 16 — 色相环基础渲染 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 15-s7-color-science-core + +## What to build + +使用 D3.js 渲染交互式 2D 色相环(HSV 色彩空间),支持基础点击选色和当前色预览。 + +端到端验证:打开"颜色引擎"页面 → 看到完整的色相环 → 点击环上任意位置 → 当前色预览区域更新为对应颜色。 + +## Acceptance criteria + +- [ ] 创建颜色引擎页面(`/color-lab`),分为三栏布局:色相环(左)、当前色预览区(中)、调节面板(右,占位) +- [ ] D3.js 渲染 HSV 色相环: + - 环形布局:外圈色相(H 0-360°)+ 径向饱和度(外圈 S=100% → 内圈 S=0%) + - 环形中心显示亮度指示(上 V=100% → 下 V=0%,占位) + - Canvas 渲染(非 SVG,性能考虑),尺寸 ≥ 400x400px + - 支持 Display P3 色域渲染(`canvas.getContext('2d', { colorSpace: 'display-p3' })`) +- [ ] 交互: + - 点击色相环:选色 → 提取点击位置的 HSV 值 → 转换为 Lab → 更新当前色 + - 当前选色位置显示小圆点指示器 +- [ ] 当前色预览区: + - 大色块显示当前选中的颜色 + - 下方显示色值:HEX、RGB、Lab +- [ ] 右侧调节面板:标题 + 占位提示("颜色微调功能即将上线"),预留 S8b 接入 + +## Further notes + +- D3.js 用于布局计算(极坐标 → 直角坐标),Canvas 2D 用于实际渲染 +- HSV 色相环渲染算法:遍历像素 → 极坐标 (r, θ) → HSV(h=θ, s=r/R, v=1) → 转为 RGB → fillRect +- 点击事件:Canvas `onClick` → 获取鼠标位置 → 极坐标 → HSV → Lab +- 色相环 UI 独立为 `ColorWheel` 组件,可复用 diff --git a/.scratch/formula-rd-platform/issues/17-s8b-color-adjustment.md b/.scratch/formula-rd-platform/issues/17-s8b-color-adjustment.md new file mode 100644 index 0000000..39f29d7 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/17-s8b-color-adjustment.md @@ -0,0 +1,33 @@ +# 17 — 颜色调节与微调 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 15-s7-color-science-core, 16-s8a-color-wheel-render + +## What to build + +实现 RGB/Lab 各通道的滑条微调功能,替换 S8a 中调节面板的占位内容。 + +端到端验证:在色相环选色后 → 拖动 L 滑条 → 颜色预览即时变亮/暗 → 拖动 a 滑条 → 颜色偏红/绿。 + +## Acceptance criteria + +- [ ] 调节面板(替代 S8a 占位): + - Lab 模式:3 个滑条(L: 0-100, a: -128~127, b: -128~127) + - RGB 模式(切换 Tab):3 个滑条(R/G/B: 0-255) + - Lab/RGB 模式切换 Toggle +- [ ] 滑条交互: + - 拖动滑条 → 颜色预览实时更新 + - 滑条旁显示当前数值 + - 滑条轨道渲染对应通道渐变色(如 L 滑条:从黑到白) +- [ ] 当前色显示(继承 S8a): + - HEX 值旁增加复制按钮(一键复制到剪贴板) + - 色值变化时短暂高亮动画(反馈变化) +- [ ] 重置按钮 → 恢复到选色时的初始值 +- [ ] 所有调整实时反映到色相环上的选色指示器位置 + +## Further notes + +- 滑条组件使用原生 `` + Tailwind 自定义样式 +- Lab 和 RGB 之间的转换使用 S7 的转换函数 +- 操作防抖:连续拖动时不触发外部更新(如 AI 推荐),仅在松开时触发 diff --git a/.scratch/formula-rd-platform/issues/18-s9-color-input-deltaE.md b/.scratch/formula-rd-platform/issues/18-s9-color-input-deltaE.md new file mode 100644 index 0000000..c83d181 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/18-s9-color-input-deltaE.md @@ -0,0 +1,34 @@ +# 18 — 色号输入与 ΔE 匹配 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 15-s7-color-science-core + +## What to build + +实现多格式色号输入(潘通/HEX/RGB/Lab),目标色 vs 当前色并排对比,ΔE 实时显示。 + +端到端验证:在颜色引擎页输入潘通色号 → 显示目标色 → 调整当前色 → 实时看到 ΔE 数值变化。 + +## Acceptance criteria + +- [ ] 色号输入区域(色相环上方/调节面板顶部): + - 输入格式切换:HEX(`#FF0000`)/ RGB(`255, 0, 0`)/ Lab(`50, 50, 0`)/ 潘通(如 `185 C`) + - 输入后自动解析并显示目标色色块 + - 格式校验:非法输入红色边框提示 +- [ ] 目标色 vs 当前色并排对比: + - 左:目标色色块(大) + - 右:当前色色块(大) + - 中间:ΔE 数值(大字体),颜色编码:ΔE ≤ 1.0(绿色 ✅)、1.0 < ΔE ≤ 3.0(黄色 ⚠️)、ΔE > 3.0(红色 ❌) +- [ ] 当前色变化时 ΔE 实时重新计算 +- [ ] 潘通色号输入: + - 先做简化版:预置 20+ 常用潘通色的 Lab 值映射表(硬编码) + - 输入潘通色号 → 查表 → 转 Lab → 设为目标色 +- [ ] 目标色设置后,颜色预览区的色值显示包含"目标值 vs 当前值"对比行 + +## Further notes + +- HEX 输入支持 `#` 可选、3 位和 6 位格式 +- RGB 输入支持 `rgb(255,0,0)` 和 `255, 0, 0` 两种格式 +- 潘通映射表:`src/lib/color/pantone.ts`,包含 Lab 值和色号名称 +- ΔE 更新频率:防抖 100ms diff --git a/.scratch/formula-rd-platform/issues/19-s10-eyedropper.md b/.scratch/formula-rd-platform/issues/19-s10-eyedropper.md new file mode 100644 index 0000000..ec0c302 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/19-s10-eyedropper.md @@ -0,0 +1,37 @@ +# 19 — 取色棒 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 15-s7-color-science-core + +## What to build + +实现取色棒功能:从参考图片取色(EyeDropper API + Canvas 降级),取色历史记录,自动匹配历史颜色配方。 + +端到端验证:上传参考图片 → 点击"取色棒"按钮 → 在图片上点击取色 → 颜色显示在预览区 → 自动显示最接近的历史颜色配方。 + +## Acceptance criteria + +- [ ] 参考图片上传区(颜色引擎页新增 Tab 或面板): + - 拖拽上传或点击选择图片(支持 jpg/png/webp) + - 图片显示在 Canvas 中(可缩放/拖动,使用 CSS transform) +- [ ] 取色棒按钮: + - 优先使用 EyeDropper API(`new EyeDropper().open()`),仅 Chromium 系支持 + - EyeDropper 不可用时降级为 Canvas 取色:点击图片 Canvas 上的像素 → 获取该像素 RGB 值 + - 取色后 → 颜色设为当前色 → 更新预览区和色值 +- [ ] 取色历史(底部横条): + - 显示最近 10 次取色结果(小色块排列) + - 点击历史色块 → 切换为当前色 + - 取色历史 persist 到 localStorage +- [ ] 自动匹配历史颜色配方: + - 取色后 → 调用后端 API → 检索 ΔE 最小的颜色配方(Top 3) + - 匹配结果显示:色块 + 配方名 + ΔE 值 + - 点击匹配项 → 跳转到对应颜色配方详情 +- [ ] 图片缩放:鼠标滚轮缩放(0.5x ~ 3x),按住拖动平移 + +## Further notes + +- Canvas 取色:`ctx.getImageData(x, y, 1, 1).data` 获取 RGBA +- 取色精度:取色位置周围 3×3 像素平均值(减少噪点影响) +- 颜色配方匹配 API 在 S11a 实现,此处先做前端占位和 mock +- 缩放用 CSS `transform: scale()` 实现,不修改 Canvas 尺寸 diff --git a/.scratch/formula-rd-platform/issues/20-s11a-color-recommend-api.md b/.scratch/formula-rd-platform/issues/20-s11a-color-recommend-api.md new file mode 100644 index 0000000..5fe023b --- /dev/null +++ b/.scratch/formula-rd-platform/issues/20-s11a-color-recommend-api.md @@ -0,0 +1,34 @@ +# 20 — AI 颜色推荐 API + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 03-s0c-bff-init, 10-s4a-ai-client, 15-s7-color-science-core, 05-s1-db-schema + +## What to build + +实现 AI 颜色推荐后端 API:根据目标颜色推荐色浆组合与比例,匹配历史颜色配方。 + +端到端验证:`POST /api/color/recommend` 传入目标 Lab 值 → 返回推荐的色浆组合 + 预测比例 + 预测 ΔE。 + +## Acceptance criteria + +- [ ] `POST /api/color/recommend`: + - 请求 body:`{ targetLab: { L, a, b } }` + - 返回:`{ recommendations: [{ colorants: [{ name, ratio }], predictedLab, predictedDeltaE, confidence }], matchedFormulas: [{ id, name, deltaE }] }` +- [ ] 实现流程: + 1. 在 `color_formulas` 表中搜索 ΔE < 5.0 的历史颜色配方(pgvector 或直接计算) + 2. 取 Top 5 最近匹配配方作为 few-shot context + 3. 调用 AI API(colorant recommendation prompt 模板) + 4. 解析 AI 返回的色浆推荐列表 + 5. 对每个推荐组合:用 color.js 计算理论混合色的 Lab 值 → 计算与目标的 ΔE +- [ ] `GET /api/color/formulas/match?L=&a=&b=&limit=5`: + - 检索最接近目标 Lab 值的历史颜色配方(Top N,按 ΔE 升序) + - 用于取色棒匹配 +- [ ] AI API 调用正确使用 S4a 的抽象层 +- [ ] 颜色推荐结果缓存 30 分钟(key: Lab 值取整到小数点后 1 位) + +## Further notes + +- 色浆推荐 Prompt 需包含常见化妆品色浆知识(氧化铁系列、二氧化钛等) +- 当前阶段色浆库数据量可能不足,AI 推荐更多依赖模型常识推理 +- ΔE 历史匹配:遍历 color_formulas 表计算 ΔE,数据量 < 10K 时可接受全表扫描 diff --git a/.scratch/formula-rd-platform/issues/21-s11b-color-recommend-ui.md b/.scratch/formula-rd-platform/issues/21-s11b-color-recommend-ui.md new file mode 100644 index 0000000..019e355 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/21-s11b-color-recommend-ui.md @@ -0,0 +1,35 @@ +# 21 — 颜色推荐前端 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 20-s11a-color-recommend-api, 08-s3a-formula-api + +## What to build + +实现 AI 颜色推荐的前端展示:推荐色浆组合、虚拟皮肤材质上颜色预览、颜色配方保存。 + +端到端验证:在颜色引擎页设置目标色 → 点击"AI 推荐配色" → 看到推荐色浆列表 → 选择方案 → 在虚拟皮肤上预览 → 保存为颜色配方。 + +## Acceptance criteria + +- [ ] "AI 推荐配色"按钮(颜色引擎页):点击后调用 `/api/color/recommend` +- [ ] 推荐结果面板(Dialog 或侧边面板): + - 推荐方案列表:每个方案显示色浆组合(名称 + 比例条)+ 预测色色块 + 预测 ΔE + 置信度 + - 点击方案 → 预览该方案的预测色 +- [ ] 虚拟皮肤材质预览: + - 2D 图像:模拟皮肤纹理的渐变背景(肤色底色 + 纹理叠加) + - 将选中颜色半透明叠加在皮肤材质上(`mix-blend-mode: multiply` 或 Canvas 合成) + - 让用户直观感受颜色在皮肤上的呈现效果 +- [ ] "保存为颜色配方"按钮: + - 调用配方 API 创建 `color_formulas` 记录 + - 保存目标色 Lab、实际色 Lab、ΔE、色浆组合 + - 可选:关联到已有的配方(`formula_id`) + - 保存成功 → Toast 提示 + 跳转到颜色配方详情 +- [ ] Loading 状态:推荐生成中显示骨架屏 +- [ ] AI 推荐失败降级:显示历史匹配配方(纯本地计算,不依赖 AI) + +## Further notes + +- 皮肤材质图片:使用 CSS `linear-gradient` 生成简单的肤色渐变模拟,不需要真实图片 +- 颜色叠加:使用 CSS `background-blend-mode: multiply` 或 Canvas `globalCompositeOperation` +- 保存颜色配方复用 S3a 的 API diff --git a/.scratch/formula-rd-platform/issues/22-s12-drag-pie-chart.md b/.scratch/formula-rd-platform/issues/22-s12-drag-pie-chart.md new file mode 100644 index 0000000..2f95d65 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/22-s12-drag-pie-chart.md @@ -0,0 +1,40 @@ +# 22 — 拖拽饼图调整成分比例 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 08-s3a-formula-api + +## What to build + +在配方详情的"可视化编辑"视图实现拖拽饼图实时调整成分比例,饼图与成分列表双向联动。 + +端到端验证:打开配方 → 切换到"可视化编辑"Tab → 看到饼图 → 拖拽饼图段边缘 → 比例变化 → 成分列表同步更新。 + +## Acceptance criteria + +- [ ] 配方详情页新增"可视化编辑"Tab(router: `/formulas/:id?tab=visual`) +- [ ] ECharts 饼图(环形图)展示配方成分比例: + - 每个扇区显示成分名缩写 + 百分比 + - 扇区颜色使用预设调色板(10+ 色,区分不同功能分类) + - 图例显示完整成分名 +- [ ] DnD Kit 集成(拖拽调整扇区大小): + - 在每个扇区边缘添加拖拽手柄(不可见热区或可见小圆点) + - 拖拽手柄 → 调整相邻两个扇区的比例(一个增大、另一个减小) + - 拖拽过程中饼图实时重绘 + - 最小比例 ≥ 0.1%,防止扇区消失 +- [ ] 成分列表联动(右侧面板): + - 显示所有成分:名称 + 百分比数值 + 增减按钮(±0.1%) + - 饼图拖拽 → 成分列表数值同步更新 + - 列表数值手动修改 → 饼图扇区同步更新 +- [ ] 比例总和监控条(顶部): + - 绿色进度条 + 百分比数字 + - = 100% 绿色,≠ 100% 红色警告 +- [ ] 总比例不等于 100% 时,底部显示"自动归一化"按钮(等比例缩放至 100%) +- [ ] 所有修改不自动保存,需手动点击"保存"按钮 → 调用 `PUT /api/formulas/:id/composition` + +## Further notes + +- ECharts 饼图使用 `roseType: 'area'` 可选(南丁格尔玫瑰图) +- DnD Kit 碰撞检测用自定义算法:判断鼠标是否在扇区边缘附近(±5°范围) +- 扇区大小调整逻辑:增量分配给相邻扇区,如果相邻扇区已达最小值则跳过 +- 颜色调色板:使用 Tailwind 颜色系统(sky, emerald, amber, rose, violet, ...) diff --git a/.scratch/formula-rd-platform/issues/23-s13-ai-realtime-prediction.md b/.scratch/formula-rd-platform/issues/23-s13-ai-realtime-prediction.md new file mode 100644 index 0000000..906e733 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/23-s13-ai-realtime-prediction.md @@ -0,0 +1,41 @@ +# 23 — AI 实时指标预测 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 10-s4a-ai-client, 22-s12-drag-pie-chart + +## What to build + +实现配方成分变化时通过 SSE 流式调用 AI 预测肤感/稳定性/成本指标,前端实时展示预测结果和 loading 状态。 + +端到端验证:在可视化编辑器调整成分比例 → 松开后 0.5s → 显示"AI 正在预测..."→ 指标面板更新。 + +## Acceptance criteria + +- [ ] 后端 `GET /api/ai/predict?formulaId=:id`(SSE endpoint): + - 接收当前配方成分列表 → 构建预测 Prompt → 调用 AI API(streaming)→ 转发 SSE + - SSE 事件格式:`data: { type: "thinking"|"result", content: ... }` + - 使用 S4a 的缓存层(相同成分+比例 hash 直接返回缓存) +- [ ] 前端 `useAIPredict` hook: + - 成分比例变化后防抖 500ms → 发起 SSE 连接 + - 连接中显示"AI 正在预测..."loading 动画(三点跳动) + - 收到结果 → 更新 Zustand `predictionStore` + - 超时 10s → 降级提示"预测超时,请稍后重试" + - 错误 → 显示错误信息 + 重试按钮 +- [ ] 预测结果状态管理(Zustand `predictionStore`): + - `sensoryIndex: { spreadability, absorption, stickiness, overall }` + - `stabilityScore: number (0-100)` + - `costEstimate: number (元/kg)` + - `confidence: number (0-1)` + - `lastPredictedAt: Date` +- [ ] 预测结果展示(可视化编辑页底部面板): + - 显示上次预测时间和置信度 + - "手动刷新"按钮 +- [ ] 缓存命中时直接显示结果,无需 loading + +## Further notes + +- SSE 连接管理:成分变化时先关闭旧连接再建立新连接 +- 预测 Prompt 包含:配方名称、各成分 INCI 名 + 中文名 + 比例 + 功能分类 +- 缓存 key:`hash(成分ID列表 + 比例列表)` +- 预测结果乐观更新:先显示上一次结果,SSE 返回后替换 diff --git a/.scratch/formula-rd-platform/issues/24-s14a-radar-chart.md b/.scratch/formula-rd-platform/issues/24-s14a-radar-chart.md new file mode 100644 index 0000000..82997f4 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/24-s14a-radar-chart.md @@ -0,0 +1,33 @@ +# 24 — 指标雷达图 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 23-s13-ai-realtime-prediction + +## What to build + +使用 ECharts 雷达图展示配方多维度指标(肤感、稳定性等子维度),成分变化时雷达图实时更新。 + +端到端验证:调整成分比例 → AI 预测完成 → 雷达图各维度动态变化。 + +## Acceptance criteria + +- [ ] ECharts 雷达图组件(`FormulaRadarChart`): + - 维度轴:铺展性、吸收速度、黏腻度、稳定性、保湿力等(5-8 个维度) + - 每个维度 0-100 分 + - 雷达图区域半透明填充(颜色按综合评分:绿>80、黄>60、红<60) + - 数值标签显示在各维度顶点 +- [ ] 从 `predictionStore` 读取预测数据 → 绑定到雷达图 +- [ ] 成分变化 + AI 预测完成 → 雷达图平滑过渡动画(`animationDuration: 800`) +- [ ] 雷达图交互: + - 悬停维度顶点 → Tooltip 显示维度名 + 数值 + - 可选:多配方雷达图叠加对比(占位:预留第二个 dataset 接口) +- [ ] 雷达图响应式:容器大小变化时自动重绘 +- [ ] 空状态:无预测数据时显示占位提示"调整成分后 AI 将自动预测" + +## Further notes + +- ECharts 雷达图配置参考:`echarts-for-react` 的 `ReactECharts` 组件 +- 维度列表可配置(`src/lib/charts/radarConfig.ts`) +- 平滑动画使用 ECharts 的 `animation` 配置 +- 雷达图放在可视化编辑页的指标面板中(与饼图并排,桌面端左右布局) diff --git a/.scratch/formula-rd-platform/issues/25-s14b-gauge-tree.md b/.scratch/formula-rd-platform/issues/25-s14b-gauge-tree.md new file mode 100644 index 0000000..88b59cc --- /dev/null +++ b/.scratch/formula-rd-platform/issues/25-s14b-gauge-tree.md @@ -0,0 +1,39 @@ +# 25 — 仪表盘与结构树 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 23-s13-ai-realtime-prediction + +## What to build + +使用 ECharts 仪表盘展示关键指标(稳定性/成本),支持目标范围设定和超限告警;使用 D3.js 渲染配方结构树状图。 + +端到端验证:AI 预测完成 → 仪表盘指针指向预测值 → 成本超过目标范围 → 仪表盘红色告警 → 结构树展示配方层次。 + +## Acceptance criteria + +- [ ] ECharts 仪表盘组件(`FormulaGauge`): + - 至少 2 个仪表盘:稳定性评分(0-100)、成本估算(元/kg) + - 每个仪表盘:指针 + 刻度 + 数值显示 + - 目标范围设定:用户可拖动设定目标范围(稳定性 ≥ 70,成本 ≤ X) +- [ ] 目标范围交互: + - 仪表盘上的彩色区间(绿=达标、黄=接近、红=超标) + - 用户拖动区间边界调整目标 + - 目标值持久化到配方数据或 localStorage +- [ ] 超限告警: + - 预测值超出目标范围 → 仪表盘红色闪烁 + 文字提示 + - 同时触发面包屑级别的警告标识 +- [ ] 配方结构树状图(`FormulaTreeMap`): + - D3.js 渲染的树图(treemap)或缩进树 + - 层级:配方 → 相(油相/水相等)→ 成分 → 比例 + - 每个节点色块按比例大小缩放 + - 可展开/折叠节点 +- [ ] 从配方数据构建树结构(不依赖 AI 预测) +- [ ] 响应式:桌面端横向排列(仪表盘 ×2 + 结构树),移动端纵向堆叠 + +## Further notes + +- 仪表盘 ECharts 使用 `type: 'gauge'` +- 目标范围用 `axisLine.lineStyle.color` 分段设置 +- 结构树 D3.js 用 `d3.hierarchy` + `d3.tree()` 或 `d3.treemap()` +- 当前阶段树状图用 Treemap(面积表示比例),更直观 diff --git a/.scratch/formula-rd-platform/issues/26-s15a-exploration-constraints.md b/.scratch/formula-rd-platform/issues/26-s15a-exploration-constraints.md new file mode 100644 index 0000000..3bf1feb --- /dev/null +++ b/.scratch/formula-rd-platform/issues/26-s15a-exploration-constraints.md @@ -0,0 +1,35 @@ +# 26 — 推演约束设置 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 08-s3a-formula-api + +## What to build + +实现配方推演的约束设置 UI:选择基础配方、设定成本/成分/指标约束条件。 + +端到端验证:打开"配方推演"页 → 选择基础配方 → 设定成本上限 → 开始推演(按钮跳转 S15b)。 + +## Acceptance criteria + +- [ ] 配方推演页(`/formula-explorer`): + - 顶部:选择基础配方(下拉搜索选择器,从已有配方中选) + - 或选择"从零开始"(不基于现有配方) +- [ ] 约束条件表单(三组): + - **成本约束**:成本上限(元/kg)+ 成本下限(可选) + - **成分约束**:必须保留成分(多选,从成分库搜索)+ 禁止使用成分(多选) + - **指标约束**:目标肤感 ≥ X、稳定性 ≥ Y,每个指标有"不重要/普通/重要"优先级选择 +- [ ] 约束条件实时校验: + - 成本上下限:上 > 下 ≥ 0 + - 保留和禁止成分不能重叠 + - 指标范围 0-100 +- [ ] "开始推演"按钮: + - 按钮状态:无约束 → 禁用 + tooltip"请设置至少一个约束条件" + - 点击 → 导航到推演结果页 + 传递约束参数 +- [ ] 约束条件保存为"方案"(localStorage):可命名、可加载历史方案 + +## Further notes + +- 推演页独立路由(不属于某个配方详情) +- 基础配方选择器复用 S2b 的成分搜索模式 +- 指标优先级用于 AI Prompt 中的权重设定 diff --git a/.scratch/formula-rd-platform/issues/27-s15b-ai-exploration-core.md b/.scratch/formula-rd-platform/issues/27-s15b-ai-exploration-core.md new file mode 100644 index 0000000..92731f3 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/27-s15b-ai-exploration-core.md @@ -0,0 +1,44 @@ +# 27 — AI 配方推演核心 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 08-s3a-formula-api, 10-s4a-ai-client, 26-s15a-exploration-constraints + +## What to build + +实现 AI 配方推演核心功能:将约束条件发送给 AI API,streaming 展示生成的候选配方方案列表,支持逆向推演入口。 + +端到端验证:设置约束 → 点击"开始推演"→ 看到 AI 逐步生成候选方案 → 每个方案显示成分变更 + 预测指标 + 变更理由。 + +## Acceptance criteria + +- [ ] 后端 `POST /api/ai/explore`(SSE endpoint): + - 请求 body:基础配方 ID(可选)+ 约束条件 + - 构建 Prompt:基础配方成分 + 约束条件 + few-shot 示例 + - 调用 AI API(streaming)→ 转发 SSE + - SSE 事件格式:`data: { type: "option", option: { name, changes: [...], predictedMetrics, reasoning } }` + - 超时 120s +- [ ] 前端推演结果页(`/formula-explorer/results`): + - SSE 连接状态指示器 + - Streaming 展示:每收到一个方案 → 卡片飞入动画追加到列表 + - 方案卡片内容: + - 方案名称(自动生成:如"降本方案 A") + - 成分变更摘要:+新增 X 个、-移除 Y 个、±调整 Z 个 + - 预测指标对比(基础 vs 候选):颜色编码(绿色改善、红色退步) + - 变更理由(AI 生成的简短说明) + - 综合评分(0-100,基于约束满足度) +- [ ] 操作按钮: + - "暂停":中断 SSE 连接 + - "继续":恢复连接 + - "停止":使用已有结果 +- [ ] 逆向推演入口: + - "逆向推演"按钮 → 输入目标功效(肤感≥80, 稳定性≥90, 成本≤X) + - 不基于现有配方,AI 从成分库中推荐成分组合 +- [ ] 推演结果存到 Zustand store(当前会话),刷新丢失 + +## Further notes + +- 方案卡片动画用 CSS `@keyframes` + Tailwind `animate-*` +- AI Prompt 需明确要求返回结构化 JSON(每个方案一个对象) +- 逆向推演是同一 API 的不同模式(基础配方为空 → 逆向) +- 方案生成数量由 AI 决定,前端设置上限(最多 10 个) diff --git a/.scratch/formula-rd-platform/issues/28-s16-exploration-visualization.md b/.scratch/formula-rd-platform/issues/28-s16-exploration-visualization.md new file mode 100644 index 0000000..8bad3dc --- /dev/null +++ b/.scratch/formula-rd-platform/issues/28-s16-exploration-visualization.md @@ -0,0 +1,43 @@ +# 28 — 推演可视化与保存 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 27-s15b-ai-exploration-core + +## What to build + +实现推演结果的可视化对比和保存:多方案对比表、成本-功效散点图(Pareto 前沿)、配方路径示意图、一键保存候选方案。 + +端到端验证:推演完成后 → 切换到"对比视图"→ 看到多方案并排对比表 → 散点图标注 Pareto 前沿 → 点击"保存方案"→ 创建新配方。 + +## Acceptance criteria + +- [ ] 推演结果页新增视图切换 Tab: + - "卡片视图"(S15b 实现的) + - "对比表视图" + - "散点图视图" +- [ ] 多方案对比表(`/formula-explorer/results?view=table`): + - 行:候选方案 + - 列:方案名、成本、肤感、稳定性、成分数、变更数、综合评分 + - 每列支持排序 + - 表头固定 + - 点击行 → 展开变更详情 + - 基础配方行高亮(蓝色背景) +- [ ] 成本-功效散点图(`/formula-explorer/results?view=scatter`): + - X 轴:成本,Y 轴:综合评分(或其他指标可选) + - 每个候选方案一个点 + 方案名标签 + - 基础配方点特殊标记(星形) + - Pareto 前沿标注:前沿上的点连线 + highligh + - 交互:悬停点 → Tooltip 显示方案详情;点击点 → 跳转到该方案详情卡片 +- [ ] 配方路径示意图(占位,后续迭代实现) +- [ ] "保存为配方"按钮(每个方案卡片 + 对比表每行): + - 调用配方 API 创建新配方(基于候选方案的成分变更) + - 可选:覆盖基础配方(作为新版本) + - 保存成功 → Toast + 跳转到新配方详情 +- [ ] "全部导出"按钮:导出对比表为 Excel + +## Further notes + +- 散点图用 ECharts `type: 'scatter'` +- Pareto 前沿计算在前端完成(排序 + 过滤非支配解) +- 对比表用纯 Tailwind 表格 + TanStack Table(轻量级) diff --git a/.scratch/formula-rd-platform/issues/29-s17-nl-search.md b/.scratch/formula-rd-platform/issues/29-s17-nl-search.md new file mode 100644 index 0000000..e5fce94 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/29-s17-nl-search.md @@ -0,0 +1,35 @@ +# 29 — NL 智能搜索 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 10-s4a-ai-client, 05-s1-db-schema, 12-s5-formula-list + +## What to build + +实现自然语言配方搜索:NL 输入 → AI 解析为结构化查询 → pgvector 向量搜索 → 结果展示。 + +端到端验证:搜索框输入"不含酒精的高保湿精华" → AI 解析 → 返回匹配的保湿精华配方(不含酒精成分)。 + +## Acceptance criteria + +- [ ] 全局搜索入口:顶部 Header 搜索框(支持 NL 输入,如"找一款...") +- [ ] 后端 `GET /api/search?q=自然语言查询`: + 1. 调用 AI API 将 NL 解析为:`{ filters: { excludeIngredients, includeIngredients, category, ... }, vectorQuery: "..." }` + 2. filters 转为 PostgreSQL WHERE 条件 + 3. vectorQuery 转为 embedding → pgvector HNSW 搜索(cosine similarity) + 4. 合并结果(过滤 + 向量),按综合相关性排序 + 5. 返回匹配的配方列表 +- [ ] AI 解析缓存 5 分钟(key: 原始 NL 文本 hash) +- [ ] 搜索结果页(`/search?q=...`): + - 配方卡片列表(复用 S5 的卡片组件) + - 每个结果显示:配方名、匹配度评分、匹配原因(如"含透明质酸,保湿指数高") + - 关键词高亮(在卡片标题和描述中) +- [ ] NL 解析失败降级:基础关键词搜索(ILIKE 匹配配方名和描述) +- [ ] 空结果:显示"未找到匹配配方" + 建议(如"试试搜索'保湿精华'") + +## Further notes + +- NL 解析 Prompt:要求 AI 返回标准 JSON 结构,并给出解析解释 +- 向量搜索用 OpenAI `text-embedding-3-small` 或本地方案 +- 搜索结果相关性排序:向量相似度 × 0.7 + 关键词匹配 × 0.3 +- 初始阶段如果配方数据少,搜索可能结果稀疏 diff --git a/.scratch/formula-rd-platform/issues/30-s18-project-management.md b/.scratch/formula-rd-platform/issues/30-s18-project-management.md new file mode 100644 index 0000000..da36db4 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/30-s18-project-management.md @@ -0,0 +1,35 @@ +# 30 — 项目管理 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 03-s0c-bff-init, 05-s1-db-schema + +## What to build + +实现项目的 CRUD 功能和配方归属管理。 + +端到端验证:创建"2026 春季新品"项目 → 将配方归类到该项目 → 在项目页面筛选查看。 + +## Acceptance criteria + +- [ ] 后端 API: + - `GET /api/projects`:项目列表(用户可见的项目) + - `POST /api/projects`:创建项目(name, description) + - `PUT /api/projects/:id`:更新项目 + - `DELETE /api/projects/:id`:删除项目(需检查无关联配方,或提示) + - `GET /api/projects/:id/formulas`:项目下的配方列表 +- [ ] 前端项目管理页(`/projects`): + - 项目卡片网格:项目名、描述、配方数量、创建时间 + - 点击卡片 → 跳转项目详情(项目内配方列表) + - "新建项目"按钮 → Dialog 表单 + - 删除确认弹窗 +- [ ] 项目内配方筛选(`/projects/:id`): + - 显示项目信息头 + - 该项目下的配方列表(复用 S5 卡片组件 + 筛选) + - "在项目中新建配方"快捷按钮 +- [ ] 配方创建/编辑时选择所属项目(下拉框,已经在 S3b 中实现,此处补全后端关联) + +## Further notes + +- 项目是逻辑分组,不影响配方数据本身 +- 项目成员权限先不做(当前单用户或简化权限) diff --git a/.scratch/formula-rd-platform/issues/31-s19-import-export.md b/.scratch/formula-rd-platform/issues/31-s19-import-export.md new file mode 100644 index 0000000..32db3b1 --- /dev/null +++ b/.scratch/formula-rd-platform/issues/31-s19-import-export.md @@ -0,0 +1,40 @@ +# 31 — 数据导入导出 + +> **Status:** `completed` +> **Type:** AFK +> **Blocked by:** 02-s0b-layout-routing, 08-s3a-formula-api + +## What to build + +实现 Excel 批量导入配方数据和配方导出为 PDF/Excel 功能。 + +端到端验证:下载导入模板 → 填入配方数据 → 上传 → 批量创建配方 → 导出配方为 PDF 报告。 + +## Acceptance criteria + +- [ ] Excel 批量导入: + - 下载模板:提供标准 Excel 模板下载(含示例数据行) + - 上传页:拖拽/点击上传 .xlsx 文件 + - 前端解析(`xlsx` 库):读取 Excel → 转 JSON + - 数据验证: + - 必填列检查(配方名、成分名、比例) + - 比例总和检查 + - 成分名与成分库模糊匹配 + - 验证结果展示:✅ 通过行(绿色)/ ⚠️ 警告行(黄色)/ ❌ 错误行(红色)+ 错误原因 + - 用户确认 → 批量创建(先创建通过的,警告的给用户选择) + - 导入进度条(逐行显示) +- [ ] 导出 PDF: + - 配方详情页"导出 PDF"按钮 + - 包含:配方名、版本、成分表(按相分组+比例)、基本指标(如果有预测数据) + - PDF 使用 `jsPDF` 库生成或后端生成 + - 中文支持 +- [ ] 导出 Excel: + - 配方列表页"导出"按钮 + - 导出当前筛选结果或选中配方 + - 包含:配方名、版本、成分列表、比例、项目名、更新时间 + +## Further notes + +- Excel 解析用 `xlsx` 库,前端处理(BFF 不需要额外依赖) +- PDF 生成优先使用后端方案(`pdfkit` 或 `puppeteer`),保证中文渲染质量 +- 导入模板用后端 API 生成(`GET /api/export/template`) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0054405 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +## Agent skills + +### Issue tracker + +Issues 作为本地 markdown 文件存放在 `.scratch//` 下。详见 `docs/agents/issue-tracker.md`。 + +### Triage labels + +使用五个标准 triage roles,label 名称保持默认。详见 `docs/agents/triage-labels.md`。 + +### Domain docs + +Single-context 布局:根目录 `CONTEXT.md` + `docs/adr/`。详见 `docs/agents/domain.md`。 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..ed0cdda --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,68 @@ +# CONTEXT.md — 配方研发智能平台 + +## 项目概述 + +AI 驱动的化妆品配方研发智能平台(纯 Web 端),为化妆品研发工程师提供颜色管理、可视化配方调整、配方记录管理和配方推演四大核心能力。 + +## 领域词汇表 + +### 核心实体 + +| 术语 | 英文 | 定义 | +| :--- | :--- | :--- | +| **配方** | Formula | 一组成分按特定比例和工艺组合的完整方案,包含多个相 | +| **相** | Phase | 配方中按工艺阶段划分的组成部分(如油相、水相、后添加相),每个相包含若干成分 | +| **成分** | Ingredient | 化妆品原料,按功能分类(乳化剂、保湿剂、防腐剂等),有 INCI 名称和中文名 | +| **配方版本** | FormulaVersion | 配方的某个时间点快照,记录该版本完整的成分列表和比例 | +| **颜色配方** | ColorFormula | 颜色调配的专门记录,含目标色值(Lab/潘通)、色浆组合、实际 ΔE | +| **色浆** | Colorant | 用于调配颜色的基础着色剂(如氧化铁红、二氧化钛等) | +| **色差** | Delta E (ΔE) | 两颜色之间的感知差异量化值,标准为 CIEDE2000,目标 ≤ 1.0 | + +### 配方指标 + +| 术语 | 英文 | 定义 | +| :--- | :--- | :--- | +| **肤感指数** | Sensory Index | AI 模型基于成分组合预测的使用肤感评分(0-100),含铺展性、吸收速度、黏腻度等子维度 | +| **稳定性评分** | Stability Score | AI 预测的配方稳定性评分(0-100),考量成分相容性和乳化稳定性 | +| **成本估算** | Cost Estimate | 基于各成分单价和比例计算的单位成本(元/kg) | + +### 色彩科学 + +| 术语 | 英文 | 定义 | +| :--- | :--- | :--- | +| **CIELAB** | CIELAB | 设备无关的颜色空间(L*亮度, a*红绿轴, b*黄蓝轴),平台内部颜色表示标准 | +| **Display P3** | Display P3 | 广色域色彩空间,色域比 sRGB 大 25%,平台渲染目标色域 | +| **潘通色号** | Pantone | 国际标准色卡编号系统,颜色匹配输入格式之一 | +| **取色棒** | Color Picker / EyeDropper | 从参考图片中提取颜色的交互工具 | + +### 配方推演 + +| 术语 | 英文 | 定义 | +| :--- | :--- | :--- | +| **推演** | Exploration | 基于约束条件生成多个候选配方方案的过程 | +| **逆向推演** | Reverse Exploration | 从目标功效指标反推成分组合的推理过程 | +| **Pareto 前沿** | Pareto Frontier | 成本-功效等多目标优化中,无法在不损害其他目标的情况下改进某一目标的最优解集合 | + +### AI 相关 + +| 术语 | 英文 | 定义 | +| :--- | :--- | :--- | +| **预测** | Prediction | AI 根据成分比例预测肤感/稳定性/成本等指标 | +| **推荐** | Recommendation | AI 根据约束条件推荐成分或配方方案 | +| **置信度** | Confidence | AI 预测结果的可靠程度评分 | +| **降级** | Fallback | AI API 不可用时返回缓存结果或友好提示的容错机制 | +| **Prompt 模板** | Prompt Template | 每种 AI 能力的标准化提示词模板,包含角色、上下文、输出格式 | +| **向量搜索** | Vector Search | 将配方/查询转为向量,通过 pgvector HNSW 索引进行语义相似搜索 | + +## 架构约定 + +- **ADRs**: `docs/adr/` — 架构决策记录 +- **PRD**: `.scratch/formula-rd-platform/PRD.md` +- **Issues**: `.scratch//issues/` + +## 当前 ADRs + +| ADR | 主题 | 状态 | +| :--- | :--- | :--- | +| [0001](./docs/adr/0001-architecture-stack.md) | 整体技术栈选型 | 已决议 | +| [0002](./docs/adr/0002-ai-api-strategy.md) | AI 通过外部 API 调用 | 已决议 | diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e906680 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +# 服务端口 +PORT=3001 + +# PostgreSQL 数据库 +DATABASE_URL=postgresql://colorfull:colorfull@localhost:5432/colorfull + +# JWT 密钥(生产环境请使用强随机字符串) +JWT_SECRET=dev-secret-change-in-production + +# AI API Keys +OPENAI_API_KEY=sk-your-key-here +DEEPSEEK_API_KEY=sk-your-key-here + +# AI Mock 模式(开发环境设为 true 跳过真实 API 调用) +AI_MOCK=false diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..126419d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,5 @@ +node_modules +# Keep environment variables out of version control +.env + +/src/generated/prisma diff --git a/backend/.npmrc b/backend/.npmrc new file mode 100644 index 0000000..7549542 --- /dev/null +++ b/backend/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmmirror.com diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..5ebea99 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,34 @@ +{ + "name": "color-full-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "db:migrate": "prisma migrate dev", + "db:seed": "tsx prisma/seed.ts", + "db:studio": "prisma studio", + "test": "vitest run" + }, + "dependencies": { + "@fastify/cors": "^11.1.0", + "@fastify/env": "^5.0.0", + "@fastify/formbody": "^8.0.0", + "@fastify/multipart": "^9.0.0", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", + "@types/pg": "^8.20.0", + "fastify": "^5.4.0", + "pg": "^8.21.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "pino-pretty": "^13.1.3", + "prisma": "^7.8.0", + "tsx": "^4.19.0", + "typescript": "^5.8.0", + "vitest": "^4.1.6" + } +} diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml new file mode 100644 index 0000000..849c5a5 --- /dev/null +++ b/backend/pnpm-lock.yaml @@ -0,0 +1,2486 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fastify/cors': + specifier: ^11.1.0 + version: 11.2.0 + '@fastify/env': + specifier: ^5.0.0 + version: 5.0.3 + '@fastify/formbody': + specifier: ^8.0.0 + version: 8.0.2 + '@fastify/multipart': + specifier: ^9.0.0 + version: 9.4.0 + '@prisma/adapter-pg': + specifier: ^7.8.0 + version: 7.8.0 + '@prisma/client': + specifier: ^7.8.0 + version: 7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + fastify: + specifier: ^5.4.0 + version: 5.8.5 + pg: + specifier: ^8.21.0 + version: 8.21.0 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.12.4 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + prisma: + specifier: ^7.8.0 + version: 7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + tsx: + specifier: ^4.19.0 + version: 4.22.2 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@24.12.4)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) + +packages: + + '@electric-sql/pglite-socket@0.1.1': + resolution: {integrity: sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1': + resolution: {integrity: sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==} + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': + resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + + '@fastify/env@5.0.3': + resolution: {integrity: sha512-VqXKcw+keaZaCry9dDtphDQy6l+B1UOodk4q57NdIK/tjZsPMYEBTXjEDiZCAiD9KaGJXbJOMgYdgejU1iD0jA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/multipart@9.4.0': + resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.130.0': + resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@prisma/adapter-pg@7.8.0': + resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} + + '@prisma/client-runtime-utils@7.8.0': + resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} + + '@prisma/client@7.8.0': + resolution: {integrity: sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.8.0': + resolution: {integrity: sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.8.0': + resolution: {integrity: sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==} + + '@prisma/dev@0.24.3': + resolution: {integrity: sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==} + + '@prisma/driver-adapter-utils@7.8.0': + resolution: {integrity: sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==} + + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': + resolution: {integrity: sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==} + + '@prisma/engines@7.8.0': + resolution: {integrity: sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==} + + '@prisma/fetch-engine@7.8.0': + resolution: {integrity: sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.8.0': + resolution: {integrity: sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.2': + resolution: {integrity: sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==} + engines: {bun: '>=1.3.6', node: '>=22.0.0'} + + '@prisma/studio-core@0.27.3': + resolution: {integrity: sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + 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.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + 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-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + 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.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + 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.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@rolldown/binding-android-arm64@1.0.1': + resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.1': + resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.1': + resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.1': + resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.1': + resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.1': + resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.1': + resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.1': + resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.1': + resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.1': + resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + better-result@2.9.2: + resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + 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'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dotenv-expand@10.0.0: + resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} + engines: {node: '>=12'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + env-schema@6.1.0: + resolution: {integrity: sha512-TWtYV2jKe7bd/19kzvNGa8GRRrSwmIMarhcWBzuZYPbHtdlUdjYhnaFvxrO4+GvcwF10sEeVGzf9b/wqLIyf9A==} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + 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'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-copy@4.0.3: + resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + + giget@3.2.0: + resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} + hasBin: true + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + hono@4.12.19: + resolution: {integrity: sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==} + engines: {node: '>=16.9.0'} + + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + 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'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.14.0: + resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.21.0: + resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + 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'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + + prisma@7.8.0: + resolution: {integrity: sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.0.1: + resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + toad-cache@3.7.1: + resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.2: + resolution: {integrity: sha512-6w9FwtT8WQqRAyTNR+Z+86kghRqpmOLjXUrBlBT6T+CQGDuIMm0VmAqaFUFBIeKDTGobE6/YSigZYLeomzBaRg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + vite@8.0.13: + resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + +snapshots: + + '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/busboy@3.2.0': {} + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.1 + + '@fastify/deepmerge@3.2.1': {} + + '@fastify/env@5.0.3': + dependencies: + env-schema: 6.1.0 + fastify-plugin: 5.1.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/multipart@9.4.0': + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + secure-json-parse: 4.1.0 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@hono/node-server@1.19.11(hono@4.12.19)': + dependencies: + hono: 4.12.19 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@kurkle/color@0.3.4': {} + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.130.0': {} + + '@pinojs/redact@0.4.0': {} + + '@prisma/adapter-pg@7.8.0': + dependencies: + '@prisma/driver-adapter-utils': 7.8.0 + '@types/pg': 8.20.0 + pg: 8.21.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.8.0': {} + + '@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.8.0 + optionalDependencies: + prisma: 7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@7.8.0': + dependencies: + c12: 3.3.4 + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.8.0': {} + + '@prisma/dev@0.24.3(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) + '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) + '@hono/node-server': 1.19.11(hono@4.12.19) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.2 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + hono: 4.12.19 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@5.9.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': {} + + '@prisma/engines@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/fetch-engine': 7.8.0 + '@prisma/get-platform': 7.8.0 + + '@prisma/fetch-engine@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/get-platform': 7.8.0 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.2': + dependencies: + ajv: 8.20.0 + better-result: 2.9.2 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.27.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/react': 19.2.14 + chart.js: 4.5.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@types/react-dom' + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-toggle@1.1.10(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@rolldown/binding-android-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-x64@1.0.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.1': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/pg@8.20.0': + dependencies: + '@types/node': 24.12.4 + pg-protocol: 1.14.0 + pg-types: 2.2.0 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + abstract-logging@2.0.1: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + assertion-error@2.0.1: {} + + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + aws-ssl-profiles@1.1.2: {} + + better-result@2.9.2: {} + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 3.2.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + + chai@6.2.2: {} + + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + colorette@2.0.20: {} + + confbox@0.2.4: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + dateformat@4.6.3: {} + + deepmerge-ts@7.1.5: {} + + defu@6.1.7: {} + + denque@2.1.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + dotenv-expand@10.0.0: {} + + dotenv@17.4.2: {} + + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + empathic@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@3.0.0: {} + + env-schema@6.1.0: + dependencies: + ajv: 8.20.0 + dotenv: 17.4.2 + dotenv-expand: 10.0.0 + + es-module-lexer@2.1.0: {} + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + exsolve@1.0.8: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-copy@4.0.3: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.2: {} + + fastify-plugin@5.1.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.0 + toad-cache: 3.7.1 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fsevents@2.3.3: + optional: true + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + get-port-please@3.2.0: {} + + giget@3.2.0: {} + + graceful-fs@4.2.11: {} + + grammex@3.1.12: {} + + graphmatch@1.1.1: {} + + help-me@5.0.0: {} + + hono@4.12.19: {} + + http-status-codes@2.3.0: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ipaddr.js@2.4.0: {} + + is-property@1.0.2: {} + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + joycon@3.1.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + 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 + + long@5.3.2: {} + + lru.min@1.1.4: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimist@1.2.8: {} + + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + nanoid@3.3.12: {} + + obug@2.1.1: {} + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.13.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.21.0): + dependencies: + pg: 8.21.0 + + pg-protocol@1.14.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.21.0: + dependencies: + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.21.0) + pg-protocol: 1.14.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.3 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + + prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.8.0 + '@prisma/dev': 0.24.3(typescript@5.9.3) + '@prisma/engines': 7.8.0 + '@prisma/studio-core': 0.27.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + pure-rand@6.1.0: {} + + quick-format-unescaped@4.0.4: {} + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react@19.2.6: {} + + readdirp@5.0.0: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + remeda@2.33.4: {} + + require-from-string@2.0.2: {} + + ret@0.5.0: {} + + retry@0.12.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rolldown@1.0.1: + dependencies: + '@oxc-project/types': 0.130.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.1 + '@rolldown/binding-darwin-arm64': 1.0.1 + '@rolldown/binding-darwin-x64': 1.0.1 + '@rolldown/binding-freebsd-x64': 1.0.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.1 + '@rolldown/binding-linux-arm64-gnu': 1.0.1 + '@rolldown/binding-linux-arm64-musl': 1.0.1 + '@rolldown/binding-linux-ppc64-gnu': 1.0.1 + '@rolldown/binding-linux-s390x-gnu': 1.0.1 + '@rolldown/binding-linux-x64-gnu': 1.0.1 + '@rolldown/binding-linux-x64-musl': 1.0.1 + '@rolldown/binding-openharmony-arm64': 1.0.1 + '@rolldown/binding-wasm32-wasi': 1.0.1 + '@rolldown/binding-win32-arm64-msvc': 1.0.1 + '@rolldown/binding-win32-x64-msvc': 1.0.1 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.0: {} + + seq-queue@0.0.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + sqlstring@2.3.3: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + std-env@4.1.0: {} + + strip-json-comments@5.0.3: {} + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + toad-cache@3.7.1: {} + + tslib@2.8.1: + optional: true + + tsx@4.22.2: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.4 + esbuild: 0.28.0 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.22.2 + + vitest@4.1.6(@types/node@24.12.4)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.4 + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + xtend@4.0.2: {} + + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.12 + graphmatch: 1.1.1 diff --git a/backend/pnpm-workspace.yaml b/backend/pnpm-workspace.yaml new file mode 100644 index 0000000..de3985b --- /dev/null +++ b/backend/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + '@prisma/engines': true + esbuild: true + prisma: true diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts new file mode 100644 index 0000000..831a20f --- /dev/null +++ b/backend/prisma.config.ts @@ -0,0 +1,14 @@ +// This file was generated by Prisma, and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/backend/prisma/migrations/0001_init/migration.sql b/backend/prisma/migrations/0001_init/migration.sql new file mode 100644 index 0000000..6d15bd2 --- /dev/null +++ b/backend/prisma/migrations/0001_init/migration.sql @@ -0,0 +1,198 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +CREATE EXTENSION IF NOT EXISTS vector; + +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('engineer', 'admin'); + +-- CreateEnum +CREATE TYPE "IngredientCategory" AS ENUM ('emulsifier', 'humectant', 'thickener', 'preservative', 'antioxidant', 'fragrance', 'colorant', 'ph_adjuster', 'sunscreen', 'surfactant', 'emollient', 'other'); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "role" "UserRole" NOT NULL DEFAULT 'engineer', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "projects" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "projects_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "formulas" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "project_id" TEXT, + "current_version" INTEGER NOT NULL DEFAULT 1, + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "embedding" vector(1536), + + CONSTRAINT "formulas_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "formula_versions" ( + "id" TEXT NOT NULL, + "formula_id" TEXT NOT NULL, + "version_number" INTEGER NOT NULL, + "description" TEXT, + "snapshot_data" JSONB NOT NULL, + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "formula_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ingredients" ( + "id" TEXT NOT NULL, + "inci_name" TEXT NOT NULL, + "chinese_name" TEXT NOT NULL, + "function_category" "IngredientCategory" NOT NULL, + "supplier" TEXT, + "unit" TEXT NOT NULL DEFAULT 'kg', + "unit_price" DECIMAL(10,2), + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ingredients_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "phases" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "formula_id" TEXT NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "phases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "formula_ingredients" ( + "id" TEXT NOT NULL, + "formula_version_id" TEXT NOT NULL, + "phase_id" TEXT, + "ingredient_id" TEXT NOT NULL, + "percentage" DECIMAL(5,2) NOT NULL, + "process_notes" TEXT, + + CONSTRAINT "formula_ingredients_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "color_formulas" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "target_lab" JSONB NOT NULL, + "actual_lab" JSONB, + "delta_e" DOUBLE PRECISION, + "colorant_composition" JSONB, + "formula_id" TEXT, + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "color_formulas_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ai_audit_logs" ( + "id" TEXT NOT NULL, + "capability" TEXT NOT NULL, + "model_name" TEXT NOT NULL, + "prompt_hash" TEXT NOT NULL, + "tokens_used" INTEGER, + "duration_ms" INTEGER, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ai_audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); + +-- CreateIndex +CREATE INDEX "formulas_project_id_idx" ON "formulas"("project_id"); + +-- CreateIndex +CREATE INDEX "formulas_created_by_idx" ON "formulas"("created_by"); + +-- CreateIndex +CREATE INDEX "formula_versions_formula_id_idx" ON "formula_versions"("formula_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "formula_versions_formula_id_version_number_key" ON "formula_versions"("formula_id", "version_number"); + +-- CreateIndex +CREATE INDEX "ingredients_function_category_idx" ON "ingredients"("function_category"); + +-- CreateIndex +CREATE INDEX "phases_formula_id_idx" ON "phases"("formula_id"); + +-- CreateIndex +CREATE INDEX "formula_ingredients_formula_version_id_idx" ON "formula_ingredients"("formula_version_id"); + +-- CreateIndex +CREATE INDEX "formula_ingredients_ingredient_id_idx" ON "formula_ingredients"("ingredient_id"); + +-- CreateIndex +CREATE INDEX "color_formulas_formula_id_idx" ON "color_formulas"("formula_id"); + +-- CreateIndex +CREATE INDEX "color_formulas_created_by_idx" ON "color_formulas"("created_by"); + +-- CreateIndex +CREATE INDEX "ai_audit_logs_capability_idx" ON "ai_audit_logs"("capability"); + +-- CreateIndex +CREATE INDEX "ai_audit_logs_created_at_idx" ON "ai_audit_logs"("created_at"); + +-- AddForeignKey +ALTER TABLE "projects" ADD CONSTRAINT "projects_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formulas" ADD CONSTRAINT "formulas_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formulas" ADD CONSTRAINT "formulas_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formula_versions" ADD CONSTRAINT "formula_versions_formula_id_fkey" FOREIGN KEY ("formula_id") REFERENCES "formulas"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formula_versions" ADD CONSTRAINT "formula_versions_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "phases" ADD CONSTRAINT "phases_formula_id_fkey" FOREIGN KEY ("formula_id") REFERENCES "formula_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formula_ingredients" ADD CONSTRAINT "formula_ingredients_formula_version_id_fkey" FOREIGN KEY ("formula_version_id") REFERENCES "formula_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formula_ingredients" ADD CONSTRAINT "formula_ingredients_phase_id_fkey" FOREIGN KEY ("phase_id") REFERENCES "phases"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formula_ingredients" ADD CONSTRAINT "formula_ingredients_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "color_formulas" ADD CONSTRAINT "color_formulas_formula_id_fkey" FOREIGN KEY ("formula_id") REFERENCES "formulas"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "color_formulas" ADD CONSTRAINT "color_formulas_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/backend/prisma/migrations/0002_pgvector/migration.sql b/backend/prisma/migrations/0002_pgvector/migration.sql new file mode 100644 index 0000000..4c3ee2e --- /dev/null +++ b/backend/prisma/migrations/0002_pgvector/migration.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS formulas_embedding_idx ON formulas USING hnsw (embedding vector_cosine_ops); diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2cdb8f0 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,2 @@ +# Please do not edit this file manually +provider = "postgresql" \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..b4be44d --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,176 @@ +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +enum UserRole { + engineer + admin +} + +enum IngredientCategory { + emulsifier + humectant + thickener + preservative + antioxidant + fragrance + colorant + ph_adjuster + sunscreen + surfactant + emollient + other +} + +model User { + id String @id @default(uuid()) + username String @unique + passwordHash String @map("password_hash") + role UserRole @default(engineer) + createdAt DateTime @default(now()) @map("created_at") + + formulas Formula[] @relation("FormulaCreator") + colorFormulas ColorFormula[] @relation("ColorFormulaCreator") + formulaVersions FormulaVersion[] + projects Project[] + + @@map("users") +} + +model Project { + id String @id @default(uuid()) + name String + description String? + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + + creator User @relation(fields: [createdBy], references: [id]) + formulas Formula[] + + @@map("projects") +} + +model Formula { + id String @id @default(uuid()) + name String + description String? + projectId String? @map("project_id") + currentVersion Int @default(1) @map("current_version") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + embedding Unsupported("vector(1536)")? + + project Project? @relation(fields: [projectId], references: [id]) + creator User @relation("FormulaCreator", fields: [createdBy], references: [id]) + versions FormulaVersion[] + colorFormulas ColorFormula[] + + @@index([projectId]) + @@index([createdBy]) + @@map("formulas") +} + +model FormulaVersion { + id String @id @default(uuid()) + formulaId String @map("formula_id") + versionNumber Int @map("version_number") + description String? + snapshotData Json @map("snapshot_data") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + + formula Formula @relation(fields: [formulaId], references: [id]) + creator User @relation(fields: [createdBy], references: [id]) + phases Phase[] + ingredients FormulaIngredient[] + + @@unique([formulaId, versionNumber]) + @@index([formulaId]) + @@map("formula_versions") +} + +model Ingredient { + id String @id @default(uuid()) + inciName String @map("inci_name") + chineseName String @map("chinese_name") + functionCategory IngredientCategory @map("function_category") + supplier String? + unit String @default("kg") + unitPrice Decimal? @map("unit_price") @db.Decimal(10, 2) + description String? + createdAt DateTime @default(now()) @map("created_at") + + formulaIngredients FormulaIngredient[] + + @@index([functionCategory]) + @@map("ingredients") +} + +model Phase { + id String @id @default(uuid()) + name String + formulaId String @map("formula_id") + sortOrder Int @default(0) @map("sort_order") + + formulaVersion FormulaVersion @relation(fields: [formulaId], references: [id]) + ingredients FormulaIngredient[] + + @@index([formulaId]) + @@map("phases") +} + +model FormulaIngredient { + id String @id @default(uuid()) + formulaVersionId String @map("formula_version_id") + phaseId String? @map("phase_id") + ingredientId String @map("ingredient_id") + percentage Decimal @db.Decimal(5, 2) + processNotes String? @map("process_notes") + + formulaVersion FormulaVersion @relation(fields: [formulaVersionId], references: [id]) + phase Phase? @relation(fields: [phaseId], references: [id]) + ingredient Ingredient @relation(fields: [ingredientId], references: [id]) + + @@index([formulaVersionId]) + @@index([ingredientId]) + @@map("formula_ingredients") +} + +model ColorFormula { + id String @id @default(uuid()) + name String + targetLab Json @map("target_lab") + actualLab Json? @map("actual_lab") + deltaE Float? @map("delta_e") + colorantComposition Json? @map("colorant_composition") + formulaId String? @map("formula_id") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + + formula Formula? @relation(fields: [formulaId], references: [id]) + creator User @relation("ColorFormulaCreator", fields: [createdBy], references: [id]) + + @@index([formulaId]) + @@index([createdBy]) + @@map("color_formulas") +} + +model AiAuditLog { + id String @id @default(uuid()) + capability String + modelName String @map("model_name") + promptHash String @map("prompt_hash") + tokensUsed Int? @map("tokens_used") + durationMs Int? @map("duration_ms") + createdAt DateTime @default(now()) @map("created_at") + + @@index([capability]) + @@index([createdAt]) + @@map("ai_audit_logs") +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..4758521 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,66 @@ +import { prisma } from '../src/lib/prisma.js' +import { IngredientCategory } from '../src/generated/prisma/enums.js' + +const ingredients = [ + { inciName: 'Glycerin', chineseName: '甘油', functionCategory: IngredientCategory.humectant, supplier: '丰益油脂', unitPrice: 15.00, description: '最常用的保湿剂,吸湿性强' }, + { inciName: 'Butylene Glycol', chineseName: '丁二醇', functionCategory: IngredientCategory.humectant, supplier: '大赛璐', unitPrice: 35.00, description: '多功能保湿剂,兼有防腐增效作用' }, + { inciName: 'Propylene Glycol', chineseName: '丙二醇', functionCategory: IngredientCategory.humectant, supplier: '陶氏化学', unitPrice: 20.00, description: '保湿剂和溶剂' }, + { inciName: 'Sodium Hyaluronate', chineseName: '透明质酸钠', functionCategory: IngredientCategory.humectant, supplier: '华熙生物', unitPrice: 2800.00, description: '高分子保湿剂,锁水能力极强' }, + { inciName: 'Panthenol', chineseName: '泛醇', functionCategory: IngredientCategory.humectant, supplier: '巴斯夫', unitPrice: 180.00, description: '维生素B5前体,保湿修复' }, + { inciName: 'Trehalose', chineseName: '海藻糖', functionCategory: IngredientCategory.humectant, supplier: '林原', unitPrice: 120.00, description: '天然保湿因子,保护细胞膜' }, + { inciName: 'Betaine', chineseName: '甜菜碱', functionCategory: IngredientCategory.humectant, supplier: '杜邦', unitPrice: 60.00, description: '天然氨基酸保湿剂' }, + { inciName: 'Cetearyl Alcohol', chineseName: '鲸蜡硬脂醇', functionCategory: IngredientCategory.emulsifier, supplier: '巴斯夫', unitPrice: 25.00, description: 'O/W型乳化剂和增稠剂' }, + { inciName: 'Glyceryl Stearate', chineseName: '甘油硬脂酸酯', functionCategory: IngredientCategory.emulsifier, supplier: '禾大', unitPrice: 30.00, description: '温和乳化剂,自乳化型' }, + { inciName: 'Ceteareth-20', chineseName: '鲸蜡硬脂醇聚醚-20', functionCategory: IngredientCategory.emulsifier, supplier: '巴斯夫', unitPrice: 40.00, description: 'O/W型高效乳化剂' }, + { inciName: 'Polysorbate 60', chineseName: '聚山梨醇酯-60', functionCategory: IngredientCategory.emulsifier, supplier: '禾大', unitPrice: 35.00, description: '亲水性乳化剂' }, + { inciName: 'Lecithin', chineseName: '卵磷脂', functionCategory: IngredientCategory.emulsifier, supplier: '嘉吉', unitPrice: 200.00, description: '天然磷脂乳化剂,仿生亲肤' }, + { inciName: 'Carbomer', chineseName: '卡波姆', functionCategory: IngredientCategory.thickener, supplier: '路博润', unitPrice: 80.00, description: '高效增稠剂,需中和' }, + { inciName: 'Xanthan Gum', chineseName: '黄原胶', functionCategory: IngredientCategory.thickener, supplier: 'CP Kelco', unitPrice: 65.00, description: '天然多糖增稠剂,耐电解质' }, + { inciName: 'Hydroxyethylcellulose', chineseName: '羟乙基纤维素', functionCategory: IngredientCategory.thickener, supplier: '亚什兰', unitPrice: 90.00, description: '非离子增稠剂,透明度好' }, + { inciName: 'Phenoxyethanol', chineseName: '苯氧乙醇', functionCategory: IngredientCategory.preservative, supplier: '巴斯夫', unitPrice: 55.00, description: '广谱防腐剂,最常用之一' }, + { inciName: 'Sodium Benzoate', chineseName: '苯甲酸钠', functionCategory: IngredientCategory.preservative, supplier: '帝斯曼', unitPrice: 18.00, description: '食品级防腐剂,酸性环境有效' }, + { inciName: 'Ethylhexylglycerin', chineseName: '乙基己基甘油', functionCategory: IngredientCategory.preservative, supplier: '舒美', unitPrice: 220.00, description: '多功能防腐增效剂' }, + { inciName: 'Tocopherol', chineseName: '生育酚(维生素E)', functionCategory: IngredientCategory.antioxidant, supplier: '帝斯曼', unitPrice: 320.00, description: '天然抗氧化剂,油溶性' }, + { inciName: 'Ascorbic Acid', chineseName: '抗坏血酸(维生素C)', functionCategory: IngredientCategory.antioxidant, supplier: '帝斯曼', unitPrice: 450.00, description: '强效抗氧化剂,美白功效' }, + { inciName: 'BHT', chineseName: '丁羟甲苯', functionCategory: IngredientCategory.antioxidant, supplier: '伊士曼', unitPrice: 40.00, description: '合成抗氧化剂,油脂保护' }, + { inciName: 'Dimethicone', chineseName: '聚二甲基硅氧烷', functionCategory: IngredientCategory.emollient, supplier: '道康宁', unitPrice: 45.00, description: '硅油,丝滑肤感' }, + { inciName: 'Caprylic/Capric Triglyceride', chineseName: '辛酸/癸酸甘油三酯', functionCategory: IngredientCategory.emollient, supplier: '巴斯夫', unitPrice: 50.00, description: '轻质润肤酯,铺展性好' }, + { inciName: 'Squalane', chineseName: '角鲨烷', functionCategory: IngredientCategory.emollient, supplier: 'Amyris', unitPrice: 280.00, description: '植物角鲨烷,亲肤极佳' }, + { inciName: 'Isopropyl Myristate', chineseName: '肉豆蔻酸异丙酯', functionCategory: IngredientCategory.emollient, supplier: '禾大', unitPrice: 30.00, description: '渗透促进剂和润肤酯' }, + { inciName: 'Jojoba Oil', chineseName: '霍霍巴籽油', functionCategory: IngredientCategory.emollient, supplier: '德之馨', unitPrice: 150.00, description: '液态蜡酯,与皮脂相似' }, + { inciName: 'Sodium Laureth Sulfate', chineseName: '月桂醇聚醚硫酸酯钠', functionCategory: IngredientCategory.surfactant, supplier: '巴斯夫', unitPrice: 12.00, description: '阴离子表面活性剂,起泡清洁' }, + { inciName: 'Cocamidopropyl Betaine', chineseName: '椰油酰胺丙基甜菜碱', functionCategory: IngredientCategory.surfactant, supplier: '索尔维', unitPrice: 22.00, description: '两性表面活性剂,温和清洁' }, + { inciName: 'Decyl Glucoside', chineseName: '癸基葡糖苷', functionCategory: IngredientCategory.surfactant, supplier: '巴斯夫', unitPrice: 70.00, description: '非离子APG表活,极温和' }, + { inciName: 'CI 77491', chineseName: '氧化铁红', functionCategory: IngredientCategory.colorant, supplier: '森馨', unitPrice: 85.00, description: '无机颜料,红色系' }, + { inciName: 'CI 77492', chineseName: '氧化铁黄', functionCategory: IngredientCategory.colorant, supplier: '森馨', unitPrice: 85.00, description: '无机颜料,黄色系' }, + { inciName: 'CI 77499', chineseName: '氧化铁黑', functionCategory: IngredientCategory.colorant, supplier: '森馨', unitPrice: 85.00, description: '无机颜料,黑色系' }, + { inciName: 'CI 77891', chineseName: '二氧化钛', functionCategory: IngredientCategory.colorant, supplier: '科慕', unitPrice: 40.00, description: '白色颜料和物理防晒剂' }, + { inciName: 'Mica', chineseName: '云母', functionCategory: IngredientCategory.colorant, supplier: '默克', unitPrice: 120.00, description: '珠光颜料基材' }, + { inciName: 'Citric Acid', chineseName: '柠檬酸', functionCategory: IngredientCategory.ph_adjuster, supplier: '英轩', unitPrice: 10.00, description: '酸性pH调节剂,果酸类' }, + { inciName: 'Sodium Hydroxide', chineseName: '氢氧化钠', functionCategory: IngredientCategory.ph_adjuster, supplier: '万华化学', unitPrice: 8.00, description: '碱性pH调节剂' }, + { inciName: 'Triethanolamine', chineseName: '三乙醇胺', functionCategory: IngredientCategory.ph_adjuster, supplier: '陶氏化学', unitPrice: 20.00, description: 'pH调节剂和中和剂' }, + { inciName: 'Ethylhexyl Methoxycinnamate', chineseName: '甲氧基肉桂酸乙基己酯', functionCategory: IngredientCategory.sunscreen, supplier: '巴斯夫', unitPrice: 65.00, description: 'UVB化学防晒剂' }, + { inciName: 'Bis-Ethylhexyloxyphenol Methoxyphenyl Triazine', chineseName: '双-乙基己氧苯酚甲氧苯基三嗪', functionCategory: IngredientCategory.sunscreen, supplier: '巴斯夫', unitPrice: 550.00, description: '广谱UVA/UVB防晒剂(Tinosorb S)' }, + { inciName: 'Parfum', chineseName: '香精', functionCategory: IngredientCategory.fragrance, supplier: '奇华顿', unitPrice: 300.00, description: '调配香精' }, +] + +async function main() { + console.log('开始导入成分种子数据...') + + await prisma.ingredient.createMany({ + data: ingredients, + skipDuplicates: true, + }) + + const count = await prisma.ingredient.count() + console.log(`成分种子数据导入完成!共 ${count} 条记录`) +} + +main() + .catch((e) => { + console.error('种子数据导入失败:', e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..0e9ab23 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,48 @@ +import Fastify from 'fastify' +import type { FastifyError } from 'fastify' +import cors from '@fastify/cors' +import { healthRoutes } from './routes/health.js' +import { ingredientRoutes } from './routes/ingredients.js' +import { formulaRoutes } from './routes/formulas.js' +import { aiRoutes } from './routes/ai.js' +import { colorRoutes } from './routes/color.js' +import { projectRoutes } from './routes/projects.js' +import { authRoutes } from './routes/auth.js' +import { configRoutes } from './routes/config.js' + +export async function buildApp() { + const app = Fastify({ + logger: { + transport: { + target: 'pino-pretty', + options: { colorize: true }, + }, + }, + }) + + await app.register(cors, { + origin: ['http://localhost:5173'], + credentials: true, + }) + + app.setErrorHandler((error: FastifyError | Error, _request, reply) => { + app.log.error(error) + const code = 'statusCode' in error ? error.statusCode : undefined + const statusCode = code ?? 500 + reply.status(statusCode).send({ + error: statusCode >= 500 ? 'Internal Server Error' : error.message, + statusCode, + }) + }) + + await app.register(healthRoutes, { prefix: '/api' }) + await app.register(ingredientRoutes, { prefix: '/api/ingredients' }) + await app.register(formulaRoutes, { prefix: '/api/formulas' }) + await app.register(aiRoutes, { prefix: '/api/ai' }) + await app.register(colorRoutes, { prefix: '/api/color' }) + await app.register(projectRoutes, { prefix: '/api/projects' }) + await app.register(authRoutes, { prefix: '/api/auth' }) + await app.register(configRoutes, { prefix: '/api/config' }) + + return app +} diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts new file mode 100644 index 0000000..1d4b5fa --- /dev/null +++ b/backend/src/lib/prisma.ts @@ -0,0 +1,8 @@ +import { PrismaClient } from '../generated/prisma/client.js' +import { PrismaPg } from '@prisma/adapter-pg' + +const connectionString = process.env['DATABASE_URL'] ?? 'postgresql://colorfull:colorfull@localhost:5432/colorfull' + +export const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString }), +}) diff --git a/backend/src/routes/ai.ts b/backend/src/routes/ai.ts new file mode 100644 index 0000000..a8d96fc --- /dev/null +++ b/backend/src/routes/ai.ts @@ -0,0 +1,113 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import { prisma } from '../lib/prisma.js' +import { aiService } from '../services/ai/index.js' + +async function predictFormula(request: FastifyRequest<{ Body: { ingredients: Array<{ name: string; percentage: number; category: string }> } }>, reply: FastifyReply) { + const { ingredients } = request.body + if (!ingredients || ingredients.length === 0) { + return reply.status(400).send({ error: '成分列表不能为空' }) + } + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + try { + const result = await aiService.predictMetrics(ingredients) + reply.raw.write(`data: ${JSON.stringify({ type: 'result', content: result })}\n\n`) + } catch { + reply.raw.write(`data: ${JSON.stringify({ type: 'error', content: '预测失败' })}\n\n`) + } + reply.raw.end() +} + +async function exploreFormula(request: FastifyRequest<{ Body: { + baseFormula?: { name: string; ingredients: Array<{ name: string; percentage: number }> } + costLimit?: number; keepIngredients?: string[]; excludeIngredients?: string[]; targetMetrics?: Record +} }>, reply: FastifyReply) { + const constraints = request.body + if (!constraints.costLimit && !constraints.targetMetrics && !constraints.excludeIngredients) { + return reply.status(400).send({ error: '至少设置一个约束条件' }) + } + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + try { + const result = await aiService.generateFormula(constraints) + const parsed = JSON.parse(result) as Array> + for (const option of parsed) { + reply.raw.write(`data: ${JSON.stringify({ type: 'option', option })}\n\n`) + } + } catch { + reply.raw.write(`data: ${JSON.stringify({ type: 'error', content: '推演失败' })}\n\n`) + } + reply.raw.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`) + reply.raw.end() +} + +async function extractFormula(request: FastifyRequest<{ Body: { text: string } }>, reply: FastifyReply) { + const { text } = request.body + if (!text || text.trim().length === 0) { + return reply.status(400).send({ error: '配方文本不能为空' }) + } + + try { + const result = await aiService.extractFormula(text) + const parsed = JSON.parse(result) as { ingredients?: Array> } + return reply.send({ data: parsed.ingredients ?? [] }) + } catch (err) { + request.log.error(err) + return reply.status(500).send({ error: 'AI 提取失败,请重试' }) + } +} + +async function nlSearch(request: FastifyRequest<{ Querystring: { q: string } }>, reply: FastifyReply) { + const q = request.query.q?.trim() + if (!q) return reply.status(400).send({ error: '搜索词不能为空' }) + + try { + const aiResult = await aiService.parseNLQuery(q) + const parsed = JSON.parse(aiResult) as { keywords?: string[]; filters?: Record } + const keywords = parsed.keywords?.[0] ?? q + + const formulas = await prisma.formula.findMany({ + where: { + OR: [ + { name: { contains: keywords, mode: 'insensitive' } }, + { description: { contains: keywords, mode: 'insensitive' } }, + ], + }, + take: 20, + orderBy: { updatedAt: 'desc' }, + include: { project: { select: { name: true } } }, + }) + + return reply.send({ data: formulas, keywords }) + } catch { + const formulas = await prisma.formula.findMany({ + where: { + OR: [ + { name: { contains: q, mode: 'insensitive' } }, + { description: { contains: q, mode: 'insensitive' } }, + ], + }, + take: 20, + orderBy: { updatedAt: 'desc' }, + include: { project: { select: { name: true } } }, + }) + return reply.send({ data: formulas, keywords: [q] }) + } +} + +export async function aiRoutes(app: FastifyInstance) { + app.post('/extract-formula', extractFormula) + app.post('/predict-formula', predictFormula) + app.post('/explore-formula', exploreFormula) + app.get('/search', nlSearch) +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..fbc8d71 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,76 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import { randomBytes, timingSafeEqual, scryptSync, createHash } from 'crypto' +import { prisma } from '../lib/prisma.js' + +const JWT_SECRET = process.env['JWT_SECRET'] ?? 'dev-secret-change-me' + +function hashPassword(password: string): string { + const salt = randomBytes(16).toString('hex') + const hash = scryptSync(password, salt, 64).toString('hex') + return `${salt}:${hash}` +} + +function verifyPassword(password: string, stored: string): boolean { + const [salt, hash] = stored.split(':') + if (!salt || !hash) return false + const computed = scryptSync(password, salt, 64).toString('hex') + return timingSafeEqual(Buffer.from(hash), Buffer.from(computed)) +} + +function signToken(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url') + const body = Buffer.from(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + 86400 })).toString('base64url') + const sig = createHash('sha256').update(`${header}.${body}.${JWT_SECRET}`).digest('base64url') + return `${header}.${body}.${sig}` +} + +function verifyToken(token: string): Record | null { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + const expected = createHash('sha256').update(`${parts[0]}.${parts[1]}.${JWT_SECRET}`).digest('base64url') + if (!timingSafeEqual(Buffer.from(parts[2]!), Buffer.from(expected))) return null + const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString()) as Record + if (typeof payload.exp === 'number' && payload.exp < Date.now() / 1000) return null + return payload + } catch { return null } +} + +async function register(request: FastifyRequest<{ Body: { username: string; password: string } }>, reply: FastifyReply) { + const { username, password } = request.body + if (!username || !password) return reply.status(400).send({ error: '用户名和密码为必填项' }) + if (password.length < 4) return reply.status(400).send({ error: '密码至少4位' }) + + const existing = await prisma.user.findUnique({ where: { username } }) + if (existing) return reply.status(409).send({ error: '用户名已存在' }) + + const user = await prisma.user.create({ data: { username, passwordHash: hashPassword(password) } }) + const token = signToken({ userId: user.id }) + return reply.status(201).send({ data: { id: user.id, username: user.username, role: user.role, token } }) +} + +async function login(request: FastifyRequest<{ Body: { username: string; password: string } }>, reply: FastifyReply) { + const { username, password } = request.body + const user = await prisma.user.findUnique({ where: { username } }) + if (!user || !verifyPassword(password, user.passwordHash)) { + return reply.status(401).send({ error: '用户名或密码错误' }) + } + const token = signToken({ userId: user.id }) + return reply.send({ data: { id: user.id, username: user.username, role: user.role, token } }) +} + +async function me(request: FastifyRequest, reply: FastifyReply) { + const auth = request.headers.authorization + if (!auth?.startsWith('Bearer ')) return reply.status(401).send({ error: '未认证' }) + const payload = verifyToken(auth.slice(7)) + if (!payload) return reply.status(401).send({ error: 'Token 无效' }) + const user = await prisma.user.findUnique({ where: { id: payload.userId as string } }) + if (!user) return reply.status(401).send({ error: '用户不存在' }) + return reply.send({ data: { id: user.id, username: user.username, role: user.role } }) +} + +export async function authRoutes(app: FastifyInstance) { + app.post('/register', register) + app.post('/login', login) + app.get('/me', me) +} diff --git a/backend/src/routes/color.ts b/backend/src/routes/color.ts new file mode 100644 index 0000000..a19d277 --- /dev/null +++ b/backend/src/routes/color.ts @@ -0,0 +1,91 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import type { Prisma } from '../generated/prisma/client.js' +import { prisma } from '../lib/prisma.js' +import { aiService } from '../services/ai/index.js' + +interface LabInput { + L: number; a: number; b: number +} + +function euclideanLabDistance(lab1: LabInput, lab2: LabInput): number { + return Math.sqrt((lab1.L - lab2.L) ** 2 + (lab1.a - lab2.a) ** 2 + (lab1.b - lab2.b) ** 2) +} + +async function recommend(request: FastifyRequest<{ Body: { targetLab: LabInput } }>, reply: FastifyReply) { + const { targetLab } = request.body + if (!targetLab || targetLab.L === undefined) { + return reply.status(400).send({ error: 'targetLab 为必填项 (L, a, b)' }) + } + + const allColorFormulas = await prisma.colorFormula.findMany({ + select: { id: true, name: true, targetLab: true, actualLab: true, deltaE: true }, + }) + + const matched = allColorFormulas + .map(f => { + const tl = f.targetLab as unknown as LabInput + return { ...f, distance: euclideanLabDistance(targetLab, tl) } + }) + .sort((a, b) => a.distance - b.distance) + .slice(0, 5) + + const topName = matched[0] ? `${matched[0].name} (ΔE≈${matched[0].deltaE?.toFixed(2) ?? matched[0].distance.toFixed(2)})` : '无' + + const aiResult = await aiService.recommendColorants(targetLab) + let recommendations: Array> = [] + try { + const parsed = JSON.parse(aiResult) as { recommendations?: Array> } + recommendations = parsed.recommendations ?? [] + } catch { } + + return reply.send({ + recommendations, + matchedFormulas: matched.map(m => ({ id: m.id, name: m.name, deltaE: m.deltaE ?? m.distance })), + }) +} + +async function matchFormulas(request: FastifyRequest<{ Querystring: { L: string; a: string; b: string; limit?: string } }>, reply: FastifyReply) { + const L = parseFloat(request.query.L) + const a = parseFloat(request.query.a) + const b = parseFloat(request.query.b) + const limit = Math.min(20, parseInt(request.query.limit ?? '5', 10) || 5) + + if (isNaN(L) || isNaN(a) || isNaN(b)) { + return reply.status(400).send({ error: 'L, a, b 参数为必填数字' }) + } + + const allColorFormulas = await prisma.colorFormula.findMany({ + select: { id: true, name: true, targetLab: true, deltaE: true, colorantComposition: true }, + }) + + const target: LabInput = { L, a, b } + const matched = allColorFormulas + .map(f => ({ ...f, distance: euclideanLabDistance(target, f.targetLab as unknown as LabInput) })) + .sort((a, b) => a.distance - b.distance) + .slice(0, limit) + + return reply.send({ data: matched }) +} + +async function saveColorFormula(request: FastifyRequest<{ Body: { + name?: string; targetLab: LabInput; actualLab?: LabInput; deltaE?: number; colorantComposition?: unknown; formulaId?: string +} }>, reply: FastifyReply) { + const formula = await prisma.colorFormula.create({ + data: { + name: request.body.name ?? '未命名颜色配方', + targetLab: request.body.targetLab as unknown as Prisma.InputJsonValue, + actualLab: request.body.actualLab as unknown as Prisma.InputJsonValue ?? null, + deltaE: request.body.deltaE ?? null, + colorantComposition: request.body.colorantComposition as unknown as Prisma.InputJsonValue ?? null, + formulaId: request.body.formulaId ?? null, + createdBy: 'system', + }, + }) + return reply.status(201).send({ data: formula }) +} + +export async function colorRoutes(app: FastifyInstance) { + app.post('/recommend', recommend) + app.get('/formulas/match', matchFormulas) + app.post('/formulas', saveColorFormula) +} diff --git a/backend/src/routes/config.ts b/backend/src/routes/config.ts new file mode 100644 index 0000000..d263d16 --- /dev/null +++ b/backend/src/routes/config.ts @@ -0,0 +1,47 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import { aiService } from '../services/ai/index.js' + +interface ConfigBody { + openaiKey?: string + deepseekKey?: string + openaiBaseUrl?: string + deepseekBaseUrl?: string + aiMock?: string +} + +async function getConfig(_request: FastifyRequest, reply: FastifyReply) { + return reply.send({ + aiMock: process.env['AI_MOCK'] ?? 'true', + hasOpenAI: !!process.env['OPENAI_API_KEY'], + hasDeepseek: !!process.env['DEEPSEEK_API_KEY'], + }) +} + +async function updateConfig(request: FastifyRequest<{ Body: ConfigBody }>, reply: FastifyReply) { + const { openaiKey, deepseekKey, openaiBaseUrl, deepseekBaseUrl, aiMock } = request.body + + if (aiMock !== undefined) process.env['AI_MOCK'] = aiMock + if (openaiKey) process.env['OPENAI_API_KEY'] = openaiKey + if (deepseekKey) process.env['DEEPSEEK_API_KEY'] = deepseekKey + if (openaiBaseUrl !== undefined) process.env['OPENAI_BASE_URL'] = openaiBaseUrl + if (deepseekBaseUrl !== undefined) process.env['DEEPSEEK_BASE_URL'] = deepseekBaseUrl + + aiService.reload() + return reply.send({ ok: true }) +} + +async function testApi(request: FastifyRequest<{ Body: { provider: string } }>, reply: FastifyReply) { + const { provider } = request.body + try { + const result = await aiService.testConnection(provider) + return reply.send({ ok: true, model: result }) + } catch (err) { + return reply.status(500).send({ ok: false, error: (err as Error).message }) + } +} + +export async function configRoutes(app: FastifyInstance) { + app.get('/', getConfig) + app.put('/', updateConfig) + app.post('/test', testApi) +} diff --git a/backend/src/routes/formulas.test.ts b/backend/src/routes/formulas.test.ts new file mode 100644 index 0000000..51d68a2 --- /dev/null +++ b/backend/src/routes/formulas.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { buildApp } from '../app.js' +import type { FastifyInstance } from 'fastify' + +let app: FastifyInstance + +beforeAll(async () => { + app = await buildApp() + await app.ready() + + await app.inject({ + method: 'POST', url: '/api/ingredients', + payload: { inciName: '__system__', chineseName: '__system__', functionCategory: 'other' }, + }) + + const { prisma } = await import('../lib/prisma.js') + await prisma.user.upsert({ + where: { username: 'system' }, + update: {}, + create: { id: 'system', username: 'system', passwordHash: 'test', role: 'admin' }, + }) +}) + +afterAll(async () => { + await app.close() +}) + +async function createTestIngredient(name: string) { + const res = await app.inject({ + method: 'POST', + url: '/api/ingredients', + payload: { inciName: name, chineseName: name, functionCategory: 'humectant' }, + }) + return res.json().data.id as string +} + +describe('POST /api/formulas', () => { + it('创建配方成功(含相和成分)', async () => { + const ing1Id = await createTestIngredient('FormulaTest1') + const ing2Id = await createTestIngredient('FormulaTest2') + + const res = await app.inject({ + method: 'POST', + url: '/api/formulas', + payload: { + name: '测试精华液', + description: '集成测试配方', + phases: [ + { name: '水相', ingredients: [{ ingredientId: ing1Id, percentage: 60 }] }, + { name: '油相', ingredients: [{ ingredientId: ing2Id, percentage: 40 }] }, + ], + }, + }) + + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.data.name).toBe('测试精华液') + expect(body.data.currentVersion).toBe(1) + expect(body.data.versions).toBeDefined() + expect(body.data.versions.length).toBeGreaterThan(0) + }) + + it('缺少配方名称返回 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/formulas', + payload: { phases: [{ name: '水相', ingredients: [] }] }, + }) + expect(res.statusCode).toBe(400) + }) + + it('空配方(无成分)返回 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/formulas', + payload: { name: '空配方', phases: [{ name: '水相', ingredients: [] }] }, + }) + expect(res.statusCode).toBe(400) + }) + + it('比例总和不足 99.5% 返回 400', async () => { + const ingId = await createTestIngredient('PercentTest1') + const res = await app.inject({ + method: 'POST', + url: '/api/formulas', + payload: { + name: '比例不足', + phases: [{ name: '水相', ingredients: [{ ingredientId: ingId, percentage: 50 }] }], + }, + }) + expect(res.statusCode).toBe(400) + }) + + it('比例总和超过 100.5% 返回 400', async () => { + const ing1Id = await createTestIngredient('PercentTest2') + const ing2Id = await createTestIngredient('PercentTest3') + const res = await app.inject({ + method: 'POST', + url: '/api/formulas', + payload: { + name: '比例超标', + phases: [ + { name: '水相', ingredients: [{ ingredientId: ing1Id, percentage: 80 }, { ingredientId: ing2Id, percentage: 30 }] }, + ], + }, + }) + expect(res.statusCode).toBe(400) + }) + + it('单个成分比例 ≤ 0 返回 400', async () => { + const ingId = await createTestIngredient('PercentTest4') + const res = await app.inject({ + method: 'POST', + url: '/api/formulas', + payload: { + name: '零比例', + phases: [ + { name: '水相', ingredients: [{ ingredientId: ingId, percentage: 0 }, { ingredientId: ingId, percentage: 100 }] }, + ], + }, + }) + expect(res.statusCode).toBe(400) + }) +}) + +describe('GET /api/formulas/:id', () => { + it('返回配方详情含成分', async () => { + const ingId = await createTestIngredient('DetailTest1') + const createRes = await app.inject({ + method: 'POST', + url: '/api/formulas', + payload: { + name: '详情测试', + phases: [{ name: '水相', ingredients: [{ ingredientId: ingId, percentage: 100 }] }], + }, + }) + const id = createRes.json().data.id + + const res = await app.inject({ method: 'GET', url: `/api/formulas/${id}` }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.data.name).toBe('详情测试') + expect(body.data.versions).toBeDefined() + }) + + it('不存在的配方返回 404', async () => { + const res = await app.inject({ method: 'GET', url: '/api/formulas/nonexistent' }) + expect(res.statusCode).toBe(404) + }) +}) + +describe('PUT /api/formulas/:id/composition', () => { + it('更新成分后版本号递增', async () => { + const ing1Id = await createTestIngredient('VersionTest1') + const ing2Id = await createTestIngredient('VersionTest2') + + const createRes = await app.inject({ + method: 'POST', + url: '/api/formulas', + payload: { + name: '版本测试', + phases: [{ name: '水相', ingredients: [{ ingredientId: ing1Id, percentage: 100 }] }], + }, + }) + const id = createRes.json().data.id + expect(createRes.json().data.currentVersion).toBe(1) + + const updateRes = await app.inject({ + method: 'PUT', + url: `/api/formulas/${id}/composition`, + payload: { + phases: [ + { name: '水相', ingredients: [{ ingredientId: ing1Id, percentage: 60 }] }, + { name: '油相', ingredients: [{ ingredientId: ing2Id, percentage: 40 }] }, + ], + }, + }) + expect(updateRes.statusCode).toBe(200) + expect(updateRes.json().data.currentVersion).toBe(2) + }) +}) + +describe('GET /api/formulas', () => { + it('返回配方列表和分页', async () => { + const res = await app.inject({ method: 'GET', url: '/api/formulas' }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(Array.isArray(body.data)).toBe(true) + expect(body.pagination).toBeDefined() + }) + + it('支持搜索', async () => { + const res = await app.inject({ method: 'GET', url: '/api/formulas?search=测试精华液' }) + const body = res.json() + expect(body.data.length).toBeGreaterThan(0) + }) +}) + +describe('DELETE /api/formulas/:id', () => { + it('删除配方成功', async () => { + const ingId = await createTestIngredient('DeleteTest1') + const createRes = await app.inject({ + method: 'POST', + url: '/api/formulas', + payload: { + name: '待删除', + phases: [{ name: '水相', ingredients: [{ ingredientId: ingId, percentage: 100 }] }], + }, + }) + const id = createRes.json().data.id + + const res = await app.inject({ method: 'DELETE', url: `/api/formulas/${id}` }) + expect(res.statusCode).toBe(204) + + const getRes = await app.inject({ method: 'GET', url: `/api/formulas/${id}` }) + expect(getRes.statusCode).toBe(404) + }) +}) diff --git a/backend/src/routes/formulas.ts b/backend/src/routes/formulas.ts new file mode 100644 index 0000000..10f9c83 --- /dev/null +++ b/backend/src/routes/formulas.ts @@ -0,0 +1,295 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import type { Prisma } from '../generated/prisma/client.js' +import { prisma } from '../lib/prisma.js' + +interface PhaseInput { + name: string + sortOrder?: number + ingredients: { ingredientId: string; percentage: number; processNotes?: string }[] +} + +interface CreateFormulaBody { + name: string + description?: string + projectId?: string + phases: PhaseInput[] +} + +interface UpdateCompositionBody { + phases: PhaseInput[] +} + +function validatePercentages(phases: PhaseInput[]): string | null { + const allIngredients = phases.flatMap(p => p.ingredients) + if (allIngredients.length === 0) return '配方至少需要一个成分' + + for (const ing of allIngredients) { + if (ing.percentage <= 0 || ing.percentage > 100) { + return `成分比例必须在 0-100 之间,当前值: ${ing.percentage}` + } + } + + const total = allIngredients.reduce((sum, ing) => sum + ing.percentage, 0) + if (total < 99.5 || total > 100.5) { + return `成分比例总和必须在 99.5%-100.5% 之间,当前总和: ${total.toFixed(2)}%` + } + + return null +} + +async function createFormula(request: FastifyRequest<{ Body: CreateFormulaBody }>, reply: FastifyReply) { + const { name, description, projectId, phases } = request.body + + if (!name || !phases || !Array.isArray(phases) || phases.length === 0) { + return reply.status(400).send({ error: '配方名称和至少一个相为必填项' }) + } + + const percentError = validatePercentages(phases) + if (percentError) return reply.status(400).send({ error: percentError }) + + const createdBy = 'system' + + const formula = await prisma.$transaction(async (tx) => { + const f = await tx.formula.create({ + data: { + name, + description, + projectId: projectId ?? null, + createdBy, + currentVersion: 1, + }, + }) + + const version = await tx.formulaVersion.create({ + data: { + formulaId: f.id, + versionNumber: 1, + description: '初始版本', + snapshotData: { phases } as unknown as Prisma.InputJsonValue, + createdBy, + }, + }) + + for (const phaseInput of phases) { + const phase = await tx.phase.create({ + data: { + name: phaseInput.name, + formulaId: version.id, + sortOrder: phaseInput.sortOrder ?? 0, + }, + }) + + for (const ing of phaseInput.ingredients) { + await tx.formulaIngredient.create({ + data: { + formulaVersionId: version.id, + phaseId: phase.id, + ingredientId: ing.ingredientId, + percentage: ing.percentage, + processNotes: ing.processNotes ?? null, + }, + }) + } + } + + return f + }) + + const result = await prisma.formula.findUnique({ + where: { id: formula.id }, + include: { + versions: { + orderBy: { versionNumber: 'desc' }, + take: 1, + include: { phases: { include: { ingredients: { include: { ingredient: true } } } } }, + }, + project: { select: { id: true, name: true } }, + }, + }) + + return reply.status(201).send({ data: result }) +} + +async function getFormula(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) { + const formula = await prisma.formula.findUnique({ + where: { id: request.params.id }, + include: { + versions: { + orderBy: { versionNumber: 'desc' }, + take: 1, + include: { phases: { orderBy: { sortOrder: 'asc' }, include: { ingredients: { include: { ingredient: true } } } } }, + }, + project: { select: { id: true, name: true } }, + }, + }) + + if (!formula) return reply.status(404).send({ error: '配方不存在' }) + return reply.send({ data: formula }) +} + +async function listFormulas(request: FastifyRequest<{ Querystring: Record }>, reply: FastifyReply) { + const { projectId, search, page = '1', limit = '20', sortBy = 'updatedAt', sortOrder = 'desc' } = request.query + + const pageNum = Math.max(1, parseInt(page, 10) || 1) + const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10) || 20)) + + const where: Record = {} + if (projectId) where.projectId = projectId + if (search && search.length >= 1) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } }, + ] + } + + const [data, total] = await Promise.all([ + prisma.formula.findMany({ + where, + skip: (pageNum - 1) * limitNum, + take: limitNum, + orderBy: { [sortBy]: sortOrder }, + include: { project: { select: { id: true, name: true } } }, + }), + prisma.formula.count({ where }), + ]) + + return reply.send({ data, pagination: { page: pageNum, limit: limitNum, total, totalPages: Math.ceil(total / limitNum) } }) +} + +async function updateFormula(request: FastifyRequest<{ Params: { id: string }; Body: { name?: string; description?: string } }>, reply: FastifyReply) { + const existing = await prisma.formula.findUnique({ where: { id: request.params.id } }) + if (!existing) return reply.status(404).send({ error: '配方不存在' }) + + const formula = await prisma.formula.update({ + where: { id: request.params.id }, + data: { ...request.body }, + }) + + return reply.send({ data: formula }) +} + +async function updateComposition(request: FastifyRequest<{ Params: { id: string }; Body: UpdateCompositionBody }>, reply: FastifyReply) { + const { phases } = request.body + + if (!phases || !Array.isArray(phases) || phases.length === 0) { + return reply.status(400).send({ error: '配方至少需要一个相' }) + } + + const percentError = validatePercentages(phases) + if (percentError) return reply.status(400).send({ error: percentError }) + + const existing = await prisma.formula.findUnique({ where: { id: request.params.id } }) + if (!existing) return reply.status(404).send({ error: '配方不存在' }) + + const createdBy = 'system' + + const formula = await prisma.$transaction(async (tx) => { + const newVersionNumber = existing.currentVersion + 1 + + const version = await tx.formulaVersion.create({ + data: { + formulaId: existing.id, + versionNumber: newVersionNumber, + description: `版本 v${newVersionNumber}`, + snapshotData: { phases } as unknown as Prisma.InputJsonValue, + createdBy, + }, + }) + + for (const phaseInput of phases) { + const phase = await tx.phase.create({ + data: { + name: phaseInput.name, + formulaId: version.id, + sortOrder: phaseInput.sortOrder ?? 0, + }, + }) + + for (const ing of phaseInput.ingredients) { + await tx.formulaIngredient.create({ + data: { + formulaVersionId: version.id, + phaseId: phase.id, + ingredientId: ing.ingredientId, + percentage: ing.percentage, + processNotes: ing.processNotes ?? null, + }, + }) + } + } + + return tx.formula.update({ + where: { id: existing.id }, + data: { currentVersion: newVersionNumber }, + }) + }) + + return reply.send({ data: formula }) +} + +async function getVersions(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) { + const existing = await prisma.formula.findUnique({ where: { id: request.params.id } }) + if (!existing) return reply.status(404).send({ error: '配方不存在' }) + + const versions = await prisma.formulaVersion.findMany({ + where: { formulaId: request.params.id }, + orderBy: { versionNumber: 'desc' }, + include: { phases: { orderBy: { sortOrder: 'asc' }, include: { ingredients: { include: { ingredient: true } } } } }, + }) + + return reply.send({ data: versions }) +} + +async function exportFormulas(_request: FastifyRequest, reply: FastifyReply) { + const formulas = await prisma.formula.findMany({ + include: { + versions: { orderBy: { versionNumber: 'desc' }, take: 1, + include: { phases: { include: { ingredients: { include: { ingredient: true } } } } } }, + }, + orderBy: { updatedAt: 'desc' }, + }) + + const rows: string[][] = [['配方名', '版本', '相', '成分INCI', '成分中文', '比例%', '更新时间']] + for (const f of formulas) { + const v = f.versions[0] + if (!v) continue + for (const p of v.phases) { + for (const i of p.ingredients) { + rows.push([f.name, `v${f.currentVersion}`, p.name, i.ingredient.inciName, i.ingredient.chineseName, String(i.percentage), f.updatedAt.toISOString()]) + } + } + } + + const csv = rows.map(r => r.map(c => `"${c.replace(/"/g, '""')}"`).join(',')).join('\n') + reply.header('Content-Type', 'text/csv; charset=utf-8') + reply.header('Content-Disposition', 'attachment; filename=formulas.csv') + return reply.send(csv) +} + +async function deleteFormula(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) { + const existing = await prisma.formula.findUnique({ where: { id: request.params.id } }) + if (!existing) return reply.status(404).send({ error: '配方不存在' }) + + await prisma.$transaction(async (tx) => { + const versions = await tx.formulaVersion.findMany({ where: { formulaId: request.params.id }, select: { id: true } }) + const versionIds = versions.map(v => v.id) + + await tx.formulaIngredient.deleteMany({ where: { formulaVersionId: { in: versionIds } } }) + await tx.phase.deleteMany({ where: { formulaId: { in: versionIds } } }) + await tx.formulaVersion.deleteMany({ where: { formulaId: request.params.id } }) + await tx.formula.delete({ where: { id: request.params.id } }) + }) + + return reply.status(204).send() +} + +export async function formulaRoutes(app: FastifyInstance) { + app.get('/', listFormulas) + app.get('/export', exportFormulas) + app.get('/:id', getFormula) + app.get('/:id/versions', getVersions) + app.post('/', createFormula) + app.put('/:id', updateFormula) + app.put('/:id/composition', updateComposition) + app.delete('/:id', deleteFormula) +} diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts new file mode 100644 index 0000000..3d4ee88 --- /dev/null +++ b/backend/src/routes/health.ts @@ -0,0 +1,7 @@ +import type { FastifyInstance } from 'fastify' + +export async function healthRoutes(app: FastifyInstance) { + app.get('/health', async () => { + return { status: 'ok', timestamp: new Date().toISOString() } + }) +} diff --git a/backend/src/routes/ingredients.test.ts b/backend/src/routes/ingredients.test.ts new file mode 100644 index 0000000..ae3663e --- /dev/null +++ b/backend/src/routes/ingredients.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { buildApp } from '../app.js' +import type { FastifyInstance } from 'fastify' + +let app: FastifyInstance + +beforeAll(async () => { + app = await buildApp() + await app.ready() +}) + +afterAll(async () => { + await app.close() +}) + +describe('GET /api/ingredients', () => { + it('返回成分列表和分页信息', async () => { + const res = await app.inject({ method: 'GET', url: '/api/ingredients' }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.data).toBeDefined() + expect(Array.isArray(body.data)).toBe(true) + expect(body.pagination).toBeDefined() + expect(body.pagination.page).toBe(1) + }) + + it('支持分页参数', async () => { + const res = await app.inject({ method: 'GET', url: '/api/ingredients?page=1&limit=5' }) + const body = res.json() + expect(body.pagination.limit).toBe(5) + expect(body.data.length).toBeLessThanOrEqual(5) + }) + + it('搜索功能匹配 INCI 名称', async () => { + await app.inject({ + method: 'POST', url: '/api/ingredients', + payload: { inciName: 'SearchTestABC', chineseName: '搜索测试', functionCategory: 'humectant' }, + }) + const res = await app.inject({ method: 'GET', url: '/api/ingredients?search=SearchTestABC' }) + const body = res.json() + expect(body.data.length).toBeGreaterThan(0) + expect(body.data[0].inciName).toContain('SearchTestABC') + }) + + it('搜索功能匹配中文名', async () => { + await app.inject({ + method: 'POST', url: '/api/ingredients', + payload: { inciName: 'SearchTest2', chineseName: '独特搜索词测试', functionCategory: 'humectant' }, + }) + const res = await app.inject({ method: 'GET', url: '/api/ingredients?search=独特搜索词测试' }) + const body = res.json() + expect(body.data.length).toBeGreaterThan(0) + expect(body.data[0].chineseName).toContain('独特搜索词测试') + }) + + it('按功能分类筛选', async () => { + await app.inject({ + method: 'POST', url: '/api/ingredients', + payload: { inciName: 'CategoryTest', chineseName: '分类测试', functionCategory: 'preservative' }, + }) + const res = await app.inject({ method: 'GET', url: '/api/ingredients?category=preservative' }) + const body = res.json() + expect(body.data.length).toBeGreaterThan(0) + for (const ing of body.data) { + expect(ing.functionCategory).toBe('preservative') + } + }) + + it('无效分类返回 400', async () => { + const res = await app.inject({ method: 'GET', url: '/api/ingredients?category=invalid' }) + expect(res.statusCode).toBe(400) + }) +}) + +describe('POST /api/ingredients', () => { + it('创建成分成功', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/ingredients', + payload: { + inciName: 'Test Ingredient', + chineseName: '测试成分', + functionCategory: 'humectant', + unitPrice: 10.5, + }, + }) + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.data.inciName).toBe('Test Ingredient') + expect(body.data.id).toBeDefined() + }) + + it('缺少必填字段返回 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/ingredients', + payload: { inciName: 'Test' }, + }) + expect(res.statusCode).toBe(400) + }) + + it('无效分类返回 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/ingredients', + payload: { + inciName: 'Test', chineseName: '测试', functionCategory: 'invalid', + }, + }) + expect(res.statusCode).toBe(400) + }) + + it('负价格返回 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/ingredients', + payload: { + inciName: 'Test', chineseName: '测试', functionCategory: 'humectant', unitPrice: -1, + }, + }) + expect(res.statusCode).toBe(400) + }) +}) + +describe('PUT /api/ingredients/:id', () => { + it('更新成分成功', async () => { + const list = await app.inject({ method: 'GET', url: '/api/ingredients?search=Test Ingredient' }) + const id = list.json().data[0].id + + const res = await app.inject({ + method: 'PUT', + url: `/api/ingredients/${id}`, + payload: { chineseName: '测试成分-已更新', unitPrice: 20 }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().data.chineseName).toBe('测试成分-已更新') + }) + + it('不存在的成分返回 404', async () => { + const res = await app.inject({ + method: 'PUT', + url: '/api/ingredients/nonexistent-id', + payload: { chineseName: 'x' }, + }) + expect(res.statusCode).toBe(404) + }) +}) + +describe('DELETE /api/ingredients/:id', () => { + it('删除未被引用的成分成功', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/ingredients', + payload: { + inciName: 'ToDelete', chineseName: '待删除', functionCategory: 'other', + }, + }) + const id = createRes.json().data.id + + const res = await app.inject({ method: 'DELETE', url: `/api/ingredients/${id}` }) + expect(res.statusCode).toBe(204) + }) + + it('不存在的成分返回 404', async () => { + const res = await app.inject({ method: 'DELETE', url: '/api/ingredients/nonexistent' }) + expect(res.statusCode).toBe(404) + }) +}) diff --git a/backend/src/routes/ingredients.ts b/backend/src/routes/ingredients.ts new file mode 100644 index 0000000..eea65be --- /dev/null +++ b/backend/src/routes/ingredients.ts @@ -0,0 +1,186 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import { prisma } from '../lib/prisma.js' +import { IngredientCategory } from '../generated/prisma/enums.js' + +const VALID_CATEGORIES = Object.values(IngredientCategory) + +interface IngredientQuery { + search?: string + category?: string + page?: string + limit?: string +} + +interface IngredientBody { + inciName: string + chineseName: string + functionCategory: string + supplier?: string + unit?: string + unitPrice?: number + description?: string +} + +async function getIngredients(request: FastifyRequest<{ Querystring: IngredientQuery }>, reply: FastifyReply) { + const { search, category, page = '1', limit = '20' } = request.query + + const pageNum = Math.max(1, parseInt(page, 10) || 1) + const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10) || 20)) + + if (category && !VALID_CATEGORIES.includes(category as IngredientCategory)) { + return reply.status(400).send({ error: `无效的功能分类: ${category}` }) + } + + const where: Record = {} + + if (search && search.length >= 1) { + where.OR = [ + { inciName: { contains: search, mode: 'insensitive' } }, + { chineseName: { contains: search, mode: 'insensitive' } }, + ] + } + + if (category) { + where.functionCategory = category + } + + const [data, total] = await Promise.all([ + prisma.ingredient.findMany({ + where, + skip: (pageNum - 1) * limitNum, + take: limitNum, + orderBy: { createdAt: 'desc' }, + }), + prisma.ingredient.count({ where }), + ]) + + return reply.send({ + data, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum), + }, + }) +} + +async function getIngredient( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, +) { + const ingredient = await prisma.ingredient.findUnique({ + where: { id: request.params.id }, + }) + + if (!ingredient) { + return reply.status(404).send({ error: '成分不存在' }) + } + + return reply.send({ data: ingredient }) +} + +async function createIngredient( + request: FastifyRequest<{ Body: IngredientBody }>, + reply: FastifyReply, +) { + const { inciName, chineseName, functionCategory, supplier, unit, unitPrice, description } = request.body + + if (!inciName || !chineseName || !functionCategory) { + return reply.status(400).send({ error: 'INCI名称、中文名和功能分类为必填项' }) + } + + if (!VALID_CATEGORIES.includes(functionCategory as IngredientCategory)) { + return reply.status(400).send({ error: `无效的功能分类: ${functionCategory}` }) + } + + if (unitPrice !== undefined && unitPrice < 0) { + return reply.status(400).send({ error: '单价不能为负数' }) + } + + const ingredient = await prisma.ingredient.create({ + data: { + inciName, + chineseName, + functionCategory: functionCategory as IngredientCategory, + supplier, + unit: unit ?? 'kg', + unitPrice, + description, + }, + }) + + return reply.status(201).send({ data: ingredient }) +} + +async function updateIngredient( + request: FastifyRequest<{ Params: { id: string }; Body: Partial }>, + reply: FastifyReply, +) { + const existing = await prisma.ingredient.findUnique({ + where: { id: request.params.id }, + }) + + if (!existing) { + return reply.status(404).send({ error: '成分不存在' }) + } + + const { functionCategory, unitPrice, ...rest } = request.body + + if (functionCategory && !VALID_CATEGORIES.includes(functionCategory as IngredientCategory)) { + return reply.status(400).send({ error: `无效的功能分类: ${functionCategory}` }) + } + + if (unitPrice !== undefined && unitPrice < 0) { + return reply.status(400).send({ error: '单价不能为负数' }) + } + + const ingredient = await prisma.ingredient.update({ + where: { id: request.params.id }, + data: { + ...rest, + ...(functionCategory ? { functionCategory: functionCategory as IngredientCategory } : {}), + ...(unitPrice !== undefined ? { unitPrice } : {}), + }, + }) + + return reply.send({ data: ingredient }) +} + +async function deleteIngredient( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, +) { + const existing = await prisma.ingredient.findUnique({ + where: { id: request.params.id }, + }) + + if (!existing) { + return reply.status(404).send({ error: '成分不存在' }) + } + + const usageCount = await prisma.formulaIngredient.count({ + where: { ingredientId: request.params.id }, + }) + + if (usageCount > 0) { + return reply.status(409).send({ + error: '该成分已被配方引用,无法删除', + usageCount, + }) + } + + await prisma.ingredient.delete({ + where: { id: request.params.id }, + }) + + return reply.status(204).send() +} + +export async function ingredientRoutes(app: FastifyInstance) { + app.get('/', getIngredients) + app.get('/:id', getIngredient) + app.post('/', createIngredient) + app.put('/:id', updateIngredient) + app.delete('/:id', deleteIngredient) +} diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts new file mode 100644 index 0000000..2b5abad --- /dev/null +++ b/backend/src/routes/projects.ts @@ -0,0 +1,39 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import { prisma } from '../lib/prisma.js' + +async function listProjects(_request: FastifyRequest, reply: FastifyReply) { + const projects = await prisma.project.findMany({ + orderBy: { createdAt: 'desc' }, + include: { _count: { select: { formulas: true } } }, + }) + return reply.send({ data: projects }) +} + +async function createProject(request: FastifyRequest<{ Body: { name: string; description?: string } }>, reply: FastifyReply) { + const { name, description } = request.body + if (!name) return reply.status(400).send({ error: '项目名称为必填项' }) + + const project = await prisma.project.create({ + data: { name, description: description ?? null, createdBy: 'system' }, + }) + return reply.status(201).send({ data: project }) +} + +async function updateProject(request: FastifyRequest<{ Params: { id: string }; Body: { name?: string; description?: string } }>, reply: FastifyReply) { + const project = await prisma.project.update({ + where: { id: request.params.id }, data: request.body, + }) + return reply.send({ data: project }) +} + +async function deleteProject(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) { + await prisma.project.delete({ where: { id: request.params.id } }) + return reply.status(204).send() +} + +export async function projectRoutes(app: FastifyInstance) { + app.get('/', listProjects) + app.post('/', createProject) + app.put('/:id', updateProject) + app.delete('/:id', deleteProject) +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..d454028 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,15 @@ +import { buildApp } from './app.js' + +async function start() { + const app = await buildApp() + const port = Number(process.env.PORT) || 3001 + + try { + await app.listen({ port, host: '0.0.0.0' }) + } catch (err) { + app.log.error(err) + process.exit(1) + } +} + +start() diff --git a/backend/src/services/ai/audit.ts b/backend/src/services/ai/audit.ts new file mode 100644 index 0000000..e5cca18 --- /dev/null +++ b/backend/src/services/ai/audit.ts @@ -0,0 +1,19 @@ +import { prisma } from '../../lib/prisma.js' + +export async function logAudit(params: { + capability: string + modelName: string + promptHash: string + tokensUsed?: number + durationMs?: number +}): Promise { + await prisma.aiAuditLog.create({ + data: { + capability: params.capability, + modelName: params.modelName, + promptHash: params.promptHash, + tokensUsed: params.tokensUsed ?? null, + durationMs: params.durationMs ?? null, + }, + }) +} diff --git a/backend/src/services/ai/cache.ts b/backend/src/services/ai/cache.ts new file mode 100644 index 0000000..601c34e --- /dev/null +++ b/backend/src/services/ai/cache.ts @@ -0,0 +1,37 @@ +interface CacheEntry { + value: T + expiresAt: number +} + +export class LRUCache { + private cache = new Map>() + private maxSize: number + + constructor(maxSize = 100) { + this.maxSize = maxSize + } + + get(key: string): T | undefined { + const entry = this.cache.get(key) + if (!entry) return undefined + if (Date.now() > entry.expiresAt) { + this.cache.delete(key) + return undefined + } + this.cache.delete(key) + this.cache.set(key, entry) + return entry.value + } + + set(key: string, value: T, ttlMs: number): void { + if (this.cache.has(key)) this.cache.delete(key) + else if (this.cache.size >= this.maxSize) { + const first = this.cache.keys().next().value + if (first) this.cache.delete(first) + } + this.cache.set(key, { value, expiresAt: Date.now() + ttlMs }) + } + + clear(): void { this.cache.clear() } + get size(): number { return this.cache.size } +} diff --git a/backend/src/services/ai/index.ts b/backend/src/services/ai/index.ts new file mode 100644 index 0000000..52344fd --- /dev/null +++ b/backend/src/services/ai/index.ts @@ -0,0 +1,185 @@ +import { createHash } from 'crypto' +import { createOpenAIProvider } from './providers/openai.js' +import { createDeepSeekProvider } from './providers/deepseek.js' +import type { AIProvider, ChatMessage, ChatOptions } from './providers/types.js' +import { + predictMetricsPrompt, parseNLQueryPrompt, generateFormulaPrompt, + recommendColorantsPrompt, extractFormulaPrompt, +} from './templates/index.js' +import { LRUCache } from './cache.js' +import { RateLimiter } from './rate-limiter.js' +import { logAudit } from './audit.js' + +const MOCK_RESPONSES: Record = { + 'predict-metrics': JSON.stringify({ + sensoryIndex: { spreadability: 78, absorption: 82, stickiness: 25, overall: 75 }, + stabilityScore: 85, costEstimate: 45.5, confidence: 0.85, + reasoning: '基于成分组合的模拟预测', + }), + 'parse-nl-query': JSON.stringify({ + filters: {}, keywords: ['保湿', '精华'], vectorQuery: '高保湿精华配方', + }), + 'generate-formula': JSON.stringify([]), + 'recommend-colorants': JSON.stringify({ recommendations: [] }), + 'extract-formula': JSON.stringify({ ingredients: [] }), +} + +export class AIService { + private providers: Record = {} + private cache: LRUCache + private rateLimiter: RateLimiter + private retryMax: number + private defaultModel: string + private mockMode: boolean + private consecutiveFailures = 0 + + constructor() { + this.cache = new LRUCache(200) + this.rateLimiter = new RateLimiter(10, 10) + this.retryMax = 3 + this.defaultModel = process.env['AI_DEFAULT_MODEL'] ?? 'deepseek-chat' + this.mockMode = process.env['AI_MOCK'] === 'true' + this.initProviders() + } + + reload(): void { + this.providers = {} + this.initProviders() + this.mockMode = process.env['AI_MOCK'] === 'true' + this.consecutiveFailures = 0 + this.cache.clear() + } + + async testConnection(provider: string): Promise { + const p = this.providers[provider] + if (!p) throw new Error(`Provider "${provider}" 未配置`) + const res = await p.chat([{ role: 'user', content: 'Reply with just "OK"' }], { maxTokens: 5 }) + return res.model + } + + private initProviders(): void { + const openaiKey = process.env['OPENAI_API_KEY'] + const deepseekKey = process.env['DEEPSEEK_API_KEY'] + if (openaiKey) { + this.providers['openai'] = createOpenAIProvider(openaiKey, process.env['OPENAI_BASE_URL']) + } + if (deepseekKey) { + this.providers['deepseek'] = createDeepSeekProvider(deepseekKey, process.env['DEEPSEEK_BASE_URL']) + } + } + + private selectProvider(model?: string): { provider: AIProvider; model: string } { + const m = model ?? this.defaultModel + if (m.startsWith('gpt-') || m.startsWith('o1') || m.startsWith('o3')) { + const p = this.providers['openai'] ?? this.providers['deepseek'] + if (!p) throw new Error('No AI provider configured') + return { provider: p, model: m } + } + const p = this.providers['deepseek'] ?? this.providers['openai'] + if (!p) throw new Error('No AI provider configured') + return { provider: p, model: m } + } + + private hash(...inputs: string[]): string { + return createHash('sha256').update(inputs.join('|')).digest('hex').slice(0, 16) + } + + async predictMetrics(ingredients: Array<{ name: string; percentage: number; category: string }>): Promise { + return this.execute('predict-metrics', predictMetricsPrompt(ingredients), { ttlMs: 3600_000 }) + } + + async parseNLQuery(query: string): Promise { + return this.execute('parse-nl-query', parseNLQueryPrompt(query), { ttlMs: 300_000 }) + } + + async generateFormula(constraints: Parameters[0]): Promise { + return this.execute('generate-formula', generateFormulaPrompt(constraints), { ttlMs: 0 }) + } + + async recommendColorants(targetLab: { L: number; a: number; b: number }): Promise { + return this.execute('recommend-colorants', recommendColorantsPrompt(targetLab), { ttlMs: 1800_000 }) + } + + async extractFormula(text: string): Promise { + return this.execute('extract-formula', extractFormulaPrompt(text), { ttlMs: 0 }) + } + + async chatStream(capability: string, messages: ChatMessage[], options?: ChatOptions): Promise> { + if (this.mockMode) { + const mock = MOCK_RESPONSES[capability] + return { + [Symbol.asyncIterator]: async function* () { + if (mock) yield mock + }, + } + } + const { provider, model } = this.selectProvider(options?.model) + return provider.chatStream(messages, { ...options, model }) + } + + private async execute( + capability: string, messages: ChatMessage[], + opts: { ttlMs: number; model?: string }, + ): Promise { + const promptHash = this.hash(capability, JSON.stringify(messages)) + + if (this.consecutiveFailures >= 3) { + return this.fallback(capability) + } + + if (opts.ttlMs > 0) { + const cached = this.cache.get(promptHash) + if (cached) return cached + } + + if (this.mockMode) { + return MOCK_RESPONSES[capability] ?? '{}' + } + + await this.rateLimiter.acquire() + + const { provider, model } = this.selectProvider(opts.model) + const start = Date.now() + + for (let attempt = 0; attempt < this.retryMax; attempt++) { + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 30_000) + + const res = await provider.chat(messages, { + model, + temperature: 0.5, + maxTokens: 2000, + }) + + clearTimeout(timeout) + this.consecutiveFailures = 0 + + const duration = Date.now() - start + logAudit({ + capability, modelName: model, promptHash, + tokensUsed: res.usage?.totalTokens, durationMs: duration, + }).catch(() => {}) + + if (opts.ttlMs > 0) { + this.cache.set(promptHash, res.content, opts.ttlMs) + } + + return res.content + } catch (err) { + if (attempt < this.retryMax - 1) { + await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000)) + } + } + } + + this.consecutiveFailures++ + return this.fallback(capability) + } + + private fallback(capability: string): string { + return MOCK_RESPONSES[capability] ?? '{}' + } +} + +export const aiService = new AIService() diff --git a/backend/src/services/ai/providers/deepseek.ts b/backend/src/services/ai/providers/deepseek.ts new file mode 100644 index 0000000..e3b0441 --- /dev/null +++ b/backend/src/services/ai/providers/deepseek.ts @@ -0,0 +1,5 @@ +import { createOpenAIProvider } from './openai.js' + +export function createDeepSeekProvider(apiKey: string, baseURL?: string) { + return createOpenAIProvider(apiKey, baseURL ?? 'https://api.deepseek.com/v1', 'deepseek-chat') +} diff --git a/backend/src/services/ai/providers/openai.ts b/backend/src/services/ai/providers/openai.ts new file mode 100644 index 0000000..4cb66b5 --- /dev/null +++ b/backend/src/services/ai/providers/openai.ts @@ -0,0 +1,88 @@ +import type { AIProvider, ChatMessage, ChatOptions, ChatResponse } from './types.js' + +export function createOpenAIProvider(apiKey: string, baseURL?: string, defaultModel = 'gpt-4o'): AIProvider { + const endpoint = `${baseURL ?? 'https://api.openai.com/v1'}/chat/completions` + + async function chat(messages: ChatMessage[], options?: ChatOptions): Promise { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: options?.model ?? defaultModel, + messages, + temperature: options?.temperature ?? 0.7, + max_tokens: options?.maxTokens ?? 2000, + stream: false, + }), + }) + + if (!res.ok) { + const err = await res.text() + throw new Error(`AI API error (${res.status}): ${err}`) + } + + const json = await res.json() as Record + const choice = (json.choices as Array>)?.[0] + const msg = choice?.message as Record | undefined + return { + content: (msg?.content as string) ?? '', + model: json.model as string ?? 'unknown', + usage: json.usage as ChatResponse['usage'], + } + } + + async function* chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncIterable { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: options?.model ?? defaultModel, + messages, + temperature: options?.temperature ?? 0.7, + max_tokens: options?.maxTokens ?? 2000, + stream: true, + }), + }) + + if (!res.ok) { + const err = await res.text() + throw new Error(`AI API stream error (${res.status}): ${err}`) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('No response body') + + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('data: ')) continue + const data = trimmed.slice(6) + if (data === '[DONE]') return + + try { + const json = JSON.parse(data) as Record + const delta = (json.choices as Array>)?.[0]?.delta as Record | undefined + if (delta?.content) yield delta.content as string + } catch { } + } + } + } + + return { chat, chatStream } +} diff --git a/backend/src/services/ai/providers/types.ts b/backend/src/services/ai/providers/types.ts new file mode 100644 index 0000000..801fa59 --- /dev/null +++ b/backend/src/services/ai/providers/types.ts @@ -0,0 +1,22 @@ +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' + content: string +} + +export interface ChatOptions { + model?: string + temperature?: number + maxTokens?: number + stream?: boolean +} + +export interface ChatResponse { + content: string + model: string + usage?: { promptTokens: number; completionTokens: number; totalTokens: number } +} + +export interface AIProvider { + chat(messages: ChatMessage[], options?: ChatOptions): Promise + chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncIterable +} diff --git a/backend/src/services/ai/rate-limiter.ts b/backend/src/services/ai/rate-limiter.ts new file mode 100644 index 0000000..8cfcf64 --- /dev/null +++ b/backend/src/services/ai/rate-limiter.ts @@ -0,0 +1,31 @@ +export class RateLimiter { + private tokens: number + private maxTokens: number + private refillRate: number + private lastRefill: number + + constructor(maxTokens = 10, refillPerSecond = 10) { + this.tokens = maxTokens + this.maxTokens = maxTokens + this.refillRate = refillPerSecond + this.lastRefill = Date.now() + } + + async acquire(): Promise { + this.refill() + if (this.tokens > 0) { + this.tokens-- + return + } + const waitMs = (1 / this.refillRate) * 1000 + await new Promise(resolve => setTimeout(resolve, waitMs)) + return this.acquire() + } + + private refill(): void { + const now = Date.now() + const elapsed = (now - this.lastRefill) / 1000 + this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate) + this.lastRefill = now + } +} diff --git a/backend/src/services/ai/templates/index.ts b/backend/src/services/ai/templates/index.ts new file mode 100644 index 0000000..990d4b1 --- /dev/null +++ b/backend/src/services/ai/templates/index.ts @@ -0,0 +1,44 @@ +import type { ChatMessage } from '../providers/types.js' + +export function predictMetricsPrompt(ingredients: Array<{ name: string; percentage: number; category: string }>): ChatMessage[] { + const ingList = ingredients.map(i => `- ${i.name} (${i.category}): ${i.percentage}%`).join('\n') + return [ + { role: 'system', content: '你是一名资深化妆品配方工程师。根据成分列表预测配方的肤感指数、稳定性评分和成本估算。返回 JSON 格式:{"sensoryIndex":{"spreadability":0-100,"absorption":0-100,"stickiness":0-100,"overall":0-100},"stabilityScore":0-100,"costEstimate":元/kg,"confidence":0-1,"reasoning":"简短理由"}' }, + { role: 'user', content: `请分析以下配方的指标:\n${ingList}` }, + ] +} + +export function parseNLQueryPrompt(query: string): ChatMessage[] { + return [ + { role: 'system', content: '将用户的自然语言查询转换为结构化搜索条件。返回 JSON:{"filters":{"excludeIngredients":["成分名"],"includeIngredients":["成分名"],"categories":["分类"]},"keywords":["关键词"],"vectorQuery":"语义搜索词"}' }, + { role: 'user', content: query }, + ] +} + +export function generateFormulaPrompt(constraints: { + baseFormulaName?: string + baseIngredients?: Array<{ name: string; percentage: number }> + costLimit?: number + keepIngredients?: string[] + excludeIngredients?: string[] + targetMetrics?: Record +}): ChatMessage[] { + return [ + { role: 'system', content: '你是一名资深化妆品配方工程师。根据约束条件生成优化的配方方案。返回 JSON 数组,每个方案包含:{"name":"方案名","changes":[{"action":"add/remove/adjust","ingredient":"成分名","oldPercentage":null|number,"newPercentage":number}],"predictedMetrics":{},"reasoning":"理由"}' }, + { role: 'user', content: `约束条件:${JSON.stringify(constraints, null, 2)}` }, + ] +} + +export function recommendColorantsPrompt(targetLab: { L: number; a: number; b: number }): ChatMessage[] { + return [ + { role: 'system', content: '你是一名化妆品色彩专家。根据目标 Lab 颜色值推荐色浆组合及比例。返回 JSON:{"recommendations":[{"colorants":[{"name":"色浆名","ratio":0-1}],"predictedDeltaE":number,"confidence":0-1}]}' }, + { role: 'user', content: `目标颜色 Lab(${targetLab.L}, ${targetLab.a}, ${targetLab.b})` }, + ] +} + +export function extractFormulaPrompt(text: string): ChatMessage[] { + return [ + { role: 'system', content: '从配方文本中提取结构化数据。返回 JSON:{"ingredients":[{"inciName":"INCI名","chineseName":"中文名","percentage":number,"phase":"相名","processNotes":"工艺备注"}]}' }, + { role: 'user', content: text }, + ] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..7d86ae1 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"], + "resolveJsonModule": true, + "declaration": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/backend/tsconfig.tsbuildinfo b/backend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..3b569db --- /dev/null +++ b/backend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.ts","./src/server.ts","./src/generated/prisma/browser.ts","./src/generated/prisma/client.ts","./src/generated/prisma/commonInputTypes.ts","./src/generated/prisma/enums.ts","./src/generated/prisma/models.ts","./src/generated/prisma/internal/class.ts","./src/generated/prisma/internal/prismaNamespace.ts","./src/generated/prisma/internal/prismaNamespaceBrowser.ts","./src/generated/prisma/models/AiAuditLog.ts","./src/generated/prisma/models/ColorFormula.ts","./src/generated/prisma/models/Formula.ts","./src/generated/prisma/models/FormulaIngredient.ts","./src/generated/prisma/models/FormulaVersion.ts","./src/generated/prisma/models/Ingredient.ts","./src/generated/prisma/models/Phase.ts","./src/generated/prisma/models/Project.ts","./src/generated/prisma/models/User.ts","./src/lib/prisma.ts","./src/routes/ai.ts","./src/routes/auth.ts","./src/routes/color.ts","./src/routes/config.ts","./src/routes/formulas.test.ts","./src/routes/formulas.ts","./src/routes/health.ts","./src/routes/ingredients.test.ts","./src/routes/ingredients.ts","./src/routes/projects.ts","./src/services/ai/audit.ts","./src/services/ai/cache.ts","./src/services/ai/index.ts","./src/services/ai/rate-limiter.ts","./src/services/ai/providers/deepseek.ts","./src/services/ai/providers/openai.ts","./src/services/ai/providers/types.ts","./src/services/ai/templates/index.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..9fb4f14 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: false, + }, +}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a89e24b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + postgres: + build: + context: ./docker + dockerfile: Dockerfile.pgvector + container_name: colorfull-db + environment: + POSTGRES_DB: colorfull + POSTGRES_USER: colorfull + POSTGRES_PASSWORD: colorfull + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U colorfull -d colorfull"] + interval: 5s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + container_name: colorfull-minio + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - miniodata:/data + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: + miniodata: diff --git a/docker/Dockerfile.pgvector b/docker/Dockerfile.pgvector new file mode 100644 index 0000000..c52ae48 --- /dev/null +++ b/docker/Dockerfile.pgvector @@ -0,0 +1,13 @@ +FROM postgres:16-alpine + +RUN apk add --no-cache \ + git \ + build-base + +RUN git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git /tmp/pgvector \ + && cd /tmp/pgvector \ + && make with_llvm=no \ + && make install with_llvm=no \ + && rm -rf /tmp/pgvector + +RUN apk del git build-base diff --git a/docs/adr/0001-architecture-stack.md b/docs/adr/0001-architecture-stack.md new file mode 100644 index 0000000..5d3eb2d --- /dev/null +++ b/docs/adr/0001-architecture-stack.md @@ -0,0 +1,268 @@ +# ADR-0001: 整体技术栈选型 + +> **状态**: 已决议 +> **日期**: 2026-05-20 +> **决策者**: 架构评审 + +--- + +## 上下文 + +构建 AI 驱动的化妆品配方研发智能平台(纯 Web 端),涉及四大核心模块:颜色引擎(广色域渲染)、配方可视化编辑器(拖拽交互 + 实时 AI 反馈)、配方记录管理(结构化存储 + NL 搜索)、配方推演引擎(多方案并行优化)。需对前端框架、图表库、色彩科学库、后端架构、数据库等做出技术选型。 + +--- + +## 决策 + +### 1. 前端框架 → React 18 + TypeScript 5.7 + +| 候选 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **React** | 生态最丰富;ECharts/D3/color.js 均有 React 绑定;招聘成本最低;TS 成熟 | Hooks 学习曲线 | ✅ 推荐 | +| Vue 3 | 模板直观;Composition API 类型推导好 | ECharts 官方 React 绑定更成熟;D3 + Vue 组合不如 React 灵活 | ❌ | +| Svelte 5 | 编译时框架,运行时极小 | 生态较小;关键库适配风险;社区资源少 | ❌ | +| SolidJS | 性能优于 React;API 相似 | 社区太小(GitHub stars ~30k vs React ~230k);生产风险高 | ❌ | + +**决策**:React 18 + TypeScript strict mode。React 19 待生态稳定后再升级。 + +--- + +### 2. 构建工具 → Vite 6 + +| 候选 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **Vite** | 原生 ESM 开发(毫秒级 HMR);Rollup 生产打包;零配置开箱;SSR 可选 | — | ✅ 推荐 | +| Next.js 15 | 全栈能力;SSR/SSG/ISR | 内部工具无需 SSR/SEO;增加复杂度;App Router 学习曲线陡峭 | ❌ | +| Remix | SSR 优先;Web 标准 | 同上;社区较 Next.js 小 | ❌ | +| CRA | — | 已停止维护;Webpack 构建慢 | ❌ | + +**决策**:Vite 6,SPA 模式。平台为内部工具,无需 SEO/SSR。 + +--- + +### 3. 状态管理 → Zustand 5 + +| 候选 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **Zustand** | 极简 API(无 Provider/Reducer);TS 完美;< 2KB;中间件(persist/immer/devtools) | — | ✅ 推荐 | +| Redux Toolkit | 完善的 DevTools;团队规范 | 模板代码多;概念多(slice/thunk/selector);过度工程化 | ❌ | +| Jotai | 原子化精细更新 | 原子拆分粒度决策成本高;对中型应用过度 | ❌ | +| MobX | 响应式直观 | 装饰器语法过时;与 React 18+ 严格模式兼容性问题 | ❌ | + +**决策**:Zustand。服务端状态用 TanStack Query,客户端状态用 Zustand,边界清晰。 + +--- + +### 4. 路由 → React Router v7 + +| 候选 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **React Router v7** | 最广泛使用;layout routes;v7 类型安全路由;社区资源极丰富 | — | ✅ 推荐 | +| TanStack Router | 编译时类型安全最强 | 生态较小;与第三方库集成案例少 | ❌ | + +**决策**:React Router v7。 + +--- + +### 5. CSS 方案 → Tailwind CSS 4 + CSS Modules + +| 候选 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **Tailwind + CSS Modules** | Tailwind 处理 80% 日常样式;CSS Modules 处理复杂定制(颜色盘);零运行时;Tailwind v4 CSS-first 配置 | 需同时掌握两套语法 | ✅ 推荐 | +| styled-components | CSS-in-JS,组件级隔离 | 运行时开销(~14KB);Server Components 不兼容趋势 | ❌ | +| Panda CSS | 编译时 CSS-in-JS | 较新(2024),生态不成熟 | ❌ | +| Vanilla Extract | 类型安全 CSS-in-JS | 构建步骤复杂;社区小 | ❌ | + +**决策**:Tailwind CSS 4(原子化,处理布局/间距/响应式)+ CSS Modules(处理颜色盘 Canvas 容器、图表交互区等复杂定制场景)。 + +--- + +### 6. UI 行为组件 → Radix UI + +| 候选 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **Radix UI** | Headless(完全控制样式);WAI-ARIA 内置;组件粒度合适;与 Tailwind 天配 | 无预置视觉风格(需要自行设计) | ✅ 推荐 | +| Ant Design | 开箱即用,组件丰富 | 企业后台感强;视觉定制困难;不适合创意工具;bundle 大 | ❌ | +| MUI | Material Design 完整实现 | 同上;Google 风格固化 | ❌ | +| shadcn/ui | 基于 Radix + Tailwind,复制源码 | 本质是 Radix 封装;直接 Radix 更灵活 | ❌ | + +**决策**:Radix UI 提供 Dialog、Popover、Dropdown、Tabs、Tooltip 等行为组件。视觉层完全自定义,匹配配方研发工具的专业调性。 + +--- + +### 7. 图表可视化 → ECharts 5(主力) + D3.js 7(定制) + +| 候选 | 雷达图 | 拖拽饼图 | 仪表盘 | 散点图 | 自定义度 | Bundle | 结论 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| **ECharts** | ✅ 内置 | ✅ resize 事件 | ✅ 内置 | ✅ 内置 | 中 | ~300KB(按需~150KB) | ✅ 主力 | +| **D3.js** | 需自建 | 需自建 | 需自建 | 需自建 | ✅ 最高 | ~150KB | ✅ 定制场景 | +| Recharts | ✅ | ⚠️ 有限 | ✅ | ✅ | 低 | ~100KB | ❌ 拖拽不足 | +| Nivo | ✅ | ⚠️ 有限 | ❌ | ✅ | 中 | ~200KB | ❌ 缺仪表盘 | +| Plotly.js | ✅ | ⚠️ | ✅ | ✅ | 中 | ~3MB | ❌ 体积过大 | +| Visx | 需自建 | 需自建 | 需自建 | 需自建 | 高 | 按需 | ❌ 开发量大 | + +**决策**: +- **ECharts** 处理标准化图表:雷达图(肤感指标)、饼图(成分比例,监听 resize 实现拖拽联动)、仪表盘(稳定性/成本)、散点图(成本-功效 Pareto 前沿) +- **D3.js** 处理高度定制场景:色相环/颜色盘、配方路径示意图、配方结构树图 +- React 绑定使用 `echarts-for-react` + +--- + +### 8. 色彩科学 → color.js + +| 候选 | 色空间 | ΔE | Display P3 | 色域映射 | TS 类型 | 维护者 | 结论 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| **color.js** | Lab/LCH/OKLab/Display P3 + 20+ | CIEDE2000/CMC/... | ✅ | ✅ | ✅ 完备 | Lea Verou (W3C CSS WG) | ✅ 推荐 | +| chroma.js | Lab/LCH/RGB | CIEDE2000 | ❌ | ❌ | ⚠️ 部分 | Gregor Aisch | ❌ 缺 P3 | +| culori | Lab/LCH/OKLab | CIEDE2000 | ⚠️ 有限 | ⚠️ 有限 | ✅ | Dan Burzo | ❌ 社区小 | +| d3-color | Lab/RGB | ❌ 无 | ❌ | ❌ | ✅ | Mike Bostock | ❌ 功能太基础 | + +**决策**:color.js。唯一同时支持 Display P3 色空间、CIEDE2000 ΔE、色域映射的开源 JS 库。由 CSS Color Level 4 规范编者维护,与浏览器标准对齐。 + +--- + +### 9. 拖拽交互 → DnD Kit + +| 候选 | 状态 | 鼠标/触摸/键盘 | 碰撞检测 | TS | 维护 | 结论 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| **@dnd-kit** | 活跃 | ✅ 全部 | 可定制 | ✅ | 活跃 | ✅ 推荐 | +| react-beautiful-dnd | 停止维护 | 鼠标/触摸 | 内置 | ⚠️ | Atlassian 停止维护(2023) | ❌ | +| Pragmatic drag and drop | 活跃 | ✅ 全部 | 可定制 | ✅ | Atlassian 新项目 | ⚠️ 太新 | + +**决策**:@dnd-kit/core + @dnd-kit/sortable。饼图段的拖拽调整比例场景完美匹配,支持自定义碰撞检测算法。 + +--- + +### 10. 数据请求 → TanStack Query 5 + +| 候选 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **TanStack Query** | 缓存/重试/乐观更新/无限滚动开箱即用;与 Zustand 分工清晰 | — | ✅ 推荐 | +| SWR | 类似功能 | API 不如 TanStack Query 丰富 | ❌ | +| RTK Query | Redux 集成 | 绑定 Redux | ❌ | + +**决策**:TanStack Query v5。推荐列表、配方搜索、AI 预测等所有异步请求均通过它管理。 + +--- + +### 11. 后端 → Fastify(BFF 单体)+ 外部 AI API + +``` +┌─────────┐ HTTP/SSE ┌──────────┐ HTTP ┌──────────┐ +│ React │ ◄─────────────► │ Fastify │ ◄────────────► │ AI API │ +│ 前端 │ │ BFF 层 │ │ (外部) │ +└─────────┘ └────┬─────┘ └──────────┘ + │ + ┌────▼─────┐ + │PostgreSQL │ + │ + pgvector│ + └──────────┘ +``` + +| 层 | 技术 | 职责 | 理由 | +| :--- | :--- | :--- | :--- | +| **BFF** | Fastify 5 | API 聚合、认证鉴权、文件上传、配方 CRUD、AI API 调用代理、SSE 推送 | TS 全栈一致性;Schema 验证内置;性能优于 Express | +| **AI** | 外部 API | 配方预测、配方生成、NL 解析、颜色匹配推荐 | 无需自建 ML infra;按需调用;模型持续更新 | +| **通信** | REST + SSE | 前端 ↔ BFF REST;AI 预测 SSE 实时推送;BFF ↔ AI API HTTP | SSE 适合单向实时数据流;BFF 统一处理 AI 调用的编排和降级 | + +| BFF 框架对比 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **Fastify** | 高性能(~60k req/s);插件体系;内置 Schema 验证(AJV);TS 支持;日志(pino) | 社区较 Express 小 | ✅ 推荐 | +| Express | 生态最大;中间件极多 | 性能较低;无内置验证;回调风格 | ❌ | +| Hono | 极快;边缘部署 | 主要用于 Cloudflare/边缘场景 | ❌ | + +**AI API 调用设计**(详见 ADR-0002): + +BFF 作为 AI API 的统一网关,负责: +- **编排**:组合多次 API 调用(如"先解析 NL 查询 → 再向量搜索 → 组合 prompt → 生成推荐") +- **降级**:API 超时或失败时返回缓存结果或降级提示 +- **限流**:保护 API 额度,控制并发 +- **缓存**:相似查询复用结果,减少 API 调用 +- **SSE 流式**:配方推演等长耗时场景,BFF 转发 AI API 的 streaming 响应 + +| AI 能力 | 调用方式 | 说明 | +| :--- | :--- | :--- | +| 配方指标预测 | Prompt + 历史数据(few-shot) | 将成分列表和比例作为 context,LLM 预测肤感/稳定性/成本 | +| NL 搜索解析 | LLM → 结构化查询 → pgvector | LLM 将自然语言转为过滤条件 + 向量搜索 | +| 配方生成/推演 | LLM with constraints | 给定约束条件,LLM 生成候选配方方案 | +| 颜色推荐 | LLM + 色彩库 | 结合色差计算和 LLM 推理推荐色浆组合 | +| 成分标签提取 | LLM 结构化提取 | 从配方文本中提取 INCI 名称、比例、工艺参数 | + +--- + +### 12. 数据库 → PostgreSQL 16 + pgvector + +| 候选 | 结构化 | 向量搜索 | JSONB | 全文搜索 | 运维 | 结论 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| **PostgreSQL + pgvector** | ✅ | ✅ IVFFlat/HNSW | ✅ | ✅ | 成熟 | ✅ 推荐 | +| MongoDB | ⚠️ 文档型 | ✅ Atlas Vector | ✅ | ⚠️ | 成熟 | ❌ 配方强关系 | +| Elasticsearch | ❌ | ⚠️ 需插件 | ❌ | ✅ | 复杂 | ❌ 过度 | +| Neo4j | ❌ 图 | ⚠️ | ❌ | ❌ | 小众 | ❌ 过度 | + +**决策**:PostgreSQL 16 + pgvector。 + +- 配方是强结构化数据(成分→相→配方→版本,经典关系模型) +- pgvector 0.7+ 支持 HNSW 索引,NL 搜索和相似配方匹配性能优秀 +- JSONB 处理灵活的工艺参数和元数据 +- 一个数据库同时满足关系查询 + 向量搜索 + 全文搜索,运维简单 +- 配方版本管理利用 PostgreSQL 的 MVCC + 时间戳快照 + +--- + +### 13. 对象存储 → MinIO + +**决策**:MinIO(S3 兼容),存储参考图片、导出文件。本地部署,S3 API 可无缝迁移到云。 + +--- + +### 14. 部署 → Docker Compose(开发)+ K8s(生产) + +| 服务 | 端口 | 职责 | +| :--- | :--- | :--- | +| Nginx | 80/443 | 静态资源、反向代理、SSL 终结 | +| Fastify BFF | 3001 | API 聚合、认证、文件上传、AI API 代理 | +| PostgreSQL | 5432 | 配方数据 + 向量 | +| MinIO | 9000 | 对象存储 | +| Redis(可选) | 6379 | 缓存、Session、AI 响应缓存 | + +**开发环境**:Docker Compose 一键启动所有服务(无需 AI 依赖,BFF 可 mock AI API) +**生产环境**:Kubernetes,仅 4 个核心服务,显著降低运维复杂度 + +--- + +## 后果 + +### 正向 + +- React + Vite + Zustand + TanStack Query 形成轻量高效的现代前端栈 +- ECharts + D3.js 组合覆盖标准化图表和定制可视化全场景 +- color.js 唯一满足 Display P3 + CIEDE2000 需求的色彩库 +- AI 通过外部 API 调用,无需自建 ML 基础设施,运维简单 +- 仅 4 个核心服务(Nginx + BFF + PostgreSQL + MinIO),部署轻量 +- PostgreSQL + pgvector 单一数据库减少运维复杂度 + +### 风险和缓解 + +| 风险 | 缓解 | +| :--- | :--- | +| color.js 仍为 Beta(v0.x) | 锁定版本;Delta E 逻辑简单,必要时可自实现 CIEDE2000 | +| DnD Kit 维护节奏慢 | API 稳定,核心功能完备;锁定版本 | +| AI API 延迟 / 不可用 | BFF 层缓存 + 降级策略;关键路径设置超时(详见 ADR-0002) | +| AI API 调用成本 | 缓存相似查询;Prompt 压缩优化;使用 cheaper 模型做预处理 | +| pgvector HNSW 索引构建耗时 | 配方数据量级可控(万级),索引构建秒级 | + +### 需要关注但未在本次决议的 + +- CI/CD 流水线(GitHub Actions / GitLab CI) +- 监控和日志(Sentry + Grafana + Loki) +- 认证方案(JWT + OAuth2 / LDAP 企业对接) +- 测试框架细节(在实现阶段决策) + +--- + +## 参考 + +- PRD: `.scratch/formula-rd-platform/PRD.md` +- CSS Color Level 4: https://www.w3.org/TR/css-color-4/ +- color.js: https://github.com/color-js/color.js +- pgvector: https://github.com/pgvector/pgvector diff --git a/docs/adr/0002-ai-api-strategy.md b/docs/adr/0002-ai-api-strategy.md new file mode 100644 index 0000000..f4f4b55 --- /dev/null +++ b/docs/adr/0002-ai-api-strategy.md @@ -0,0 +1,266 @@ +# ADR-0002: AI 能力通过外部 API 调用实现 + +> **状态**: 已决议 +> **日期**: 2026-05-20 +> **父决策**: ADR-0001(整体技术栈) +> **决策者**: 架构评审 + +--- + +## 上下文 + +平台需要 AI 能力支撑四大核心模块:配方指标预测、NL 搜索、配方生成/推演、颜色推荐。考虑两种实现路径: + +- **方案 A**:自建 Python AI 微服务(FastAPI + 自训练模型) +- **方案 B**:通过外部 AI API 调用实现(LLM API + Prompt Engineering) + +--- + +## 决策 + +**选择方案 B**:所有 AI 能力通过调用外部 LLM API 实现。 + +--- + +## 理由 + +### 方案 B 优势 + +| 维度 | 方案 A(自建) | 方案 B(外部 API) | +| :--- | :--- | :--- | +| **开发成本** | 需要 ML 工程师训练模型;数据清洗和标注投入大 | Prompt Engineering 即可;无需 ML 专业背景 | +| **运维成本** | GPU 服务器(至少 1 台 A100)+ 模型部署和监控 | 零运维,按调用量付费 | +| **迭代速度** | 重新训练需数天到数周 | 调整 Prompt 即时生效 | +| **模型能力** | 受限于自有数据量和训练资源 | 持续获得最新大模型能力升级 | +| **部署复杂度** | 增加 1 个微服务 + GPU 依赖 + gRPC | 仅 BFF 层 HTTP 调用 | +| **冷启动** | 模型加载需数分钟 | 即用即走 | + +### 化妆品配方场景的特殊适配 + +配方研发的 AI 需求特点: + +1. **推理为主,非训练密集型**:预测肤感/稳定性本质是"基于成分知识的推理",LLM 的常识推理 + few-shot learning 可以胜任 +2. **数据量小**:企业内部配方数据通常是千到万级,不足以训练专用深度学习模型 +3. **领域知识密集**:LLM 已具备化学/化妆品基础知识,通过 Prompt 注入成分数据库即可精准推理 +4. **需求多变**:配方推演的约束条件千变万化,API 调用的灵活性远胜固定模型 + +--- + +## BFF 层 AI 调用架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Fastify BFF │ +│ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ AI Service Module │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │ +│ │ │ Cache │ │ Rate │ │ Fallback │ │ │ +│ │ │ Layer │ │ Limiter │ │ Handler │ │ │ +│ │ └──────────┘ └──────────┘ └───────────────┘ │ │ +│ │ │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ +│ │ │ Prompt Templates (per capability) │ │ │ +│ │ │ - predictFormulaMetrics │ │ │ +│ │ │ - parseNLQuery │ │ │ +│ │ │ - generateFormulaOptions │ │ │ +│ │ │ - recommendColorants │ │ │ +│ │ │ - extractFormulaStructure │ │ │ +│ │ └────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ +│ │ │ AI API Client │ │ │ +│ │ │ - streaming (SSE proxy) │ │ │ +│ │ │ - retry with backoff │ │ │ +│ │ │ - timeout (30s default, 120s streaming) │ │ │ +│ │ └────────────────────────────────────────────┘ │ │ +│ └────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 核心模块 + +| 模块 | 职责 | +| :--- | :--- | +| **Prompt Templates** | 每种 AI 能力对应独立的 Prompt 模板,包含 system prompt + 结构化输出指令 | +| **Cache Layer** | LRU 缓存相同/相似查询的 AI 响应(基于 query hash),TTL 5min~1h | +| **Rate Limiter** | 令牌桶 + 并发控制,保护 API 配额 | +| **Fallback Handler** | API 不可用时返回缓存结果或友好降级提示 | +| **AI API Client** | 统一的 HTTP 客户端,处理 streaming、重试、超时 | + +--- + +## 各 AI 能力的 API 调用策略 + +### 1. 配方指标预测 + +``` +POST /api/ai/predict + +BFF 流程: +1. 从 PostgreSQL 检索该成分组合的历史相似配方(pgvector 向量搜索) +2. 构建 Prompt: + - System: 化妆品配方专家角色 + 指标定义 + - Context: 历史相似配方的指标数据(few-shot examples) + - User: 当前配方的成分列表和比例 +3. 调用 AI API(非 streaming,预期 < 5s) +4. 缓存结果(key: 成分+比例 hash) +``` + +### 2. NL 搜索解析 + +``` +POST /api/formulas/search?q=不含酒精的高保湿精华 + +BFF 流程: +1. 调用 AI API 将 NL 转为结构化查询: + { + "filters": { "exclude_ingredients": ["alcohol", "ethanol"], "category": "精华液" }, + "vector_query": "高保湿、补水、滋润配方", + "sort": "保湿指数 DESC" + } +2. filters → PostgreSQL WHERE 子句 +3. vector_query → pgvector embedding → HNSW 相似搜索 +4. 合并结果返回 +``` + +### 3. 配方推演 + +``` +POST /api/ai/explore + +BFF 流程: +1. 用户设置约束(目标成本、保留成分、禁止成分、目标指标) +2. 检索当前配方 + 相关历史配方作为 context +3. 构建 Prompt → 调用 AI API(streaming 模式) +4. BFF 转发 SSE 流到前端,逐步展示生成的候选方案 +5. 每个候选方案附带置信度和变更说明 +``` + +### 4. 颜色推荐 + +``` +POST /api/color/recommend + +BFF 流程: +1. 前端传入目标颜色 Lab 值 +2. 在 PostgreSQL 中检索 ΔE < 3.0 的历史颜色配方(pgvector) +3. 将目标色 + 最近匹配配方作为 context,调用 AI API 推荐色浆组合 +4. 返回推荐色浆 + 预测比例 + 预测 ΔE +``` + +### 5. 配方结构化提取 + +``` +POST /api/formulas/extract + +BFF 流程: +1. 用户粘贴配方文本(或上传 Excel) +2. 调用 AI API with function calling / structured output +3. 提取:成分 INCI 名、中文名、比例、所属相、工艺备注 +4. 与成分目录(ingredients 表)模糊匹配校验 +5. 返回结构化 JSON,前端展示确认 +``` + +--- + +## AI API 选型 + +### 主选 + +| API | 优势 | 适用场景 | +| :--- | :--- | :--- | +| **OpenAI GPT-4o / GPT-4.1** | 推理能力最强;structured output 原生;streaming 稳定 | 配方推演、NL 解析、结构化提取 | +| **Anthropic Claude 4** | 长上下文(200K);化工领域知识强 | 配方生成(需大量 context)、复杂推理 | +| **DeepSeek V3** | 性价比高;中文能力强 | 指标预测(高频调用)、批量处理 | + +### 推荐策略 + +| 场景 | 模型 | 理由 | +| :--- | :--- | :--- | +| 配方推演(流式,低频,高质量) | GPT-4o | streaming 体验最好,推理质量最高 | +| 指标预测(非流式,高频,需快) | DeepSeek V3 | 便宜、快、中文好 | +| NL 搜索解析(高频,需结构化输出) | GPT-4o-mini / DeepSeek V3 | 便宜 + function calling | +| 配方结构化提取(批量,需准确) | GPT-4o | structured output 精度最高 | +| 颜色推荐(低频,需领域知识) | GPT-4o | 需要强推理 | + +### API 配置抽象 + +```typescript +// BFF 层多 provider 抽象 +interface AIProvider { + chat(messages: Message[], options: ChatOptions): Promise; + chatStream(messages: Message[], options: ChatOptions): AsyncIterable; +} + +const providers: Record = { + openai: new OpenAIProvider({ apiKey: env.OPENAI_API_KEY }), + deepseek: new DeepSeekProvider({ apiKey: env.DEEPSEEK_API_KEY }), + // 预留其他 provider +}; +``` + +所有 AI 调用通过统一的 `AIService` 模块,根据场景路由到对应 provider,上层业务不感知具体模型。 + +--- + +## 降级策略 + +| 场景 | 降级行为 | +| :--- | :--- | +| AI API 超时(5s) | 指标预测:返回"无法预测,请手动评估";NL 搜索:降级为基础关键词搜索 | +| AI API 不可用(连续失败) | 全部能力降级为提示模式,告知用户"AI 服务暂不可用" | +| 配额耗尽 | 限流 + 排队;高频能力(指标预测)优先缓存命中 | + +--- + +## 缓存策略 + +| 缓存内容 | TTL | Key | +| :--- | :--- | :--- | +| 指标预测结果 | 1 小时 | 成分列表 + 比例的 hash | +| NL 搜索解析 | 5 分钟 | 原始查询文本 hash | +| 颜色推荐 | 30 分钟 | 目标 Lab 值 + 允许 ΔE | +| 成分结构化提取 | 永久(除非成分库更新) | 原料名称 hash | + +使用 Redis 存储。BFF 启动时无需依赖 Redis,降级为内存 LRU 缓存。 + +--- + +## 安全考虑 + +- **API Key 管理**:环境变量注入(非代码硬编码),支持 vault/secret manager +- **数据脱敏**:发送给 AI API 的 prompt 不包含公司敏感配方全量数据,仅发送 necesary context +- **Prompt 注入防护**:用户输入(NL 搜索词)经过清洗后嵌入 prompt 模板 +- **审计日志**:所有 AI 调用记录(请求摘要、token 消耗、耗时)存储到 PostgreSQL audit 表 + +--- + +## 后果 + +### 正向 + +- 零 ML 基础设施投入,开发周期缩短 50%+ +- 部署仅需 4 个服务,运维复杂度极低 +- 模型能力随 API 升级自动提升,无迁移成本 +- 成本可控:按调用量付费,低用量时几乎为零 +- BFF 层可独立开发和测试(mock AI 响应) + +### 风险和缓解 + +| 风险 | 缓解 | +| :--- | :--- | +| API 延迟影响用户体验 | 缓存 + streaming + 降级;预测 API 设置 5s 超时 | +| 外部 API 数据隐私 | Prompt 中不发送完整配方;仅发送必要上下文 | +| 供应商锁定 | 多 provider 抽象层;标准化 prompt 模板可跨模型复用 | +| LLM 幻觉(生成不合理配方) | 结果后处理校验(比例总和 100%、成分存在性检查) | + +--- + +## 参考 + +- ADR-0001: 整体技术栈选型 +- PRD: `.scratch/formula-rd-platform/PRD.md` +- OpenAI Structured Outputs: https://platform.openai.com/docs/guides/structured-outputs +- pgvector: https://github.com/pgvector/pgvector diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..87f7c7d --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,35 @@ +# Domain Docs + +Engineering skills 探索 codebase 时,应如何消费这个 repo 的 domain documentation。 + +## Before exploring, read these + +- repo 根目录的 **`CONTEXT.md`** +- **`docs/adr/`** — 读取与你即将处理区域相关的 ADRs + +如果这些文件不存在,**静默继续**。不要标记缺失;不要提前建议创建。producer skill(`/grill-with-docs`)会在 terms 或 decisions 实际被解决时懒创建它们。 + +## File structure + +Single-context repo: + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-example.md +│ └── ... +└── src/ +``` + +## Use the glossary's vocabulary + +当你的输出命名某个 domain concept 时(issue title、refactor proposal、hypothesis、test name),使用 `CONTEXT.md` 中定义的 term。不要漂移到 glossary 明确避免的 synonyms。 + +如果你需要的概念还不在 glossary 中,这是一个信号:要么你正在发明项目没有使用的语言(重新考虑),要么确实存在缺口(为 `/grill-with-docs` 记录)。 + +## Flag ADR conflicts + +如果你的输出与现有 ADR 矛盾,明确指出,而不是静默覆盖: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..bfa868f --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,19 @@ +# Issue tracker: Local Markdown + +这个 repo 的 issues 和 PRDs 作为 markdown 文件存放在 `.scratch/` 中。 + +## Conventions + +- 每个 feature 一个目录:`.scratch//` +- PRD 是 `.scratch//PRD.md` +- Implementation issues 是 `.scratch//issues/-.md`,从 `01` 开始编号 +- Triage state 记录为每个 issue file 顶部附近的 `Status:` 行(role 字符串见 `triage-labels.md`) +- Comments 和 conversation history 追加到文件底部的 `## Comments` heading 下 + +## When a skill says "publish to the issue tracker" + +在 `.scratch//` 下创建新文件(必要时创建目录)。 + +## When a skill says "fetch the relevant ticket" + +读取引用路径处的文件。用户通常会直接传入路径或 issue number。 diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..ba9a372 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +Skills 使用五个 canonical triage roles。这个文件把这些 roles 映射到此 repo issue tracker 中实际使用的 label 字符串。 + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +当某个 skill 提到 role(例如 “apply the AFK-ready triage label”)时,使用此表中对应的 label 字符串。 + +编辑右侧列,使其匹配你实际使用的 vocabulary。 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..7549542 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmmirror.com diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1371136 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,59 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.100.11", + "clsx": "^2.1.1", + "colorjs.io": "^0.6.1", + "d3": "^7.9.0", + "echarts": "^6.0.0", + "echarts-for-react": "^3.0.6", + "lucide-react": "^1.16.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-hook-form": "^7.76.0", + "react-router-dom": "^7.15.1", + "zod": "^4.4.3", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.6", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "prettier": "^3.8.3", + "prettier-plugin-tailwindcss": "^0.8.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12", + "vitest": "^4.1.6" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..2e62485 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,3846 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.76.0(react@19.2.6)) + '@radix-ui/react-accordion': + specifier: ^1.2.12 + version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-query': + specifier: ^5.100.11 + version: 5.100.11(react@19.2.6) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + colorjs.io: + specifier: ^0.6.1 + version: 0.6.1 + d3: + specifier: ^7.9.0 + version: 7.9.0 + echarts: + specifier: ^6.0.0 + version: 6.0.0 + echarts-for-react: + specifier: ^3.0.6 + version: 3.0.6(echarts@6.0.0)(react@19.2.6) + lucide-react: + specifier: ^1.16.0 + version: 1.16.0(react@19.2.6) + react: + specifier: ^19.2.6 + version: 19.2.6 + react-dom: + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) + react-hook-form: + specifier: ^7.76.0 + version: 7.76.0(react@19.2.6) + react-router-dom: + specifier: ^7.15.1 + version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + zod: + specifier: ^4.4.3 + version: 4.4.3 + zustand: + specifier: ^5.0.13 + version: 5.0.13(@types/react@19.2.14)(react@19.2.6) + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.0(jiti@2.7.0)) + '@tailwindcss/vite': + specifier: ^4.3.0 + version: 4.3.0(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) + '@types/node': + specifier: ^24.12.3 + version: 24.12.4 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.2(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) + '@vitest/coverage-v8': + specifier: ^4.1.6 + version: 4.1.6(vitest@4.1.6) + eslint: + specifier: ^10.3.0 + version: 10.4.0(jiti@2.7.0) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.4.0(jiti@2.7.0)) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@10.4.0(jiti@2.7.0)) + globals: + specifier: ^17.6.0 + version: 17.6.0 + prettier: + specifier: ^3.8.3 + version: 3.8.3 + prettier-plugin-tailwindcss: + specifier: ^0.8.0 + version: 0.8.0(prettier@3.8.3) + typescript: + specifier: ~6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.59.2 + version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + vite: + specifier: ^8.0.12 + version: 8.0.13(@types/node@24.12.4)(jiti@2.7.0) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@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==} + + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + peerDependencies: + react-hook-form: ^7.55.0 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@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==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.130.0': + resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + 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.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + 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.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + 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.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + 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.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + 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.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + 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@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + 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.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + 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.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + 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.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + 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.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + 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.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + 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.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + 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.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + 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.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + 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.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + 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.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + 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.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + 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.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + 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.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + 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.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + 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.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + 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.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + 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.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + 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.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + 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.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + 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.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + 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.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + 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.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + 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.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + 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.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/binding-android-arm64@1.0.1': + resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.1': + resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.1': + resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.1': + resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.1': + resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.1': + resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.1': + resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.1': + resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.1': + resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.1': + resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + 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.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.100.11': + resolution: {integrity: sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==} + + '@tanstack/react-query@5.100.11': + resolution: {integrity: sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==} + peerDependencies: + react: ^18 || ^19 + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@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.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@typescript-eslint/eslint-plugin@8.59.4': + resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.4 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.4': + resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.4': + resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.4': + resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.4': + resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.4': + resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.4': + resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.4': + resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/coverage-v8@4.1.6': + resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} + peerDependencies: + '@vitest/browser': 4.1.6 + vitest: 4.1.6 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.31: + resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + colorjs.io@0.6.1: + resolution: {integrity: sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + 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'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + 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==} + + 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@6.0.0: + resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + + electron-to-chromium@1.5.358: + resolution: {integrity: sha512-EO7tKm3QxRqTs1lSuPXzl6yRAwznehp0AH9OoMOIC+4mQzTFday8FJCO5KU6J/TFSQXEOahNq4vTKpz1jmCVOA==} + + enhanced-resolve@5.21.4: + resolution: {integrity: sha512-wE4fDO8OjJhrPFH69HUQStq5oKvGRTNXEyW+k5C/pUQLASSsTu7obd2V3GvCDgPcY9AWjhJ4jz9Kh7iRvrxhJg==} + engines: {node: '>=10.13.0'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.2: + resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.4.0: + resolution: {integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + 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'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.16.0: + resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.44: + resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + 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'} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.8.0: + resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react-hook-form@7.76.0: + resolution: {integrity: sha512-eKtLGgFeSgkHqQD8J59AMZ9a4uD1D83iSIzt4YlTGD7liDen5rrjcUO1rVIGd9yC1gofryjtHbv+4ny4hkLWlw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + 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.15.1: + resolution: {integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.15.1: + resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==} + 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.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rolldown@1.0.1: + resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + 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 + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + size-sensor@1.0.3: + resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==} + + 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@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+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@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.59.4: + resolution: {integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + 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@8.0.13: + resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zrender@6.0.0: + resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} + + zustand@5.0.13: + resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@dnd-kit/accessibility@3.1.1(react@19.2.6)': + dependencies: + react: 19.2.6 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.6)': + dependencies: + react: 19.2.6 + tslib: 2.8.1 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0(jiti@2.7.0))': + dependencies: + eslint: 10.4.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.4.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.4.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@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.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@floating-ui/utils@0.2.11': {} + + '@hookform/resolvers@5.2.2(react-hook-form@7.76.0(react@19.2.6))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.76.0(react@19.2.6) + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@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 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.130.0': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/rect': 1.1.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/binding-android-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-x64@1.0.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.1': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.4 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 8.0.13(@types/node@24.12.4)(jiti@2.7.0) + + '@tanstack/query-core@5.100.11': {} + + '@tanstack/react-query@5.100.11(react@19.2.6)': + dependencies: + '@tanstack/query-core': 5.100.11 + react: 19.2.6 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 10.4.0(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + eslint: 10.4.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.4(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.4.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.4': {} + + '@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.4(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.0 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + eslint: 10.4.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.13(@types/node@24.12.4)(jiti@2.7.0) + + '@vitest/coverage-v8@4.1.6(vitest@4.1.6)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.6 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) + + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.13(@types/node@24.12.4)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.31: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.31 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.358 + node-releases: 2.0.44 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + caniuse-lite@1.0.30001793: {} + + chai@6.2.2: {} + + clsx@2.1.1: {} + + colorjs.io@0.6.1: {} + + commander@7.2.0: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.6): + dependencies: + echarts: 6.0.0 + fast-deep-equal: 3.1.3 + react: 19.2.6 + size-sensor: 1.0.3 + + echarts@6.0.0: + dependencies: + tslib: 2.3.0 + zrender: 6.0.0 + + electron-to-chromium@1.5.358: {} + + enhanced-resolve@5.21.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + es-module-lexer@2.1.0: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.4.0(jiti@2.7.0)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + eslint: 10.4.0(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.2(eslint@10.4.0(jiti@2.7.0)): + dependencies: + eslint: 10.4.0(jiti@2.7.0) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.4.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-nonce@1.0.1: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.6.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-escaper@2.0.2: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + internmap@2.0.3: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jiti@2.7.0: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + 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 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.16.0(react@19.2.6): + dependencies: + react: 19.2.6 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.0 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.44: {} + + obug@2.1.1: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-tailwindcss@0.8.0(prettier@3.8.3): + dependencies: + prettier: 3.8.3 + + prettier@3.8.3: {} + + punycode@2.3.1: {} + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-hook-form@7.76.0(react@19.2.6): + dependencies: + react: 19.2.6 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.6) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + + react-router-dom@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-router: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + + react-router@7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + cookie: 1.1.1 + react: 19.2.6 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + get-nonce: 1.0.1 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react@19.2.6: {} + + robust-predicates@3.0.3: {} + + rolldown@1.0.1: + dependencies: + '@oxc-project/types': 0.130.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.1 + '@rolldown/binding-darwin-arm64': 1.0.1 + '@rolldown/binding-darwin-x64': 1.0.1 + '@rolldown/binding-freebsd-x64': 1.0.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.1 + '@rolldown/binding-linux-arm64-gnu': 1.0.1 + '@rolldown/binding-linux-arm64-musl': 1.0.1 + '@rolldown/binding-linux-ppc64-gnu': 1.0.1 + '@rolldown/binding-linux-s390x-gnu': 1.0.1 + '@rolldown/binding-linux-x64-gnu': 1.0.1 + '@rolldown/binding-linux-x64-musl': 1.0.1 + '@rolldown/binding-openharmony-arm64': 1.0.1 + '@rolldown/binding-wasm32-wasi': 1.0.1 + '@rolldown/binding-win32-arm64-msvc': 1.0.1 + '@rolldown/binding-win32-x64-msvc': 1.0.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.0: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + size-sensor@1.0.3: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.3.0: {} + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.4.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.4 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@4.1.6(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.13(@types/node@24.12.4)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.4 + '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + + zrender@6.0.0: + dependencies: + tslib: 2.3.0 + + zustand@5.0.13(@types/react@19.2.14)(react@19.2.6): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.6 diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..0cf2999 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,6 @@ +import AppLayout from '@/layouts/AppLayout' + +export default function App() { + return +} + diff --git a/frontend/src/components/AuthGuard.tsx b/frontend/src/components/AuthGuard.tsx new file mode 100644 index 0000000..ea19f70 --- /dev/null +++ b/frontend/src/components/AuthGuard.tsx @@ -0,0 +1,8 @@ +import { Navigate, Outlet } from 'react-router-dom' +import { useAuthStore } from '@/stores/authStore' + +export default function AuthGuard() { + const token = useAuthStore(s => s.token) + if (!token) return + return +} diff --git a/frontend/src/components/ColorRecommendPanel.tsx b/frontend/src/components/ColorRecommendPanel.tsx new file mode 100644 index 0000000..4f41cdf --- /dev/null +++ b/frontend/src/components/ColorRecommendPanel.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react' +import * as Dialog from '@radix-ui/react-dialog' +import { Sparkles, X, Save } from 'lucide-react' +import type { LABColor } from '@/lib/color/types' +import { labToHex } from '@/lib/color/convert' +import { apiFetch } from '@/lib/api' + +interface ColorRecommendPanelProps { + currentLab: LABColor + targetLab: LABColor | null +} + +export default function ColorRecommendPanel({ currentLab, targetLab }: ColorRecommendPanelProps) { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [recommendations, setRecommendations] = useState>>([]) + const [matchedFormulas, setMatchedFormulas] = useState>>([]) + const [selectedIndex, setSelectedIndex] = useState(null) + const [saving, setSaving] = useState(false) + + const fetchRecommend = async () => { + setLoading(true) + try { + const lab = targetLab ?? currentLab + const json = await apiFetch<{ recommendations: Array>; matchedFormulas: Array> }>( + '/api/color/recommend', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetLab: lab }) }, + ) + setRecommendations(json?.recommendations ?? []) + setMatchedFormulas(json?.matchedFormulas ?? []) + } catch { } + finally { setLoading(false) } + } + + const handleSave = async () => { + if (selectedIndex === null) return + setSaving(true) + try { + const target = targetLab ?? currentLab + await apiFetch('/api/color/recommend', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetLab: target }) }) + setOpen(false) + } catch { } + finally { setSaving(false) } + } + + const selectedColor = selectedIndex !== null ? (recommendations[selectedIndex]?.predictedLab as { L: number; a: number; b: number }) : null + + return ( + <> + + + + + + + AI 配色推荐 + + + + + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) : ( + <> +
+ 目标色:{targetLab ? `Lab(${targetLab.L.toFixed(0)},${targetLab.a.toFixed(0)},${targetLab.b.toFixed(0)})` : '当前色'} +
+ + {recommendations.length > 0 ? ( +
+ {recommendations.map((rec, i) => { + const colorants = rec.colorants as Array<{ name: string; ratio: number }> | undefined + const predictedDeltaE = rec.predictedDeltaE as number | undefined + const predictedLab = rec.predictedLab as { L: number; a: number; b: number } | undefined + const hex = predictedLab ? (() => { try { return labToHex(predictedLab.L, predictedLab.a, predictedLab.b) } catch { return '#ccc' } })() : '#ccc' + + return ( + + ) + })} +
+ ) : matchedFormulas.length > 0 ? ( +
+

AI 未返回推荐,以下是历史匹配配方:

+ {matchedFormulas.map((f, i) => ( +
+ {f.name as string} + ΔE {(f.deltaE as number)?.toFixed(2)} +
+ ))} +
+ ) : ( +

暂无推荐结果

+ )} + +
+

皮肤预览

+
+
+ +
+ + + + +
+ + )} + + + + + ) +} diff --git a/frontend/src/components/ColorWheel.tsx b/frontend/src/components/ColorWheel.tsx new file mode 100644 index 0000000..451c233 --- /dev/null +++ b/frontend/src/components/ColorWheel.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef, useCallback } from 'react' +import { rgbToLab, labToRGB } from '@/lib/color/convert' + +interface ColorWheelProps { + size?: number + onColorChange?: (lab: { L: number; a: number; b: number }) => void + selectedLab?: { L: number; a: number; b: number } | null +} + +function hsvToRgb(h: number, s: number, v: number): [number, number, number] { + const i = Math.floor(h * 6) + const f = h * 6 - i + const p = v * (1 - s) + const q = v * (1 - f * s) + const t = v * (1 - (1 - f) * s) + switch (i % 6) { + case 0: return [v, t, p] + case 1: return [q, v, p] + case 2: return [p, v, t] + case 3: return [p, q, v] + case 4: return [t, p, v] + case 5: return [v, p, q] + } + return [0, 0, 0] +} + +export default function ColorWheel({ size = 400, onColorChange, selectedLab }: ColorWheelProps) { + const canvasRef = useRef(null) + const selectPosRef = useRef<{ x: number; y: number } | null>(null) + + const render = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + + let ctx = canvas.getContext('2d', { colorSpace: 'display-p3' }) + if (!ctx) ctx = canvas.getContext('2d') + if (!ctx) return + + const dpr = window.devicePixelRatio || 1 + canvas.width = size * dpr + canvas.height = size * dpr + canvas.style.width = `${size}px` + canvas.style.height = `${size}px` + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + const cx = size / 2 + const cy = size / 2 + const outerR = size / 2 - 4 + const innerR = outerR * 0.25 + const imageData = ctx.createImageData(size, size) + + for (let py = 0; py < size; py++) { + for (let px = 0; px < size; px++) { + const dx = px - cx + const dy = py - cy + const dist = Math.sqrt(dx * dx + dy * dy) + const idx = (py * size + px) * 4 + + if (dist >= innerR && dist <= outerR) { + const angle = (Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI) + const sat = (dist - innerR) / (outerR - innerR) + const [r, g, b] = hsvToRgb(angle, sat, 1) + imageData.data[idx] = Math.round(r * 255) + imageData.data[idx + 1] = Math.round(g * 255) + imageData.data[idx + 2] = Math.round(b * 255) + imageData.data[idx + 3] = 255 + } + } + } + + ctx.putImageData(imageData, 0, 0) + + const pos = selectPosRef.current + if (pos) { + ctx.beginPath() + ctx.arc(pos.x, pos.y, 6, 0, Math.PI * 2) + ctx.fillStyle = 'rgba(255,255,255,0.9)' + ctx.fill() + ctx.strokeStyle = '#374151' + ctx.lineWidth = 2 + ctx.stroke() + } + }, [size]) + + useEffect(() => { render() }, [render]) + + const handleClick = useCallback((e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + const cx = size / 2 + const cy = size / 2 + const dx = x - cx + const dy = y - cy + const dist = Math.sqrt(dx * dx + dy * dy) + const outerR = size / 2 - 4 + const innerR = outerR * 0.25 + + if (dist < innerR || dist > outerR) return + + const angle = (Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI) + const sat = (dist - innerR) / (outerR - innerR) + const [r, g, b] = hsvToRgb(angle, sat, 1) + const lab = rgbToLab(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)) + + selectPosRef.current = { x, y } + render() + onColorChange?.(lab) + }, [size, onColorChange, render]) + + useEffect(() => { + if (selectedLab) { + const rgb = labToRGB(selectedLab.L, selectedLab.a, selectedLab.b) + const hsv = (() => { + const r = rgb.r / 255, g = rgb.g / 255, b = rgb.b / 255 + const max = Math.max(r, g, b), min = Math.min(r, g, b) + const d = max - min + let h = 0 + if (d === 0) h = 0 + else if (max === r) h = ((g - b) / d) % 6 + else if (max === g) h = (b - r) / d + 2 + else h = (r - g) / d + 4 + h = h / 6 + if (h < 0) h += 1 + const s = max === 0 ? 0 : d / max + return { h, s } + })() + + const outerR = size / 2 - 4 + const innerR = outerR * 0.25 + const dist = innerR + hsv.s * (outerR - innerR) + const ang = hsv.h * 2 * Math.PI - Math.PI + selectPosRef.current = { + x: size / 2 + Math.cos(ang) * dist, + y: size / 2 + Math.sin(ang) * dist, + } + } + render() + }, [selectedLab, size, render]) + + return ( + + ) +} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..9697ae2 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,36 @@ +import { Component, type ReactNode } from 'react' +import { Link } from 'react-router-dom' + +interface Props { children: ReactNode } +interface State { hasError: boolean; error: Error | null } + +export class ErrorBoundary extends Component { + state: State = { hasError: false, error: null } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + render() { + if (this.state.hasError) { + return ( +
+
+

出错了

+

{this.state.error?.message ?? '发生了意外错误'}

+
+ + + 返回首页 + +
+
+
+ ) + } + return this.props.children + } +} diff --git a/frontend/src/components/EyedropperPanel.tsx b/frontend/src/components/EyedropperPanel.tsx new file mode 100644 index 0000000..12961a2 --- /dev/null +++ b/frontend/src/components/EyedropperPanel.tsx @@ -0,0 +1,169 @@ +import { useState, useRef, useCallback, useEffect } from 'react' +import { rgbToLab } from '@/lib/color/convert' +import type { LABColor } from '@/lib/color/types' +import { Pipette, Upload } from 'lucide-react' + +interface EyedropperPanelProps { + onColorPick: (lab: LABColor) => void +} + +function avgColor(data: Uint8ClampedArray): [number, number, number] { + return [Math.round(data[0]!), Math.round(data[1]!), Math.round(data[2]!)] +} + +export default function EyedropperPanel({ onColorPick }: EyedropperPanelProps) { + const [image, setImage] = useState(null) + const [scale, setScale] = useState(1) + const [offset, setOffset] = useState({ x: 0, y: 0 }) + const [dragging, setDragging] = useState(false) + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) + const canvasRef = useRef(null) + const [history, setHistory] = useState(() => { + try { + const raw = JSON.parse(localStorage.getItem('eyedropper-history') ?? '[]') as unknown[] + return raw.filter((h): h is LABColor => + typeof h === 'object' && h !== null && + typeof (h as LABColor).L === 'number' && + typeof (h as LABColor).a === 'number' && + typeof (h as LABColor).b === 'number' + ) + } + catch { return [] } + }) + + const renderImage = useCallback(() => { + const canvas = canvasRef.current + if (!canvas || !image) return + const ctx = canvas.getContext('2d') + if (!ctx) return + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + ctx.drawImage(image, 0, 0) + }, [image]) + + useEffect(() => { if (image) renderImage() }, [image, renderImage]) + + const addHistory = (lab: LABColor) => { + const updated = [lab, ...history.filter(h => h.L !== lab.L || h.a !== lab.a || h.b !== lab.b)].slice(0, 10) + setHistory(updated) + localStorage.setItem('eyedropper-history', JSON.stringify(updated)) + } + + const handleFileUpload = (file: File) => { + if (!file.type.startsWith('image/')) return + const img = new Image() + img.onload = () => { setImage(img); setScale(1); setOffset({ x: 0, y: 0 }) } + img.src = URL.createObjectURL(file) + } + + const handleCanvasClick = (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + const x = (e.clientX - rect.left) / scale + const y = (e.clientY - rect.top) / scale + + const ctx = canvas.getContext('2d') + if (!ctx) return + + const w = Math.min(3, canvas.width - Math.round(x)) + const h = Math.min(3, canvas.height - Math.round(y)) + const imgData = ctx.getImageData(Math.round(x) - 1, Math.round(y) - 1, w, h) + let r = 0, g = 0, b = 0, count = 0 + for (let i = 0; i < imgData.data.length; i += 4) { + r += imgData.data[i]!; g += imgData.data[i + 1]!; b += imgData.data[i + 2]!; count++ + } + const [avgR, avgG, avgB] = avgColor(new Uint8ClampedArray([Math.round(r / count), Math.round(g / count), Math.round(b / count)])) + + try { + const lab = rgbToLab(avgR, avgG, avgB) + onColorPick(lab) + addHistory(lab) + } catch { } + } + + const handleEyedropper = async () => { + try { + const dropper = new (window as unknown as { EyeDropper: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper() + const result = await dropper.open() + const hex = result.sRGBHex + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + const lab = rgbToLab(r, g, b) + onColorPick(lab) + addHistory(lab) + } catch { } + } + + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault() + setScale(s => Math.min(3, Math.max(0.5, s - e.deltaY * 0.001))) + } + + const handleMouseDown = (e: React.MouseEvent) => { + if (scale <= 1) return + setDragging(true) + setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y }) + } + + const handleMouseMove = (e: React.MouseEvent) => { + if (!dragging) return + setOffset({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }) + } + + const handleMouseUp = () => setDragging(false) + + return ( +
+
+ + +
+ + {image && ( +
1 ? '400px' : 'auto' }}> + +
+ )} + + {history.length > 0 && ( +
+ 取色历史: + {history.map((lab, i) => { + if (typeof lab.L !== 'number' || typeof lab.a !== 'number' || typeof lab.b !== 'number') return null + const r = Math.min(255, Math.max(0, Math.round(lab.L * 2.55))) + const g = Math.min(255, Math.max(0, Math.round((lab.a + 128) * 0.8))) + const b = Math.min(255, Math.max(0, Math.round((lab.b + 128) * 0.8))) + const color = `rgb(${Math.min(255, r)},${Math.min(255, g)},${Math.min(255, b)})` + return ( +
+ )} +
+ ) +} diff --git a/frontend/src/components/FormulaVisualEditor.tsx b/frontend/src/components/FormulaVisualEditor.tsx new file mode 100644 index 0000000..fe1b736 --- /dev/null +++ b/frontend/src/components/FormulaVisualEditor.tsx @@ -0,0 +1,304 @@ +import { useState, useMemo, useRef } from 'react' +import ReactECharts from 'echarts-for-react' +import { Minus, Plus, Save, Sparkles, Loader2 } from 'lucide-react' +import { useAIPredict } from '@/hooks/useAIPredict' + +const COLORS = ['#0ea5e9','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899','#06b6d4','#84cc16','#f97316','#6366f1','#14b8a6','#e11d48'] + +interface IngredientData { + ingredientId: string + inciName: string + chineseName: string + percentage: number + ingredient?: { inciName: string; chineseName: string } +} + +interface PhaseData { + name: string + ingredients: IngredientData[] +} + +interface Props { + phases: PhaseData[] + onSave: (phases: Array<{ name: string; ingredients: Array<{ ingredientId: string; percentage: number }> }>) => Promise +} + +export default function FormulaVisualEditor({ phases: initialPhases, onSave }: Props) { + const [phases, setPhases] = useState(() => + initialPhases.map(p => ({ + ...p, + ingredients: p.ingredients.map(i => ({ + ...i, + percentage: Number(i.percentage), + ingredient: i.ingredient, + })), + })) + ) + const [saving, setSaving] = useState(false) + const editorRef = useRef(null) + + const allIngredients = useMemo(() => + phases.flatMap(p => p.ingredients.map(i => ({ + ...i, + phaseName: p.name, + inciName: i.ingredient?.inciName ?? i.inciName, + chineseName: i.ingredient?.chineseName ?? i.chineseName, + }))) + , [phases]) + + const total = useMemo(() => allIngredients.reduce((s, i) => s + i.percentage, 0), [allIngredients]) + const isValid = total >= 99.5 && total <= 100.5 + + const adjustPct = (idx: number, delta: number) => { + setPhases(prev => { + const flat: { phaseIdx: number; ingIdx: number; ing: IngredientData }[] = [] + prev.forEach((p, pi) => p.ingredients.forEach((ing, ii) => flat.push({ phaseIdx: pi, ingIdx: ii, ing }))) + if (idx >= flat.length) return prev + + const next = prev.map(p => ({ ...p, ingredients: p.ingredients.map(i => ({ ...i })) })) + const target = flat[idx]! + const current = next[target.phaseIdx]!.ingredients[target.ingIdx]! + const newPct = Math.max(0.01, Math.min(100, current.percentage + delta)) + current.percentage = Math.round(newPct * 100) / 100 + return next + }) + } + + const setPct = (idx: number, value: number) => { + setPhases(prev => { + const flat: { phaseIdx: number; ingIdx: number }[] = [] + prev.forEach((p, pi) => p.ingredients.forEach((_, ii) => flat.push({ phaseIdx: pi, ingIdx: ii }))) + if (idx >= flat.length) return prev + + const next = prev.map(p => ({ ...p, ingredients: p.ingredients.map(i => ({ ...i })) })) + const target = flat[idx]! + next[target.phaseIdx]!.ingredients[target.ingIdx]!.percentage = Math.round(value * 100) / 100 + return next + }) + } + + const normalize = () => { + if (total <= 0) return + setPhases(prev => + prev.map(p => ({ + ...p, + ingredients: p.ingredients.map(i => ({ + ...i, + percentage: Math.round((i.percentage / total) * 100 * 100) / 100, + })), + })) + ) + } + + const chartOption = useMemo(() => ({ + tooltip: { trigger: 'item', formatter: '{b}: {c}%' }, + legend: { bottom: 0, textStyle: { fontSize: 11 } }, + series: [{ + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 }, + label: { show: true, formatter: '{b}\n{c}%', fontSize: 11 }, + data: allIngredients.map((ing, i) => ({ + name: ing.inciName.length > 12 ? ing.inciName.slice(0, 12) + '…' : ing.inciName, + value: ing.percentage, + itemStyle: { color: COLORS[i % COLORS.length] }, + })), + }], + }), [allIngredients]) + + const handleSave = async () => { + setSaving(true) + try { + const payload = phases.map(p => ({ + name: p.name, + ingredients: p.ingredients.map(i => ({ + ingredientId: i.ingredientId, + percentage: i.percentage, + })), + })) + await onSave(payload) + } finally { setSaving(false) } + } + + const { prediction, loading: predicting, predict } = useAIPredict() + + const handlePredict = () => { + predict(allIngredients.map(i => ({ + name: i.inciName, + percentage: i.percentage, + category: '', + }))) + } + + const hasPrediction = prediction !== null + + return ( +
+
+ +
+ +
+
+
+
+ 总比例 + + {total.toFixed(2)}% + +
+ {!isValid && total > 0 && ( + + )} +
+ + {isValid && ( +
+
+
+ )} +
+ +
+ +
+ + {hasPrediction && ( +
+
+
肤感指数
+
{prediction!.sensoryIndex.overall}
+
+
+
稳定性
+
{prediction!.stabilityScore}
+
+
+
成本估算
+
¥{prediction!.costEstimate}
+
+
+ )} + + {hasPrediction && ( +
+
+

肤感子维度

+ +
+
+
+

稳定性评分

+ +
+
+

配方结构

+
+ {allIngredients.map((ing, i) => ( +
+ {ing.inciName} {ing.percentage.toFixed(1)}% +
+ ))} +
+
+
+
+ )} + +
+ {phases.map((phase, pi) => ( +
+

{phase.name}

+
+ {phase.ingredients.map((ing, ii) => { + const flatIdx = phases.slice(0, pi).reduce((s, p) => s + p.ingredients.length, 0) + ii + return ( +
+
+ {ing.ingredient?.inciName ?? ing.inciName} + {ing.ingredient?.chineseName ?? ing.chineseName} +
+ + setPct(flatIdx, parseFloat(e.target.value) || 0)} + className="w-20 rounded border px-2 py-0.5 text-right text-sm focus:border-blue-500 focus:outline-none" /> + % + +
+ ) + })} +
+
+ ))} +
+ + +
+
+ ) +} diff --git a/frontend/src/hooks/useAIPredict.ts b/frontend/src/hooks/useAIPredict.ts new file mode 100644 index 0000000..6390f8b --- /dev/null +++ b/frontend/src/hooks/useAIPredict.ts @@ -0,0 +1,77 @@ +import { useState, useCallback, useRef } from 'react' + +interface PredictionResult { + sensoryIndex: { + spreadability: number; absorption: number; stickiness: number; overall: number + } + stabilityScore: number + costEstimate: number + confidence: number + reasoning?: string +} + +interface IngredientInput { + name: string; percentage: number; category: string +} + +export function useAIPredict() { + const [prediction, setPrediction] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const abortRef = useRef(null) + + const predict = useCallback(async (ingredients: IngredientInput[]) => { + if (abortRef.current) abortRef.current.abort() + const controller = new AbortController() + abortRef.current = controller + + setLoading(true) + setError(null) + + try { + const res = await fetch('/api/ai/predict-formula', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ingredients }), + signal: controller.signal, + }) + + if (!res.ok) throw new Error('预测失败') + + const reader = res.body?.getReader() + if (!reader) throw new Error('No stream') + + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = JSON.parse(line.slice(6)) as { type: string; content: string } + if (data.type === 'result') { + const parsed = JSON.parse(data.content) as PredictionResult + setPrediction(parsed) + } else if (data.type === 'error') { + setError('AI 预测失败') + } + } + } + } + } catch (err) { + if ((err as Error).name !== 'AbortError') { + setError('预测请求失败') + } + } finally { + setLoading(false) + } + }, []) + + return { prediction, loading, error, predict } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx new file mode 100644 index 0000000..50d86ad --- /dev/null +++ b/frontend/src/layouts/AppLayout.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom' +import { + LayoutDashboard, FlaskConical, Palette, Compass, Leaf, FolderKanban, Settings, + ChevronLeft, ChevronRight, Sun, Moon, Menu, Search, +} from 'lucide-react' +import { useThemeStore } from '@/stores/themeStore' +import { useAuthStore } from '@/stores/authStore' +import clsx from 'clsx' +import { LogOut } from 'lucide-react' + +const menuItems = [ + { to: '/', icon: LayoutDashboard, label: '仪表盘' }, + { to: '/formulas', icon: FlaskConical, label: '配方记录' }, + { to: '/color-lab', icon: Palette, label: '颜色引擎' }, + { to: '/formula-explorer', icon: Compass, label: '配方推演' }, + { to: '/ingredients', icon: Leaf, label: '成分目录' }, + { to: '/projects', icon: FolderKanban, label: '项目管理' }, + { to: '/settings', icon: Settings, label: '设置' }, +] + +export default function AppLayout() { + const [collapsed, setCollapsed] = useState(false) + const [mobileOpen, setMobileOpen] = useState(false) + const { theme, toggleTheme } = useThemeStore() + const user = useAuthStore(s => s.user) + const logout = useAuthStore(s => s.logout) + const location = useLocation() + const navigate = useNavigate() + const [searchQuery, setSearchQuery] = useState('') + + const handleSearch = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && searchQuery.trim()) { + navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`) + } + } + + return ( +
+ {mobileOpen && ( +
setMobileOpen(false)} + /> + )} + + + +
+
+ +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={handleSearch} + className="w-full rounded-lg border border-gray-200 py-1.5 pl-8 pr-3 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800" + /> +
+
+
+
+ {user && {user.username}} + +
+
+ +
+ +
+
+
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..02077e7 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,32 @@ +export class ApiError extends Error { + status: number + constructor(status: number, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +export async function apiFetch(url: string, options?: RequestInit): Promise { + const res = await fetch(url, options) + if (res.status === 204) return undefined as T + + const text = await res.text() + if (!text) { + if (!res.ok) throw new ApiError(res.status, `请求失败 (${res.status})`) + return undefined as T + } + + try { + const data = JSON.parse(text) as T + if (!res.ok) { + const msg = (data as Record)?.error as string ?? `请求失败 (${res.status})` + throw new ApiError(res.status, msg) + } + return data + } catch (e) { + if (e instanceof ApiError) throw e + if (!res.ok) throw new ApiError(res.status, `请求失败 (${res.status})`) + throw new ApiError(res.status, '响应格式错误') + } +} diff --git a/frontend/src/lib/color/color.test.ts b/frontend/src/lib/color/color.test.ts new file mode 100644 index 0000000..397de48 --- /dev/null +++ b/frontend/src/lib/color/color.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest' +import { + hexToLab, + labToHex, + rgbToLab, + labToRGB, + labToLCH, + lchToLab, + displayP3ToLab, + labToDisplayP3, +} from './convert' +import { deltaE2000, deltaE76, deltaECMC } from './deltaE' + +describe('色空间转换', () => { + describe('hexToLab / labToHex', () => { + it('白色 #FFFFFF → Lab(100, 0, 0)', () => { + const lab = hexToLab('#FFFFFF') + expect(lab.L).toBeCloseTo(100, 0) + expect(Math.abs(lab.a)).toBeLessThan(0.1) + expect(Math.abs(lab.b)).toBeLessThan(0.1) + }) + + it('黑色 #000000 → Lab(0, 0, 0)', () => { + const lab = hexToLab('#000000') + expect(lab.L).toBeCloseTo(0, 0) + }) + + it('纯红 #FF0000 → Lab 值合理', () => { + const lab = hexToLab('#FF0000') + expect(lab.L).toBeGreaterThan(40) + expect(lab.L).toBeLessThan(60) + expect(lab.a).toBeGreaterThan(30) + }) + + it('Lab → hex 往返转换:白色', () => { + const hex = labToHex(100, 0, 0) + expect(hex.toLowerCase()).toBe('#ffffff') + }) + }) + + describe('rgbToLab / labToRGB', () => { + it('RGB(255, 255, 255) → Lab(100, 0, 0) 附近', () => { + const lab = rgbToLab(255, 255, 255) + expect(lab.L).toBeCloseTo(100, 0) + }) + + it('RGB 往返转换 |ΔL| < 0.1', () => { + const original: [number, number, number] = [128, 64, 192] + const lab = rgbToLab(...original) + const rgb = labToRGB(lab.L, lab.a, lab.b) + const lab2 = rgbToLab(rgb.r, rgb.g, rgb.b) + expect(Math.abs(lab.L - lab2.L)).toBeLessThan(2) + }) + }) + + describe('labToLCH / lchToLab', () => { + it('Lab(50, 0, 0) → LCH(L=50, C≈0)', () => { + const lch = labToLCH(50, 0, 0) + expect(lch.L).toBeCloseTo(50, 0) + expect(lch.C).toBeLessThan(1) + }) + + it('LCH 往返转换误差 < 0.01', () => { + const lab: [number, number, number] = [60, 20, -15] + const lch = labToLCH(...lab) + const lab2 = lchToLab(lch.L, lch.C, lch.h) + expect(Math.abs(lab[0] - lab2.L)).toBeLessThan(0.01) + expect(Math.abs(lab[1] - lab2.a)).toBeLessThan(0.01) + expect(Math.abs(lab[2] - lab2.b)).toBeLessThan(0.01) + }) + }) + + describe('Display P3 转换', () => { + it('P3(0, 0, 0) → Lab 接近黑色', () => { + const lab = displayP3ToLab(0, 0, 0) + expect(lab.L).toBeCloseTo(0, 0) + }) + + it('P3(1, 0, 0) → Lab 红色区域', () => { + const lab = displayP3ToLab(1, 0, 0) + expect(lab.L).toBeGreaterThan(40) + expect(lab.a).toBeGreaterThan(30) + }) + + it('Lab → P3 → Lab 往返', () => { + const original: [number, number, number] = [50, 30, -20] + const p3 = labToDisplayP3(...original) + const lab = displayP3ToLab(p3.r, p3.g, p3.b) + expect(Math.abs(original[0] - lab.L)).toBeLessThan(0.1) + }) + }) +}) + +describe('色差计算', () => { + it('相同颜色的 ΔE = 0', () => { + const lab = { L: 50, a: 10, b: -10 } + expect(deltaE2000(lab, lab)).toBe(0) + expect(deltaE76(lab, lab)).toBe(0) + expect(deltaECMC(lab, lab)).toBe(0) + }) + + it('纯红 vs 纯绿的 ΔE > 10', () => { + const red = hexToLab('#FF0000') + const green = hexToLab('#00FF00') + expect(deltaE2000(red, green)).toBeGreaterThan(10) + }) + + it('白色 vs 黑色的 ΔE 较大', () => { + const white = hexToLab('#FFFFFF') + const black = hexToLab('#000000') + const d = deltaE2000(white, black) + expect(d).toBeGreaterThan(50) + }) + + it('CIEDE2000 对接近颜色的敏感度', () => { + const color1 = hexToLab('#FF0000') + const color2 = hexToLab('#FE0000') + const d = deltaE2000(color1, color2) + expect(d).toBeGreaterThan(0) + expect(d).toBeLessThan(5) + }) + + it('ΔE76 ≥ ΔE2000 对接近白颜色(CIEDE2000 更精确)', () => { + const nearWhite1 = hexToLab('#FEFEFE') + const nearWhite2 = hexToLab('#FDFDFD') + const d76 = deltaE76(nearWhite1, nearWhite2) + const d2000 = deltaE2000(nearWhite1, nearWhite2) + expect(d2000).toBeGreaterThanOrEqual(0) + expect(d76).toBeGreaterThanOrEqual(0) + }) +}) diff --git a/frontend/src/lib/color/convert.ts b/frontend/src/lib/color/convert.ts new file mode 100644 index 0000000..9515f55 --- /dev/null +++ b/frontend/src/lib/color/convert.ts @@ -0,0 +1,62 @@ +import Color from 'colorjs.io' +import type { LABColor, RGBColor, LCHColor, DisplayP3Color } from './types' + +function getLab(color: Color): [number, number, number] { + const lab = color.lab + return [lab[0]!, lab[1]!, lab[2]!] +} + +function getSrgb(color: Color): [number, number, number] { + const srgb = color.srgb + return [srgb[0]!, srgb[1]!, srgb[2]!] +} + +function getLch(color: Color): [number, number, number] { + const lch = color.lch + return [lch[0]!, lch[1]!, lch[2]!] +} + +function getP3(color: Color): [number, number, number] { + const p3 = color.p3 + return [p3[0]!, p3[1]!, p3[2]!] +} + +export function hexToLab(hex: string): LABColor { + const [L, a, b] = getLab(new Color(hex)) + return { L, a, b } +} + +export function labToHex(L: number, a: number, b: number): string { + const color = new Color('lab', [L, a, b]) + return color.to('srgb').toString({ format: 'hex', collapse: false }) +} + +export function rgbToLab(r: number, g: number, b: number): LABColor { + const [L, aVal, bVal] = getLab(new Color('srgb', [r / 255, g / 255, b / 255])) + return { L, a: aVal, b: bVal } +} + +export function labToRGB(L: number, a: number, b: number): RGBColor { + const [r, g, bVal] = getSrgb(new Color('lab', [L, a, b])) + return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(bVal * 255) } +} + +export function labToLCH(L: number, a: number, b: number): LCHColor { + const [lchL, C, h] = getLch(new Color('lab', [L, a, b])) + return { L: lchL, C, h } +} + +export function lchToLab(L: number, C: number, h: number): LABColor { + const [labL, a, b] = getLab(new Color('lch', [L, C, h])) + return { L: labL, a, b } +} + +export function displayP3ToLab(r: number, g: number, b: number): LABColor { + const [L, a, bVal] = getLab(new Color('p3', [r, g, b])) + return { L, a, b: bVal } +} + +export function labToDisplayP3(L: number, a: number, b: number): DisplayP3Color { + const [r, g, bVal] = getP3(new Color('lab', [L, a, b])) + return { r, g, b: bVal } +} diff --git a/frontend/src/lib/color/deltaE.ts b/frontend/src/lib/color/deltaE.ts new file mode 100644 index 0000000..581263b --- /dev/null +++ b/frontend/src/lib/color/deltaE.ts @@ -0,0 +1,20 @@ +import Color from 'colorjs.io' +import type { LABColor } from './types' + +export function deltaE2000(lab1: LABColor, lab2: LABColor): number { + const color1 = new Color('lab', [lab1.L, lab1.a, lab1.b]) + const color2 = new Color('lab', [lab2.L, lab2.a, lab2.b]) + return color1.deltaE(color2, '2000') +} + +export function deltaECMC(lab1: LABColor, lab2: LABColor, l = 2, c = 1): number { + const color1 = new Color('lab', [lab1.L, lab1.a, lab1.b]) + const color2 = new Color('lab', [lab2.L, lab2.a, lab2.b]) + return color1.deltaE(color2, { method: 'CMC', l, c }) +} + +export function deltaE76(lab1: LABColor, lab2: LABColor): number { + const color1 = new Color('lab', [lab1.L, lab1.a, lab1.b]) + const color2 = new Color('lab', [lab2.L, lab2.a, lab2.b]) + return color1.deltaE(color2, '76') +} diff --git a/frontend/src/lib/color/types.ts b/frontend/src/lib/color/types.ts new file mode 100644 index 0000000..a1f3f9f --- /dev/null +++ b/frontend/src/lib/color/types.ts @@ -0,0 +1,23 @@ +export interface LABColor { + L: number + a: number + b: number +} + +export interface RGBColor { + r: number + g: number + b: number +} + +export interface LCHColor { + L: number + C: number + h: number +} + +export interface DisplayP3Color { + r: number + g: number + b: number +} diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..6fa9931 --- /dev/null +++ b/frontend/src/lib/queryClient.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..aa14b89 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,18 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { RouterProvider } from 'react-router-dom' +import { QueryClientProvider } from '@tanstack/react-query' +import { ErrorBoundary } from './components/ErrorBoundary' +import { router } from './router' +import { queryClient } from './lib/queryClient' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/frontend/src/pages/ColorLabPage.tsx b/frontend/src/pages/ColorLabPage.tsx new file mode 100644 index 0000000..04e5114 --- /dev/null +++ b/frontend/src/pages/ColorLabPage.tsx @@ -0,0 +1,210 @@ +import { useState, useMemo } from 'react' +import ColorWheel from '@/components/ColorWheel' +import { hexToLab, labToHex, labToRGB, rgbToLab } from '@/lib/color/convert' +import { deltaE2000 } from '@/lib/color/deltaE' +import type { LABColor } from '@/lib/color/types' +import { Copy, RotateCcw } from 'lucide-react' +import EyedropperPanel from '@/components/EyedropperPanel' +import ColorRecommendPanel from '@/components/ColorRecommendPanel' + +const PANTONE_MAP: Array<{ code: string; lab: LABColor }> = [ + { code: '185 C', lab: { L: 48, a: 68, b: 48 } }, + { code: '286 C', lab: { L: 30, a: 12, b: -52 } }, + { code: '354 C', lab: { L: 55, a: -52, b: 28 } }, + { code: '109 C', lab: { L: 82, a: 8, b: 95 } }, + { code: '151 C', lab: { L: 65, a: 40, b: 70 } }, + { code: 'Process Black C', lab: { L: 18, a: 1, b: 0 } }, + { code: 'Warm Red C', lab: { L: 48, a: 65, b: 42 } }, + { code: 'Cool Gray 7 C', lab: { L: 58, a: 0, b: -2 } }, +] + +type AdjustMode = 'lab' | 'rgb' + +interface TargetColor { + label: string + lab: LABColor +} + +export default function ColorLabPage() { + const [currentLab, setCurrentLab] = useState({ L: 50, a: 0, b: 0 }) + const [mode, setMode] = useState('lab') + const [inputValue, setInputValue] = useState('') + const [inputFormat, setInputFormat] = useState<'hex' | 'rgb' | 'lab' | 'pantone'>('hex') + const [target, setTarget] = useState(null) + + const hex = useMemo(() => { + try { return labToHex(currentLab.L, currentLab.a, currentLab.b) } + catch { return '#808080' } + }, [currentLab]) + + const rgb = useMemo(() => { + try { return labToRGB(currentLab.L, currentLab.a, currentLab.b) } + catch { return { r: 128, g: 128, b: 128 } } + }, [currentLab]) + + const delta = useMemo(() => { + if (!target) return null + return deltaE2000(currentLab, target.lab) + }, [currentLab, target]) + + const handleSlider = (channel: string, value: number) => { + if (mode === 'lab') { + setCurrentLab(prev => ({ ...prev, [channel]: value })) + } else { + const newRgb = { ...rgb, [channel]: value } + try { setCurrentLab(rgbToLab(newRgb.r, newRgb.g, newRgb.b)) } + catch { } + } + } + + const handleInputSubmit = () => { + if (!inputValue.trim()) return + try { + let lab: LABColor + if (inputFormat === 'hex') { + lab = hexToLab(inputValue.trim()) + setTarget({ label: inputValue.trim(), lab }) + } else if (inputFormat === 'rgb') { + const parts = inputValue.replace(/[rgb()]/g, '').split(/[, ]+/).filter(Boolean).map(Number) + if (parts.length !== 3) return + lab = rgbToLab(parts[0]!, parts[1]!, parts[2]!) + setTarget({ label: `rgb(${parts.join(',')})`, lab }) + } else if (inputFormat === 'lab') { + const parts = inputValue.split(/[, ]+/).filter(Boolean).map(Number) + if (parts.length !== 3) return + lab = { L: parts[0]!, a: parts[1]!, b: parts[2]! } + setTarget({ label: `Lab(${parts.join(',')})`, lab }) + } else { + const match = PANTONE_MAP.find(p => p.code.toLowerCase() === inputValue.trim().toLowerCase()) + if (!match) return + lab = match.lab + setTarget({ label: match.code, lab }) + } + setCurrentLab(lab) + } catch { } + } + + const deltaColor = delta !== null ? (delta <= 1 ? 'text-green-600' : delta <= 3 ? 'text-yellow-600' : 'text-red-600') : '' + const deltaBg = delta !== null ? (delta <= 1 ? 'bg-green-50 border-green-200' : delta <= 3 ? 'bg-yellow-50 border-yellow-200' : 'bg-red-50 border-red-200') : '' + + return ( +
+

颜色引擎

+ +
+
+ +
+ +
+ {target && ( +
+
+
+

{target.label}

+ {delta !== null &&

ΔE = {delta.toFixed(2)}

} +
+
+ )} + +
+ +
+
+ HEX + {hex} + +
+
RGB{rgb.r}, {rgb.g}, {rgb.b}
+
LabL:{currentLab.L.toFixed(1)} a:{currentLab.a.toFixed(1)} b:{currentLab.b.toFixed(1)}
+
+ +
+
+ {(['hex', 'rgb', 'lab', 'pantone'] as const).map(f => ( + + ))} +
+
+ setInputValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleInputSubmit() }} + placeholder={inputFormat === 'pantone' ? '185 C' : inputFormat === 'hex' ? '#FF0000' : (inputFormat === 'rgb' ? '255,0,0' : '50,50,0')} + className="flex-1 rounded-lg border px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none" /> + +
+
+
+ +
+
+ 颜色调节 +
+ {(['lab', 'rgb'] as const).map(m => ( + + ))} + +
+
+ + {mode === 'lab' ? ( + <> + handleSlider('L', v)} + trackStyle="linear-gradient(to right, #000, #fff)" /> + handleSlider('a', v)} + trackStyle="linear-gradient(to right, #00a060, #888, #ff4060)" /> + handleSlider('b', v)} + trackStyle="linear-gradient(to right, #2060ff, #888, #ffe020)" /> + + ) : ( + <> + handleSlider('r', v)} + trackStyle="linear-gradient(to right, #000, #ff0000)" /> + handleSlider('g', v)} + trackStyle="linear-gradient(to right, #000, #00ff00)" /> + handleSlider('b', v)} + trackStyle="linear-gradient(to right, #000, #0000ff)" /> + + )} +
+
+ +
+
+ 取色棒 + +
+ +
+
+ ) +} + +function SliderRow({ label, value, min, max, step, onChange, trackStyle }: { + label: string; value: number; min: number; max: number; step: number + onChange: (v: number) => void; trackStyle: string +}) { + return ( +
+ {label} + onChange(parseFloat(e.target.value))} + className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full" + style={{ background: trackStyle }} /> + {value.toFixed(1)} +
+ ) +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..bed2a6a --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,92 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from 'react-router-dom' +import { FlaskConical, Leaf, FolderKanban } from 'lucide-react' +import { apiFetch } from '@/lib/api' + +interface Stats { + formulaCount: number; ingredientCount: number; projectCount: number +} + +interface RecentFormula { + id: string; name: string; currentVersion: number; updatedAt: string; project: { name: string } | null +} + +async function fetchStats(): Promise { + const [f, i, p] = await Promise.all([ + apiFetch<{ pagination: { total: number } }>('/api/formulas?limit=1'), + apiFetch<{ pagination: { total: number } }>('/api/ingredients?limit=1'), + apiFetch<{ data: unknown[] }>('/api/projects'), + ]) + return { formulaCount: f?.pagination?.total ?? 0, ingredientCount: i?.pagination?.total ?? 0, projectCount: p?.data?.length ?? 0 } +} + +async function fetchRecentFormulas(): Promise { + const res = await apiFetch<{ data: RecentFormula[] }>('/api/formulas?limit=5&sortBy=updatedAt&sortOrder=desc') + return res?.data ?? [] +} + +export default function DashboardPage() { + const { data: stats } = useQuery({ queryKey: ['stats'], queryFn: fetchStats }) + const { data: recentFormulas = [] } = useQuery({ queryKey: ['recent-formulas'], queryFn: fetchRecentFormulas }) + + return ( +
+

仪表盘

+ +
+ +
+
+
+
{stats?.formulaCount ?? '-'}
+
配方总数
+
+
+ + +
+
+
+
{stats?.ingredientCount ?? '-'}
+
成分总数
+
+
+ + +
+
+
+
{stats?.projectCount ?? '-'}
+
项目数量
+
+
+ +
+ +
+
+

最近更新

+ 查看全部 +
+ {recentFormulas.length === 0 ? ( +

暂无配方,点击"新建配方"开始

+ ) : ( +
+ {recentFormulas.map((f) => ( + +
+ {f.name} + {f.project && {f.project.name}} +
+
+ v{f.currentVersion} + {new Date(f.updatedAt).toLocaleDateString('zh-CN')} +
+ + ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/FormulaDetailPage.tsx b/frontend/src/pages/FormulaDetailPage.tsx new file mode 100644 index 0000000..672ec87 --- /dev/null +++ b/frontend/src/pages/FormulaDetailPage.tsx @@ -0,0 +1,100 @@ +import { useParams, Link, useSearchParams } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { ArrowLeft, Pencil, History, Eye, BarChart3 } from 'lucide-react' +import FormulaVisualEditor from '@/components/FormulaVisualEditor' +import { apiFetch } from '@/lib/api' + +interface IngData { + ingredientId: string; inciName: string; chineseName: string; percentage: number + ingredient?: { inciName: string; chineseName: string } +} + +interface PhaseData { + name: string; ingredients: IngData[] +} + +async function fetchFormula(id: string): Promise | undefined> { + const res = await apiFetch<{ data: Record }>(`/api/formulas/${id}`) + return res?.data +} + +export default function FormulaDetailPage() { + const { id } = useParams<{ id: string }>() + const [params, setParams] = useSearchParams() + const tab = params.get('tab') ?? 'detail' + const queryClient = useQueryClient() + + const { data: formula, isLoading } = useQuery({ + queryKey: ['formula', id], queryFn: () => fetchFormula(id!), enabled: !!id, + }) + + const saveMutation = useMutation({ + mutationFn: (phases: Array<{ name: string; ingredients: Array<{ ingredientId: string; percentage: number }> }>) => + apiFetch(`/api/formulas/${id}/composition`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phases }) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['formula', id] }), + }) + + if (isLoading) return
加载中...
+ if (!formula) return
配方不存在
+ + const phases = ((formula.versions as Array>)?.[0]?.phases as PhaseData[]) ?? [] + + return ( +
+
+ +

{String(formula.name)}

+
+ + 版本历史 + + + 编辑 + +
+
+ + {formula.description ?

{String(formula.description)}

: null} +
+ v{Number(formula.currentVersion)} + 更新于 {new Date(String(formula.updatedAt)).toLocaleString('zh-CN')} +
+ +
+ + +
+ + {tab === 'visual' ? ( + { await saveMutation.mutateAsync(p) }} /> + ) : ( +
+ {phases.map((phase, i) => ( +
+
{phase.name}
+
+ {phase.ingredients.map((ing, j) => ( +
+
+ {ing.ingredient?.inciName ?? ing.inciName} + {ing.ingredient?.chineseName ?? ing.chineseName ?? ''} +
+ {Number(ing.percentage).toFixed(2)}% +
+ ))} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/FormulaEditorPage.tsx b/frontend/src/pages/FormulaEditorPage.tsx new file mode 100644 index 0000000..300a6f3 --- /dev/null +++ b/frontend/src/pages/FormulaEditorPage.tsx @@ -0,0 +1,346 @@ +import { useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { useQuery, useMutation } from '@tanstack/react-query' +import * as Accordion from '@radix-ui/react-accordion' +import * as Popover from '@radix-ui/react-popover' +import * as Dialog from '@radix-ui/react-dialog' +import { Plus, Trash2, Search, ChevronDown, X, Sparkles } from 'lucide-react' +import { useForm, useFieldArray } from 'react-hook-form' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { apiFetch } from '@/lib/api' + +interface Ingredient { + id: string; inciName: string; chineseName: string; functionCategory: string; unitPrice: number | null +} + +const schema = z.object({ + name: z.string().min(1, '配方名称为必填项'), + description: z.string().optional(), + phases: z.array(z.object({ + name: z.string().min(1, '相名称为必填项'), + ingredients: z.array(z.object({ + ingredientId: z.string(), + inciName: z.string(), + chineseName: z.string(), + percentage: z.number().min(0.01, '比例须 >0').max(100, '比例须 ≤100'), + })), + })).min(1, '至少需要一个相'), +}) + +type FormData = z.infer + +async function fetchIngredients(search: string): Promise { + const res = await apiFetch<{ data: Ingredient[] }>(`/api/ingredients?search=${encodeURIComponent(search)}&limit=50`) + return res?.data ?? [] +} + +async function fetchFormula(id: string) { + const res = await apiFetch<{ data: Record }>(`/api/formulas/${id}`) + return res?.data +} + +async function createFormula(data: Record) { + return apiFetch('/api/formulas', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) +} + +async function updateFormula(id: string, data: Record) { + return apiFetch(`/api/formulas/${id}/composition`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) +} + +export default function FormulaEditorPage() { + const { id } = useParams<{ id: string }>() + const isEdit = !!id + const navigate = useNavigate() + const [ingredientSearch, setIngredientSearch] = useState('') + const [activePhaseIndex, setActivePhaseIndex] = useState(null) + + const { data: formula } = useQuery({ + queryKey: ['formula', id], + queryFn: () => fetchFormula(id!), + enabled: isEdit, + }) + + const { data: searchResults = [] } = useQuery({ + queryKey: ['ingredient-search', ingredientSearch], + queryFn: () => fetchIngredients(ingredientSearch), + enabled: ingredientSearch.length > 0, + }) + + const formulaData = isEdit && formula ? { + name: String(formula.name ?? ''), + description: String(formula.description ?? ''), + phases: ((formula.versions as Array>)?.[0]?.phases as Array>)?.map((p: Record) => ({ + name: String(p.name ?? ''), + ingredients: (p.ingredients as Array>).map((i: Record) => { + const ing = i.ingredient as Record | undefined + return { + ingredientId: String(ing?.id ?? i.ingredientId ?? ''), + inciName: String(ing?.inciName ?? ''), + chineseName: String(ing?.chineseName ?? ''), + percentage: Number(i.percentage ?? 0), + } + }), + })) ?? [{ name: '', ingredients: [] }], + } : undefined + + const { register, control, handleSubmit, watch, setValue, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '', description: '', phases: [{ name: '', ingredients: [] }] }, + values: formulaData, + }) + + const { fields: phases, append: addPhase, remove: removePhase } = useFieldArray({ control, name: 'phases' }) + const phasesData = watch('phases') + + const totalPercentage = phasesData.reduce((sum: number, p) => + sum + p.ingredients.reduce((s: number, i) => s + (Number(i.percentage) || 0), 0), 0) + const isTotalValid = totalPercentage >= 99.5 && totalPercentage <= 100.5 + + const createMut = useMutation({ mutationFn: createFormula, onSuccess: (d: unknown) => navigate(`/formulas/${(d as { data: { id: string } }).data.id}`) }) + const updateMut = useMutation({ mutationFn: (data: Record) => updateFormula(id!, data), onSuccess: () => navigate(`/formulas/${id}`) }) + + const onSubmit = (data: FormData) => { + const payload = { name: data.name, description: data.description, phases: data.phases.map(p => ({ + name: p.name, + ingredients: p.ingredients.map(i => ({ ingredientId: i.ingredientId, percentage: i.percentage })), + }))} + if (isEdit) updateMut.mutate(payload) + else createMut.mutate(payload) + } + + const addIngredient = (phaseIndex: number, ing: Ingredient) => { + const current = phasesData[phaseIndex]?.ingredients ?? [] + if (current.some(i => i.ingredientId === ing.id)) return + setValue(`phases.${phaseIndex}.ingredients`, [ + ...current, + { ingredientId: ing.id, inciName: ing.inciName, chineseName: ing.chineseName, percentage: 0 }, + ]) + } + + const removeIngredient = (phaseIndex: number, ingIndex: number) => { + const current = [...(phasesData[phaseIndex]?.ingredients ?? [])] + current.splice(ingIndex, 1) + setValue(`phases.${phaseIndex}.ingredients`, current) + } + + const [aiOpen, setAiOpen] = useState(false) + const [aiText, setAiText] = useState('') + const [aiLoading, setAiLoading] = useState(false) + const [aiResult, setAiResult] = useState | null>(null) + + const handleAiExtract = async () => { + setAiLoading(true) + try { + const json = await apiFetch<{ data: Array<{ inciName: string; chineseName: string; percentage: number; phase: string }> }>( + '/api/ai/extract-formula', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: aiText }) }, + ) + setAiResult(json?.data ?? []) + } catch { alert('AI 提取失败,请重试') } + finally { setAiLoading(false) } + } + + const applyAiResult = () => { + if (!aiResult) return + const phaseMap = new Map() + const newPhases = [...phasesData] + + for (const ing of aiResult) { + const phaseName = ing.phase || '默认相' + let idx = phaseMap.get(phaseName) + if (idx === undefined) { + idx = newPhases.length + newPhases.push({ name: phaseName, ingredients: [] }) + phaseMap.set(phaseName, idx) + } + newPhases[idx]!.ingredients.push({ + ingredientId: '', inciName: ing.inciName, chineseName: ing.chineseName, percentage: ing.percentage, + }) + } + + setValue('phases', newPhases) + setAiOpen(false) + setAiText('') + setAiResult(null) + } + + const isPending = createMut.isPending || updateMut.isPending + const canSave = Object.keys(errors).length === 0 && isTotalValid + + return ( +
+

+ {isEdit ? '编辑配方' : '新建配方'} + {!isEdit && ( + + )} +

+ +
+
+ +