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:
qichi.liang
2026-05-20 17:50:37 +08:00
commit 23e5cb4006
125 changed files with 14454 additions and 0 deletions

View File

@@ -0,0 +1,204 @@
# PRDAI 驱动的配方研发智能平台
> **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 + TypeScriptSPA 架构,使用 Vite 构建
- **状态管理**Zustand轻量、TS 友好、避免 Redux 模板代码)
- **路由**React Router v7支持 layout routes 嵌套布局)
- **样式方案**Tailwind CSS + CSS ModulesTailwind 处理布局/间距CSS Modules 处理组件级复杂样式)
- **UI 组件库**Radix UI无样式行为组件+ 自定义样式
- **图表**EChartsecharts-for-react作为主图表库雷达图/饼图/散点图/仪表盘全覆盖支持拖拽交互D3.js 用于颜色盘等高度定制化场景
- **色彩科学**color.jsColor Science library处理色空间转换、ΔE 计算CSS Color Level 4 的 `color(display-p3 ...)` 用于广色域渲染
- **AI 集成**:前端通过 REST API + SSE 流式推送与后端 AI 服务通信
- **后端**Node.jsFastify作为 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 APIChromium 系)+ Canvas getImageData 降级方案
---
## Testing Decisions
### 测试策略
- **单元测试**核心逻辑层色空间转换、ΔE 计算、比例约束校验、AI 结果解析)
- **组件测试**:关键交互组件(颜色盘拖拽、饼图拖拽调整比例、版本差异对比渲染)
- **集成测试**AI API 调用链路(请求→响应→前端状态更新)、配方 CRUD 全流程
- **E2E 测试**:核心用户流程(新建配方→调整比例→查看预测→保存)
### 测试工具
- Vitest单元/组件测试)
- React Testing Library组件交互测试
- PlaywrightE2E
- MSWAPI 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 的显示器)和校准建议
- 考虑将颜色引擎作为可独立部署的微前端模块,未来可嵌入其他系统

View File

@@ -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 中)

View 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 实现)

View 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

View 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`:用户名 + 密码 → 验证 → 返回 JWTaccess + 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 后续实现

View 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`,未来可迁移到其他维度

View 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 关键词长度 ≥ 1category 值在允许列表中
- [ ] 响应格式统一:`{ data, pagination: { page, limit, total, totalPages } }`
- [ ] 所有路由挂载在 `/api` 前缀下
## Further notes
- 功能分类枚举emulsifier乳化剂、humectant保湿剂、thickener增稠剂、preservative防腐剂、antioxidant抗氧化剂、fragrance香精、colorant着色剂、ph_adjusterpH 调节剂、sunscreen防晒剂、surfactant表面活性剂、emollient润肤剂、other
- 先不实现复杂权限,所有认证用户可查看和搜索,管理员可增删改

View 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 名/中文名模糊匹配
- [ ] 功能分类下拉筛选器
- [ ] 分页控件(上一页/下一页/页码)
- [ ] 点击成分行 → 弹出详情 DialogRadix Dialog显示所有字段 + 编辑/删除按钮
- [ ] 新建成分表单Dialog 内INCI 名、中文名、功能分类、供应商、单位、单价、描述
- [ ] 编辑成分表单:预填现有数据
- [ ] 删除确认弹窗Radix AlertDialog
- [ ] 表单验证INCI 名和中文名必填,单价 ≥ 0
- [ ] 使用 TanStack Query 管理 API 请求(自动缓存/重新获取)
- [ ] Loading 状态 + 空状态提示
## Further notes
- 表格使用 Tailwind 自定义样式(不用第三方表格组件)
- 操作按钮:编辑、删除仅管理员可见
- 移动端适配:表格在小屏改为卡片列表

View 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、ingredientsJOIN 查询)
- [ ] `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) 类型

View File

@@ -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 追加

View 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默认指标预测 1hNL 解析 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架构支持后续扩展

View File

@@ -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 APIstructured 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` 库在前端读取后转文本再提交

View 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 填充)

View File

@@ -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 做深度比较

View File

@@ -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` 缓存

View File

@@ -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` 空间)

View File

@@ -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` 组件,可复用

View File

@@ -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 模式(切换 Tab3 个滑条R/G/B: 0-255
- Lab/RGB 模式切换 Toggle
- [ ] 滑条交互:
- 拖动滑条 → 颜色预览实时更新
- 滑条旁显示当前数值
- 滑条轨道渲染对应通道渐变色(如 L 滑条:从黑到白)
- [ ] 当前色显示(继承 S8a
- HEX 值旁增加复制按钮(一键复制到剪贴板)
- 色值变化时短暂高亮动画(反馈变化)
- [ ] 重置按钮 → 恢复到选色时的初始值
- [ ] 所有调整实时反映到色相环上的选色指示器位置
## Further notes
- 滑条组件使用原生 `<input type="range">` + Tailwind 自定义样式
- Lab 和 RGB 之间的转换使用 S7 的转换函数
- 操作防抖:连续拖动时不触发外部更新(如 AI 推荐),仅在松开时触发

View File

@@ -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

View 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 尺寸

View File

@@ -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 APIcolorant 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 时可接受全表扫描

View File

@@ -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

View 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
- [ ] 配方详情页新增"可视化编辑"Tabrouter: `/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, ...

View File

@@ -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 APIstreaming→ 转发 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 返回后替换

View 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` 配置
- 雷达图放在可视化编辑页的指标面板中(与饼图并排,桌面端左右布局)

View 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面积表示比例更直观

View File

@@ -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 中的权重设定

View File

@@ -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 APIstreaming 展示生成的候选配方方案列表,支持逆向推演入口。
端到端验证:设置约束 → 点击"开始推演"→ 看到 AI 逐步生成候选方案 → 每个方案显示成分变更 + 预测指标 + 变更理由。
## Acceptance criteria
- [ ] 后端 `POST /api/ai/explore`SSE endpoint
- 请求 body基础配方 ID可选+ 约束条件
- 构建 Prompt基础配方成分 + 约束条件 + few-shot 示例
- 调用 AI APIstreaming→ 转发 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 个)

View File

@@ -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轻量级

View 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
- 初始阶段如果配方数据少,搜索可能结果稀疏

View File

@@ -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
- 项目是逻辑分组,不影响配方数据本身
- 项目成员权限先不做(当前单用户或简化权限)

View 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
View File

@@ -0,0 +1,13 @@
## Agent skills
### Issue tracker
Issues 作为本地 markdown 文件存放在 `.scratch/<feature-slug>/` 下。详见 `docs/agents/issue-tracker.md`
### Triage labels
使用五个标准 triage roleslabel 名称保持默认。详见 `docs/agents/triage-labels.md`
### Domain docs
Single-context 布局:根目录 `CONTEXT.md` + `docs/adr/`。详见 `docs/agents/domain.md`

68
CONTEXT.md Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/src/generated/prisma

1
backend/.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmmirror.com

34
backend/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
allowBuilds:
'@prisma/engines': true
esbuild: true
prisma: true

14
backend/prisma.config.ts Normal file
View 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"],
},
});

View 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;

View File

@@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS formulas_embedding_idx ON formulas USING hnsw (embedding vector_cosine_ops);

View File

@@ -0,0 +1,2 @@
# Please do not edit this file manually
provider = "postgresql"

View 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
View 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
View 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
}

View 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
View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
})
})

View 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)
}

View 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() }
})
}

View 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)
})
})

View 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)
}

View 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
View 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()

View 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,
},
})
}

View 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 }
}

View 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()

View 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')
}

View 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 }
}

View 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>
}

View 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
}
}

View 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
View 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"]
}

View 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
View 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
View 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:

View 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

View 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 | 性能优于 ReactAPI 相似 | 社区太小GitHub stars ~30k vs React ~230k生产风险高 | ❌ |
**决策**React 18 + TypeScript strict mode。React 19 待生态稳定后再升级。
---
### 2. 构建工具 → Vite 6
| 候选 | 优势 | 劣势 | 结论 |
| :--- | :--- | :--- | :--- |
| **Vite** | 原生 ESM 开发(毫秒级 HMRRollup 生产打包零配置开箱SSR 可选 | — | ✅ 推荐 |
| Next.js 15 | 全栈能力SSR/SSG/ISR | 内部工具无需 SSR/SEO增加复杂度App Router 学习曲线陡峭 | ❌ |
| Remix | SSR 优先Web 标准 | 同上;社区较 Next.js 小 | ❌ |
| CRA | — | 已停止维护Webpack 构建慢 | ❌ |
**决策**Vite 6SPA 模式。平台为内部工具,无需 SEO/SSR。
---
### 3. 状态管理 → Zustand 5
| 候选 | 优势 | 劣势 | 结论 |
| :--- | :--- | :--- | :--- |
| **Zustand** | 极简 API无 Provider/ReducerTS 完美;< 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 routesv7 类型安全路由;社区资源极丰富 | — | ✅ 推荐 |
| 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组件级隔离 | 运行时开销(~14KBServer 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. 后端 → FastifyBFF 单体)+ 外部 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 RESTAI 预测 SSE 实时推送BFF ↔ AI API HTTP | SSE 适合单向实时数据流BFF 统一处理 AI 调用的编排和降级 |
| BFF 框架对比 | 优势 | 劣势 | 结论 |
| :--- | :--- | :--- | :--- |
| **Fastify** | 高性能(~60k req/s插件体系内置 Schema 验证AJVTS 支持日志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 | 将成分列表和比例作为 contextLLM 预测肤感/稳定性/成本 |
| 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
**决策**MinIOS3 兼容存储参考图片、导出文件。本地部署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 仍为 Betav0.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

View 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 hashTTL 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 APIstreaming 模式)
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
View 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…_

View 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。

View 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
View 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
View File

@@ -0,0 +1 @@
registry=https://registry.npmmirror.com

73
frontend/README.md Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

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
View File

@@ -0,0 +1,6 @@
import AppLayout from '@/layouts/AppLayout'
export default function App() {
return <AppLayout />
}

View 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 />
}

View 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>
</>
)
}

View 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" />
)
}

View 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
}
}

View 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>
)
}

View 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>
)
}

View 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
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View 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
View 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, '响应格式错误')
}
}

View 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)
})
})

View 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 }
}

View 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')
}

View 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