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 色彩模块)
This commit is contained in:
204
.scratch/formula-rd-platform/PRD.md
Normal file
204
.scratch/formula-rd-platform/PRD.md
Normal file
@@ -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 的显示器)和校准建议
|
||||
- 考虑将颜色引擎作为可独立部署的微前端模块,未来可嵌入其他系统
|
||||
@@ -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 中)
|
||||
27
.scratch/formula-rd-platform/issues/02-s0b-layout-routing.md
Normal file
27
.scratch/formula-rd-platform/issues/02-s0b-layout-routing.md
Normal file
@@ -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(用户头像/设置入口)+ 内容区 `<Outlet />`
|
||||
- [ ] 侧边栏菜单项:仪表盘、配方记录、颜色引擎、配方推演、成分目录、项目管理、设置(使用 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 实现)
|
||||
30
.scratch/formula-rd-platform/issues/03-s0c-bff-init.md
Normal file
30
.scratch/formula-rd-platform/issues/03-s0c-bff-init.md
Normal file
@@ -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
|
||||
32
.scratch/formula-rd-platform/issues/04-s0d-auth-system.md
Normal file
32
.scratch/formula-rd-platform/issues/04-s0d-auth-system.md
Normal file
@@ -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 <token>`,注入 `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 后续实现
|
||||
29
.scratch/formula-rd-platform/issues/05-s1-db-schema.md
Normal file
29
.scratch/formula-rd-platform/issues/05-s1-db-schema.md
Normal file
@@ -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`,未来可迁移到其他维度
|
||||
27
.scratch/formula-rd-platform/issues/06-s2a-ingredient-api.md
Normal file
27
.scratch/formula-rd-platform/issues/06-s2a-ingredient-api.md
Normal file
@@ -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
|
||||
- 先不实现复杂权限,所有认证用户可查看和搜索,管理员可增删改
|
||||
31
.scratch/formula-rd-platform/issues/07-s2b-ingredient-ui.md
Normal file
31
.scratch/formula-rd-platform/issues/07-s2b-ingredient-ui.md
Normal file
@@ -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 自定义样式(不用第三方表格组件)
|
||||
- 操作按钮:编辑、删除仅管理员可见
|
||||
- 移动端适配:表格在小屏改为卡片列表
|
||||
32
.scratch/formula-rd-platform/issues/08-s3a-formula-api.md
Normal file
32
.scratch/formula-rd-platform/issues/08-s3a-formula-api.md
Normal file
@@ -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) 类型
|
||||
@@ -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 追加
|
||||
36
.scratch/formula-rd-platform/issues/10-s4a-ai-client.md
Normal file
36
.scratch/formula-rd-platform/issues/10-s4a-ai-client.md
Normal file
@@ -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,架构支持后续扩展
|
||||
@@ -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` 库在前端读取后转文本再提交
|
||||
31
.scratch/formula-rd-platform/issues/12-s5-formula-list.md
Normal file
31
.scratch/formula-rd-platform/issues/12-s5-formula-list.md
Normal file
@@ -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 填充)
|
||||
@@ -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 做深度比较
|
||||
@@ -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` 缓存
|
||||
@@ -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` 空间)
|
||||
@@ -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` 组件,可复用
|
||||
@@ -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
|
||||
|
||||
- 滑条组件使用原生 `<input type="range">` + Tailwind 自定义样式
|
||||
- Lab 和 RGB 之间的转换使用 S7 的转换函数
|
||||
- 操作防抖:连续拖动时不触发外部更新(如 AI 推荐),仅在松开时触发
|
||||
@@ -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
|
||||
37
.scratch/formula-rd-platform/issues/19-s10-eyedropper.md
Normal file
37
.scratch/formula-rd-platform/issues/19-s10-eyedropper.md
Normal file
@@ -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 尺寸
|
||||
@@ -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 时可接受全表扫描
|
||||
@@ -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
|
||||
40
.scratch/formula-rd-platform/issues/22-s12-drag-pie-chart.md
Normal file
40
.scratch/formula-rd-platform/issues/22-s12-drag-pie-chart.md
Normal file
@@ -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, ...)
|
||||
@@ -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 返回后替换
|
||||
33
.scratch/formula-rd-platform/issues/24-s14a-radar-chart.md
Normal file
33
.scratch/formula-rd-platform/issues/24-s14a-radar-chart.md
Normal file
@@ -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` 配置
|
||||
- 雷达图放在可视化编辑页的指标面板中(与饼图并排,桌面端左右布局)
|
||||
39
.scratch/formula-rd-platform/issues/25-s14b-gauge-tree.md
Normal file
39
.scratch/formula-rd-platform/issues/25-s14b-gauge-tree.md
Normal file
@@ -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(面积表示比例),更直观
|
||||
@@ -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 中的权重设定
|
||||
@@ -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 个)
|
||||
@@ -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(轻量级)
|
||||
35
.scratch/formula-rd-platform/issues/29-s17-nl-search.md
Normal file
35
.scratch/formula-rd-platform/issues/29-s17-nl-search.md
Normal file
@@ -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
|
||||
- 初始阶段如果配方数据少,搜索可能结果稀疏
|
||||
@@ -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
|
||||
|
||||
- 项目是逻辑分组,不影响配方数据本身
|
||||
- 项目成员权限先不做(当前单用户或简化权限)
|
||||
40
.scratch/formula-rd-platform/issues/31-s19-import-export.md
Normal file
40
.scratch/formula-rd-platform/issues/31-s19-import-export.md
Normal file
@@ -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`)
|
||||
13
AGENTS.md
Normal file
13
AGENTS.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues 作为本地 markdown 文件存放在 `.scratch/<feature-slug>/` 下。详见 `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`。
|
||||
68
CONTEXT.md
Normal file
68
CONTEXT.md
Normal file
@@ -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/<feature-slug>/issues/`
|
||||
|
||||
## 当前 ADRs
|
||||
|
||||
| ADR | 主题 | 状态 |
|
||||
| :--- | :--- | :--- |
|
||||
| [0001](./docs/adr/0001-architecture-stack.md) | 整体技术栈选型 | 已决议 |
|
||||
| [0002](./docs/adr/0002-ai-api-strategy.md) | AI 通过外部 API 调用 | 已决议 |
|
||||
15
backend/.env.example
Normal file
15
backend/.env.example
Normal file
@@ -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
|
||||
5
backend/.gitignore
vendored
Normal file
5
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/src/generated/prisma
|
||||
1
backend/.npmrc
Normal file
1
backend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
34
backend/package.json
Normal file
34
backend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
2486
backend/pnpm-lock.yaml
generated
Normal file
2486
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
backend/pnpm-workspace.yaml
Normal file
4
backend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
'@prisma/engines': true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
14
backend/prisma.config.ts
Normal file
14
backend/prisma.config.ts
Normal file
@@ -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"],
|
||||
},
|
||||
});
|
||||
198
backend/prisma/migrations/0001_init/migration.sql
Normal file
198
backend/prisma/migrations/0001_init/migration.sql
Normal file
@@ -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;
|
||||
|
||||
1
backend/prisma/migrations/0002_pgvector/migration.sql
Normal file
1
backend/prisma/migrations/0002_pgvector/migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE INDEX IF NOT EXISTS formulas_embedding_idx ON formulas USING hnsw (embedding vector_cosine_ops);
|
||||
2
backend/prisma/migrations/migration_lock.toml
Normal file
2
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
# Please do not edit this file manually
|
||||
provider = "postgresql"
|
||||
176
backend/prisma/schema.prisma
Normal file
176
backend/prisma/schema.prisma
Normal file
@@ -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")
|
||||
}
|
||||
66
backend/prisma/seed.ts
Normal file
66
backend/prisma/seed.ts
Normal file
@@ -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()
|
||||
})
|
||||
48
backend/src/app.ts
Normal file
48
backend/src/app.ts
Normal file
@@ -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
|
||||
}
|
||||
8
backend/src/lib/prisma.ts
Normal file
8
backend/src/lib/prisma.ts
Normal file
@@ -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 }),
|
||||
})
|
||||
113
backend/src/routes/ai.ts
Normal file
113
backend/src/routes/ai.ts
Normal file
@@ -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<string, number>
|
||||
} }>, 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<Record<string, unknown>>
|
||||
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<Record<string, unknown>> }
|
||||
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<string, unknown> }
|
||||
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)
|
||||
}
|
||||
76
backend/src/routes/auth.ts
Normal file
76
backend/src/routes/auth.ts
Normal file
@@ -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, unknown>): 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<string, unknown> | 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<string, unknown>
|
||||
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)
|
||||
}
|
||||
91
backend/src/routes/color.ts
Normal file
91
backend/src/routes/color.ts
Normal file
@@ -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<Record<string, unknown>> = []
|
||||
try {
|
||||
const parsed = JSON.parse(aiResult) as { recommendations?: Array<Record<string, unknown>> }
|
||||
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)
|
||||
}
|
||||
47
backend/src/routes/config.ts
Normal file
47
backend/src/routes/config.ts
Normal file
@@ -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)
|
||||
}
|
||||
218
backend/src/routes/formulas.test.ts
Normal file
218
backend/src/routes/formulas.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
295
backend/src/routes/formulas.ts
Normal file
295
backend/src/routes/formulas.ts
Normal file
@@ -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<string, string> }>, 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<string, unknown> = {}
|
||||
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)
|
||||
}
|
||||
7
backend/src/routes/health.ts
Normal file
7
backend/src/routes/health.ts
Normal file
@@ -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() }
|
||||
})
|
||||
}
|
||||
168
backend/src/routes/ingredients.test.ts
Normal file
168
backend/src/routes/ingredients.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
186
backend/src/routes/ingredients.ts
Normal file
186
backend/src/routes/ingredients.ts
Normal file
@@ -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<string, unknown> = {}
|
||||
|
||||
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<IngredientBody> }>,
|
||||
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)
|
||||
}
|
||||
39
backend/src/routes/projects.ts
Normal file
39
backend/src/routes/projects.ts
Normal file
@@ -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)
|
||||
}
|
||||
15
backend/src/server.ts
Normal file
15
backend/src/server.ts
Normal file
@@ -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()
|
||||
19
backend/src/services/ai/audit.ts
Normal file
19
backend/src/services/ai/audit.ts
Normal file
@@ -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<void> {
|
||||
await prisma.aiAuditLog.create({
|
||||
data: {
|
||||
capability: params.capability,
|
||||
modelName: params.modelName,
|
||||
promptHash: params.promptHash,
|
||||
tokensUsed: params.tokensUsed ?? null,
|
||||
durationMs: params.durationMs ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
37
backend/src/services/ai/cache.ts
Normal file
37
backend/src/services/ai/cache.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
interface CacheEntry<T> {
|
||||
value: T
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export class LRUCache<T> {
|
||||
private cache = new Map<string, CacheEntry<T>>()
|
||||
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 }
|
||||
}
|
||||
185
backend/src/services/ai/index.ts
Normal file
185
backend/src/services/ai/index.ts
Normal file
@@ -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<string, string> = {
|
||||
'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<string, AIProvider> = {}
|
||||
private cache: LRUCache<string>
|
||||
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<string> {
|
||||
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<string> {
|
||||
return this.execute('predict-metrics', predictMetricsPrompt(ingredients), { ttlMs: 3600_000 })
|
||||
}
|
||||
|
||||
async parseNLQuery(query: string): Promise<string> {
|
||||
return this.execute('parse-nl-query', parseNLQueryPrompt(query), { ttlMs: 300_000 })
|
||||
}
|
||||
|
||||
async generateFormula(constraints: Parameters<typeof generateFormulaPrompt>[0]): Promise<string> {
|
||||
return this.execute('generate-formula', generateFormulaPrompt(constraints), { ttlMs: 0 })
|
||||
}
|
||||
|
||||
async recommendColorants(targetLab: { L: number; a: number; b: number }): Promise<string> {
|
||||
return this.execute('recommend-colorants', recommendColorantsPrompt(targetLab), { ttlMs: 1800_000 })
|
||||
}
|
||||
|
||||
async extractFormula(text: string): Promise<string> {
|
||||
return this.execute('extract-formula', extractFormulaPrompt(text), { ttlMs: 0 })
|
||||
}
|
||||
|
||||
async chatStream(capability: string, messages: ChatMessage[], options?: ChatOptions): Promise<AsyncIterable<string>> {
|
||||
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<string> {
|
||||
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()
|
||||
5
backend/src/services/ai/providers/deepseek.ts
Normal file
5
backend/src/services/ai/providers/deepseek.ts
Normal file
@@ -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')
|
||||
}
|
||||
88
backend/src/services/ai/providers/openai.ts
Normal file
88
backend/src/services/ai/providers/openai.ts
Normal file
@@ -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<ChatResponse> {
|
||||
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<string, unknown>
|
||||
const choice = (json.choices as Array<Record<string, unknown>>)?.[0]
|
||||
const msg = choice?.message as Record<string, unknown> | 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<string> {
|
||||
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<string, unknown>
|
||||
const delta = (json.choices as Array<Record<string, unknown>>)?.[0]?.delta as Record<string, unknown> | undefined
|
||||
if (delta?.content) yield delta.content as string
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { chat, chatStream }
|
||||
}
|
||||
22
backend/src/services/ai/providers/types.ts
Normal file
22
backend/src/services/ai/providers/types.ts
Normal file
@@ -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<ChatResponse>
|
||||
chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>
|
||||
}
|
||||
31
backend/src/services/ai/rate-limiter.ts
Normal file
31
backend/src/services/ai/rate-limiter.ts
Normal file
@@ -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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
44
backend/src/services/ai/templates/index.ts
Normal file
44
backend/src/services/ai/templates/index.ts
Normal file
@@ -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<string, number>
|
||||
}): 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 },
|
||||
]
|
||||
}
|
||||
19
backend/tsconfig.json
Normal file
19
backend/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
1
backend/tsconfig.tsbuildinfo
Normal file
1
backend/tsconfig.tsbuildinfo
Normal file
@@ -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"}
|
||||
8
backend/vitest.config.ts
Normal file
8
backend/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false,
|
||||
},
|
||||
})
|
||||
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -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:
|
||||
13
docker/Dockerfile.pgvector
Normal file
13
docker/Dockerfile.pgvector
Normal file
@@ -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
|
||||
268
docs/adr/0001-architecture-stack.md
Normal file
268
docs/adr/0001-architecture-stack.md
Normal file
@@ -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
|
||||
266
docs/adr/0002-ai-api-strategy.md
Normal file
266
docs/adr/0002-ai-api-strategy.md
Normal file
@@ -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<ChatResponse>;
|
||||
chatStream(messages: Message[], options: ChatOptions): AsyncIterable<ChatChunk>;
|
||||
}
|
||||
|
||||
const providers: Record<string, AIProvider> = {
|
||||
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
|
||||
35
docs/agents/domain.md
Normal file
35
docs/agents/domain.md
Normal file
@@ -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…_
|
||||
19
docs/agents/issue-tracker.md
Normal file
19
docs/agents/issue-tracker.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Issue tracker: Local Markdown
|
||||
|
||||
这个 repo 的 issues 和 PRDs 作为 markdown 文件存放在 `.scratch/` 中。
|
||||
|
||||
## Conventions
|
||||
|
||||
- 每个 feature 一个目录:`.scratch/<feature-slug>/`
|
||||
- PRD 是 `.scratch/<feature-slug>/PRD.md`
|
||||
- Implementation issues 是 `.scratch/<feature-slug>/issues/<NN>-<slug>.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/<feature-slug>/` 下创建新文件(必要时创建目录)。
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
读取引用路径处的文件。用户通常会直接传入路径或 issue number。
|
||||
15
docs/agents/triage-labels.md
Normal file
15
docs/agents/triage-labels.md
Normal file
@@ -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。
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -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?
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
frontend/eslint.config.js
Normal file
22
frontend/eslint.config.js
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
frontend/package.json
Normal file
59
frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
3846
frontend/pnpm-lock.yaml
generated
Normal file
3846
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
6
frontend/src/App.tsx
Normal file
6
frontend/src/App.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import AppLayout from '@/layouts/AppLayout'
|
||||
|
||||
export default function App() {
|
||||
return <AppLayout />
|
||||
}
|
||||
|
||||
8
frontend/src/components/AuthGuard.tsx
Normal file
8
frontend/src/components/AuthGuard.tsx
Normal file
@@ -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 <Navigate to="/login" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
150
frontend/src/components/ColorRecommendPanel.tsx
Normal file
150
frontend/src/components/ColorRecommendPanel.tsx
Normal file
@@ -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<Array<Record<string, unknown>>>([])
|
||||
const [matchedFormulas, setMatchedFormulas] = useState<Array<Record<string, unknown>>>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchRecommend = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const lab = targetLab ?? currentLab
|
||||
const json = await apiFetch<{ recommendations: Array<Record<string, unknown>>; matchedFormulas: Array<Record<string, unknown>> }>(
|
||||
'/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 (
|
||||
<>
|
||||
<button onClick={() => { setOpen(true); fetchRecommend() }}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700">
|
||||
<Sparkles size={14} /> AI 推荐配色
|
||||
</button>
|
||||
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl">
|
||||
<Dialog.Title className="mb-4 text-lg font-bold">AI 配色推荐</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<button className="absolute right-4 top-4 rounded p-1 text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
||||
</Dialog.Close>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3 py-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse flex items-center gap-4 rounded-lg border p-3">
|
||||
<div className="h-10 w-10 rounded bg-gray-200" />
|
||||
<div className="flex-1"><div className="mb-1 h-4 w-1/3 rounded bg-gray-200" /><div className="h-3 w-2/3 rounded bg-gray-100" /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 text-xs text-gray-500">
|
||||
目标色:{targetLab ? `Lab(${targetLab.L.toFixed(0)},${targetLab.a.toFixed(0)},${targetLab.b.toFixed(0)})` : '当前色'}
|
||||
</div>
|
||||
|
||||
{recommendations.length > 0 ? (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{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 (
|
||||
<button key={i} onClick={() => setSelectedIndex(i)}
|
||||
className={`flex w-full items-center gap-4 rounded-lg border p-3 text-left transition-colors ${selectedIndex === i ? 'border-purple-400 bg-purple-50' : 'hover:bg-gray-50'}`}>
|
||||
<div className="h-10 w-10 flex-shrink-0 rounded border" style={{ backgroundColor: hex }} />
|
||||
<div className="flex-1 text-sm">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{colorants?.map((c, j) => (
|
||||
<span key={j} className="rounded bg-gray-100 px-1.5 py-0.5 text-xs">{c.name} {(c.ratio * 100).toFixed(0)}%</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs">
|
||||
{predictedDeltaE !== undefined && (
|
||||
<span className={predictedDeltaE <= 1 ? 'text-green-600' : predictedDeltaE <= 3 ? 'text-yellow-600' : 'text-red-600'}>
|
||||
ΔE {predictedDeltaE.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : matchedFormulas.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500">AI 未返回推荐,以下是历史匹配配方:</p>
|
||||
{matchedFormulas.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border p-2 text-sm">
|
||||
<span>{f.name as string}</span>
|
||||
<span className="text-xs text-gray-400">ΔE {(f.deltaE as number)?.toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-gray-400">暂无推荐结果</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-lg border p-3">
|
||||
<p className="mb-2 text-xs font-medium text-gray-500">皮肤预览</p>
|
||||
<div className="h-20 rounded-lg"
|
||||
style={{
|
||||
background: selectedColor
|
||||
? `linear-gradient(135deg, #f5d0c0, #e8b8a0), ${labToHex(selectedColor.L, selectedColor.a, selectedColor.b)}`
|
||||
: 'linear-gradient(135deg, #f5d0c0, #e8b8a0)',
|
||||
backgroundBlendMode: 'multiply',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Dialog.Close asChild>
|
||||
<button className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50">关闭</button>
|
||||
</Dialog.Close>
|
||||
<button onClick={handleSave} disabled={selectedIndex === null || saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
<Save size={14} /> {saving ? '保存中...' : '保存为颜色配方'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</>
|
||||
)
|
||||
}
|
||||
147
frontend/src/components/ColorWheel.tsx
Normal file
147
frontend/src/components/ColorWheel.tsx
Normal file
@@ -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<HTMLCanvasElement>(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<HTMLCanvasElement>) => {
|
||||
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 (
|
||||
<canvas ref={canvasRef} onClick={handleClick}
|
||||
className="cursor-crosshair rounded-full" />
|
||||
)
|
||||
}
|
||||
36
frontend/src/components/ErrorBoundary.tsx
Normal file
36
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -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<Props, State> {
|
||||
state: State = { hasError: false, error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
|
||||
<h1 className="mb-2 text-2xl font-bold text-red-600">出错了</h1>
|
||||
<p className="mb-4 text-sm text-gray-500">{this.state.error?.message ?? '发生了意外错误'}</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={() => { this.setState({ hasError: false, error: null }); window.location.reload() }}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700">
|
||||
刷新页面
|
||||
</button>
|
||||
<Link to="/" className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50">
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
169
frontend/src/components/EyedropperPanel.tsx
Normal file
169
frontend/src/components/EyedropperPanel.tsx
Normal file
@@ -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<HTMLImageElement | null>(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<HTMLCanvasElement>(null)
|
||||
const [history, setHistory] = useState<LABColor[]>(() => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm hover:bg-gray-50">
|
||||
<Upload size={14} /> 上传图片
|
||||
<input type="file" accept="image/*" className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileUpload(f) }} />
|
||||
</label>
|
||||
<button onClick={handleEyedropper}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-purple-300 px-3 py-1.5 text-sm text-purple-600 hover:bg-purple-50">
|
||||
<Pipette size={14} /> 取色棒
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{image && (
|
||||
<div className="overflow-hidden rounded-lg border" style={{ maxHeight: scale > 1 ? '400px' : 'auto' }}>
|
||||
<canvas ref={canvasRef}
|
||||
onClick={handleCanvasClick}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{
|
||||
transform: `scale(${scale}) translate(${offset.x / scale}px, ${offset.y / scale}px)`,
|
||||
transformOrigin: 'top left',
|
||||
cursor: 'crosshair',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
className="block" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-400">取色历史:</span>
|
||||
{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 (
|
||||
<button key={i} onClick={() => onColorPick(lab)}
|
||||
className="h-5 w-5 rounded border border-gray-300" style={{ backgroundColor: color }}
|
||||
title={`Lab(${lab.L.toFixed(0)},${lab.a.toFixed(0)},${lab.b.toFixed(0)})`} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
304
frontend/src/components/FormulaVisualEditor.tsx
Normal file
304
frontend/src/components/FormulaVisualEditor.tsx
Normal file
@@ -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<void>
|
||||
}
|
||||
|
||||
export default function FormulaVisualEditor({ phases: initialPhases, onSave }: Props) {
|
||||
const [phases, setPhases] = useState<PhaseData[]>(() =>
|
||||
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<HTMLDivElement>(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 (
|
||||
<div ref={editorRef} className="flex flex-col gap-6 lg:flex-row">
|
||||
<div className="flex-shrink-0 lg:w-[380px]">
|
||||
<ReactECharts option={chartOption} style={{ height: 380 }} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className={`mb-4 rounded-lg border p-3 ${isValid ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600">总比例</span>
|
||||
<span className={`text-xl font-bold ${isValid ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{total.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
{!isValid && total > 0 && (
|
||||
<button onClick={normalize}
|
||||
className="rounded-lg bg-white px-3 py-1 text-xs font-medium text-blue-600 shadow-sm hover:bg-blue-50">
|
||||
自动归一化
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isValid && (
|
||||
<div className="mt-1 h-1.5 w-full rounded-full bg-gray-200">
|
||||
<div className="h-full rounded-full bg-green-500 transition-all" style={{ width: `${total}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<button onClick={handlePredict} disabled={predicting}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-purple-600 px-3 py-1.5 text-sm text-white hover:bg-purple-700 disabled:opacity-50">
|
||||
{predicting ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
|
||||
{predicting ? '预测中...' : 'AI 预测指标'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasPrediction && (
|
||||
<div className="mb-4 grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-center">
|
||||
<div className="text-xs text-blue-600">肤感指数</div>
|
||||
<div className="text-xl font-bold text-blue-700">{prediction!.sensoryIndex.overall}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-green-50 p-3 text-center">
|
||||
<div className="text-xs text-green-600">稳定性</div>
|
||||
<div className="text-xl font-bold text-green-700">{prediction!.stabilityScore}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-amber-50 p-3 text-center">
|
||||
<div className="text-xs text-amber-600">成本估算</div>
|
||||
<div className="text-xl font-bold text-amber-700">¥{prediction!.costEstimate}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPrediction && (
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-500">肤感子维度</h4>
|
||||
<ReactECharts
|
||||
option={{
|
||||
radar: {
|
||||
indicator: [
|
||||
{ name: '铺展性', max: 100 },
|
||||
{ name: '吸收速度', max: 100 },
|
||||
{ name: '黏腻度', max: 100 },
|
||||
{ name: '综合', max: 100 },
|
||||
],
|
||||
shape: 'circle',
|
||||
center: ['50%', '55%'],
|
||||
radius: '70%',
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
data: [{
|
||||
value: [
|
||||
prediction!.sensoryIndex.spreadability,
|
||||
prediction!.sensoryIndex.absorption,
|
||||
prediction!.sensoryIndex.stickiness,
|
||||
prediction!.sensoryIndex.overall,
|
||||
],
|
||||
name: '当前配方',
|
||||
areaStyle: { color: 'rgba(59,130,246,0.2)' },
|
||||
lineStyle: { color: '#3b82f6' },
|
||||
itemStyle: { color: '#3b82f6' },
|
||||
}],
|
||||
}],
|
||||
}}
|
||||
style={{ height: 220 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500">稳定性评分</h4>
|
||||
<ReactECharts
|
||||
option={{
|
||||
series: [{
|
||||
type: 'gauge',
|
||||
startAngle: 210, endAngle: -30,
|
||||
min: 0, max: 100,
|
||||
progress: { show: true, width: 12, itemStyle: { color: '#10b981' } },
|
||||
axisLine: { lineStyle: { width: 12, color: [[0.6, '#ef4444'], [0.8, '#f59e0b'], [1, '#10b981']] } },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
detail: { valueAnimation: true, fontSize: 20, offsetCenter: [0, '60%'] },
|
||||
data: [{ value: prediction!.stabilityScore }],
|
||||
}],
|
||||
}}
|
||||
style={{ height: 140 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500">配方结构</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allIngredients.map((ing, i) => (
|
||||
<div key={i} className="rounded px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: COLORS[i % COLORS.length] + '20',
|
||||
color: COLORS[i % COLORS.length],
|
||||
fontSize: `${Math.max(10, 10 + ing.percentage / 5)}px`,
|
||||
}}>
|
||||
{ing.inciName} {ing.percentage.toFixed(1)}%
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto">
|
||||
{phases.map((phase, pi) => (
|
||||
<div key={pi}>
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-500">{phase.name}</h4>
|
||||
<div className="space-y-1.5">
|
||||
{phase.ingredients.map((ing, ii) => {
|
||||
const flatIdx = phases.slice(0, pi).reduce((s, p) => s + p.ingredients.length, 0) + ii
|
||||
return (
|
||||
<div key={ii} className="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium truncate block">{ing.ingredient?.inciName ?? ing.inciName}</span>
|
||||
<span className="text-xs text-gray-400">{ing.ingredient?.chineseName ?? ing.chineseName}</span>
|
||||
</div>
|
||||
<button onClick={() => adjustPct(flatIdx, -0.1)}
|
||||
className="rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600"><Minus size={12} /></button>
|
||||
<input type="number" step="0.01" min="0.01" max="100"
|
||||
value={ing.percentage} onChange={e => 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" />
|
||||
<span className="text-xs text-gray-400">%</span>
|
||||
<button onClick={() => adjustPct(flatIdx, 0.1)}
|
||||
className="rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600"><Plus size={12} /></button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={handleSave} disabled={saving || !isValid}
|
||||
className="mt-4 inline-flex items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
title={!isValid ? '比例总和需在 99.5%~100.5% 之间' : undefined}>
|
||||
<Save size={14} /> {saving ? '保存中...' : '保存配方'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
frontend/src/hooks/useAIPredict.ts
Normal file
77
frontend/src/hooks/useAIPredict.ts
Normal file
@@ -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<PredictionResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(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 }
|
||||
}
|
||||
1
frontend/src/index.css
Normal file
1
frontend/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
134
frontend/src/layouts/AppLayout.tsx
Normal file
134
frontend/src/layouts/AppLayout.tsx
Normal file
@@ -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 (
|
||||
<div className={clsx('flex min-h-screen bg-gray-50', theme === 'dark' && 'dark')}>
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed inset-y-0 left-0 z-50 flex flex-col border-r border-gray-200 bg-white transition-all duration-300 dark:border-gray-700 dark:bg-gray-900',
|
||||
collapsed && !mobileOpen ? 'w-16' : 'w-60',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between border-b border-gray-200 px-3 dark:border-gray-700">
|
||||
{!(collapsed && !mobileOpen) && (
|
||||
<span className="text-sm font-bold text-gray-900 dark:text-white">配方研发平台</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="hidden rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 lg:block dark:hover:bg-gray-800"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
||||
{menuItems.map((item) => {
|
||||
const isActive =
|
||||
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to)
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100',
|
||||
)}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
{!(collapsed && !mobileOpen) && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-200 p-2 dark:border-gray-700">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={20} /> : <Moon size={20} />}
|
||||
{!(collapsed && !mobileOpen) && <span>{theme === 'dark' ? '浅色模式' : '深色模式'}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={clsx('flex flex-1 flex-col transition-all duration-300', collapsed ? 'lg:ml-16' : 'lg:ml-60')}>
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-900">
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="rounded-md p-1 text-gray-500 hover:bg-gray-100 lg:hidden dark:hover:bg-gray-800"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="mx-4 hidden flex-1 sm:block">
|
||||
<div className="relative max-w-md">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text" placeholder="搜索配方...(支持自然语言)"
|
||||
value={searchQuery} onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 lg:hidden" />
|
||||
<div className="flex items-center gap-3">
|
||||
{user && <span className="text-sm text-gray-600">{user.username}</span>}
|
||||
<button onClick={logout} className="rounded p-1 text-gray-400 hover:text-red-500" title="退出登录">
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
frontend/src/lib/api.ts
Normal file
32
frontend/src/lib/api.ts
Normal file
@@ -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<T = unknown>(url: string, options?: RequestInit): Promise<T> {
|
||||
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<string, unknown>)?.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, '响应格式错误')
|
||||
}
|
||||
}
|
||||
131
frontend/src/lib/color/color.test.ts
Normal file
131
frontend/src/lib/color/color.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
62
frontend/src/lib/color/convert.ts
Normal file
62
frontend/src/lib/color/convert.ts
Normal file
@@ -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 }
|
||||
}
|
||||
20
frontend/src/lib/color/deltaE.ts
Normal file
20
frontend/src/lib/color/deltaE.ts
Normal file
@@ -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')
|
||||
}
|
||||
23
frontend/src/lib/color/types.ts
Normal file
23
frontend/src/lib/color/types.ts
Normal file
@@ -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user