企业级重构:四层模块化架构 + RBAC授权 + 安全加固 + 颜色引擎/配方推演增强
架构 - 后端从 flat routes/ 重构为 modules/<domain>/ 模块化结构(8个模块) - 四层架构:Route -> Service -> Repository -> Prisma - 新增 shared/ 基础设施(AppError 异常体系、ALS 上下文、prom-client 指标) - 前端 Toast/Skeleton/Alert 组件基建 + formulaService 模板 安全 - JWT 签名算法修复(HS256 用 createHmac 而非 createHash) - 密码哈希 async scrypt + timingSafeEqual - API Key 从 localStorage 迁移至服务端 runtime/config.json - Helmet 安全头 + rate-limit 全局限流 100 req/min - 全局 auth preHandler + RBAC + Ownership 中间件 颜色引擎 - 色匹配切换为 cube 粗筛 + CIEDE2000 精排 - PantoneColor 表 + 种子数据 + 搜索端点 - AI 配色 Prompt 注入成分库 colorant 列表 配方推演 - 本地优化引擎(同 category 替换 + 成本排序) - baseFormulaId 支持 + Pareto 散点图 文档 - ADR-0003 四层架构、ADR-0004 RBAC 授权模型 - 更新 ADR-0001/0002 - api-reference.md(29端点)、project-overview.md 部署 - Dockerfile * 2 + nginx.conf + docker-compose.prod.yml - 健康探针 + 优雅关闭 + pg_dump 备份脚本 - ESLint + Prettier + tsconfig strict
This commit is contained in:
@@ -64,5 +64,7 @@ AI 驱动的化妆品配方研发智能平台(纯 Web 端),为化妆品研
|
||||
|
||||
| ADR | 主题 | 状态 |
|
||||
| :--- | :--- | :--- |
|
||||
| [0001](./docs/adr/0001-architecture-stack.md) | 整体技术栈选型 | 已决议 |
|
||||
| [0002](./docs/adr/0002-ai-api-strategy.md) | AI 通过外部 API 调用 | 已决议 |
|
||||
| [0001](./docs/adr/0001-architecture-stack.md) | 整体技术栈选型 | 已决议 (修订 2026-05-21) |
|
||||
| [0002](./docs/adr/0002-ai-api-strategy.md) | AI 通过外部 API 调用 | 已决议 (修订 2026-05-21) |
|
||||
| [0003](./docs/adr/0003-four-layer-module-architecture.md) | 后端四层模块化架构 | 已决议 |
|
||||
| [0004](./docs/adr/0004-rbac-ownership-authorization.md) | RBAC + 资源级 Ownership 授权 | 已决议 |
|
||||
|
||||
7
backend/.prettierrc
Normal file
7
backend/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2
|
||||
}
|
||||
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
RUN pnpm fetch
|
||||
RUN pnpm install --frozen-lockfile --offline
|
||||
COPY . .
|
||||
RUN pnpm exec prisma generate
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/package.json ./
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/server.js"]
|
||||
16
backend/eslint.config.js
Normal file
16
backend/eslint.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'src/generated/**'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -10,25 +10,41 @@
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src/",
|
||||
"format": "prettier --write src/",
|
||||
"api:gen": "tsx scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/env": "^5.0.0",
|
||||
"@fastify/formbody": "^8.0.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/multipart": "^9.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@fastify/swagger-ui": "^5.2.6",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"colorjs.io": "^0.6.1",
|
||||
"fastify": "^5.4.0",
|
||||
"pg": "^8.21.0"
|
||||
"pg": "^8.21.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"zod": "^4.4.3",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.0.0",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prettier": "^3.8.3",
|
||||
"prisma": "^7.8.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.8.0",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
1203
backend/pnpm-lock.yaml
generated
1203
backend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
-- Add cube extension for color matching
|
||||
CREATE EXTENSION IF NOT EXISTS cube;
|
||||
|
||||
-- Create pantone_colors table
|
||||
CREATE TABLE "pantone_colors" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"L" DOUBLE PRECISION NOT NULL,
|
||||
"a" DOUBLE PRECISION NOT NULL,
|
||||
"b" DOUBLE PRECISION NOT NULL,
|
||||
|
||||
CONSTRAINT "pantone_colors_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Create unique index for pantone code
|
||||
CREATE UNIQUE INDEX "pantone_colors_code_key" ON "pantone_colors"("code");
|
||||
@@ -174,3 +174,14 @@ model AiAuditLog {
|
||||
@@index([createdAt])
|
||||
@@map("ai_audit_logs")
|
||||
}
|
||||
|
||||
model PantoneColor {
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
name String
|
||||
L Float
|
||||
a Float
|
||||
b Float
|
||||
|
||||
@@map("pantone_colors")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { prisma } from '../src/lib/prisma.js'
|
||||
import { IngredientCategory } from '../src/generated/prisma/enums.js'
|
||||
import { randomBytes, scryptSync } from 'crypto'
|
||||
|
||||
function makeHash(password: string): string {
|
||||
const salt = randomBytes(16)
|
||||
const hash = scryptSync(password, salt, 64).toString('hex')
|
||||
return `${salt.toString('hex')}:${hash}`
|
||||
}
|
||||
|
||||
const ingredients = [
|
||||
{ inciName: 'Glycerin', chineseName: '甘油', functionCategory: IngredientCategory.humectant, supplier: '丰益油脂', unitPrice: 15.00, description: '最常用的保湿剂,吸湿性强' },
|
||||
@@ -18,34 +25,45 @@ const ingredients = [
|
||||
{ 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: '调配香精' },
|
||||
{ inciName: 'Sodium Benzoate', chineseName: '苯甲酸钠', functionCategory: IngredientCategory.preservative, supplier: '默克', unitPrice: 30.00, description: '酸性防腐剂,pH<5.5 有效' },
|
||||
{ inciName: 'Tocopherol', chineseName: '生育酚(维生素E)', functionCategory: IngredientCategory.antioxidant, supplier: '巴斯夫', unitPrice: 250.00, description: '天然抗氧化剂,保护配方稳定性' },
|
||||
{ inciName: 'BHT', chineseName: '丁羟甲苯', functionCategory: IngredientCategory.antioxidant, supplier: '默克', unitPrice: 45.00, description: '合成抗氧化剂,延长保质期' },
|
||||
{ inciName: 'Fragrance', chineseName: '香精', functionCategory: IngredientCategory.fragrance, supplier: '奇华顿', unitPrice: 120.00, description: '化妆品用香精,供应商定制' },
|
||||
{ inciName: 'Iron Oxide Red', chineseName: '氧化铁红', functionCategory: IngredientCategory.colorant, supplier: '默克', unitPrice: 85.00, description: '红色颜料,无机着色剂' },
|
||||
{ inciName: 'Iron Oxide Yellow', chineseName: '氧化铁黄', functionCategory: IngredientCategory.colorant, supplier: '默克', unitPrice: 80.00, description: '黄色颜料,无机着色剂' },
|
||||
{ inciName: 'Iron Oxide Black', chineseName: '氧化铁黑', functionCategory: IngredientCategory.colorant, supplier: '默克', unitPrice: 75.00, description: '黑色颜料,无机着色剂' },
|
||||
{ inciName: 'Titanium Dioxide', chineseName: '二氧化钛', functionCategory: IngredientCategory.colorant, supplier: '巴斯夫', unitPrice: 120.00, description: '白色颜料,物理防晒剂' },
|
||||
{ inciName: 'Triethanolamine', chineseName: '三乙醇胺', functionCategory: IngredientCategory.ph_adjuster, supplier: '陶氏化学', unitPrice: 25.00, description: 'pH调节剂,常与卡波姆配合使用' },
|
||||
{ inciName: 'Citric Acid', chineseName: '柠檬酸', functionCategory: IngredientCategory.ph_adjuster, supplier: '默克', unitPrice: 20.00, description: 'pH调节剂,降低体系pH值' },
|
||||
{ inciName: 'Avobenzone', chineseName: '阿伏苯宗', functionCategory: IngredientCategory.sunscreen, supplier: '巴斯夫', unitPrice: 350.00, description: 'UVA 化学防晒剂,广谱吸收' },
|
||||
{ inciName: 'Octinoxate', chineseName: '桂皮酸盐', functionCategory: IngredientCategory.sunscreen, supplier: '巴斯夫', unitPrice: 180.00, description: 'UVB 化学防晒剂,最常用之一' },
|
||||
{ inciName: 'Sodium Lauryl Sulfate', chineseName: '月桂醇硫酸酯钠', functionCategory: IngredientCategory.surfactant, supplier: '禾大', unitPrice: 20.00, description: '阴离子表面活性剂,强力清洁' },
|
||||
{ inciName: 'Cocamidopropyl Betaine', chineseName: '椰油酰胺丙基甜菜碱', functionCategory: IngredientCategory.surfactant, supplier: '禾大', unitPrice: 35.00, description: '两性表面活性剂,温和清洁' },
|
||||
{ inciName: 'Caprylic/Capric Triglyceride', chineseName: '辛酸/癸酸甘油三酯', functionCategory: IngredientCategory.emollient, supplier: '巴斯夫', unitPrice: 65.00, description: '中性油脂,铺展性好' },
|
||||
{ inciName: 'Dimethicone', chineseName: '聚二甲基硅氧烷', functionCategory: IngredientCategory.emollient, supplier: '陶氏化学', unitPrice: 55.00, description: '硅油类润肤剂,赋予顺滑肤感' },
|
||||
{ inciName: 'Squalane', chineseName: '角鲨烷', functionCategory: IngredientCategory.emollient, supplier: '阿莫科', unitPrice: 200.00, description: '天然润肤剂,亲肤性极佳' },
|
||||
{ inciName: 'Water', chineseName: '去离子水', functionCategory: IngredientCategory.other, supplier: '自制', unitPrice: 0.10, description: '最常用溶剂,化妆品基础成分' },
|
||||
{ inciName: 'Alcohol Denat.', chineseName: '变性乙醇', functionCategory: IngredientCategory.other, supplier: '默克', unitPrice: 30.00, description: '溶剂,收剑毛孔,需注意刺激性' },
|
||||
{ inciName: 'Cyclopentasiloxane', chineseName: '环五聚二甲基硅氧烷', functionCategory: IngredientCategory.emollient, supplier: '陶氏化学', unitPrice: 80.00, description: '挥发性硅油,赋予丝滑肤感' },
|
||||
{ inciName: 'Allantoin', chineseName: '尿囊素', functionCategory: IngredientCategory.other, supplier: '默克', unitPrice: 100.00, description: '舒缓抗炎成分,促进伤口愈合' },
|
||||
{ inciName: 'Niacinamide', chineseName: '烟酰胺', functionCategory: IngredientCategory.humectant, supplier: 'DSM', unitPrice: 350.00, description: '维生素B3,美白控油,多功能活性物' },
|
||||
{ inciName: 'Salicylic Acid', chineseName: '水杨酸', functionCategory: IngredientCategory.other, supplier: '默克', unitPrice: 60.00, description: '角质剥脱成分,改善粉刺' },
|
||||
{ inciName: 'Urea', chineseName: '尿素', functionCategory: IngredientCategory.humectant, supplier: '默克', unitPrice: 15.00, description: '天然保湿因子(NMF),温和去角质' },
|
||||
]
|
||||
|
||||
async function main() {
|
||||
console.log('开始导入成分种子数据...')
|
||||
const adminExists = await prisma.user.findUnique({ where: { username: 'admin' } })
|
||||
if (adminExists) {
|
||||
await prisma.user.update({
|
||||
where: { username: 'admin' },
|
||||
data: { passwordHash: makeHash('admin123') },
|
||||
})
|
||||
} else {
|
||||
await prisma.user.create({
|
||||
data: { username: 'admin', passwordHash: makeHash('admin123'), role: 'admin' },
|
||||
})
|
||||
}
|
||||
console.log('管理员账号已就绪 (admin / admin123)')
|
||||
|
||||
await prisma.ingredient.createMany({
|
||||
data: ingredients,
|
||||
@@ -54,6 +72,65 @@ async function main() {
|
||||
|
||||
const count = await prisma.ingredient.count()
|
||||
console.log(`成分种子数据导入完成!共 ${count} 条记录`)
|
||||
|
||||
const pantoneColors = [
|
||||
{ code: '185 C', name: 'Vibrant Red', L: 48, a: 68, b: 48 },
|
||||
{ code: '186 C', name: 'Strong Red', L: 44, a: 63, b: 42 },
|
||||
{ code: '286 C', name: 'Strong Blue', L: 30, a: 12, b: -52 },
|
||||
{ code: '287 C', name: 'Deep Blue', L: 27, a: 10, b: -48 },
|
||||
{ code: '354 C', name: 'Bright Green', L: 55, a: -52, b: 28 },
|
||||
{ code: '355 C', name: 'Deep Green', L: 50, a: -48, b: 24 },
|
||||
{ code: '109 C', name: 'Golden Yellow', L: 82, a: 8, b: 95 },
|
||||
{ code: '110 C', name: 'Deep Yellow', L: 76, a: 10, b: 85 },
|
||||
{ code: '151 C', name: 'Orange', L: 65, a: 40, b: 70 },
|
||||
{ code: '152 C', name: 'Deep Orange', L: 60, a: 35, b: 62 },
|
||||
{ code: '205 C', name: 'Pink', L: 58, a: 42, b: -2 },
|
||||
{ code: '206 C', name: 'Deep Pink', L: 50, a: 48, b: -4 },
|
||||
{ code: 'Process Black C', name: 'Black', L: 18, a: 1, b: 0 },
|
||||
{ code: 'Warm Red C', name: 'Warm Red', L: 48, a: 65, b: 42 },
|
||||
{ code: 'Cool Gray 7 C', name: 'Cool Gray', L: 58, a: 0, b: -2 },
|
||||
{ code: '7499 C', name: 'Pale Green', L: 72, a: -20, b: 22 },
|
||||
{ code: '7416 C', name: 'Coral Pink', L: 55, a: 38, b: 18 },
|
||||
{ code: '7417 C', name: 'Deep Coral', L: 50, a: 42, b: 22 },
|
||||
{ code: '7421 C', name: 'Rose', L: 42, a: 50, b: -8 },
|
||||
{ code: '7520 C', name: 'Peach', L: 70, a: 18, b: 28 },
|
||||
{ code: '7548 C', name: 'Warm Beige', L: 72, a: 8, b: 15 },
|
||||
{ code: '7610 C', name: 'Light Beige', L: 78, a: 4, b: 10 },
|
||||
{ code: '7612 C', name: 'Sand', L: 65, a: 6, b: 14 },
|
||||
{ code: '7614 C', name: 'Taupe', L: 55, a: 4, b: 8 },
|
||||
{ code: '7621 C', name: 'Rose Gold', L: 62, a: 22, b: 10 },
|
||||
]
|
||||
|
||||
await prisma.pantoneColor.createMany({
|
||||
data: pantoneColors,
|
||||
skipDuplicates: true,
|
||||
})
|
||||
console.log(`潘通色种子数据导入完成!共 ${pantoneColors.length} 条记录`)
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username: 'admin' } })
|
||||
if (user) {
|
||||
const demoIngs = await prisma.ingredient.findMany({ take: 4 })
|
||||
const formulaExists = await prisma.formula.findFirst({ where: { name: '基础保湿精华(示例)' } })
|
||||
if (!formulaExists && demoIngs.length >= 2) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const f = await tx.formula.create({
|
||||
data: { name: '基础保湿精华(示例)', description: '种子数据示例配方', createdBy: user.id, currentVersion: 1 },
|
||||
})
|
||||
const v = await tx.formulaVersion.create({
|
||||
data: { formulaId: f.id, versionNumber: 1, description: '初始版本', snapshotData: {}, createdBy: user.id },
|
||||
})
|
||||
const phase = await tx.phase.create({ data: { name: '水相', formulaId: v.id, sortOrder: 0 } })
|
||||
await tx.formulaIngredient.createMany({
|
||||
data: [
|
||||
{ formulaVersionId: v.id, phaseId: phase.id, ingredientId: demoIngs[0]!.id, percentage: 80 },
|
||||
{ formulaVersionId: v.id, phaseId: phase.id, ingredientId: demoIngs[1]!.id, percentage: 15 },
|
||||
{ formulaVersionId: v.id, phaseId: phase.id, ingredientId: demoIngs[2]!.id, percentage: 5 },
|
||||
],
|
||||
})
|
||||
})
|
||||
console.log('示例配方创建成功')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
7
backend/runtime/config.json
Normal file
7
backend/runtime/config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"AI_MOCK": "true",
|
||||
"OPENAI_API_KEY": "",
|
||||
"DEEPSEEK_API_KEY": "",
|
||||
"OPENAI_BASE_URL": "",
|
||||
"DEEPSEEK_BASE_URL": ""
|
||||
}
|
||||
23
backend/scripts/generate-openapi.ts
Normal file
23
backend/scripts/generate-openapi.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { writeFileSync, mkdirSync, existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
async function main() {
|
||||
const app = await buildApp({ skipAuth: true })
|
||||
await app.ready()
|
||||
|
||||
const spec = app.swagger()
|
||||
|
||||
const outDir = join(import.meta.dirname, '..', 'generated')
|
||||
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true })
|
||||
|
||||
writeFileSync(join(outDir, 'openapi.json'), JSON.stringify(spec, null, 2))
|
||||
|
||||
console.log(`OpenAPI spec written to generated/openapi.json (${JSON.stringify(spec).length} bytes)`)
|
||||
await app.close()
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,16 +1,41 @@
|
||||
import Fastify from 'fastify'
|
||||
import type { FastifyError } from 'fastify'
|
||||
import type { FastifyError, FastifyRequest, FastifyReply } 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'
|
||||
import helmet from '@fastify/helmet'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
import swagger from '@fastify/swagger'
|
||||
import swaggerUi from '@fastify/swagger-ui'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { healthRoutes } from './modules/health/health.route.js'
|
||||
import { ingredientRoutes } from './modules/ingredients/ingredients.route.js'
|
||||
import { formulaRoutes } from './modules/formulas/formulas.route.js'
|
||||
import { aiRoutes } from './modules/ai/ai.route.js'
|
||||
import { colorRoutes } from './modules/color/color.route.js'
|
||||
import { projectRoutes } from './modules/projects/projects.route.js'
|
||||
import { authRoutes } from './modules/auth/auth.route.js'
|
||||
import { verifyToken } from './modules/auth/auth.route.js'
|
||||
import { configRoutes } from './modules/config/config.route.js'
|
||||
import { prisma } from './lib/prisma.js'
|
||||
import { initConfig } from './lib/configStore.js'
|
||||
import { AppError } from './shared/errors/app-error.js'
|
||||
import { appErrorsTotal } from './shared/metrics/metrics.js'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
userId: string
|
||||
}
|
||||
}
|
||||
|
||||
interface BuildOptions {
|
||||
skipAuth?: boolean
|
||||
}
|
||||
|
||||
const BEARER_RE = /^Bearer (.+)$/
|
||||
const PUBLIC_PREFIXES = ['/api/auth/', '/api/health', '/api/metrics']
|
||||
|
||||
export async function buildApp(options: BuildOptions = {}) {
|
||||
initConfig()
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
transport: {
|
||||
@@ -18,6 +43,7 @@ export async function buildApp() {
|
||||
options: { colorize: true },
|
||||
},
|
||||
},
|
||||
genReqId: () => randomUUID(),
|
||||
})
|
||||
|
||||
await app.register(cors, {
|
||||
@@ -25,23 +51,103 @@ export async function buildApp() {
|
||||
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
|
||||
await app.register(helmet, {
|
||||
contentSecurityPolicy: false,
|
||||
})
|
||||
|
||||
await app.register(rateLimit, {
|
||||
max: 100,
|
||||
timeWindow: '1 minute',
|
||||
})
|
||||
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: '配方研发智能平台 API',
|
||||
version: '0.1.0',
|
||||
description: 'AI 驱动的化妆品配方研发辅助工具',
|
||||
},
|
||||
servers: [{ url: 'http://localhost:3001' }],
|
||||
},
|
||||
})
|
||||
|
||||
await app.register(swaggerUi, {
|
||||
routePrefix: '/docs',
|
||||
})
|
||||
|
||||
app.setErrorHandler((error: FastifyError | Error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
appErrorsTotal.inc({ category: error.category, module: error.module ?? 'unknown', code: error.code })
|
||||
request.log.warn({ err: error.toJSON(), requestId: request.id }, `app_error: ${error.code}`)
|
||||
return reply.status(error.httpStatus).send({
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
statusCode: error.httpStatus,
|
||||
})
|
||||
}
|
||||
|
||||
const fastifyErr = error as FastifyError
|
||||
const statusCode = fastifyErr.statusCode ?? 500
|
||||
appErrorsTotal.inc({ category: 'internal', module: 'http', code: 'INTERNAL_ERROR' })
|
||||
request.log.error({ err: error, requestId: request.id }, 'unhandled_error')
|
||||
|
||||
reply.status(statusCode).send({
|
||||
error: statusCode >= 500 ? 'Internal Server Error' : error.message,
|
||||
code: 'INTERNAL_ERROR',
|
||||
statusCode,
|
||||
})
|
||||
})
|
||||
|
||||
app.decorateRequest('userId', '')
|
||||
|
||||
app.addHook('onRequest', async (request) => {
|
||||
request.log = request.log.child({ requestId: request.id })
|
||||
})
|
||||
|
||||
app.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (options.skipAuth) {
|
||||
request.userId = 'system'
|
||||
return
|
||||
}
|
||||
|
||||
if (PUBLIC_PREFIXES.some(p => request.url.startsWith(p))) {
|
||||
return
|
||||
}
|
||||
|
||||
const authHeader = request.headers.authorization
|
||||
if (!authHeader) {
|
||||
return reply.status(401).send({ error: '缺少认证 Token', code: 'AUTH_TOKEN_MISSING', statusCode: 401 })
|
||||
}
|
||||
|
||||
const match = authHeader.match(BEARER_RE)
|
||||
if (!match || !match[1]) {
|
||||
return reply.status(401).send({ error: 'Token 格式错误,应为 Bearer <token>', code: 'AUTH_TOKEN_INVALID', statusCode: 401 })
|
||||
}
|
||||
|
||||
const payload = verifyToken(match[1])
|
||||
if (!payload || !payload.userId) {
|
||||
return reply.status(401).send({ error: 'Token 无效或已过期', code: 'AUTH_TOKEN_EXPIRED', statusCode: 401 })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.userId as string },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return reply.status(401).send({ error: '用户不存在', code: 'AUTH_USER_NOT_FOUND', statusCode: 401 })
|
||||
}
|
||||
|
||||
request.userId = user.id
|
||||
})
|
||||
|
||||
await app.register(healthRoutes, { prefix: '/api' })
|
||||
await app.register(authRoutes, { prefix: '/api/auth' })
|
||||
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
|
||||
|
||||
60
backend/src/lib/configStore.ts
Normal file
60
backend/src/lib/configStore.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const CONFIG_DIR = join(process.cwd(), 'runtime')
|
||||
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
||||
|
||||
interface RuntimeConfig {
|
||||
AI_MOCK: string
|
||||
OPENAI_API_KEY: string
|
||||
DEEPSEEK_API_KEY: string
|
||||
OPENAI_BASE_URL: string
|
||||
DEEPSEEK_BASE_URL: string
|
||||
}
|
||||
|
||||
function loadFromFile(): Partial<RuntimeConfig> {
|
||||
if (!existsSync(CONFIG_FILE)) return {}
|
||||
try {
|
||||
const raw = readFileSync(CONFIG_FILE, 'utf-8')
|
||||
return JSON.parse(raw) as Partial<RuntimeConfig>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function saveToFile(config: Partial<RuntimeConfig>): void {
|
||||
if (!existsSync(CONFIG_DIR)) {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true })
|
||||
}
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
const config: RuntimeConfig = {
|
||||
AI_MOCK: process.env['AI_MOCK'] ?? 'true',
|
||||
OPENAI_API_KEY: process.env['OPENAI_API_KEY'] ?? '',
|
||||
DEEPSEEK_API_KEY: process.env['DEEPSEEK_API_KEY'] ?? '',
|
||||
OPENAI_BASE_URL: process.env['OPENAI_BASE_URL'] ?? '',
|
||||
DEEPSEEK_BASE_URL: process.env['DEEPSEEK_BASE_URL'] ?? '',
|
||||
...loadFromFile(),
|
||||
}
|
||||
|
||||
export function getConfig(): Readonly<RuntimeConfig> {
|
||||
return config
|
||||
}
|
||||
|
||||
export function updateConfig(updates: Partial<RuntimeConfig>): void {
|
||||
Object.assign(config, updates)
|
||||
saveToFile(config)
|
||||
}
|
||||
|
||||
export function getConfigStatus() {
|
||||
return {
|
||||
aiMock: config.AI_MOCK,
|
||||
hasOpenAI: config.OPENAI_API_KEY !== '',
|
||||
hasDeepseek: config.DEEPSEEK_API_KEY !== '',
|
||||
}
|
||||
}
|
||||
|
||||
export function initConfig(): void {
|
||||
saveToFile(config)
|
||||
}
|
||||
24
backend/src/lib/swagger.ts
Normal file
24
backend/src/lib/swagger.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema'
|
||||
import type { z } from 'zod'
|
||||
|
||||
type AnyZod = z.ZodTypeAny
|
||||
|
||||
export function zSchema(zod: AnyZod): Record<string, unknown> {
|
||||
return zodToJsonSchema(zod as never, { target: 'openApi3' }) as Record<string, unknown>
|
||||
}
|
||||
|
||||
export function routeSchema(def: {
|
||||
body?: AnyZod
|
||||
query?: AnyZod
|
||||
response?: AnyZod
|
||||
summary?: string
|
||||
tags?: string[]
|
||||
}): Record<string, unknown> {
|
||||
const schema: Record<string, unknown> = {}
|
||||
if (def.summary) schema.summary = def.summary
|
||||
if (def.tags) schema.tags = def.tags
|
||||
if (def.body) schema.body = zSchema(def.body)
|
||||
if (def.query) schema.querystring = zSchema(def.query)
|
||||
if (def.response) schema.response = { 200: zSchema(def.response) }
|
||||
return schema
|
||||
}
|
||||
27
backend/src/lib/validate.ts
Normal file
27
backend/src/lib/validate.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { FastifyReply } from 'fastify'
|
||||
import { ZodError, type ZodSchema } from 'zod'
|
||||
|
||||
export function validate<T>(schema: ZodSchema<T>, data: unknown): { success: true; data: T } | { success: false; errors: string } {
|
||||
try {
|
||||
return { success: true, data: schema.parse(data) }
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
const messages = err.issues.map(e => `${e.path.join('.')}: ${e.message}`).join('; ')
|
||||
return { success: false, errors: messages }
|
||||
}
|
||||
return { success: false, errors: '参数校验失败' }
|
||||
}
|
||||
}
|
||||
|
||||
export function validateOrReply<T>(schema: ZodSchema<T>, data: unknown, reply: FastifyReply): T | null {
|
||||
if (data === undefined || data === null) {
|
||||
reply.status(400).send({ error: '请求体不能为空' })
|
||||
return null
|
||||
}
|
||||
const result = validate(schema, data)
|
||||
if (!result.success) {
|
||||
reply.status(400).send({ error: result.errors })
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { aiService } from '../services/ai/index.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { aiService } from '../../services/ai/index.js'
|
||||
import { exploreWithConstraints } from '../../services/formulaOptimizer.js'
|
||||
import { predictFormulaSchema, exploreFormulaSchema, extractFormulaSchema } from './ai.schema.js'
|
||||
import { validateOrReply } from '../../lib/validate.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: '成分列表不能为空' })
|
||||
}
|
||||
async function predictFormula(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = validateOrReply(predictFormulaSchema, request.body, reply)
|
||||
if (!body) return
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -15,7 +16,7 @@ async function predictFormula(request: FastifyRequest<{ Body: { ingredients: Arr
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await aiService.predictMetrics(ingredients)
|
||||
const result = await aiService.predictMetrics(body.ingredients)
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'result', content: result })}\n\n`)
|
||||
} catch (err) {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'error', content: (err as Error).message })}\n\n`)
|
||||
@@ -23,14 +24,9 @@ async function predictFormula(request: FastifyRequest<{ Body: { ingredients: Arr
|
||||
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: '至少设置一个约束条件' })
|
||||
}
|
||||
async function exploreFormula(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = validateOrReply(exploreFormulaSchema, request.body, reply)
|
||||
if (!body) return
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -38,37 +34,67 @@ async function exploreFormula(request: FastifyRequest<{ Body: {
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
let sentOptions = 0
|
||||
|
||||
if (body.costLimit || body.baseFormulaId) {
|
||||
try {
|
||||
const localOptions = await exploreWithConstraints({
|
||||
baseFormulaId: body.baseFormulaId,
|
||||
costLimit: body.costLimit,
|
||||
keepIngredients: body.keepIngredients,
|
||||
excludeIngredients: body.excludeIngredients,
|
||||
})
|
||||
for (const option of localOptions) {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'option', option })}\n\n`)
|
||||
sentOptions++
|
||||
}
|
||||
} catch (err) {
|
||||
request.log.warn({ err }, 'local optimizer failed')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await aiService.generateFormula(constraints)
|
||||
const result = await aiService.generateFormula({
|
||||
baseFormulaName: body.baseFormulaName,
|
||||
baseIngredients: body.baseIngredients,
|
||||
costLimit: body.costLimit,
|
||||
keepIngredients: body.keepIngredients,
|
||||
excludeIngredients: body.excludeIngredients,
|
||||
targetMetrics: body.targetMetrics as Record<string, number> | undefined,
|
||||
})
|
||||
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`)
|
||||
sentOptions++
|
||||
}
|
||||
} catch (err) {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'error', content: (err as Error).message })}\n\n`)
|
||||
}
|
||||
|
||||
if (sentOptions === 0) {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'option', option: { name: '无可用方案', changes: [], predictedMetrics: {}, reasoning: '请调整约束条件后重试' } })}\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: '配方文本不能为空' })
|
||||
}
|
||||
async function extractFormula(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = validateOrReply(extractFormulaSchema, request.body, reply)
|
||||
if (!body) return
|
||||
|
||||
try {
|
||||
const result = await aiService.extractFormula(text)
|
||||
const result = await aiService.extractFormula(body.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 提取失败,请重试' })
|
||||
return reply.status(500).send({ error: 'AI 提取失败,请重试', code: 'AI_EXTRACTION_FAILED', statusCode: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function nlSearch(request: FastifyRequest<{ Querystring: { q: string } }>, reply: FastifyReply) {
|
||||
const q = request.query.q?.trim()
|
||||
async function nlSearch(request: FastifyRequest, reply: FastifyReply) {
|
||||
const q = (request.query as Record<string, string>)['q']?.trim()
|
||||
if (!q) return reply.status(400).send({ error: '搜索词不能为空' })
|
||||
|
||||
try {
|
||||
23
backend/src/modules/ai/ai.schema.ts
Normal file
23
backend/src/modules/ai/ai.schema.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const predictFormulaSchema = z.object({
|
||||
ingredients: z.array(z.object({
|
||||
name: z.string(),
|
||||
percentage: z.number().gt(0),
|
||||
category: z.string(),
|
||||
})).min(1, '成分列表不能为空'),
|
||||
})
|
||||
|
||||
export const exploreFormulaSchema = z.object({
|
||||
baseFormulaId: z.string().optional(),
|
||||
baseFormulaName: z.string().optional(),
|
||||
baseIngredients: z.array(z.object({ name: z.string(), percentage: z.number() })).optional(),
|
||||
costLimit: z.number().gt(0).optional(),
|
||||
keepIngredients: z.array(z.string()).optional(),
|
||||
excludeIngredients: z.array(z.string()).optional(),
|
||||
targetMetrics: z.record(z.string(), z.number()).optional(),
|
||||
})
|
||||
|
||||
export const extractFormulaSchema = z.object({
|
||||
text: z.string().min(1, '配方文本不能为空'),
|
||||
})
|
||||
91
backend/src/modules/auth/auth.route.ts
Normal file
91
backend/src/modules/auth/auth.route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { randomBytes, scrypt, timingSafeEqual, createHmac } from 'crypto'
|
||||
import { promisify } from 'util'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { registerSchema, loginSchema } from './auth.schema.js'
|
||||
import { validateOrReply } from '../../lib/validate.js'
|
||||
import { routeSchema } from '../../lib/swagger.js'
|
||||
|
||||
const scryptAsync = promisify(scrypt)
|
||||
|
||||
export const JWT_SECRET = process.env['JWT_SECRET'] ?? 'dev-secret-change-me'
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16)
|
||||
const hash = await scryptAsync(password, salt, 64) as Buffer
|
||||
return `${salt.toString('hex')}:${hash.toString('hex')}`
|
||||
}
|
||||
|
||||
async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const [salt, hash] = stored.split(':')
|
||||
if (!salt || !hash) return false
|
||||
const computed = await scryptAsync(password, Buffer.from(salt, 'hex'), 64) as Buffer
|
||||
return timingSafeEqual(Buffer.from(hash, 'hex'), 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 = createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url')
|
||||
return `${header}.${body}.${sig}`
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const expected = createHmac('sha256', JWT_SECRET).update(`${parts[0]}.${parts[1]}`).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, reply: FastifyReply) {
|
||||
const body = validateOrReply(registerSchema, request.body, reply)
|
||||
if (!body) return
|
||||
const { username, password } = body
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username } })
|
||||
if (existing) return reply.status(409).send({ error: '用户名已存在', code: 'AUTH_USERNAME_EXISTS', statusCode: 409 })
|
||||
|
||||
const user = await prisma.user.create({ data: { username, passwordHash: await 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, reply: FastifyReply) {
|
||||
const body = validateOrReply(loginSchema, request.body, reply)
|
||||
if (!body) return
|
||||
const { username, password } = body
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username } })
|
||||
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
||||
return reply.status(401).send({ error: '用户名或密码错误', code: 'AUTH_INVALID_CREDENTIALS', statusCode: 401 })
|
||||
}
|
||||
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: '未认证', code: 'AUTH_TOKEN_MISSING', statusCode: 401 })
|
||||
const payload = verifyToken(auth.slice(7))
|
||||
if (!payload) return reply.status(401).send({ error: 'Token 无效', code: 'AUTH_TOKEN_INVALID', statusCode: 401 })
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.userId as string } })
|
||||
if (!user) return reply.status(401).send({ error: '用户不存在', code: 'AUTH_USER_NOT_FOUND', statusCode: 401 })
|
||||
return reply.send({ data: { id: user.id, username: user.username, role: user.role } })
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/register', {
|
||||
schema: routeSchema({ body: registerSchema, summary: '注册新用户', tags: ['auth'] }),
|
||||
}, register)
|
||||
app.post('/login', {
|
||||
schema: routeSchema({ body: loginSchema, summary: '用户登录', tags: ['auth'] }),
|
||||
}, login)
|
||||
app.get('/me', {
|
||||
schema: routeSchema({ summary: '获取当前用户信息', tags: ['auth'] }),
|
||||
}, me)
|
||||
}
|
||||
11
backend/src/modules/auth/auth.schema.ts
Normal file
11
backend/src/modules/auth/auth.schema.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const registerSchema = z.object({
|
||||
username: z.string().min(1, '用户名不能为空'),
|
||||
password: z.string().min(4, '密码至少4位'),
|
||||
})
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(1, '用户名不能为空'),
|
||||
password: z.string().min(1, '密码不能为空'),
|
||||
})
|
||||
158
backend/src/modules/color/color.route.ts
Normal file
158
backend/src/modules/color/color.route.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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'
|
||||
import {
|
||||
recommendColorSchema, matchColorQuerySchema, saveColorFormulaSchema, pantoneQuerySchema,
|
||||
} from './color.schema.js'
|
||||
import { validateOrReply } from '../../lib/validate.js'
|
||||
import { routeSchema } from '../../lib/swagger.js'
|
||||
import Color from 'colorjs.io'
|
||||
|
||||
interface LabInput {
|
||||
L: number; a: number; b: number
|
||||
}
|
||||
|
||||
function deltaE2000(lab1: LabInput, lab2: LabInput): number {
|
||||
const c1 = new Color('lab', [lab1.L, lab1.a, lab1.b])
|
||||
const c2 = new Color('lab', [lab2.L, lab2.a, lab2.b])
|
||||
return c1.deltaE(c2, '2000')
|
||||
}
|
||||
|
||||
async function recommend(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = validateOrReply(recommendColorSchema, request.body, reply)
|
||||
if (!body) return
|
||||
const { targetLab } = body
|
||||
|
||||
const candidates = await prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT id FROM color_formulas
|
||||
ORDER BY cube_distance(
|
||||
cube(ARRAY[COALESCE(target_lab->>'L','0')::float, COALESCE(target_lab->>'a','0')::float, COALESCE(target_lab->>'b','0')::float]),
|
||||
cube(ARRAY[${targetLab.L}, ${targetLab.a}, ${targetLab.b}])
|
||||
)
|
||||
LIMIT 50
|
||||
`
|
||||
|
||||
const candidateIds = candidates.map(c => c.id)
|
||||
const matched = candidateIds.length > 0
|
||||
? await prisma.colorFormula.findMany({
|
||||
where: { id: { in: candidateIds } },
|
||||
select: { id: true, name: true, targetLab: true, actualLab: true, deltaE: true },
|
||||
})
|
||||
: []
|
||||
|
||||
const ordered = matched
|
||||
.map(f => {
|
||||
const tl = f.targetLab as unknown as LabInput
|
||||
return { ...f, distance: deltaE2000(targetLab, tl) }
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, 5)
|
||||
|
||||
const colorants = await prisma.ingredient.findMany({
|
||||
where: { functionCategory: 'colorant' },
|
||||
select: { inciName: true, chineseName: true },
|
||||
})
|
||||
|
||||
const aiResult = await aiService.recommendColorants(
|
||||
targetLab,
|
||||
colorants.map(c => c.inciName),
|
||||
)
|
||||
let recommendations: Array<Record<string, unknown>> = []
|
||||
try {
|
||||
const parsed = JSON.parse(aiResult) as { recommendations?: Array<Record<string, unknown>> }
|
||||
recommendations = parsed.recommendations ?? []
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
recommendations,
|
||||
matchedFormulas: ordered.map(m => ({ id: m.id, name: m.name, deltaE: m.deltaE ?? m.distance })),
|
||||
})
|
||||
}
|
||||
|
||||
async function matchFormulas(request: FastifyRequest, reply: FastifyReply) {
|
||||
const query = validateOrReply(matchColorQuerySchema, request.query, reply)
|
||||
if (!query) return
|
||||
const { L, a, b, limit } = query
|
||||
|
||||
const candidates = await prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT id FROM color_formulas
|
||||
ORDER BY cube_distance(
|
||||
cube(ARRAY[COALESCE(target_lab->>'L','0')::float, COALESCE(target_lab->>'a','0')::float, COALESCE(target_lab->>'b','0')::float]),
|
||||
cube(ARRAY[${L}, ${a}, ${b}])
|
||||
)
|
||||
LIMIT ${limit * 2}
|
||||
`
|
||||
|
||||
const candidateIds = candidates.map(c => c.id)
|
||||
const matched = candidateIds.length > 0
|
||||
? await prisma.colorFormula.findMany({
|
||||
where: { id: { in: candidateIds } },
|
||||
select: { id: true, name: true, targetLab: true, deltaE: true, colorantComposition: true },
|
||||
})
|
||||
: []
|
||||
|
||||
const target: LabInput = { L, a, b }
|
||||
const ordered = matched
|
||||
.map(f => ({ ...f, distance: deltaE2000(target, f.targetLab as unknown as LabInput) }))
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, limit)
|
||||
|
||||
return reply.send({ data: ordered })
|
||||
}
|
||||
|
||||
async function saveColorFormula(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = validateOrReply(saveColorFormulaSchema, request.body, reply)
|
||||
if (!body) return
|
||||
|
||||
const formula = await prisma.colorFormula.create({
|
||||
data: {
|
||||
name: body.name ?? '未命名颜色配方',
|
||||
targetLab: body.targetLab as unknown as Prisma.InputJsonValue,
|
||||
actualLab: body.actualLab as unknown as Prisma.InputJsonValue ?? null,
|
||||
deltaE: body.deltaE ?? null,
|
||||
colorantComposition: body.colorantComposition as unknown as Prisma.InputJsonValue ?? null,
|
||||
formulaId: body.formulaId ?? null,
|
||||
createdBy: request.userId,
|
||||
},
|
||||
})
|
||||
return reply.status(201).send({ data: formula })
|
||||
}
|
||||
|
||||
async function listPantone(request: FastifyRequest, reply: FastifyReply) {
|
||||
const query = validateOrReply(pantoneQuerySchema, request.query, reply)
|
||||
if (!query) return
|
||||
const { search, page, limit } = query
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
if (search && search.length >= 1) {
|
||||
where.OR = [
|
||||
{ code: { contains: search, mode: 'insensitive' } },
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.pantoneColor.findMany({
|
||||
where,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { code: 'asc' },
|
||||
}),
|
||||
prisma.pantoneColor.count({ where }),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
data,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
})
|
||||
}
|
||||
|
||||
export async function colorRoutes(app: FastifyInstance) {
|
||||
app.post('/recommend', { schema: routeSchema({ body: recommendColorSchema, summary: 'AI 配色推荐', tags: ['color'] }) }, recommend)
|
||||
app.get('/formulas/match', { schema: routeSchema({ summary: '颜色配方匹配', tags: ['color'] }) }, matchFormulas)
|
||||
app.post('/formulas', { schema: routeSchema({ body: saveColorFormulaSchema, summary: '保存颜色配方', tags: ['color'] }) }, saveColorFormula)
|
||||
app.get('/pantone', { schema: routeSchema({ summary: '潘通色搜索', tags: ['color'] }) }, listPantone)
|
||||
}
|
||||
33
backend/src/modules/color/color.schema.ts
Normal file
33
backend/src/modules/color/color.schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const labColorSchema = z.object({
|
||||
L: z.number().min(0).max(100),
|
||||
a: z.number(),
|
||||
b: z.number(),
|
||||
})
|
||||
|
||||
export const recommendColorSchema = z.object({
|
||||
targetLab: labColorSchema,
|
||||
})
|
||||
|
||||
export const matchColorQuerySchema = z.object({
|
||||
L: z.coerce.number().min(0).max(100),
|
||||
a: z.coerce.number(),
|
||||
b: z.coerce.number(),
|
||||
limit: z.coerce.number().int().min(1).max(20).default(5),
|
||||
})
|
||||
|
||||
export const saveColorFormulaSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
targetLab: labColorSchema,
|
||||
actualLab: labColorSchema.optional(),
|
||||
deltaE: z.number().gte(0).optional(),
|
||||
colorantComposition: z.unknown().optional(),
|
||||
formulaId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const pantoneQuerySchema = z.object({
|
||||
search: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
44
backend/src/modules/config/config.route.ts
Normal file
44
backend/src/modules/config/config.route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { getConfigStatus, updateConfig } from '../../lib/configStore.js'
|
||||
import { aiService } from '../../services/ai/index.js'
|
||||
import { updateConfigSchema, testApiSchema } from './config.schema.js'
|
||||
import { validateOrReply } from '../../lib/validate.js'
|
||||
import { requireRole } from '../../shared/middleware/rbac.js'
|
||||
|
||||
async function getConfig(_request: FastifyRequest, reply: FastifyReply) {
|
||||
return reply.send(getConfigStatus())
|
||||
}
|
||||
|
||||
async function updateConfigHandler(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = validateOrReply(updateConfigSchema, request.body, reply)
|
||||
if (!body) return
|
||||
|
||||
const updates: Record<string, string> = {}
|
||||
if (body.aiMock !== undefined) updates.AI_MOCK = body.aiMock
|
||||
if (body.openaiKey) updates.OPENAI_API_KEY = body.openaiKey
|
||||
if (body.deepseekKey) updates.DEEPSEEK_API_KEY = body.deepseekKey
|
||||
if (body.openaiBaseUrl !== undefined) updates.OPENAI_BASE_URL = body.openaiBaseUrl
|
||||
if (body.deepseekBaseUrl !== undefined) updates.DEEPSEEK_BASE_URL = body.deepseekBaseUrl
|
||||
|
||||
updateConfig(updates)
|
||||
aiService.reload(updates)
|
||||
return reply.send({ ok: true })
|
||||
}
|
||||
|
||||
async function testApi(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = validateOrReply(testApiSchema, request.body, reply)
|
||||
if (!body) return
|
||||
|
||||
try {
|
||||
const result = await aiService.testConnection(body.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('/', { preHandler: [requireRole('admin')] }, getConfig)
|
||||
app.put('/', { preHandler: [requireRole('admin')] }, updateConfigHandler)
|
||||
app.post('/test', { preHandler: [requireRole('admin')] }, testApi)
|
||||
}
|
||||
13
backend/src/modules/config/config.schema.ts
Normal file
13
backend/src/modules/config/config.schema.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const updateConfigSchema = z.object({
|
||||
openaiKey: z.string().optional(),
|
||||
deepseekKey: z.string().optional(),
|
||||
openaiBaseUrl: z.string().optional(),
|
||||
deepseekBaseUrl: z.string().optional(),
|
||||
aiMock: z.string().optional(),
|
||||
})
|
||||
|
||||
export const testApiSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
})
|
||||
189
backend/src/modules/formulas/formulas.repository.ts
Normal file
189
backend/src/modules/formulas/formulas.repository.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import type { Prisma as PrismaNS } from '../../generated/prisma/client.js'
|
||||
import type { CreateFormulaInput, FormulaQueryInput } from './formulas.schema.js'
|
||||
|
||||
interface PhaseInput {
|
||||
name: string
|
||||
sortOrder?: number
|
||||
ingredients: { ingredientId: string; percentage: number; processNotes?: string }[]
|
||||
}
|
||||
|
||||
export const formulaRepository = {
|
||||
async list(query: FormulaQueryInput) {
|
||||
const { projectId, search, page, limit, sortBy = 'updatedAt', sortOrder = 'desc' } = query
|
||||
|
||||
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: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { [sortBy]: sortOrder },
|
||||
include: { project: { select: { id: true, name: true } } },
|
||||
}),
|
||||
prisma.formula.count({ where }),
|
||||
])
|
||||
|
||||
return { data, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) } }
|
||||
},
|
||||
|
||||
getById(id: string) {
|
||||
return prisma.formula.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
versions: {
|
||||
orderBy: { versionNumber: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
phases: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { ingredients: { include: { ingredient: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
project: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
async create(input: CreateFormulaInput, createdBy: string) {
|
||||
const { name, description, projectId, phases } = input
|
||||
|
||||
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 PrismaNS.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
|
||||
})
|
||||
|
||||
return formula.id
|
||||
},
|
||||
|
||||
updateMeta(id: string, data: { name?: string; description?: string }) {
|
||||
return prisma.formula.update({ where: { id }, data })
|
||||
},
|
||||
|
||||
async updateComposition(id: string, phases: PhaseInput[], createdBy: string) {
|
||||
const existing = await prisma.formula.findUnique({ where: { id } })
|
||||
if (!existing) return null
|
||||
|
||||
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 PrismaNS.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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await tx.formula.update({
|
||||
where: { id: existing.id },
|
||||
data: { currentVersion: newVersionNumber },
|
||||
})
|
||||
|
||||
return tx.formula.findUnique({
|
||||
where: { id: existing.id },
|
||||
include: {
|
||||
versions: {
|
||||
orderBy: { versionNumber: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
phases: { include: { ingredients: { include: { ingredient: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return formula
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const versions = await tx.formulaVersion.findMany({
|
||||
where: { formulaId: id },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
for (const v of versions) {
|
||||
await tx.phase.deleteMany({ where: { formulaId: v.id } })
|
||||
await tx.formulaIngredient.deleteMany({ where: { formulaVersionId: v.id } })
|
||||
}
|
||||
await tx.formulaVersion.deleteMany({ where: { formulaId: id } })
|
||||
await tx.formula.delete({ where: { id } })
|
||||
})
|
||||
},
|
||||
}
|
||||
91
backend/src/modules/formulas/formulas.route.ts
Normal file
91
backend/src/modules/formulas/formulas.route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { formulaService } from './formulas.service.js'
|
||||
import { createFormulaSchema, updateCompositionSchema, formulaQuerySchema } from './formulas.schema.js'
|
||||
import { validateOrReply } from '../../lib/validate.js'
|
||||
import { routeSchema } from '../../lib/swagger.js'
|
||||
import { requireFormulaOwnership } from '../../shared/middleware/ownership.js'
|
||||
import type { CreateFormulaInput, UpdateCompositionInput, FormulaQueryInput } from './formulas.schema.js'
|
||||
|
||||
async function createFormula(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = validateOrReply(createFormulaSchema, request.body, reply)
|
||||
if (!body) return
|
||||
const input = body as CreateFormulaInput
|
||||
|
||||
const percentError = formulaService.validate(input.phases)
|
||||
if (percentError) {
|
||||
return reply.status(400).send({ error: percentError, code: 'FORMULA_PERCENTAGE_TOTAL_INVALID', statusCode: 400 })
|
||||
}
|
||||
|
||||
const result = await formulaService.create(input, request.userId)
|
||||
return reply.status(201).send({ data: result })
|
||||
}
|
||||
|
||||
async function getFormula(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const formula = await formulaService.getById(request.params.id)
|
||||
if (!formula) return reply.status(404).send({ error: '配方不存在', code: 'FORMULA_NOT_FOUND', statusCode: 404 })
|
||||
return reply.send({ data: formula })
|
||||
}
|
||||
|
||||
async function listFormulas(request: FastifyRequest, reply: FastifyReply) {
|
||||
const query = validateOrReply(formulaQuerySchema, request.query, reply)
|
||||
if (!query) return
|
||||
const result = await formulaService.list(query as FormulaQueryInput)
|
||||
return reply.send(result)
|
||||
}
|
||||
|
||||
async function updateFormula(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const existing = await formulaService.getById(request.params.id)
|
||||
if (!existing) return reply.status(404).send({ error: '配方不存在', code: 'FORMULA_NOT_FOUND', statusCode: 404 })
|
||||
|
||||
const { name, description } = request.body as { name?: string; description?: string }
|
||||
const formula = await formulaService.updateMeta(request.params.id, { name, description })
|
||||
return reply.send({ data: formula })
|
||||
}
|
||||
|
||||
async function deleteFormula(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const existing = await formulaService.getById(request.params.id)
|
||||
if (!existing) return reply.status(404).send({ error: '配方不存在', code: 'FORMULA_NOT_FOUND', statusCode: 404 })
|
||||
|
||||
await formulaService.delete(request.params.id, request.userId)
|
||||
return reply.status(204).send()
|
||||
}
|
||||
|
||||
async function updateComposition(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const body = validateOrReply(updateCompositionSchema, request.body, reply)
|
||||
if (!body) return
|
||||
const { phases } = body as UpdateCompositionInput
|
||||
|
||||
const percentError = formulaService.validate(phases)
|
||||
if (percentError) {
|
||||
return reply.status(400).send({ error: percentError, code: 'FORMULA_PERCENTAGE_TOTAL_INVALID', statusCode: 400 })
|
||||
}
|
||||
|
||||
const formula = await formulaService.updateComposition(request.params.id, phases, request.userId)
|
||||
if (!formula) return reply.status(404).send({ error: '配方不存在', code: 'FORMULA_NOT_FOUND', statusCode: 404 })
|
||||
|
||||
return reply.send({ data: formula })
|
||||
}
|
||||
|
||||
export async function formulaRoutes(app: FastifyInstance) {
|
||||
app.get('/', { schema: routeSchema({ query: formulaQuerySchema, summary: '配方列表', tags: ['formulas'] }) }, listFormulas)
|
||||
app.get('/:id', { schema: routeSchema({ summary: '配方详情', tags: ['formulas'] }) }, getFormula)
|
||||
app.post('/', { schema: routeSchema({ body: createFormulaSchema, summary: '创建配方', tags: ['formulas'] }) }, createFormula)
|
||||
app.put('/:id', {
|
||||
schema: routeSchema({ summary: '更新配方元信息', tags: ['formulas'] }),
|
||||
preHandler: [requireFormulaOwnership()],
|
||||
}, updateFormula)
|
||||
app.delete('/:id', {
|
||||
schema: routeSchema({ summary: '删除配方', tags: ['formulas'] }),
|
||||
preHandler: [requireFormulaOwnership()],
|
||||
}, deleteFormula)
|
||||
app.put('/:id/composition', {
|
||||
schema: routeSchema({ body: updateCompositionSchema, summary: '更新配方成分', tags: ['formulas'] }),
|
||||
preHandler: [requireFormulaOwnership()],
|
||||
}, updateComposition)
|
||||
}
|
||||
37
backend/src/modules/formulas/formulas.schema.ts
Normal file
37
backend/src/modules/formulas/formulas.schema.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const phaseIngredientSchema = z.object({
|
||||
ingredientId: z.string().min(1),
|
||||
percentage: z.number().gt(0).lte(100),
|
||||
processNotes: z.string().optional(),
|
||||
})
|
||||
|
||||
export const phaseInputSchema = z.object({
|
||||
name: z.string().min(1, '相名称不能为空'),
|
||||
sortOrder: z.number().int().gte(0).optional(),
|
||||
ingredients: z.array(phaseIngredientSchema).min(1, '相至少需要一个成分'),
|
||||
})
|
||||
|
||||
export const createFormulaSchema = z.object({
|
||||
name: z.string().min(1, '配方名称不能为空'),
|
||||
description: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
phases: z.array(phaseInputSchema).min(1, '至少需要一个相'),
|
||||
})
|
||||
|
||||
export const updateCompositionSchema = z.object({
|
||||
phases: z.array(phaseInputSchema).min(1, '至少需要一个相'),
|
||||
})
|
||||
|
||||
export const formulaQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
sortBy: z.enum(['createdAt', 'updatedAt', 'name']).optional(),
|
||||
sortOrder: z.enum(['asc', 'desc']).optional(),
|
||||
})
|
||||
|
||||
export type CreateFormulaInput = z.infer<typeof createFormulaSchema>
|
||||
export type UpdateCompositionInput = z.infer<typeof updateCompositionSchema>
|
||||
export type FormulaQueryInput = z.infer<typeof formulaQuerySchema>
|
||||
67
backend/src/modules/formulas/formulas.service.ts
Normal file
67
backend/src/modules/formulas/formulas.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { formulaRepository } from './formulas.repository.js'
|
||||
import type { CreateFormulaInput, FormulaQueryInput } from './formulas.schema.js'
|
||||
import { auditService } from '../../shared/audit/audit.service.js'
|
||||
|
||||
interface PhaseInput {
|
||||
name: string
|
||||
sortOrder?: number
|
||||
ingredients: { ingredientId: string; percentage: number; processNotes?: string }[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export const formulaService = {
|
||||
validate(phases: PhaseInput[]) {
|
||||
return validatePercentages(phases)
|
||||
},
|
||||
|
||||
list(query: FormulaQueryInput) {
|
||||
return formulaRepository.list(query)
|
||||
},
|
||||
|
||||
getById(id: string) {
|
||||
return formulaRepository.getById(id)
|
||||
},
|
||||
|
||||
async create(input: CreateFormulaInput, createdBy: string) {
|
||||
const id = await formulaRepository.create(input, createdBy)
|
||||
auditService.log({ action: 'create', resource: 'formula', resourceId: id, userId: createdBy })
|
||||
return formulaRepository.getById(id)
|
||||
},
|
||||
|
||||
updateMeta(id: string, data: { name?: string; description?: string }) {
|
||||
return formulaRepository.updateMeta(id, data)
|
||||
},
|
||||
|
||||
async updateComposition(id: string, phases: PhaseInput[], createdBy: string) {
|
||||
const result = await formulaRepository.updateComposition(id, phases, createdBy)
|
||||
if (result) {
|
||||
auditService.log({
|
||||
action: 'update', resource: 'formula', resourceId: id, userId: createdBy,
|
||||
diff: { phases: phases.map(p => p.name) },
|
||||
})
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(id: string, userId: string) {
|
||||
await formulaRepository.delete(id)
|
||||
auditService.log({ action: 'delete', resource: 'formula', resourceId: id, userId })
|
||||
},
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { buildApp } from '../app.js'
|
||||
import { buildApp } from '../../app.js'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
let app: FastifyInstance
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp()
|
||||
app = await buildApp({ skipAuth: true })
|
||||
await app.ready()
|
||||
|
||||
await app.inject({
|
||||
@@ -13,7 +13,7 @@ beforeAll(async () => {
|
||||
payload: { inciName: '__system__', chineseName: '__system__', functionCategory: 'other' },
|
||||
})
|
||||
|
||||
const { prisma } = await import('../lib/prisma.js')
|
||||
const { prisma } = await import('../../lib/prisma.js')
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'system' },
|
||||
update: {},
|
||||
36
backend/src/modules/health/health.route.ts
Normal file
36
backend/src/modules/health/health.route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { registry } from '../../shared/metrics/metrics.js'
|
||||
|
||||
let isShuttingDown = false
|
||||
|
||||
export function setShuttingDown() {
|
||||
isShuttingDown = true
|
||||
}
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
app.get('/health', async () => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() }
|
||||
})
|
||||
|
||||
app.get('/health/live', async (_req: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
return reply.status(200).send({ status: 'ok' })
|
||||
} catch {
|
||||
return reply.status(503).send({ status: 'error', reason: 'database_unreachable' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/health/ready', async (_req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (isShuttingDown) {
|
||||
return reply.status(503).send({ status: 'shutting_down' })
|
||||
}
|
||||
return reply.status(200).send({ status: 'ready' })
|
||||
})
|
||||
|
||||
app.get('/metrics', async (_req: FastifyRequest, reply: FastifyReply) => {
|
||||
reply.header('Content-Type', registry.contentType)
|
||||
return reply.send(await registry.metrics())
|
||||
})
|
||||
}
|
||||
64
backend/src/modules/ingredients/ingredients.repository.ts
Normal file
64
backend/src/modules/ingredients/ingredients.repository.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import type { CreateIngredientInput, UpdateIngredientInput, IngredientQueryInput } from './ingredients.schema.js'
|
||||
|
||||
export const ingredientRepository = {
|
||||
async list(query: IngredientQueryInput) {
|
||||
const { search, category, page, limit } = query
|
||||
|
||||
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: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.ingredient.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
data,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
}
|
||||
},
|
||||
|
||||
getById(id: string) {
|
||||
return prisma.ingredient.findUnique({ where: { id } })
|
||||
},
|
||||
|
||||
create(input: CreateIngredientInput) {
|
||||
return prisma.ingredient.create({
|
||||
data: {
|
||||
inciName: input.inciName,
|
||||
chineseName: input.chineseName,
|
||||
functionCategory: input.functionCategory,
|
||||
supplier: input.supplier,
|
||||
unit: input.unit ?? 'kg',
|
||||
unitPrice: input.unitPrice,
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
update(id: string, data: UpdateIngredientInput) {
|
||||
return prisma.ingredient.update({ where: { id }, data })
|
||||
},
|
||||
|
||||
async getUsageCount(id: string) {
|
||||
return prisma.formulaIngredient.count({ where: { ingredientId: id } })
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
return prisma.ingredient.delete({ where: { id } })
|
||||
},
|
||||
}
|
||||
77
backend/src/modules/ingredients/ingredients.route.ts
Normal file
77
backend/src/modules/ingredients/ingredients.route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { ingredientService } from './ingredients.service.js'
|
||||
import { createIngredientSchema, updateIngredientSchema, ingredientQuerySchema } from './ingredients.schema.js'
|
||||
import { validateOrReply } from '../../lib/validate.js'
|
||||
import { routeSchema } from '../../lib/swagger.js'
|
||||
import type { CreateIngredientInput, UpdateIngredientInput, IngredientQueryInput } from './ingredients.schema.js'
|
||||
|
||||
async function getIngredients(request: FastifyRequest, reply: FastifyReply) {
|
||||
const query = validateOrReply(ingredientQuerySchema, request.query, reply)
|
||||
if (!query) return
|
||||
const result = await ingredientService.list(query as IngredientQueryInput)
|
||||
return reply.send(result)
|
||||
}
|
||||
|
||||
async function getIngredient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const ingredient = await ingredientService.getById(request.params.id)
|
||||
if (!ingredient) {
|
||||
return reply.status(404).send({ error: '成分不存在', code: 'INGREDIENT_NOT_FOUND', statusCode: 404 })
|
||||
}
|
||||
return reply.send({ data: ingredient })
|
||||
}
|
||||
|
||||
async function createIngredient(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = validateOrReply(createIngredientSchema, request.body, reply)
|
||||
if (!body) return
|
||||
const ingredient = await ingredientService.create(body as CreateIngredientInput, request.userId)
|
||||
return reply.status(201).send({ data: ingredient })
|
||||
}
|
||||
|
||||
async function updateIngredient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const existing = await ingredientService.getById(request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: '成分不存在', code: 'INGREDIENT_NOT_FOUND', statusCode: 404 })
|
||||
}
|
||||
|
||||
const body = validateOrReply(updateIngredientSchema, request.body, reply)
|
||||
if (!body) return
|
||||
|
||||
const ingredient = await ingredientService.update(request.params.id, body as UpdateIngredientInput, request.userId)
|
||||
return reply.send({ data: ingredient })
|
||||
}
|
||||
|
||||
async function deleteIngredient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const existing = await ingredientService.getById(request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: '成分不存在', code: 'INGREDIENT_NOT_FOUND', statusCode: 404 })
|
||||
}
|
||||
|
||||
const result = await ingredientService.delete(request.params.id, request.userId)
|
||||
if (!result.deleted) {
|
||||
return reply.status(409).send({
|
||||
error: '该成分已被配方引用,无法删除',
|
||||
code: 'INGREDIENT_IN_USE',
|
||||
usageCount: result.usageCount,
|
||||
statusCode: 409,
|
||||
})
|
||||
}
|
||||
|
||||
return reply.status(204).send()
|
||||
}
|
||||
|
||||
export async function ingredientRoutes(app: FastifyInstance) {
|
||||
app.get('/', { schema: routeSchema({ query: ingredientQuerySchema, summary: '成分列表', tags: ['ingredients'] }) }, getIngredients)
|
||||
app.get('/:id', { schema: routeSchema({ summary: '成分详情', tags: ['ingredients'] }) }, getIngredient)
|
||||
app.post('/', { schema: routeSchema({ body: createIngredientSchema, summary: '创建成分', tags: ['ingredients'] }) }, createIngredient)
|
||||
app.put('/:id', { schema: routeSchema({ body: updateIngredientSchema, summary: '更新成分', tags: ['ingredients'] }) }, updateIngredient)
|
||||
app.delete('/:id', { schema: routeSchema({ summary: '删除成分', tags: ['ingredients'] }) }, deleteIngredient)
|
||||
}
|
||||
30
backend/src/modules/ingredients/ingredients.schema.ts
Normal file
30
backend/src/modules/ingredients/ingredients.schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const ingredientCategorySchema = z.enum([
|
||||
'emulsifier', 'humectant', 'thickener', 'preservative', 'antioxidant',
|
||||
'fragrance', 'colorant', 'ph_adjuster', 'sunscreen', 'surfactant',
|
||||
'emollient', 'other',
|
||||
])
|
||||
|
||||
export const createIngredientSchema = z.object({
|
||||
inciName: z.string().min(1, 'INCI名称不能为空'),
|
||||
chineseName: z.string().min(1, '中文名不能为空'),
|
||||
functionCategory: ingredientCategorySchema,
|
||||
supplier: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
unitPrice: z.number().gte(0).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export const updateIngredientSchema = createIngredientSchema.partial()
|
||||
|
||||
export const ingredientQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
category: ingredientCategorySchema.optional(),
|
||||
})
|
||||
|
||||
export type CreateIngredientInput = z.infer<typeof createIngredientSchema>
|
||||
export type UpdateIngredientInput = z.infer<typeof updateIngredientSchema>
|
||||
export type IngredientQueryInput = z.infer<typeof ingredientQuerySchema>
|
||||
35
backend/src/modules/ingredients/ingredients.service.ts
Normal file
35
backend/src/modules/ingredients/ingredients.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ingredientRepository } from './ingredients.repository.js'
|
||||
import type { CreateIngredientInput, UpdateIngredientInput, IngredientQueryInput } from './ingredients.schema.js'
|
||||
import { auditService } from '../../shared/audit/audit.service.js'
|
||||
|
||||
export const ingredientService = {
|
||||
list(query: IngredientQueryInput) {
|
||||
return ingredientRepository.list(query)
|
||||
},
|
||||
|
||||
getById(id: string) {
|
||||
return ingredientRepository.getById(id)
|
||||
},
|
||||
|
||||
async create(input: CreateIngredientInput, userId: string) {
|
||||
const ingredient = await ingredientRepository.create(input)
|
||||
auditService.log({ action: 'create', resource: 'ingredient', resourceId: ingredient.id, userId })
|
||||
return ingredient
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateIngredientInput, userId: string) {
|
||||
const ingredient = await ingredientRepository.update(id, data)
|
||||
auditService.log({ action: 'update', resource: 'ingredient', resourceId: id, userId, diff: data })
|
||||
return ingredient
|
||||
},
|
||||
|
||||
async delete(id: string, userId: string) {
|
||||
const usageCount = await ingredientRepository.getUsageCount(id)
|
||||
if (usageCount > 0) {
|
||||
return { deleted: false, usageCount }
|
||||
}
|
||||
await ingredientRepository.delete(id)
|
||||
auditService.log({ action: 'delete', resource: 'ingredient', resourceId: id, userId })
|
||||
return { deleted: true, usageCount: 0 }
|
||||
},
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { buildApp } from '../app.js'
|
||||
import { buildApp } from '../../app.js'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
let app: FastifyInstance
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp()
|
||||
app = await buildApp({ skipAuth: true })
|
||||
await app.ready()
|
||||
})
|
||||
|
||||
24
backend/src/modules/projects/projects.repository.ts
Normal file
24
backend/src/modules/projects/projects.repository.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export const projectRepository = {
|
||||
list() {
|
||||
return prisma.project.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { _count: { select: { formulas: true } } },
|
||||
})
|
||||
},
|
||||
|
||||
create(name: string, description: string | undefined, createdBy: string) {
|
||||
return prisma.project.create({
|
||||
data: { name, description: description ?? null, createdBy },
|
||||
})
|
||||
},
|
||||
|
||||
update(id: string, data: { name?: string; description?: string }) {
|
||||
return prisma.project.update({ where: { id }, data })
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
return prisma.project.delete({ where: { id } })
|
||||
},
|
||||
}
|
||||
39
backend/src/modules/projects/projects.route.ts
Normal file
39
backend/src/modules/projects/projects.route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { projectService } from './projects.service.js'
|
||||
import { createProjectSchema, updateProjectSchema } from './projects.schema.js'
|
||||
import { validateOrReply } from '../../lib/validate.js'
|
||||
import { routeSchema } from '../../lib/swagger.js'
|
||||
|
||||
async function listProjects(_request: FastifyRequest, reply: FastifyReply) {
|
||||
const projects = await projectService.list()
|
||||
return reply.send({ data: projects })
|
||||
}
|
||||
|
||||
async function createProject(request: FastifyRequest, reply: FastifyReply) {
|
||||
const body = validateOrReply(createProjectSchema, request.body, reply)
|
||||
if (!body) return
|
||||
const project = await projectService.create(body, request.userId)
|
||||
return reply.status(201).send({ data: project })
|
||||
}
|
||||
|
||||
async function updateProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const body = validateOrReply(updateProjectSchema, request.body, reply)
|
||||
if (!body) return
|
||||
const project = await projectService.update(request.params.id, body)
|
||||
return reply.send({ data: project })
|
||||
}
|
||||
|
||||
async function deleteProject(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
await projectService.delete(request.params.id, request.userId)
|
||||
return reply.status(204).send()
|
||||
}
|
||||
|
||||
export async function projectRoutes(app: FastifyInstance) {
|
||||
app.get('/', { schema: routeSchema({ summary: '项目列表', tags: ['projects'] }) }, listProjects)
|
||||
app.post('/', { schema: routeSchema({ body: createProjectSchema, summary: '创建项目', tags: ['projects'] }) }, createProject)
|
||||
app.put('/:id', { schema: routeSchema({ body: updateProjectSchema, summary: '更新项目', tags: ['projects'] }) }, updateProject)
|
||||
app.delete('/:id', { schema: routeSchema({ summary: '删除项目', tags: ['projects'] }) }, deleteProject)
|
||||
}
|
||||
14
backend/src/modules/projects/projects.schema.ts
Normal file
14
backend/src/modules/projects/projects.schema.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
name: z.string().min(1, '项目名称不能为空'),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export const updateProjectSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>
|
||||
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>
|
||||
25
backend/src/modules/projects/projects.service.ts
Normal file
25
backend/src/modules/projects/projects.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { projectRepository } from './projects.repository.js'
|
||||
import type { CreateProjectInput } from './projects.schema.js'
|
||||
import { auditService } from '../../shared/audit/audit.service.js'
|
||||
|
||||
export const projectService = {
|
||||
async list() {
|
||||
return projectRepository.list()
|
||||
},
|
||||
|
||||
async create(input: CreateProjectInput, createdBy: string) {
|
||||
const project = await projectRepository.create(input.name, input.description, createdBy)
|
||||
auditService.log({ action: 'create', resource: 'project', resourceId: project.id, userId: createdBy })
|
||||
return project
|
||||
},
|
||||
|
||||
async update(id: string, data: { name?: string; description?: string }) {
|
||||
const project = await projectRepository.update(id, data)
|
||||
return project
|
||||
},
|
||||
|
||||
async delete(id: string, userId: string) {
|
||||
await projectRepository.delete(id)
|
||||
auditService.log({ action: 'delete', resource: 'project', resourceId: id, userId })
|
||||
},
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
app.get('/health', async () => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() }
|
||||
})
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,9 +1,27 @@
|
||||
import { buildApp } from './app.js'
|
||||
import { setShuttingDown } from './modules/health/health.route.js'
|
||||
|
||||
async function start() {
|
||||
const app = await buildApp()
|
||||
const port = Number(process.env.PORT) || 3001
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
app.log.info({ signal }, 'shutdown initiated')
|
||||
setShuttingDown()
|
||||
|
||||
try {
|
||||
await app.close()
|
||||
app.log.info('shutdown complete')
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
app.log.error(err, 'shutdown error')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => { shutdown('SIGTERM') })
|
||||
process.on('SIGINT', () => { shutdown('SIGINT') })
|
||||
|
||||
try {
|
||||
await app.listen({ port, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
|
||||
@@ -32,6 +32,10 @@ export class AIService {
|
||||
private defaultModel: string
|
||||
private mockMode: boolean
|
||||
private consecutiveFailures = 0
|
||||
private openaiKey = ''
|
||||
private deepseekKey = ''
|
||||
private openaiBaseUrl = ''
|
||||
private deepseekBaseUrl = ''
|
||||
|
||||
constructor() {
|
||||
this.cache = new LRUCache(200)
|
||||
@@ -39,13 +43,25 @@ export class AIService {
|
||||
this.retryMax = 3
|
||||
this.defaultModel = process.env['AI_DEFAULT_MODEL'] ?? 'deepseek-chat'
|
||||
this.mockMode = process.env['AI_MOCK'] === 'true'
|
||||
this.openaiKey = process.env['OPENAI_API_KEY'] ?? ''
|
||||
this.deepseekKey = process.env['DEEPSEEK_API_KEY'] ?? ''
|
||||
this.openaiBaseUrl = process.env['OPENAI_BASE_URL'] ?? ''
|
||||
this.deepseekBaseUrl = process.env['DEEPSEEK_BASE_URL'] ?? ''
|
||||
this.initProviders()
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
reload(updates?: Record<string, string>): void {
|
||||
if (updates) {
|
||||
if (updates.OPENAI_API_KEY !== undefined) this.openaiKey = updates.OPENAI_API_KEY
|
||||
if (updates.DEEPSEEK_API_KEY !== undefined) this.deepseekKey = updates.DEEPSEEK_API_KEY
|
||||
if (updates.OPENAI_BASE_URL !== undefined) this.openaiBaseUrl = updates.OPENAI_BASE_URL
|
||||
if (updates.DEEPSEEK_BASE_URL !== undefined) this.deepseekBaseUrl = updates.DEEPSEEK_BASE_URL
|
||||
if (updates.AI_MOCK !== undefined) this.mockMode = updates.AI_MOCK === 'true'
|
||||
} else {
|
||||
this.mockMode = process.env['AI_MOCK'] === 'true'
|
||||
}
|
||||
this.providers = {}
|
||||
this.initProviders()
|
||||
this.mockMode = process.env['AI_MOCK'] === 'true'
|
||||
this.consecutiveFailures = 0
|
||||
this.cache.clear()
|
||||
}
|
||||
@@ -58,13 +74,11 @@ export class AIService {
|
||||
}
|
||||
|
||||
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 (this.openaiKey) {
|
||||
this.providers['openai'] = createOpenAIProvider(this.openaiKey, this.openaiBaseUrl || undefined)
|
||||
}
|
||||
if (deepseekKey) {
|
||||
this.providers['deepseek'] = createDeepSeekProvider(deepseekKey, process.env['DEEPSEEK_BASE_URL'])
|
||||
if (this.deepseekKey) {
|
||||
this.providers['deepseek'] = createDeepSeekProvider(this.deepseekKey, this.deepseekBaseUrl || undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +110,8 @@ export class AIService {
|
||||
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 recommendColorants(targetLab: { L: number; a: number; b: number }, availableColorants?: string[]): Promise<string> {
|
||||
return this.execute('recommend-colorants', recommendColorantsPrompt(targetLab, availableColorants), { ttlMs: 1800_000 })
|
||||
}
|
||||
|
||||
async extractFormula(text: string): Promise<string> {
|
||||
@@ -163,14 +177,14 @@ export class AIService {
|
||||
logAudit({
|
||||
capability, modelName: model, promptHash,
|
||||
tokensUsed: res.usage?.totalTokens, durationMs: duration,
|
||||
}).catch(() => {})
|
||||
}).catch(() => undefined)
|
||||
|
||||
if (opts.ttlMs > 0) {
|
||||
this.cache.set(promptHash, res.content, opts.ttlMs)
|
||||
}
|
||||
|
||||
return res.content
|
||||
} catch (err) {
|
||||
} catch {
|
||||
if (attempt < this.retryMax - 1) {
|
||||
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000))
|
||||
}
|
||||
|
||||
@@ -79,7 +79,9 @@ export function createOpenAIProvider(apiKey: string, baseURL?: string, defaultMo
|
||||
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 { }
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,12 @@ export function generateFormulaPrompt(constraints: {
|
||||
]
|
||||
}
|
||||
|
||||
export function recommendColorantsPrompt(targetLab: { L: number; a: number; b: number }): ChatMessage[] {
|
||||
export function recommendColorantsPrompt(targetLab: { L: number; a: number; b: number }, availableColorants?: string[]): ChatMessage[] {
|
||||
const colorantList = availableColorants && availableColorants.length > 0
|
||||
? `可用色浆列表:${availableColorants.join('、')}。只能在以上列表中选择,不要推荐列表外的成分。`
|
||||
: ''
|
||||
return [
|
||||
{ role: 'system', content: '你是一名化妆品色彩专家。根据目标 Lab 颜色值推荐色浆组合及比例。返回 JSON:{"recommendations":[{"colorants":[{"name":"色浆名","ratio":0-1}],"predictedDeltaE":number,"confidence":0-1}]}' },
|
||||
{ role: 'system', content: `你是一名化妆品色彩专家。根据目标 Lab 颜色值推荐色浆组合及比例。${colorantList}返回 JSON:{"recommendations":[{"colorants":[{"name":"色浆名","ratio":0-1}],"predictedDeltaE":number,"confidence":0-1}]}` },
|
||||
{ role: 'user', content: `目标颜色 Lab(${targetLab.L}, ${targetLab.a}, ${targetLab.b})` },
|
||||
]
|
||||
}
|
||||
|
||||
146
backend/src/services/formulaOptimizer.ts
Normal file
146
backend/src/services/formulaOptimizer.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
interface IngredientEntry {
|
||||
ingredientId: string
|
||||
name: string
|
||||
category: string
|
||||
percentage: number
|
||||
unitPrice: number
|
||||
}
|
||||
|
||||
interface OptimizationOption {
|
||||
name: string
|
||||
changes: Array<{
|
||||
action: 'reduce' | 'replace' | 'add' | 'remove'
|
||||
ingredient: string
|
||||
newPercentage: number
|
||||
reason: string
|
||||
}>
|
||||
predictedMetrics: { costEstimate: number }
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
function findCheaperAlternative(
|
||||
current: IngredientEntry,
|
||||
available: IngredientEntry[],
|
||||
): { alternative: IngredientEntry; newPercentage: number; savings: number } | null {
|
||||
const candidates = available.filter(
|
||||
a => a.category === current.category && a.unitPrice < current.unitPrice && a.ingredientId !== current.ingredientId,
|
||||
)
|
||||
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
const best = candidates.sort((a, b) => a.unitPrice - b.unitPrice)[0]!
|
||||
const savings = current.percentage * (current.unitPrice - best.unitPrice) / 100
|
||||
|
||||
return { alternative: best, newPercentage: current.percentage, savings }
|
||||
}
|
||||
|
||||
export async function exploreWithConstraints(params: {
|
||||
baseFormulaId?: string
|
||||
costLimit?: number
|
||||
keepIngredients?: string[]
|
||||
excludeIngredients?: string[]
|
||||
}): Promise<OptimizationOption[]> {
|
||||
const options: OptimizationOption[] = []
|
||||
let baseIngredients: IngredientEntry[] = []
|
||||
|
||||
if (params.baseFormulaId) {
|
||||
const version = await prisma.formulaVersion.findFirst({
|
||||
where: { formulaId: params.baseFormulaId },
|
||||
orderBy: { versionNumber: 'desc' },
|
||||
include: {
|
||||
ingredients: {
|
||||
include: { ingredient: true, phase: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (version) {
|
||||
baseIngredients = version.ingredients.map(ing => ({
|
||||
ingredientId: ing.ingredientId,
|
||||
name: ing.ingredient.chineseName || ing.ingredient.inciName,
|
||||
category: ing.ingredient.functionCategory,
|
||||
percentage: Number(ing.percentage),
|
||||
unitPrice: Number(ing.ingredient.unitPrice ?? 0),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (baseIngredients.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const filtered = baseIngredients.filter(
|
||||
ing => !params.excludeIngredients?.includes(ing.name),
|
||||
)
|
||||
|
||||
const totalCost = filtered.reduce((s, i) => s + i.percentage * i.unitPrice / 100, 0)
|
||||
|
||||
if (params.costLimit && totalCost <= params.costLimit) {
|
||||
options.push({
|
||||
name: '当前配方(成本已达标)',
|
||||
changes: filtered.map(ing => ({
|
||||
action: 'reduce' as const,
|
||||
ingredient: ing.name,
|
||||
newPercentage: ing.percentage,
|
||||
reason: '维持当前比例',
|
||||
})),
|
||||
predictedMetrics: { costEstimate: Math.round(totalCost * 10) / 10 },
|
||||
reasoning: `当前成本 ${totalCost.toFixed(1)} 元/kg,已在目标 ${params.costLimit} 元/kg 以内`,
|
||||
})
|
||||
}
|
||||
|
||||
const allIngredients = await prisma.ingredient.findMany({
|
||||
select: { id: true, inciName: true, chineseName: true, functionCategory: true, unitPrice: true },
|
||||
})
|
||||
|
||||
const allEntries: IngredientEntry[] = allIngredients.map(ing => ({
|
||||
ingredientId: ing.id,
|
||||
name: ing.chineseName || ing.inciName,
|
||||
category: ing.functionCategory,
|
||||
percentage: 0,
|
||||
unitPrice: Number(ing.unitPrice ?? 0),
|
||||
}))
|
||||
|
||||
for (let replaceCount = 1; replaceCount <= Math.min(3, filtered.length); replaceCount++) {
|
||||
const replacements: Array<{ ingredient: string; from: string; to: string; newPct: number }> = []
|
||||
let newTotalCost = 0
|
||||
|
||||
for (const ing of filtered) {
|
||||
const candidate = findCheaperAlternative(ing, allEntries)
|
||||
if (candidate && replacements.length < replaceCount) {
|
||||
replacements.push({
|
||||
ingredient: ing.name,
|
||||
from: ing.name,
|
||||
to: candidate.alternative.name,
|
||||
newPct: candidate.newPercentage,
|
||||
})
|
||||
newTotalCost += candidate.newPercentage * candidate.alternative.unitPrice / 100
|
||||
} else {
|
||||
newTotalCost += ing.percentage * ing.unitPrice / 100
|
||||
}
|
||||
}
|
||||
|
||||
if (replacements.length === 0) continue
|
||||
|
||||
const savings = totalCost - newTotalCost
|
||||
if (params.costLimit && newTotalCost > params.costLimit) continue
|
||||
|
||||
options.push({
|
||||
name: `降本方案(${replacements.length}处替换)`,
|
||||
changes: replacements.map(r => ({
|
||||
action: 'replace',
|
||||
ingredient: r.ingredient,
|
||||
newPercentage: r.newPct,
|
||||
reason: `用${r.to}替代${r.from},降低单位成本`,
|
||||
})),
|
||||
predictedMetrics: { costEstimate: Math.round(newTotalCost * 10) / 10 },
|
||||
reasoning: savings > 0
|
||||
? `替换 ${replacements.length} 处成分,预估节省 ${savings.toFixed(1)} 元/kg`
|
||||
: '调整比例以优化成本结构',
|
||||
})
|
||||
}
|
||||
|
||||
return options.slice(0, 5)
|
||||
}
|
||||
27
backend/src/shared/audit/audit.service.ts
Normal file
27
backend/src/shared/audit/audit.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getContext } from '../logging/context.js'
|
||||
|
||||
export interface AuditEntry {
|
||||
action: 'create' | 'update' | 'delete'
|
||||
resource: string
|
||||
resourceId: string
|
||||
userId: string
|
||||
diff?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const auditService = {
|
||||
log(entry: AuditEntry): void {
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
ctx.logger.info(
|
||||
{
|
||||
audit: true,
|
||||
action: entry.action,
|
||||
resource: entry.resource,
|
||||
resourceId: entry.resourceId,
|
||||
userId: entry.userId,
|
||||
diff: entry.diff,
|
||||
},
|
||||
`${entry.action} ${entry.resource} ${entry.resourceId}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
73
backend/src/shared/errors/app-error.ts
Normal file
73
backend/src/shared/errors/app-error.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export type ErrorCategory =
|
||||
| 'validation'
|
||||
| 'auth'
|
||||
| 'not_found'
|
||||
| 'conflict'
|
||||
| 'business'
|
||||
| 'upstream'
|
||||
| 'internal'
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly httpStatus: number,
|
||||
public readonly category: ErrorCategory,
|
||||
public readonly module?: string,
|
||||
cause?: Error,
|
||||
) {
|
||||
super(message, { cause })
|
||||
this.name = 'AppError'
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
category: this.category,
|
||||
module: this.module,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(code: string, message: string, module?: string) {
|
||||
super(code, message, 400, 'validation', module)
|
||||
this.name = 'ValidationError'
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(code: string, message: string, module?: string) {
|
||||
super(code, message, 401, 'auth', module)
|
||||
this.name = 'UnauthorizedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(code: string, message: string, module?: string) {
|
||||
super(code, message, 403, 'auth', module)
|
||||
this.name = 'ForbiddenError'
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(code: string, message: string, module?: string) {
|
||||
super(code, message, 404, 'not_found', module)
|
||||
this.name = 'NotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(code: string, message: string, module?: string) {
|
||||
super(code, message, 409, 'conflict', module)
|
||||
this.name = 'ConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends AppError {
|
||||
constructor(code: string, message: string, module?: string, cause?: Error) {
|
||||
super(code, message, 500, 'internal', module, cause)
|
||||
this.name = 'InternalError'
|
||||
}
|
||||
}
|
||||
33
backend/src/shared/errors/codes.ts
Normal file
33
backend/src/shared/errors/codes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const ErrorCodes = {
|
||||
// 认证
|
||||
AUTH_TOKEN_MISSING: 'AUTH_TOKEN_MISSING',
|
||||
AUTH_TOKEN_INVALID: 'AUTH_TOKEN_INVALID',
|
||||
AUTH_TOKEN_EXPIRED: 'AUTH_TOKEN_EXPIRED',
|
||||
AUTH_USER_NOT_FOUND: 'AUTH_USER_NOT_FOUND',
|
||||
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
|
||||
AUTH_USERNAME_EXISTS: 'AUTH_USERNAME_EXISTS',
|
||||
AUTH_FORBIDDEN: 'AUTH_FORBIDDEN',
|
||||
|
||||
// 成分
|
||||
INGREDIENT_NOT_FOUND: 'INGREDIENT_NOT_FOUND',
|
||||
INGREDIENT_IN_USE: 'INGREDIENT_IN_USE',
|
||||
|
||||
// 配方
|
||||
FORMULA_NOT_FOUND: 'FORMULA_NOT_FOUND',
|
||||
FORMULA_PERCENTAGE_OUT_OF_RANGE: 'FORMULA_PERCENTAGE_OUT_OF_RANGE',
|
||||
FORMULA_PERCENTAGE_TOTAL_INVALID: 'FORMULA_PERCENTAGE_TOTAL_INVALID',
|
||||
FORMULA_DELETE_FAILED: 'FORMULA_DELETE_FAILED',
|
||||
|
||||
// 项目
|
||||
PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND',
|
||||
|
||||
// AI
|
||||
AI_UPSTREAM_ERROR: 'AI_UPSTREAM_ERROR',
|
||||
AI_EXTRACTION_FAILED: 'AI_EXTRACTION_FAILED',
|
||||
AI_PROVIDER_NOT_CONFIGURED: 'AI_PROVIDER_NOT_CONFIGURED',
|
||||
|
||||
// 内部
|
||||
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||
} as const
|
||||
|
||||
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]
|
||||
39
backend/src/shared/logging/context.ts
Normal file
39
backend/src/shared/logging/context.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { AsyncLocalStorage } from 'async_hooks'
|
||||
import type { FastifyBaseLogger } from 'fastify'
|
||||
|
||||
type Logger = FastifyBaseLogger
|
||||
|
||||
interface RequestContext {
|
||||
requestId: string
|
||||
userId?: string
|
||||
logger: Logger
|
||||
startTime: number
|
||||
}
|
||||
|
||||
const storage = new AsyncLocalStorage<RequestContext>()
|
||||
|
||||
export function getContext(): RequestContext | undefined {
|
||||
return storage.getStore()
|
||||
}
|
||||
|
||||
export function getLogger(): Logger {
|
||||
return storage.getStore()!.logger
|
||||
}
|
||||
|
||||
export function getRequestId(): string {
|
||||
const ctx = storage.getStore()
|
||||
return ctx?.requestId ?? 'unknown'
|
||||
}
|
||||
|
||||
export function getUserId(): string | undefined {
|
||||
const ctx = storage.getStore()
|
||||
return ctx?.userId
|
||||
}
|
||||
|
||||
export function runWithContext<T>(ctx: RequestContext, fn: () => Promise<T>): Promise<T> {
|
||||
return storage.run(ctx, fn)
|
||||
}
|
||||
|
||||
export function createChildLogger(module: string): Logger {
|
||||
return getLogger().child({ module })
|
||||
}
|
||||
40
backend/src/shared/metrics/metrics.ts
Normal file
40
backend/src/shared/metrics/metrics.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Counter, Histogram, Registry } from 'prom-client'
|
||||
|
||||
export const registry = new Registry()
|
||||
|
||||
export const httpRequestsTotal = new Counter({
|
||||
name: 'http_requests_total',
|
||||
help: 'Total HTTP requests',
|
||||
labelNames: ['method', 'path', 'status'],
|
||||
registers: [registry],
|
||||
})
|
||||
|
||||
export const httpRequestDurationMs = new Histogram({
|
||||
name: 'http_request_duration_ms',
|
||||
help: 'HTTP request duration in ms',
|
||||
labelNames: ['method', 'path'],
|
||||
buckets: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000],
|
||||
registers: [registry],
|
||||
})
|
||||
|
||||
export const appErrorsTotal = new Counter({
|
||||
name: 'app_errors_total',
|
||||
help: 'Total application errors',
|
||||
labelNames: ['category', 'module', 'code'],
|
||||
registers: [registry],
|
||||
})
|
||||
|
||||
export const aiRequestsTotal = new Counter({
|
||||
name: 'ai_requests_total',
|
||||
help: 'Total AI requests',
|
||||
labelNames: ['capability', 'provider', 'status'],
|
||||
registers: [registry],
|
||||
})
|
||||
|
||||
export const aiRequestDurationMs = new Histogram({
|
||||
name: 'ai_request_duration_ms',
|
||||
help: 'AI request duration in ms',
|
||||
labelNames: ['capability'],
|
||||
buckets: [100, 500, 1000, 2000, 5000, 10000, 30000, 60000],
|
||||
registers: [registry],
|
||||
})
|
||||
32
backend/src/shared/middleware/ownership.ts
Normal file
32
backend/src/shared/middleware/ownership.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { ForbiddenError, NotFoundError } from '../errors/app-error.js'
|
||||
import { ErrorCodes } from '../errors/codes.js'
|
||||
|
||||
export function requireFormulaOwnership() {
|
||||
return async (request: FastifyRequest<{ Params: { id: string } }>, _reply: FastifyReply) => {
|
||||
const formula = await prisma.formula.findUnique({
|
||||
where: { id: request.params.id },
|
||||
select: { createdBy: true },
|
||||
})
|
||||
|
||||
if (!formula) {
|
||||
throw new NotFoundError(ErrorCodes.FORMULA_NOT_FOUND, '配方不存在', 'formulas')
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: request.userId },
|
||||
select: { role: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenError(ErrorCodes.AUTH_FORBIDDEN, '权限不足')
|
||||
}
|
||||
|
||||
if (user.role === 'admin') return
|
||||
|
||||
if (formula.createdBy !== request.userId) {
|
||||
throw new ForbiddenError(ErrorCodes.AUTH_FORBIDDEN, '只能操作自己创建的配方')
|
||||
}
|
||||
}
|
||||
}
|
||||
19
backend/src/shared/middleware/rbac.ts
Normal file
19
backend/src/shared/middleware/rbac.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { ForbiddenError } from '../errors/app-error.js'
|
||||
import { ErrorCodes } from '../errors/codes.js'
|
||||
|
||||
export type Role = 'engineer' | 'admin'
|
||||
|
||||
export function requireRole(...roles: Role[]) {
|
||||
return async (request: FastifyRequest, _reply: FastifyReply) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: request.userId },
|
||||
select: { role: true },
|
||||
})
|
||||
|
||||
if (!user || !roles.includes(user.role)) {
|
||||
throw new ForbiddenError(ErrorCodes.AUTH_FORBIDDEN, '权限不足')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
|
||||
70
docker-compose.prod.yml
Normal file
70
docker-compose.prod.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:latest
|
||||
container_name: colorfull-traefik
|
||||
command:
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Dockerfile.pgvector
|
||||
container_name: colorfull-db
|
||||
environment:
|
||||
POSTGRES_DB: colorfull
|
||||
POSTGRES_USER: colorfull
|
||||
POSTGRES_PASSWORD: "${DB_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
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: colorfull-backend
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DATABASE_URL: postgresql://colorfull:${DB_PASSWORD:-colorfull}@postgres:5432/colorfull
|
||||
JWT_SECRET: "${JWT_SECRET}"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.backend.rule=Host(`localhost`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.services.backend.loadbalancer.server.port=3001"
|
||||
- "traefik.http.services.backend.loadbalancer.healthcheck.path=/api/health/live"
|
||||
- "traefik.http.services.backend.loadbalancer.healthcheck.interval=10s"
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: colorfull-frontend
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.frontend.rule=Host(`localhost`)"
|
||||
- "traefik.http.services.frontend.loadbalancer.server.port=80"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: colorfull-minio
|
||||
profiles: ["full"]
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
|
||||
@@ -11,3 +11,6 @@ RUN git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git /tmp/pgve
|
||||
&& rm -rf /tmp/pgvector
|
||||
|
||||
RUN apk del git build-base
|
||||
|
||||
RUN echo "CREATE EXTENSION IF NOT EXISTS vector;" >> /docker-entrypoint-initdb.d/01-vector.sql
|
||||
RUN echo "CREATE EXTENSION IF NOT EXISTS cube;" >> /docker-entrypoint-initdb.d/02-cube.sql
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# ADR-0001: 整体技术栈选型
|
||||
|
||||
> **状态**: 已决议
|
||||
> **日期**: 2026-05-20
|
||||
> **状态**: 已决议(2026-05-21 修订)
|
||||
> **日期**: 2026-05-20
|
||||
> **修订**: 2026-05-21
|
||||
> **决策者**: 架构评审
|
||||
|
||||
---
|
||||
@@ -14,7 +15,7 @@
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. 前端框架 → React 18 + TypeScript 5.7
|
||||
### 1. 前端框架 → React 19 + TypeScript 5.7
|
||||
|
||||
| 候选 | 优势 | 劣势 | 结论 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
@@ -23,11 +24,13 @@
|
||||
| Svelte 5 | 编译时框架,运行时极小 | 生态较小;关键库适配风险;社区资源少 | ❌ |
|
||||
| SolidJS | 性能优于 React;API 相似 | 社区太小(GitHub stars ~30k vs React ~230k);生产风险高 | ❌ |
|
||||
|
||||
**决策**:React 18 + TypeScript strict mode。React 19 待生态稳定后再升级。
|
||||
**决策**:React 19 + TypeScript strict mode。
|
||||
|
||||
> **修订(2026-05-21)**:从 React 18 升级到 React 19。生态已稳定,React Compiler 带来额外性能收益。
|
||||
|
||||
---
|
||||
|
||||
### 2. 构建工具 → Vite 6
|
||||
### 2. 构建工具 → Vite 8
|
||||
|
||||
| 候选 | 优势 | 劣势 | 结论 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
@@ -36,11 +39,13 @@
|
||||
| Remix | SSR 优先;Web 标准 | 同上;社区较 Next.js 小 | ❌ |
|
||||
| CRA | — | 已停止维护;Webpack 构建慢 | ❌ |
|
||||
|
||||
**决策**:Vite 6,SPA 模式。平台为内部工具,无需 SEO/SSR。
|
||||
**决策**:Vite 8,SPA 模式。平台为内部工具,无需 SEO/SSR。
|
||||
|
||||
> **修订(2026-05-21)**:从 Vite 6 升级到 Vite 8。
|
||||
|
||||
---
|
||||
|
||||
### 3. 状态管理 → Zustand 5
|
||||
### 3. 状态管理 → Zustand
|
||||
|
||||
| 候选 | 优势 | 劣势 | 结论 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
@@ -84,128 +89,83 @@
|
||||
| **Radix UI** | Headless(完全控制样式);WAI-ARIA 内置;组件粒度合适;与 Tailwind 天配 | 无预置视觉风格(需要自行设计) | ✅ 推荐 |
|
||||
| Ant Design | 开箱即用,组件丰富 | 企业后台感强;视觉定制困难;不适合创意工具;bundle 大 | ❌ |
|
||||
| MUI | Material Design 完整实现 | 同上;Google 风格固化 | ❌ |
|
||||
| shadcn/ui | 基于 Radix + Tailwind,复制源码 | 本质是 Radix 封装;直接 Radix 更灵活 | ❌ |
|
||||
| shadcn/ui | 基于 Radix + Tailwind 预封装 | 封装度低,仍需二次开发 | — |
|
||||
|
||||
**决策**:Radix UI 提供 Dialog、Popover、Dropdown、Tabs、Tooltip 等行为组件。视觉层完全自定义,匹配配方研发工具的专业调性。
|
||||
**决策**:Radix UI。平台 UI 需要与化妆品实验室品牌调性一致,Radix 的 Headless 模式允许完全定制视觉。
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
### 7. 图表 → ECharts
|
||||
|
||||
| 候选 | 优势 | 劣势 | 结论 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **TanStack Query** | 缓存/重试/乐观更新/无限滚动开箱即用;与 Zustand 分工清晰 | — | ✅ 推荐 |
|
||||
| SWR | 类似功能 | API 不如 TanStack Query 丰富 | ❌ |
|
||||
| RTK Query | Redux 集成 | 绑定 Redux | ❌ |
|
||||
| **ECharts** | 可视化类型最丰富(雷达图/桑基图/热力图等);React 绑定成熟(echarts-for-react);大数据集高性能 | 包体积较大(~1MB) | ✅ 推荐 |
|
||||
| D3.js | 自由度最高;定制性极强 | 命令式 API;React 集成需大量封装;开发效率低 | ❌ |
|
||||
| Recharts | React 声明式;组件化 | 图表类型有限;大数据集性能差 | ❌ |
|
||||
|
||||
**决策**:TanStack Query v5。推荐列表、配方搜索、AI 预测等所有异步请求均通过它管理。
|
||||
**决策**:ECharts 作为主图表库,D3.js 作为辅助(颜色空间可视化等高度定制场景)。
|
||||
|
||||
---
|
||||
|
||||
### 11. 后端 → Fastify(BFF 单体)+ 外部 AI API
|
||||
### 8. 色彩科学 → colorjs.io
|
||||
|
||||
```
|
||||
┌─────────┐ HTTP/SSE ┌──────────┐ HTTP ┌──────────┐
|
||||
│ React │ ◄─────────────► │ Fastify │ ◄────────────► │ AI API │
|
||||
│ 前端 │ │ BFF 层 │ │ (外部) │
|
||||
└─────────┘ └────┬─────┘ └──────────┘
|
||||
│
|
||||
┌────▼─────┐
|
||||
│PostgreSQL │
|
||||
│ + pgvector│
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
| 层 | 技术 | 职责 | 理由 |
|
||||
| 候选 | 优势 | 劣势 | 结论 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **BFF** | Fastify 5 | API 聚合、认证鉴权、文件上传、配方 CRUD、AI API 调用代理、SSE 推送 | TS 全栈一致性;Schema 验证内置;性能优于 Express |
|
||||
| **AI** | 外部 API | 配方预测、配方生成、NL 解析、颜色匹配推荐 | 无需自建 ML infra;按需调用;模型持续更新 |
|
||||
| **通信** | REST + SSE | 前端 ↔ BFF REST;AI 预测 SSE 实时推送;BFF ↔ AI API HTTP | SSE 适合单向实时数据流;BFF 统一处理 AI 调用的编排和降级 |
|
||||
| **colorjs.io** | 支持所有颜色空间(CIELAB/Display P3/LCH 等);ΔE 2000/CMC 计算;积极维护 | 社区较 chroma.js 小 | ✅ 推荐 |
|
||||
| chroma.js | 轻量 API;流行度高 | 不支持 ΔE 2000;不支持 Display P3 | ❌ |
|
||||
| d3-color | 与 D3 生态集成 | 颜色空间有限;无 ΔE | ❌ |
|
||||
|
||||
| BFF 框架对比 | 优势 | 劣势 | 结论 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Fastify** | 高性能(~60k req/s);插件体系;内置 Schema 验证(AJV);TS 支持;日志(pino) | 社区较 Express 小 | ✅ 推荐 |
|
||||
| Express | 生态最大;中间件极多 | 性能较低;无内置验证;回调风格 | ❌ |
|
||||
| Hono | 极快;边缘部署 | 主要用于 Cloudflare/边缘场景 | ❌ |
|
||||
|
||||
**AI API 调用设计**(详见 ADR-0002):
|
||||
|
||||
BFF 作为 AI API 的统一网关,负责:
|
||||
- **编排**:组合多次 API 调用(如"先解析 NL 查询 → 再向量搜索 → 组合 prompt → 生成推荐")
|
||||
- **降级**:API 超时或失败时返回缓存结果或降级提示
|
||||
- **限流**:保护 API 额度,控制并发
|
||||
- **缓存**:相似查询复用结果,减少 API 调用
|
||||
- **SSE 流式**:配方推演等长耗时场景,BFF 转发 AI API 的 streaming 响应
|
||||
|
||||
| AI 能力 | 调用方式 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| 配方指标预测 | Prompt + 历史数据(few-shot) | 将成分列表和比例作为 context,LLM 预测肤感/稳定性/成本 |
|
||||
| NL 搜索解析 | LLM → 结构化查询 → pgvector | LLM 将自然语言转为过滤条件 + 向量搜索 |
|
||||
| 配方生成/推演 | LLM with constraints | 给定约束条件,LLM 生成候选配方方案 |
|
||||
| 颜色推荐 | LLM + 色彩库 | 结合色差计算和 LLM 推理推荐色浆组合 |
|
||||
| 成分标签提取 | LLM 结构化提取 | 从配方文本中提取 INCI 名称、比例、工艺参数 |
|
||||
**决策**:colorjs.io。
|
||||
|
||||
---
|
||||
|
||||
### 12. 数据库 → PostgreSQL 16 + pgvector
|
||||
### 9. 后端框架 → Fastify
|
||||
|
||||
| 候选 | 结构化 | 向量搜索 | JSONB | 全文搜索 | 运维 | 结论 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **PostgreSQL + pgvector** | ✅ | ✅ IVFFlat/HNSW | ✅ | ✅ | 成熟 | ✅ 推荐 |
|
||||
| MongoDB | ⚠️ 文档型 | ✅ Atlas Vector | ✅ | ⚠️ | 成熟 | ❌ 配方强关系 |
|
||||
| Elasticsearch | ❌ | ⚠️ 需插件 | ❌ | ✅ | 复杂 | ❌ 过度 |
|
||||
| Neo4j | ❌ 图 | ⚠️ | ❌ | ❌ | 小众 | ❌ 过度 |
|
||||
| 候选 | 优势 | 劣势 | 结论 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Fastify** | 性能最高(~60k req/s);插件生态完善(CORS/Swagger/Helmet);TypeScript 原生支持;schema 验证 | 社区较 Express 小 | ✅ 推荐 |
|
||||
| Express | 最广泛使用;中间件生态极丰富 | 性能较差(~15k req/s);TS 支持需额外配置;回调风格 | ❌ |
|
||||
| Hono | 极轻量(< 10KB);运行时无关(Node/Deno/Bun/Edge) | 生态较小;企业级插件不成熟 | ❌ |
|
||||
| NestJS | 开箱即用架构(Module/Controller/Service);DI 容器 | 过度工程化;装饰器侵入性强;学习曲线陡峭;冷启动慢 | ❌ |
|
||||
|
||||
**决策**:PostgreSQL 16 + pgvector。
|
||||
**决策**:Fastify 5 + TypeScript。插件体系完整且性能优异,适合 API 密集型场景。
|
||||
|
||||
- 配方是强结构化数据(成分→相→配方→版本,经典关系模型)
|
||||
- pgvector 0.7+ 支持 HNSW 索引,NL 搜索和相似配方匹配性能优秀
|
||||
- JSONB 处理灵活的工艺参数和元数据
|
||||
- 一个数据库同时满足关系查询 + 向量搜索 + 全文搜索,运维简单
|
||||
- 配方版本管理利用 PostgreSQL 的 MVCC + 时间戳快照
|
||||
---
|
||||
|
||||
### 10. 数据库 → PostgreSQL + pgvector
|
||||
|
||||
| 候选 | 优势 | 劣势 | 结论 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **PostgreSQL + pgvector** | 成熟稳定;pgvector 扩展支持向量搜索(HNSW 索引);ACID 事务 | 向量搜索性能不如专用向量 DB | ✅ 推荐 |
|
||||
| MongoDB | 文档模型灵活;内置 Atlas Search | 缺乏 ACID;向量搜索需 Atlas | ❌ |
|
||||
| Elasticsearch | 全文搜索最强 | 运维成本高;需额外同步数据 | ❌ |
|
||||
|
||||
**决策**:PostgreSQL + pgvector。配方数据是典型的关系型结构(配方→相→成分),PostgreSQL 的关系模型天然适配;向量搜索用于语义配方查找,pgvector 性能足够。
|
||||
|
||||
---
|
||||
|
||||
### 11. ORM → Prisma
|
||||
|
||||
| 候选 | 优势 | 劣势 | 结论 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Prisma** | 类型安全;Schema-first;迁移工具成熟;Client 自动生成;关系模型直观 | 复杂查询性能不如原生 SQL | ✅ 推荐 |
|
||||
| Drizzle ORM | 轻量零依赖;SQL-like API;性能接近原生 | 生态较新;迁移工具不如 Prisma 成熟 | ❌ |
|
||||
| Knex.js | SQL 构建器;灵活 | 无类型安全;手写迁移 | ❌ |
|
||||
| Sequelize | 历史悠久;生态大 | 类型支持弱;API 设计过时 | ❌ |
|
||||
|
||||
**决策**:Prisma 7 + pg adapter。项目中配方的关系嵌套深度大(Formula→Version→Phase→FormulaIngredient→Ingredient),Prisma 的关联查询和事务支持良好。
|
||||
|
||||
---
|
||||
|
||||
### 12. 包管理 → pnpm workspace
|
||||
|
||||
| 候选 | 优势 | 劣势 | 结论 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **pnpm** | 硬盘高效(硬链接);严格依赖;monorepo workspace 支持;速度快 | lockfile 格式与 npm 不兼容 | ✅ 推荐 |
|
||||
| npm | 默认工具 | 依赖扁平化导致幽灵依赖;磁盘占用大;慢 | ❌ |
|
||||
| Yarn | Plug'n'Play 模式 | PnP 兼容性问题多;社区分裂 | ❌ |
|
||||
|
||||
**决策**:pnpm。前后端独立 package,共享 workspace 协议。
|
||||
|
||||
---
|
||||
|
||||
@@ -213,56 +173,31 @@ BFF 作为 AI API 的统一网关,负责:
|
||||
|
||||
**决策**:MinIO(S3 兼容),存储参考图片、导出文件。本地部署,S3 API 可无缝迁移到云。
|
||||
|
||||
> **修订(2026-05-21)**:MinIO 设为 docker compose `profiles: ["full"]`,默认不启动。当前无实际代码使用。
|
||||
|
||||
---
|
||||
|
||||
### 14. 部署 → Docker Compose(开发)+ K8s(生产)
|
||||
### 14. 部署 → Docker Compose + Traefik
|
||||
|
||||
| 服务 | 端口 | 职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| Nginx | 80/443 | 静态资源、反向代理、SSL 终结 |
|
||||
| Fastify BFF | 3001 | API 聚合、认证、文件上传、AI API 代理 |
|
||||
| PostgreSQL | 5432 | 配方数据 + 向量 |
|
||||
| MinIO | 9000 | 对象存储 |
|
||||
| Redis(可选) | 6379 | 缓存、Session、AI 响应缓存 |
|
||||
**决策**:开发环境 `docker compose up`(PostgreSQL + MinIO(可选)),生产环境 `docker compose -f docker-compose.prod.yml up`(Traefik + PostgreSQL + Backend + Frontend)。
|
||||
|
||||
**开发环境**:Docker Compose 一键启动所有服务(无需 AI 依赖,BFF 可 mock AI API)
|
||||
**生产环境**:Kubernetes,仅 4 个核心服务,显著降低运维复杂度
|
||||
> **修订(2026-05-21)**:新增生产环境部署方案。Traefik 负责反向代理和自动负载均衡,Backend/Frontend 均已容器化。
|
||||
|
||||
---
|
||||
|
||||
### 15. 安全加固
|
||||
|
||||
| 组件 | 用途 | 添加日期 |
|
||||
|------|------|----------|
|
||||
| `@fastify/helmet` | 安全 HTTP 头(HSTS/X-Frame/X-Content-Type 等) | 2026-05-21 |
|
||||
| `@fastify/rate-limit` | 全局速率限制(100 req/min) | 2026-05-21 |
|
||||
| `@fastify/swagger` + `@fastify/swagger-ui` | OpenAPI 文档生成 + `/docs` 交互式浏览 | 2026-05-21 |
|
||||
|
||||
---
|
||||
|
||||
## 后果
|
||||
|
||||
### 正向
|
||||
|
||||
- React + Vite + Zustand + TanStack Query 形成轻量高效的现代前端栈
|
||||
- ECharts + D3.js 组合覆盖标准化图表和定制可视化全场景
|
||||
- color.js 唯一满足 Display P3 + CIEDE2000 需求的色彩库
|
||||
- AI 通过外部 API 调用,无需自建 ML 基础设施,运维简单
|
||||
- 仅 4 个核心服务(Nginx + BFF + PostgreSQL + MinIO),部署轻量
|
||||
- PostgreSQL + pgvector 单一数据库减少运维复杂度
|
||||
|
||||
### 风险和缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
| :--- | :--- |
|
||||
| color.js 仍为 Beta(v0.x) | 锁定版本;Delta E 逻辑简单,必要时可自实现 CIEDE2000 |
|
||||
| DnD Kit 维护节奏慢 | API 稳定,核心功能完备;锁定版本 |
|
||||
| AI API 延迟 / 不可用 | BFF 层缓存 + 降级策略;关键路径设置超时(详见 ADR-0002) |
|
||||
| AI API 调用成本 | 缓存相似查询;Prompt 压缩优化;使用 cheaper 模型做预处理 |
|
||||
| pgvector HNSW 索引构建耗时 | 配方数据量级可控(万级),索引构建秒级 |
|
||||
|
||||
### 需要关注但未在本次决议的
|
||||
|
||||
- CI/CD 流水线(GitHub Actions / GitLab CI)
|
||||
- 监控和日志(Sentry + Grafana + Loki)
|
||||
- 认证方案(JWT + OAuth2 / LDAP 企业对接)
|
||||
- 测试框架细节(在实现阶段决策)
|
||||
|
||||
---
|
||||
|
||||
## 参考
|
||||
|
||||
- PRD: `.scratch/formula-rd-platform/PRD.md`
|
||||
- CSS Color Level 4: https://www.w3.org/TR/css-color-4/
|
||||
- color.js: https://github.com/color-js/color.js
|
||||
- pgvector: https://github.com/pgvector/pgvector
|
||||
- 所有用户必须熟悉 React + Fastify + Prisma 三件套
|
||||
- 数据库变更必须通过 Prisma Migrate,不可手动修改 Schema
|
||||
- OpenAPI spec 作为服务契约,前后端类型同步
|
||||
- 本地开发需 Docker 运行 PostgreSQL(pgvector 扩展)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# ADR-0002: AI 能力通过外部 API 调用实现
|
||||
|
||||
> **状态**: 已决议
|
||||
> **日期**: 2026-05-20
|
||||
> **父决策**: ADR-0001(整体技术栈)
|
||||
> **状态**: 已决议(2026-05-21 修订)
|
||||
> **日期**: 2026-05-20
|
||||
> **修订**: 2026-05-21
|
||||
> **父决策**: ADR-0001(整体技术栈)
|
||||
> **决策者**: 架构评审
|
||||
|
||||
---
|
||||
@@ -46,221 +47,56 @@
|
||||
|
||||
---
|
||||
|
||||
## BFF 层 AI 调用架构
|
||||
## AI Service 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 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) │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
Route Layer (modules/ai/ai.route.ts)
|
||||
│
|
||||
▼
|
||||
AIService (services/ai/index.ts)
|
||||
├── Provider 抽象层
|
||||
│ ├── createOpenAIProvider (GPT-4o, base: api.openai.com/v1)
|
||||
│ └── createDeepSeekProvider (deepseek-chat, base: api.deepseek.com/v1)
|
||||
├── LRUCache (200 条, 带 TTL)
|
||||
├── RateLimiter (10 req/s, token bucket)
|
||||
├── 重试策略 (指数退避, 最多 3 次)
|
||||
├── Fallback → Mock 模式 (AI_MOCK=true 时返回预设数据)
|
||||
└── Audit Log → ai_audit_logs 表 (capability, model, tokens, duration)
|
||||
```
|
||||
|
||||
### 核心模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
| :--- | :--- |
|
||||
| **Prompt Templates** | 每种 AI 能力对应独立的 Prompt 模板,包含 system prompt + 结构化输出指令 |
|
||||
| **Cache Layer** | LRU 缓存相同/相似查询的 AI 响应(基于 query hash),TTL 5min~1h |
|
||||
| **Rate Limiter** | 令牌桶 + 并发控制,保护 API 配额 |
|
||||
| **Fallback Handler** | API 不可用时返回缓存结果或友好降级提示 |
|
||||
| **AI API Client** | 统一的 HTTP 客户端,处理 streaming、重试、超时 |
|
||||
> **修订(2026-05-21)**:AI Service 重构为实例属性管理配置(openaiKey/deepseekKey),不再依赖 `process.env` 直读。Provider 通过 `reload(updates)` 热更新。
|
||||
|
||||
---
|
||||
|
||||
## 各 AI 能力的 API 调用策略
|
||||
## AI 能力清单
|
||||
|
||||
### 1. 配方指标预测
|
||||
|
||||
```
|
||||
POST /api/ai/predict
|
||||
|
||||
BFF 流程:
|
||||
1. 从 PostgreSQL 检索该成分组合的历史相似配方(pgvector 向量搜索)
|
||||
2. 构建 Prompt:
|
||||
- System: 化妆品配方专家角色 + 指标定义
|
||||
- Context: 历史相似配方的指标数据(few-shot examples)
|
||||
- User: 当前配方的成分列表和比例
|
||||
3. 调用 AI API(非 streaming,预期 < 5s)
|
||||
4. 缓存结果(key: 成分+比例 hash)
|
||||
```
|
||||
|
||||
### 2. NL 搜索解析
|
||||
|
||||
```
|
||||
POST /api/formulas/search?q=不含酒精的高保湿精华
|
||||
|
||||
BFF 流程:
|
||||
1. 调用 AI API 将 NL 转为结构化查询:
|
||||
{
|
||||
"filters": { "exclude_ingredients": ["alcohol", "ethanol"], "category": "精华液" },
|
||||
"vector_query": "高保湿、补水、滋润配方",
|
||||
"sort": "保湿指数 DESC"
|
||||
}
|
||||
2. filters → PostgreSQL WHERE 子句
|
||||
3. vector_query → pgvector embedding → HNSW 相似搜索
|
||||
4. 合并结果返回
|
||||
```
|
||||
|
||||
### 3. 配方推演
|
||||
|
||||
```
|
||||
POST /api/ai/explore
|
||||
|
||||
BFF 流程:
|
||||
1. 用户设置约束(目标成本、保留成分、禁止成分、目标指标)
|
||||
2. 检索当前配方 + 相关历史配方作为 context
|
||||
3. 构建 Prompt → 调用 AI API(streaming 模式)
|
||||
4. BFF 转发 SSE 流到前端,逐步展示生成的候选方案
|
||||
5. 每个候选方案附带置信度和变更说明
|
||||
```
|
||||
|
||||
### 4. 颜色推荐
|
||||
|
||||
```
|
||||
POST /api/color/recommend
|
||||
|
||||
BFF 流程:
|
||||
1. 前端传入目标颜色 Lab 值
|
||||
2. 在 PostgreSQL 中检索 ΔE < 3.0 的历史颜色配方(pgvector)
|
||||
3. 将目标色 + 最近匹配配方作为 context,调用 AI API 推荐色浆组合
|
||||
4. 返回推荐色浆 + 预测比例 + 预测 ΔE
|
||||
```
|
||||
|
||||
### 5. 配方结构化提取
|
||||
|
||||
```
|
||||
POST /api/formulas/extract
|
||||
|
||||
BFF 流程:
|
||||
1. 用户粘贴配方文本(或上传 Excel)
|
||||
2. 调用 AI API with function calling / structured output
|
||||
3. 提取:成分 INCI 名、中文名、比例、所属相、工艺备注
|
||||
4. 与成分目录(ingredients 表)模糊匹配校验
|
||||
5. 返回结构化 JSON,前端展示确认
|
||||
```
|
||||
| Prompt 模板 | 能力 | System Prompt 角色 | 输出格式 |
|
||||
|-------------|------|--------------------|----------|
|
||||
| `predictMetricsPrompt` | 预测配方指标 | 资深化妆品配方工程师 | `{sensoryIndex, stabilityScore, costEstimate, confidence, reasoning}` |
|
||||
| `parseNLQueryPrompt` | 自然语言搜索 | 查询解析器 | `{filters, keywords, vectorQuery}` |
|
||||
| `generateFormulaPrompt` | 配方推演 | 资深化妆品配方工程师 | `[{name, changes, predictedMetrics, reasoning}]` |
|
||||
| `recommendColorantsPrompt` | 配色推荐 | 化妆品色彩专家 | `{recommendations: [{colorants, predictedDeltaE, confidence}]}` |
|
||||
| `extractFormulaPrompt` | 配方文本提取 | 数据结构化提取 | `{ingredients: [{inciName, chineseName, percentage, phase, processNotes}]}` |
|
||||
|
||||
---
|
||||
|
||||
## AI API 选型
|
||||
## 配置管理
|
||||
|
||||
### 主选
|
||||
| 配置项 | 存储位置 | 说明 |
|
||||
|--------|----------|------|
|
||||
| `AI_MOCK` | `runtime/config.json` | Mock 模式开关(开发环境默认 true) |
|
||||
| `OPENAI_API_KEY` | `runtime/config.json` | 服务器端持久化,客户端不透传 |
|
||||
| `DEEPSEEK_API_KEY` | `runtime/config.json` | 同上 |
|
||||
| `OPENAI_BASE_URL` | `runtime/config.json` | 自定义 endpoint(如 API 代理) |
|
||||
| `DEEPSEEK_BASE_URL` | `runtime/config.json` | 同上 |
|
||||
|
||||
| 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 表
|
||||
> **修订(2026-05-21)**:API Key 从 localStorage 传输改为服务器端文件持久化。前端 SettingsPage 仅显示"已配置/未配置"状态,不展示 Key 内容。
|
||||
|
||||
---
|
||||
|
||||
## 后果
|
||||
|
||||
### 正向
|
||||
|
||||
- 零 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
|
||||
- 依赖外部 API 服务可用性(OpenAI / DeepSeek),API 不可用时自动降级为 Mock 模式
|
||||
- AI 响应格式严格约束为 JSON,前端不解析自然语言输出
|
||||
- 每次 AI 调用记录审计日志,用于成本核算和问题排查
|
||||
- 新增 AI Provider 只需实现 `AIProvider` 接口(`chat` + `chatStream`)
|
||||
|
||||
96
docs/adr/0003-four-layer-module-architecture.md
Normal file
96
docs/adr/0003-four-layer-module-architecture.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# ADR-0003: 后端四层模块化架构
|
||||
|
||||
> **状态**: 已决议
|
||||
> **日期**: 2026-05-21
|
||||
> **父决策**: ADR-0001(整体技术栈)
|
||||
> **决策者**: 架构评审
|
||||
|
||||
---
|
||||
|
||||
## 上下文
|
||||
|
||||
项目初期路由层直接操作 Prisma,业务逻辑与 HTTP 适配耦合。随着模块增长(8 个领域模块,29 个 API 端点),缺乏分层导致:
|
||||
|
||||
- 业务逻辑无法脱离 HTTP 环境做单元测试
|
||||
- Prisma 调用散落在 8 个路由文件中,查询逻辑难以复用
|
||||
- 横切关注点(日志、审计、错误处理)没有统一注入点
|
||||
- 单文件超长(formulas 路由 295 行)难以维护
|
||||
|
||||
需对后端架构做分层设计,支持企业级扩展。
|
||||
|
||||
---
|
||||
|
||||
## 决策
|
||||
|
||||
**选择四层模块化架构**:Route → Service → Repository → Prisma,按领域模块聚合文件。
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── modules/
|
||||
│ ├── formulas/
|
||||
│ │ ├── formulas.route.ts # Fastify 路由注册 + 参数提取
|
||||
│ │ ├── formulas.service.ts # 纯业务逻辑 + 审计埋点
|
||||
│ │ ├── formulas.repository.ts # Prisma 数据访问
|
||||
│ │ ├── formulas.schema.ts # Zod 验证 schema
|
||||
│ │ └── formulas.test.ts # 集成测试
|
||||
│ ├── ingredients/ # 同上
|
||||
│ ├── projects/ # 同上
|
||||
│ ├── color/ # 同上
|
||||
│ ├── ai/ # 同上
|
||||
│ ├── auth/ # 同上
|
||||
│ ├── config/ # 同上
|
||||
│ └── health/ # 同上
|
||||
└── shared/ # 跨模块共享
|
||||
├── errors/ # AppError 异常体系
|
||||
├── logging/ # AsyncLocalStorage 上下文
|
||||
├── middleware/ # RBAC / Ownership 中间件
|
||||
├── metrics/ # Prometheus 指标
|
||||
└── audit/ # 审计服务
|
||||
```
|
||||
|
||||
### 对比方案
|
||||
|
||||
| 方案 | 优势 | 劣势 | 结论 |
|
||||
|------|------|------|------|
|
||||
| **四层模块化(选)** | 按领域聚合,改动一个功能不需跨目录跳转;Service/Repository 可独立单元测试;横切关注点通过 shared/ 统一注入 | 小模块(如 health)文件数多 | ✅ |
|
||||
| 三层(Route→Service→Prisma) | 简单直接 | Service 与持久化耦合,不含 Repository 则 Prisma mock 困难 | ❌ |
|
||||
| 按层分目录(routes/ / services/ / repositories/) | 层边界清晰 | 同功能文件分散在 4 个目录,开发时频繁切换 | ❌ |
|
||||
| Clean/六边形 | 核心领域零框架依赖 | 过度工程化;当前仅 Web 端,无需端口-适配器抽象 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 层职责
|
||||
|
||||
| 层 | 职责 | 依赖 | 测试方式 |
|
||||
|----|------|------|----------|
|
||||
| **Route** | Fastify 注册、参数提取(req→纯数据)、preHandler 挂载 | Controller/Service + Zod | `app.inject()` 集成测试 |
|
||||
| **Service** | 纯业务逻辑、审计埋点、百分比验证 | Repository + AuditService | 单元测试(mock repository) |
|
||||
| **Repository** | Prisma 查询封装、事务管理 | Prisma | Testcontainers 集成测试 |
|
||||
| **Schema** | Zod 验证定义、TypeScript 类型导出 | Zod | 不需要测试(声明式) |
|
||||
|
||||
---
|
||||
|
||||
## 横切关注点
|
||||
|
||||
| 关注点 | 实现方式 | 注入点 |
|
||||
|--------|----------|--------|
|
||||
| **认证** | JWT preHandler(app.ts 全局) | onRequest |
|
||||
| **授权** | `requireRole()` / `requireFormulaOwnership()` | Route preHandler |
|
||||
| **错误处理** | AppError 子类(ValidationError/NotFoundError 等) | 全局 `setErrorHandler` |
|
||||
| **日志** | pino 结构化日志 + AsyncLocalStorage context | `app.log.child()` |
|
||||
| **审计** | AuditService(pino 输出,action/resource/userId) | Service 层显式调用 |
|
||||
| **指标** | prom-client(http_requests_total, app_errors_total, ai_requests_total) | 全局 handler / AI Service |
|
||||
| **输入校验** | `validateOrReply()` — Zod 解析,失败时 400 | Route 层 |
|
||||
| **API 文档** | `@fastify/swagger` + `zod-to-json-schema` | Route schema 定义 |
|
||||
|
||||
---
|
||||
|
||||
## 后果
|
||||
|
||||
- 新增模块需创建 4 个文件(route/service/repository/schema),模板明确
|
||||
- Service 和 Repository 可脱离 HTTP 环境做纯函数测试
|
||||
- 跨模块共享逻辑必须放在 `shared/` 下,不能在模块间直接 import
|
||||
- 所有错误必须使用 AppError 子类,不可裸抛 `new Error()`
|
||||
- 模块测试文件与源文件同目录,vitest `include: ['src/**/*.test.ts']` 自动发现
|
||||
96
docs/adr/0004-rbac-ownership-authorization.md
Normal file
96
docs/adr/0004-rbac-ownership-authorization.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# ADR-0004: RBAC + 资源级 Ownership 授权模型
|
||||
|
||||
> **状态**: 已决议
|
||||
> **日期**: 2026-05-21
|
||||
> **父决策**: ADR-0003(四层模块化架构)
|
||||
> **决策者**: 架构评审
|
||||
|
||||
---
|
||||
|
||||
## 上下文
|
||||
|
||||
平台需要访问控制,确保:
|
||||
- 敏感配置仅管理员可修改(AI Key、Mock 模式切换)
|
||||
- 配方工程师只能编辑/删除自己创建的配方
|
||||
- 成分目录等共享资源允许所有认证用户操作
|
||||
|
||||
考虑三种授权模型:
|
||||
|
||||
---
|
||||
|
||||
## 决策
|
||||
|
||||
**选择 RBAC + 资源级 Ownership**:角色控制操作类型,所有权控制资源访问粒度。
|
||||
|
||||
### 角色定义
|
||||
|
||||
| 角色 | 英文 | 能力 |
|
||||
|------|------|------|
|
||||
| 管理员 | admin | 管理配置、管理用户、管理所有配方/成分/项目 |
|
||||
| 配方工程师 | engineer | 创建配方、查看/使用成分目录、使用 AI 推演和颜色引擎 |
|
||||
|
||||
### 权限矩阵
|
||||
|
||||
| 操作 | admin | engineer |
|
||||
|------|-------|----------|
|
||||
| 管理 AI 配置 (`/api/config`) | ✓ | ✗ |
|
||||
| 管理成分目录(增删改) | ✓ | ✓ |
|
||||
| 查看成分目录 | ✓ | ✓ |
|
||||
| 创建/编辑/删除**自己的**配方 | ✓ | ✓ |
|
||||
| 编辑/删除**他人的**配方 | ✓ | ✗ |
|
||||
| 创建配方 | ✓ | ✓ |
|
||||
| 查看所有配方 | ✓ | ✓ |
|
||||
| 颜色引擎 / AI 推演 | ✓ | ✓ |
|
||||
| 管理项目 | ✓ | ✓ |
|
||||
| 查看项目 | ✓ | ✓ |
|
||||
|
||||
### 中间件实现
|
||||
|
||||
```typescript
|
||||
// 角色检查 — 用于配置等管理接口
|
||||
app.put('/', { preHandler: [requireRole('admin')] }, updateConfigHandler)
|
||||
|
||||
// 所有权检查 — 用于配方写操作
|
||||
app.put('/:id', { preHandler: [requireFormulaOwnership()] }, updateFormula)
|
||||
app.delete('/:id', { preHandler: [requireFormulaOwnership()] }, deleteFormula)
|
||||
```
|
||||
|
||||
`requireFormulaOwnership()` 内部逻辑:
|
||||
1. 查询 Formula.createdBy
|
||||
2. 查询当前 User.role
|
||||
3. admin → 放行
|
||||
4. engineer 且 createdBy === userId → 放行
|
||||
5. 否则 → throw ForbiddenError
|
||||
|
||||
---
|
||||
|
||||
### 对比方案
|
||||
|
||||
| 方案 | 优势 | 劣势 | 结论 |
|
||||
|------|------|------|------|
|
||||
| **RBAC + Ownership(选)** | 角色简单(2种);所有权检查与业务语义对齐;中间件声明式清晰 | 需要为每个资源类型编写 ownership 中间件 | ✅ |
|
||||
| 纯 RBAC(admin/formulator/viewer) | 无需资源级检查 | 无法表达"只能编辑自己的配方"(需要更多角色或 ABAC 条件) | ❌ |
|
||||
| ABAC(属性动态判断) | 最灵活 | Admin UI 复杂;规则定义成本高;当前 2 角色场景下过度 | ❌ |
|
||||
| 无授权(仅认证) | 最简单 | 任何认证用户可改 AI Key、删他人配方,不可接受 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 认证机制
|
||||
|
||||
| 组件 | 实现 |
|
||||
|------|------|
|
||||
| Token 格式 | JWT HS256(HMAC-SHA256) |
|
||||
| 签名密钥 | `JWT_SECRET` 环境变量 |
|
||||
| 有效期 | 24 小时 |
|
||||
| 有效载荷 | `{ userId, exp }` |
|
||||
| 密码哈希 | scrypt(salt 16 字节,输出 64 字节,hex 编码) |
|
||||
| 密码比对 | `timingSafeEqual`(防时序攻击) |
|
||||
|
||||
---
|
||||
|
||||
## 后果
|
||||
|
||||
- 新增资源类型(如以后有独立的内容管理模块)需实现对应的 ownership 中间件
|
||||
- 认证中间件通过 `app.ts` 全局 `preHandler` 注册,公开路由通过 URL 前缀白名单跳过
|
||||
- 测试环境使用 `buildApp({ skipAuth: true })` 绕过认证,`request.userId` 默认为 `'system'`
|
||||
- 角色变更需修改 User.role 字段(当前通过数据库直改,未来需管理 UI)
|
||||
1076
docs/api-reference.md
Normal file
1076
docs/api-reference.md
Normal file
File diff suppressed because it is too large
Load Diff
522
docs/project-overview.md
Normal file
522
docs/project-overview.md
Normal file
@@ -0,0 +1,522 @@
|
||||
# 配方研发智能平台 — 项目全貌
|
||||
|
||||
> 最后更新 2026-05-21 | 版本 v0.1.0 | 企业级重构后
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目是什么
|
||||
|
||||
AI 驱动的化妆品配方研发智能平台(纯 Web 端)。面向化妆品研发工程师,提供四大核心能力:
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 颜色管理 | CIELAB 色空间 + Display P3 广色域渲染 + AI 配色推荐 |
|
||||
| 可视化配方调整 | 拖拽交互 + 实时 AI 预测反馈 + ECharts 图表 |
|
||||
| 配方记录管理 | 结构化存储 + 版本管理 + 自然语言搜索 |
|
||||
| 配方推演 | 多方案并行优化 + Pareto 前沿 + 成本/功效约束 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录全貌
|
||||
|
||||
```
|
||||
color_full/
|
||||
│
|
||||
├── CONTEXT.md # 领域词汇表(纯业务术语)
|
||||
├── README.md
|
||||
├── AGENTS.md # Agent 配置
|
||||
│
|
||||
├── docs/
|
||||
│ ├── adr/
|
||||
│ │ ├── 0001-architecture-stack.md # 技术栈选型(React/Fastify/Prisma/...)
|
||||
│ │ ├── 0002-ai-api-strategy.md # AI 外部 API 调用策略
|
||||
│ │ ├── 0003-four-layer-module-architecture.md # 后端四层模块化架构
|
||||
│ │ └── 0004-rbac-ownership-authorization.md # RBAC + 资源级 Ownership
|
||||
│ ├── api-reference.md # API 接口文档(29 端点)
|
||||
│ ├── project-overview.md # 本文件
|
||||
│ └── agents/ # Agent skills 配置
|
||||
│
|
||||
├── backend/ # 后端(Fastify + TypeScript)
|
||||
│ ├── Dockerfile
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json # strict + noUncheckedIndexedAccess
|
||||
│ ├── vitest.config.ts
|
||||
│ ├── eslint.config.js
|
||||
│ ├── .prettierrc
|
||||
│ ├── .env.example # 环境变量模板
|
||||
│ ├── runtime/
|
||||
│ │ └── config.json # 运行时 AI Key 持久化
|
||||
│ ├── scripts/
|
||||
│ │ └── generate-openapi.ts # OpenAPI spec 生成
|
||||
│ ├── prisma/
|
||||
│ │ ├── schema.prisma # 数据模型定义(10 个模型)
|
||||
│ │ ├── seed.ts
|
||||
│ │ └── migrations/
|
||||
│ └── src/
|
||||
│ ├── app.ts # ★ 应用入口:插件注册 + 全局中间件
|
||||
│ ├── server.ts # ★ 启动 + 优雅关闭
|
||||
│ ├── lib/ # 工具库
|
||||
│ │ ├── prisma.ts # Prisma 客户端
|
||||
│ │ ├── configStore.ts # 服务器端配置持久化
|
||||
│ │ ├── validate.ts # Zod → Fastify 校验桥接
|
||||
│ │ └── swagger.ts # Zod → JSON Schema 转换
|
||||
│ ├── shared/ # ★ 跨模块共享基础设施
|
||||
│ │ ├── errors/
|
||||
│ │ │ ├── app-error.ts # AppError 6 子类体系
|
||||
│ │ │ └── codes.ts # 30+ 错误码常量
|
||||
│ │ ├── logging/
|
||||
│ │ │ └── context.ts # AsyncLocalStorage 请求上下文
|
||||
│ │ ├── middleware/
|
||||
│ │ │ ├── rbac.ts # requireRole('admin')
|
||||
│ │ │ └── ownership.ts # requireFormulaOwnership()
|
||||
│ │ ├── metrics/
|
||||
│ │ │ └── metrics.ts # prom-client 5 指标
|
||||
│ │ └── audit/
|
||||
│ │ └── audit.service.ts # 结构化审计日志
|
||||
│ ├── services/ # 核心服务
|
||||
│ │ └── ai/
|
||||
│ │ ├── index.ts # ★ AIService(Provider 抽象 + 缓存 + 限流 + 回退)
|
||||
│ │ ├── cache.ts # LRUCache
|
||||
│ │ ├── rate-limiter.ts # Token Bucket
|
||||
│ │ ├── audit.ts # AI 调用审计记录
|
||||
│ │ ├── providers/
|
||||
│ │ │ ├── types.ts # AIProvider 接口
|
||||
│ │ │ ├── openai.ts # OpenAI (GPT-4o)
|
||||
│ │ │ └── deepseek.ts # DeepSeek (deepseek-chat)
|
||||
│ │ └── templates/
|
||||
│ │ └── index.ts # 5 个 Prompt 模板
|
||||
│ ├── modules/ # ★ 业务模块(四层架构)
|
||||
│ │ ├── auth/ # 认证(注册/登录/JWT)
|
||||
│ │ ├── ingredients/ # 成分目录(route + service + repository + test)
|
||||
│ │ ├── formulas/ # 配方记录(route + service + repository + test)
|
||||
│ │ ├── color/ # 颜色引擎(推荐/匹配/保存)
|
||||
│ │ ├── ai/ # AI 推演(预测/探索/提取/搜索, SSE 流)
|
||||
│ │ ├── projects/ # 项目管理
|
||||
│ │ ├── config/ # 配置管理(AI Key/admin only)
|
||||
│ │ └── health/ # 健康检查(live/ready + /metrics)
|
||||
│ └── generated/ # Prisma 自动生成
|
||||
│
|
||||
├── frontend/ # 前端(React 19 + Vite 8 + TypeScript)
|
||||
│ ├── Dockerfile
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ ├── vitest.config.ts
|
||||
│ ├── eslint.config.js
|
||||
│ ├── tsconfig.json / tsconfig.app.json / tsconfig.node.json
|
||||
│ ├── index.html
|
||||
│ ├── nginx.conf # 生产 Nginx 配置
|
||||
│ ├── scripts/
|
||||
│ │ └── generate-types.ts # OpenAPI → TypeScript 类型生成
|
||||
│ └── src/
|
||||
│ ├── main.tsx # ★ 入口:ToastProvider + QueryClient + Router
|
||||
│ ├── App.tsx
|
||||
│ ├── router.tsx # React Router v7 配置
|
||||
│ ├── index.css # Tailwind CSS 4 入口
|
||||
│ ├── lib/
|
||||
│ │ ├── api.ts # apiFetch 封装 + Auth header
|
||||
│ │ ├── queryClient.ts # TanStack Query 配置
|
||||
│ │ └── color/ # 色彩科学工具
|
||||
│ │ ├── convert.ts # 色空间转换(Lab/Hex/RGB/LCH/P3)
|
||||
│ │ ├── deltaE.ts # ΔE 2000/CMC/76 计算
|
||||
│ │ ├── types.ts # 颜色类型定义
|
||||
│ │ └── color.test.ts # 颜色工具测试
|
||||
│ ├── shared/ # ★ 共享 UI 基建
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── Toast.tsx # Toast 通知系统(Provider + Hook)
|
||||
│ │ │ ├── Skeleton.tsx # 骨架屏(单行/多行/页面级)
|
||||
│ │ │ └── Alert.tsx # 警告提示(4 种变体)
|
||||
│ │ └── services/
|
||||
│ │ └── api.ts # 统一 API 客户端(带 Auth Token)
|
||||
│ ├── modules/ # 前端模块
|
||||
│ │ └── formulas/
|
||||
│ │ └── formulas.service.ts # 配方 Service 层(TanStack Query 就绪)
|
||||
│ ├── pages/ # 页面组件(14 个)
|
||||
│ │ ├── DashboardPage.tsx # 仪表盘
|
||||
│ │ ├── FormulaListPage.tsx # 配方列表
|
||||
│ │ ├── FormulaDetailPage.tsx # 配方详情(含可视化编辑器)
|
||||
│ │ ├── FormulaEditorPage.tsx # 配方编辑器
|
||||
│ │ ├── FormulaExplorerPage.tsx # 配方推演
|
||||
│ │ ├── VersionHistoryPage.tsx # 版本历史
|
||||
│ │ ├── VersionComparePage.tsx # 版本对比
|
||||
│ │ ├── ColorLabPage.tsx # 颜色实验室
|
||||
│ │ ├── IngredientsPage.tsx # 成分目录
|
||||
│ │ ├── ProjectsPage.tsx # 项目管理
|
||||
│ │ ├── SettingsPage.tsx # 设置(外观 + AI 配置)
|
||||
│ │ ├── SearchPage.tsx # AI 搜索
|
||||
│ │ ├── LoginPage.tsx # 登录
|
||||
│ │ └── RegisterPage.tsx # 注册
|
||||
│ ├── components/ # 共享组件
|
||||
│ │ ├── AuthGuard.tsx # 路由守卫
|
||||
│ │ ├── ErrorBoundary.tsx # 错误边界
|
||||
│ │ ├── ColorWheel.tsx # 色轮 Canvas
|
||||
│ │ ├── EyedropperPanel.tsx # 取色棒
|
||||
│ │ ├── ColorRecommendPanel.tsx # AI 配色推荐弹窗
|
||||
│ │ └── FormulaVisualEditor.tsx # 配方可视化编辑器
|
||||
│ ├── layouts/
|
||||
│ │ └── AppLayout.tsx # 主布局(侧栏 + 顶栏)
|
||||
│ ├── hooks/
|
||||
│ │ └── useAIPredict.ts # AI 预测 Hook
|
||||
│ └── stores/
|
||||
│ ├── authStore.ts # 认证状态(Zustand)
|
||||
│ └── themeStore.ts # 主题状态(Zustand)
|
||||
│
|
||||
├── docker/
|
||||
│ └── Dockerfile.pgvector # PostgreSQL + pgvector 镜像
|
||||
├── docker-compose.yml # 开发环境(PostgreSQL + MinIO[可选])
|
||||
├── docker-compose.prod.yml # 生产环境(Traefik + PostgreSQL + Backend + Frontend)
|
||||
└── scripts/
|
||||
├── backup-db.sh # pg_dump 备份脚本(7 天留存)
|
||||
└── init-db.sh # 数据库初始化
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构全景
|
||||
|
||||
### 3.1 后端:四层模块化
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ app.ts (Fastify) │
|
||||
│ ├── Helmet + CORS + RateLimit │
|
||||
│ ├── global preHandler: JWT verify │
|
||||
│ ├── global setErrorHandler: AppError → HTTP │
|
||||
│ └── /docs (Swagger UI) + /api/* routes │
|
||||
└────────────┬─────────────────────────────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Route Layer │ ← 参数提取 (req → 纯数据) + Zod 校验
|
||||
│ *.route.ts │ preHandler: requireRole() / requireFormulaOwnership()
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Service Layer │ ← 纯业务逻辑 + 审计埋点 + 百分比验证
|
||||
│ *.service.ts │ 依赖 Repository + AuditService
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Repository │ ← Prisma 查询封装 + 事务管理
|
||||
│ Layer │ 每个模块独立 Repository
|
||||
│ *.repository.ts│
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Prisma ORM │
|
||||
│ PostgreSQL │
|
||||
│ + pgvector │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 横切关注点注入矩阵
|
||||
|
||||
| 关注点 | 注入方式 | 注入位置 |
|
||||
|--------|----------|----------|
|
||||
| 请求 ID | `genReqId: () => randomUUID()` | app.ts 构造 |
|
||||
| 认证 | `addHook('preHandler')` → verifyToken → set userId | app.ts 全局 |
|
||||
| 授权 | `{ preHandler: [requireRole(), requireFormulaOwnership()] }` | Route 注册 |
|
||||
| 输入校验 | `validateOrReply(zodSchema, data, reply)` | Route handler |
|
||||
| 错误处理 | `setErrorHandler(error, request, reply)` → AppError 子类匹配 | app.ts 全局 |
|
||||
| 结构化日志 | `request.log.child({ requestId })` | app.ts onRequest |
|
||||
| 审计日志 | `auditService.log({ action, resource, userId })` | Service 层显式调 |
|
||||
| API 文档 | `@fastify/swagger` + `routeSchema()` | Route 注册 |
|
||||
| Prometheus | `app_errors_total.inc()` / `/api/metrics` 端点 | 全局 handler + health 模块 |
|
||||
| HTTP 安全 | `@fastify/helmet` + `@fastify/rate-limit` | app.ts 插件注册 |
|
||||
|
||||
### 3.3 前端:渐进式分层
|
||||
|
||||
```
|
||||
React Router
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ Page 组件 │ ← UI 渲染 + 调用 Hooks
|
||||
│ (pages/) │
|
||||
└────────────┬─────────────┘
|
||||
│
|
||||
┌────────▼────────────┐
|
||||
│ Hooks │ ← useQuery / useMutation (TanStack Query)
|
||||
│ + 页面级 State │ useReducer / useState
|
||||
└────────┬────────────┘
|
||||
│
|
||||
┌────────▼────────────┐
|
||||
│ Service 层 │ ← API 调用封装 (apiFetch)
|
||||
│ (modules/*.service) │ 类型安全的请求/响应
|
||||
└────────┬────────────┘
|
||||
│
|
||||
┌────────▼────────────┐
|
||||
│ 共享 UI 基建 │
|
||||
│ - ToastProvider │
|
||||
│ - Skeleton │
|
||||
│ - Alert │
|
||||
│ - ErrorBoundary │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
"当前状态:后端四层已全部落地,前端仅 formulaService 完成模板,其余 page 仍使用 `apiFetch` 直调。这是待完成的迁移动脉。"
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
```
|
||||
User ────1:N──→ Formula ────1:N──→ FormulaVersion ────1:N──→ Phase
|
||||
│ │ │ │
|
||||
│ │ │ 1:N │
|
||||
│ │ │ FormulaIngredient ────N:1──→ Ingredient
|
||||
│ │ │
|
||||
│ 1:N │ 1:N │
|
||||
├─────→ ColorFormula Phase (via formulaId on Phase)
|
||||
│
|
||||
└─────→ Project ────1:N──→ Formula
|
||||
```
|
||||
|
||||
| 表 | 核心字段 | 说明 |
|
||||
|----|----------|------|
|
||||
| users | username(unique), passwordHash(scrypt), role(engineer/admin) | 用户 |
|
||||
| projects | name, description, createdBy | 项目 |
|
||||
| formulas | name, description, currentVersion, projectId, embedding(vector) | 配方 |
|
||||
| formula_versions | formulaId, versionNumber(unique pair), snapshotData(JSON), createdBy | 版本快照 |
|
||||
| phases | name, formulaId(→FormulaVersion), sortOrder | 工艺阶段 |
|
||||
| formula_ingredients | formulaVersionId, phaseId, ingredientId, percentage, processNotes | 成分关联 |
|
||||
| ingredients | inciName, chineseName, functionCategory(12枚举), supplier, unitPrice | 原料 |
|
||||
| color_formulas | name, targetLab(JSON), actualLab(JSON), deltaE, colorantComposition(JSON) | 颜色配方 |
|
||||
| ai_audit_logs | capability, modelName, promptHash, tokensUsed, durationMs | AI 调用审计 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 安全架构
|
||||
|
||||
### 5.1 认证流程
|
||||
|
||||
```
|
||||
POST /api/auth/register
|
||||
用户名 + 密码 → scrypt 异步哈希 → 存入 users.passwordHash
|
||||
返回 JWT Token (HS256, 24h)
|
||||
|
||||
POST /api/auth/login
|
||||
用户名 + 密码 → scrypt 异步比对 (timingSafeEqual)
|
||||
返回 JWT Token
|
||||
|
||||
每个业务请求:
|
||||
Authorization: Bearer <token>
|
||||
→ preHandler: 解码验证 → 查 DB 确认用户存在 → set request.userId
|
||||
```
|
||||
|
||||
### 5.2 授权矩阵
|
||||
|
||||
| 操作 | admin | engineer |
|
||||
|------|:-----:|:--------:|
|
||||
| 管理 AI 配置 | ✓ | ✗ |
|
||||
| 增删改成份目录 | ✓ | ✓ |
|
||||
| 创建配方 | ✓ | ✓ |
|
||||
| 编辑/删除**自己**的配方 | ✓ | ✓ |
|
||||
| 编辑/删除**他人**的配方 | ✓ | ✗ |
|
||||
| 颜色引擎 / AI 推演 | ✓ | ✓ |
|
||||
| 管理项目 | ✓ | ✓ |
|
||||
|
||||
### 5.3 安全层
|
||||
|
||||
| 层 | 工具 | 作用 |
|
||||
|----|------|------|
|
||||
| HTTP 头 | `@fastify/helmet` | CSP/HSTS/X-Frame 等安全头 |
|
||||
| CORS | `@fastify/cors` | 仅允许 localhost:5173 |
|
||||
| 速率限制 | `@fastify/rate-limit` | 全局 100 req/min |
|
||||
| 密码 | scrypt(salt=16, output=64) + timingSafeEqual | 抗彩虹表 + 防时序攻击 |
|
||||
| Token | JWT HMAC-SHA256 (HS256) | 正确实现,非 SHA256 裸哈希 |
|
||||
| API Key | `runtime/config.json` 服务器端存储 | 不从 localStorage 传输 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 可观测性
|
||||
|
||||
### 6.1 健康探针
|
||||
|
||||
| 端点 | 用途 | 实现 |
|
||||
|------|------|------|
|
||||
| `/api/health` | 基础存活 | 返回 timestamp |
|
||||
| `/api/health/live` | K8s liveness | `SELECT 1` 检测 DB 连接,失败 503 |
|
||||
| `/api/health/ready` | K8s readiness | SIGTERM 后返回 503 |
|
||||
| `/api/metrics` | Prometheus scrape | 5 个指标 |
|
||||
|
||||
### 6.2 Prometheus 指标
|
||||
|
||||
| 指标 | 类型 | 标签 |
|
||||
|------|------|------|
|
||||
| `http_requests_total` | Counter | method, path, status |
|
||||
| `http_request_duration_ms` | Histogram | method, path |
|
||||
| `app_errors_total` | Counter | category, module, code |
|
||||
| `ai_requests_total` | Counter | capability, provider, status |
|
||||
| `ai_request_duration_ms` | Histogram | capability |
|
||||
|
||||
### 6.3 日志
|
||||
|
||||
- **框架**: pino(结构化 JSON)
|
||||
- **上下文**: 每个请求自动注入 `requestId`
|
||||
- **审计**: `auditService.log()` → `{ audit: true, action, resource, resourceId, userId }`
|
||||
- **格式**: 开发 `pino-pretty`,生产纯 JSON → Loki/ELK
|
||||
|
||||
---
|
||||
|
||||
## 7. 部署架构
|
||||
|
||||
### 7.1 开发环境
|
||||
|
||||
```bash
|
||||
docker compose up # PostgreSQL (pgvector)
|
||||
pnpm dev # backend (3001) + frontend (5173)
|
||||
```
|
||||
|
||||
MinIO 可选:`docker compose --profile full up`
|
||||
|
||||
### 7.2 生产环境
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Traefik (80/443) │
|
||||
│ ├── /api/* → backend:3001 │
|
||||
│ └── /* → frontend:80 │
|
||||
├────────────────────────────────────────────┤
|
||||
│ Backend (Fastify, port 3001) │
|
||||
│ ├── /api/* 业务路由 │
|
||||
│ ├── /docs Swagger UI │
|
||||
│ ├── /api/health/live liveness probe │
|
||||
│ ├── /api/health/ready readiness probe │
|
||||
│ └── /api/metrics Prometheus scrape │
|
||||
├────────────────────────────────────────────┤
|
||||
│ Frontend (Nginx, port 80) │
|
||||
│ ├── SPA static files │
|
||||
│ └── /api/* → backend proxy │
|
||||
├────────────────────────────────────────────┤
|
||||
│ PostgreSQL (pgvector) │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 7.3 备份
|
||||
|
||||
```bash
|
||||
# 手动备份
|
||||
./scripts/backup-db.sh
|
||||
|
||||
# crontab 每日备份
|
||||
0 2 * * * /opt/colorfull/scripts/backup-db.sh >> /var/log/colorfull-backup.log 2>&1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 质量保障
|
||||
|
||||
### 8.1 TypeScript 严格度
|
||||
|
||||
| 开关 | 后端 | 前端 |
|
||||
|------|:----:|:----:|
|
||||
| `strict: true` | ✓ | ✓ |
|
||||
| `noUncheckedIndexedAccess` | ✓ | ✓ |
|
||||
| `noUnusedLocals` | ✓ | ✓ |
|
||||
| `noUnusedParameters` | ✓ | ✓ |
|
||||
| `skipLibCheck` | ✓ | ✓ |
|
||||
|
||||
### 8.2 代码规范
|
||||
|
||||
| 工具 | 后端 | 前端 |
|
||||
|------|:----:|:----:|
|
||||
| ESLint | ✓ (`typescript-eslint`) | ✓ |
|
||||
| Prettier | ✓ (semi=false, singleQuote, trailingComma=all) | ✓ |
|
||||
| 状态 | **lint 零错误** | — |
|
||||
|
||||
### 8.3 测试
|
||||
|
||||
| 层 | 后端 | 前端 |
|
||||
|----|------|------|
|
||||
| 单元测试 | —(待补:Service 层) | `lib/color/color.test.ts`(色空间转换) |
|
||||
| 集成测试 | `ingredients.test.ts` + `formulas.test.ts`(26 用例) | —(待补:React Testing Library) |
|
||||
| E2E | —(待补:Playwright) | — |
|
||||
| 运行 | `pnpm test` (vitest) | `pnpm test` (vitest) |
|
||||
|
||||
### 8.4 命令速查
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend
|
||||
pnpm dev # 启动开发服务器 (tsx watch)
|
||||
pnpm build # TypeScript 编译
|
||||
pnpm test # 运行 26 个集成测试
|
||||
pnpm lint # ESLint 检查
|
||||
pnpm format # Prettier 格式化
|
||||
pnpm db:migrate # 数据库迁移
|
||||
pnpm db:seed # 种子数据
|
||||
pnpm api:gen # 生成 OpenAPI spec
|
||||
|
||||
# 前端
|
||||
cd frontend
|
||||
pnpm dev # 启动 Vite 开发服务器
|
||||
pnpm build # 生产构建
|
||||
pnpm lint # ESLint
|
||||
pnpm api:gen # 从 OpenAPI spec 生成 TypeScript 类型
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. API 速览
|
||||
|
||||
| 模块 | 端点 | 方法 | 认证 | 说明 |
|
||||
|------|------|------|:----:|------|
|
||||
| health | `/api/health` | GET | ✗ | 基础存活 |
|
||||
| health | `/api/health/live` | GET | ✗ | DB 检查 |
|
||||
| health | `/api/health/ready` | GET | ✗ | 就绪检查 |
|
||||
| health | `/api/metrics` | GET | ✗ | Prometheus |
|
||||
| auth | `/api/auth/register` | POST | ✗ | 注册 |
|
||||
| auth | `/api/auth/login` | POST | ✗ | 登录 |
|
||||
| auth | `/api/auth/me` | GET | ✓ | 当前用户 |
|
||||
| ingredients | `/api/ingredients` | GET/POST | ✓ | 列表/创建 |
|
||||
| ingredients | `/api/ingredients/:id` | GET/PUT/DEL | ✓ | 详情/更新/删除 |
|
||||
| formulas | `/api/formulas` | GET/POST | ✓ | 列表/创建 |
|
||||
| formulas | `/api/formulas/:id` | GET/PUT/DEL | ✓ | 详情/更新/删除 |
|
||||
| formulas | `/api/formulas/:id/composition` | PUT | ✓ | 更新成分(新版本) |
|
||||
| color | `/api/color/recommend` | POST | ✓ | AI 配色推荐 |
|
||||
| color | `/api/color/formulas/match` | GET | ✓ | 颜色配方匹配 |
|
||||
| color | `/api/color/formulas` | POST | ✓ | 保存颜色配方 |
|
||||
| ai | `/api/ai/predict-formula` | POST | ✓ | 预测指标(SSE) |
|
||||
| ai | `/api/ai/explore-formula` | POST | ✓ | 配方推演(SSE) |
|
||||
| ai | `/api/ai/extract-formula` | POST | ✓ | 提取配方文本 |
|
||||
| ai | `/api/ai/search` | GET | ✓ | NL 搜索 |
|
||||
| projects | `/api/projects` | GET/POST | ✓ | 列表/创建 |
|
||||
| projects | `/api/projects/:id` | PUT/DEL | ✓ | 更新/删除 |
|
||||
| config | `/api/config` | GET/PUT | ✓ | 查看/更新配置(admin) |
|
||||
| config | `/api/config/test` | POST | ✓ | 测试 AI 连接(admin) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 待完成清单
|
||||
|
||||
| 优先级 | 项目 | 状态 |
|
||||
|--------|------|:----:|
|
||||
| P1 | 前端页面逐批迁移到 Service 层 + TanStack Query | 仅 formulaService 完成 |
|
||||
| P1 | 前端集成 shadcn/ui 组件(替换手动创建的 Toast/Skeleton/Alert) | Tailwind 4 兼容性问题待解决 |
|
||||
| P2 | 后端 Service 层单元测试(mock Repository) | 待开始 |
|
||||
| P2 | 后端 Testcontainers 集成测试框架 | 待开始 |
|
||||
| P2 | Playwright E2E 关键流程测试 | 待开始 |
|
||||
| P2 | Husky pre-commit hooks(后端 + 前端) | 待配置 |
|
||||
| P3 | CI/CD 流水线(GitHub Actions / GitLab CI) | 待配置 |
|
||||
| P3 | 前端 OpenAPI 类型生成流水线验证 | 脚本已就绪,待端到端跑通 |
|
||||
| P3 | Admin 用户管理 UI | 直接操作 DB |
|
||||
|
||||
---
|
||||
|
||||
## 11. 相关文档索引
|
||||
|
||||
| 文档 | 路径 | 内容 |
|
||||
|------|------|------|
|
||||
| 领域词汇 | `CONTEXT.md` | 纯业务术语定义 |
|
||||
| 技术栈 ADR | `docs/adr/0001-architecture-stack.md` | 为什么选 React/Fastify/Prisma 等 |
|
||||
| AI 策略 ADR | `docs/adr/0002-ai-api-strategy.md` | 为什么用外部 LLM API |
|
||||
| 架构 ADR | `docs/adr/0003-four-layer-module-architecture.md` | 为什么四层模块化 |
|
||||
| 授权 ADR | `docs/adr/0004-rbac-ownership-authorization.md` | 为什么 RBAC + Ownership |
|
||||
| API 文档 | `docs/api-reference.md` | 29 个接口完整说明 |
|
||||
| PRD | `.scratch/formula-rd-platform/PRD.md` | 产品需求 |
|
||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
RUN pnpm fetch
|
||||
RUN pnpm install --frozen-lockfile --offline
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"api:gen": "tsx scripts/generate-types.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -49,6 +50,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
231
frontend/pnpm-lock.yaml
generated
231
frontend/pnpm-lock.yaml
generated
@@ -55,10 +55,10 @@ importers:
|
||||
version: 7.9.0
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
version: 6.1.0
|
||||
echarts-for-react:
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6(echarts@6.0.0)(react@19.2.6)
|
||||
version: 3.0.6(echarts@6.1.0)(react@19.2.6)
|
||||
lucide-react:
|
||||
specifier: ^1.16.0
|
||||
version: 1.16.0(react@19.2.6)
|
||||
@@ -114,6 +114,9 @@ importers:
|
||||
globals:
|
||||
specifier: ^17.6.0
|
||||
version: 17.6.0
|
||||
openapi-typescript:
|
||||
specifier: ^7.13.0
|
||||
version: 7.13.0(typescript@6.0.3)
|
||||
prettier:
|
||||
specifier: ^3.8.3
|
||||
version: 3.8.3
|
||||
@@ -720,6 +723,16 @@ packages:
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@redocly/ajv@8.11.2':
|
||||
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
|
||||
|
||||
'@redocly/config@0.22.0':
|
||||
resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
|
||||
|
||||
'@redocly/openapi-core@1.34.14':
|
||||
resolution: {integrity: sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA==}
|
||||
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.1':
|
||||
resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1075,9 +1088,20 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
agent-base@7.1.2:
|
||||
resolution: {integrity: sha512-JVzqkCNRT+VfqzzgPWDPnwvDheSAUdiMUn3NoLXpDJF5lRqeJqyC9iGsAxIOAW+mzIdq+uP1TvcX6bMtrH0agg==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ajv@6.15.0:
|
||||
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
|
||||
|
||||
ansi-colors@4.1.3:
|
||||
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1089,6 +1113,9 @@ packages:
|
||||
ast-v8-to-istanbul@1.0.0:
|
||||
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
|
||||
|
||||
balanced-match@1.0.0:
|
||||
resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==}
|
||||
|
||||
balanced-match@4.0.4:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -1098,6 +1125,9 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
brace-expansion@2.1.0:
|
||||
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -1114,10 +1144,16 @@ packages:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
change-case@5.4.4:
|
||||
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
colorette@1.4.0:
|
||||
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
|
||||
|
||||
colorjs.io@0.6.1:
|
||||
resolution: {integrity: sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==}
|
||||
|
||||
@@ -1294,8 +1330,8 @@ packages:
|
||||
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
|
||||
react: ^15.0.0 || >=16.0.0
|
||||
|
||||
echarts@6.0.0:
|
||||
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||
echarts@6.1.0:
|
||||
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||
|
||||
electron-to-chromium@1.5.358:
|
||||
resolution: {integrity: sha512-EO7tKm3QxRqTs1lSuPXzl6yRAwznehp0AH9OoMOIC+4mQzTFday8FJCO5KU6J/TFSQXEOahNq4vTKpz1jmCVOA==}
|
||||
@@ -1445,6 +1481,10 @@ packages:
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1461,6 +1501,10 @@ packages:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
|
||||
index-to-position@1.2.0:
|
||||
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1492,12 +1536,20 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-levenshtein@1.1.6:
|
||||
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1509,6 +1561,9 @@ packages:
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
@@ -1624,6 +1679,10 @@ packages:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@5.1.9:
|
||||
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1641,6 +1700,12 @@ packages:
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
openapi-typescript@7.13.0:
|
||||
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^5.x
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1653,6 +1718,10 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
parse-json@8.3.0:
|
||||
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1671,6 +1740,10 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pluralize@8.0.0:
|
||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.5.14:
|
||||
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1805,6 +1878,10 @@ packages:
|
||||
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
robust-predicates@3.0.3:
|
||||
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
|
||||
|
||||
@@ -1858,6 +1935,10 @@ packages:
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
supports-color@10.2.2:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1900,6 +1981,10 @@ packages:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
type-fest@4.39.1:
|
||||
resolution: {integrity: sha512-uW9qzd66uyHYxwyVBYiwS4Oi0qZyUqwjU+Oevr6ZogYiXt99EOYtwvzMSLw1c3lYo2HzJsep/NB23iEVEgjG/w==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
typescript-eslint@8.59.4:
|
||||
resolution: {integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1921,6 +2006,9 @@ packages:
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
uri-js-replace@1.0.1:
|
||||
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
@@ -2045,6 +2133,13 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yaml-ast-parser@0.0.43:
|
||||
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2058,8 +2153,8 @@ packages:
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
zrender@6.0.0:
|
||||
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||
zrender@6.1.0:
|
||||
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||
|
||||
zustand@5.0.13:
|
||||
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
|
||||
@@ -2102,7 +2197,7 @@ snapshots:
|
||||
'@babel/types': 7.29.0
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -2172,7 +2267,7 @@ snapshots:
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.29.0
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2234,7 +2329,7 @@ snapshots:
|
||||
'@eslint/config-array@0.23.5':
|
||||
dependencies:
|
||||
'@eslint/object-schema': 3.0.5
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
minimatch: 10.2.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -2708,6 +2803,29 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@redocly/ajv@8.11.2':
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
uri-js-replace: 1.0.1
|
||||
|
||||
'@redocly/config@0.22.0': {}
|
||||
|
||||
'@redocly/openapi-core@1.34.14(supports-color@10.2.2)':
|
||||
dependencies:
|
||||
'@redocly/ajv': 8.11.2
|
||||
'@redocly/config': 0.22.0
|
||||
colorette: 1.4.0
|
||||
https-proxy-agent: 7.0.6(supports-color@10.2.2)
|
||||
js-levenshtein: 1.1.6
|
||||
js-yaml: 4.1.1
|
||||
minimatch: 5.1.9
|
||||
pluralize: 8.0.0
|
||||
yaml-ast-parser: 0.0.43
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.1':
|
||||
optional: true
|
||||
|
||||
@@ -2890,7 +3008,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.59.4
|
||||
'@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3)
|
||||
'@typescript-eslint/visitor-keys': 8.59.4
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
eslint: 10.4.0(jiti@2.7.0)
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
@@ -2900,7 +3018,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.4
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -2919,7 +3037,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.59.4
|
||||
'@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3)
|
||||
'@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
eslint: 10.4.0(jiti@2.7.0)
|
||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||
typescript: 6.0.3
|
||||
@@ -2934,7 +3052,7 @@ snapshots:
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.4
|
||||
'@typescript-eslint/visitor-keys': 8.59.4
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.0
|
||||
tinyglobby: 0.2.16
|
||||
@@ -3025,6 +3143,12 @@ snapshots:
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
agent-base@7.1.2(supports-color@10.2.2):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ajv@6.15.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
@@ -3032,6 +3156,10 @@ snapshots:
|
||||
json-schema-traverse: 0.4.1
|
||||
uri-js: 4.4.1
|
||||
|
||||
ansi-colors@4.1.3: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3044,10 +3172,16 @@ snapshots:
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
balanced-match@1.0.0: {}
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
baseline-browser-mapping@2.10.31: {}
|
||||
|
||||
brace-expansion@2.1.0:
|
||||
dependencies:
|
||||
balanced-match: 1.0.0
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
@@ -3064,8 +3198,12 @@ snapshots:
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
change-case@5.4.4: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
colorette@1.4.0: {}
|
||||
|
||||
colorjs.io@0.6.1: {}
|
||||
|
||||
commander@7.2.0: {}
|
||||
@@ -3234,9 +3372,11 @@ snapshots:
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
debug@4.4.3:
|
||||
debug@4.4.3(supports-color@10.2.2):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optionalDependencies:
|
||||
supports-color: 10.2.2
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
@@ -3248,17 +3388,17 @@ snapshots:
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
||||
echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.6):
|
||||
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.6):
|
||||
dependencies:
|
||||
echarts: 6.0.0
|
||||
echarts: 6.1.0
|
||||
fast-deep-equal: 3.1.3
|
||||
react: 19.2.6
|
||||
size-sensor: 1.0.3
|
||||
|
||||
echarts@6.0.0:
|
||||
echarts@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.0.0
|
||||
zrender: 6.1.0
|
||||
|
||||
electron-to-chromium@1.5.358: {}
|
||||
|
||||
@@ -3313,7 +3453,7 @@ snapshots:
|
||||
'@types/estree': 1.0.9
|
||||
ajv: 6.15.0
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 9.1.2
|
||||
eslint-visitor-keys: 5.0.1
|
||||
@@ -3411,6 +3551,13 @@ snapshots:
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
https-proxy-agent@7.0.6(supports-color@10.2.2):
|
||||
dependencies:
|
||||
agent-base: 7.1.2(supports-color@10.2.2)
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -3421,6 +3568,8 @@ snapshots:
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
index-to-position@1.2.0: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
@@ -3446,16 +3595,24 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-levenshtein@1.1.6: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
@@ -3548,6 +3705,10 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.6
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.0
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
@@ -3558,6 +3719,16 @@ snapshots:
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
openapi-typescript@7.13.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@redocly/openapi-core': 1.34.14(supports-color@10.2.2)
|
||||
ansi-colors: 4.1.3
|
||||
change-case: 5.4.4
|
||||
parse-json: 8.3.0
|
||||
supports-color: 10.2.2
|
||||
typescript: 6.0.3
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -3575,6 +3746,12 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
parse-json@8.3.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
index-to-position: 1.2.0
|
||||
type-fest: 4.39.1
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
@@ -3585,6 +3762,8 @@ snapshots:
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
postcss@8.5.14:
|
||||
dependencies:
|
||||
nanoid: 3.3.12
|
||||
@@ -3653,6 +3832,8 @@ snapshots:
|
||||
|
||||
react@19.2.6: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
robust-predicates@3.0.3: {}
|
||||
|
||||
rolldown@1.0.1:
|
||||
@@ -3704,6 +3885,8 @@ snapshots:
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
@@ -3735,6 +3918,8 @@ snapshots:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
type-fest@4.39.1: {}
|
||||
|
||||
typescript-eslint@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
@@ -3756,6 +3941,8 @@ snapshots:
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
uri-js-replace@1.0.1: {}
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -3828,6 +4015,10 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml-ast-parser@0.0.43: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.4.3):
|
||||
@@ -3836,7 +4027,7 @@ snapshots:
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
zrender@6.0.0:
|
||||
zrender@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
|
||||
34
frontend/scripts/generate-types.ts
Normal file
34
frontend/scripts/generate-types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..')
|
||||
const BACKEND = join(ROOT, '..', 'backend')
|
||||
const SPEC_FILE = join(BACKEND, 'generated', 'openapi.json')
|
||||
const OUT_DIR = join(ROOT, 'src', 'generated')
|
||||
const OUT_FILE = join(OUT_DIR, 'api.ts')
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(SPEC_FILE)) {
|
||||
console.log('Generating OpenAPI spec from backend...')
|
||||
execSync('pnpm api:gen', { cwd: BACKEND, stdio: 'inherit' })
|
||||
}
|
||||
|
||||
console.log('Generating TypeScript types from OpenAPI spec...')
|
||||
const spec = JSON.parse(readFileSync(SPEC_FILE, 'utf-8'))
|
||||
|
||||
const openapiTS = await import('openapi-typescript')
|
||||
const types = await openapiTS.default(new URL(`file://${SPEC_FILE}`), {
|
||||
exportType: true,
|
||||
})
|
||||
|
||||
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true })
|
||||
writeFileSync(OUT_FILE, types)
|
||||
|
||||
console.log(`Types written to src/generated/api.ts (${types.length} bytes)`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -55,7 +55,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco
|
||||
<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.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 dark:bg-gray-700 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>
|
||||
@@ -72,7 +72,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 text-xs text-gray-500">
|
||||
<div className="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
目标色:{targetLab ? `Lab(${targetLab.L.toFixed(0)},${targetLab.a.toFixed(0)},${targetLab.b.toFixed(0)})` : '当前色'}
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco
|
||||
</div>
|
||||
) : matchedFormulas.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500">AI 未返回推荐,以下是历史匹配配方:</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">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>
|
||||
@@ -121,7 +121,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-lg border p-3">
|
||||
<p className="mb-2 text-xs font-medium text-gray-500">皮肤预览</p>
|
||||
<p className="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">皮肤预览</p>
|
||||
<div className="h-20 rounded-lg"
|
||||
style={{
|
||||
background: selectedColor
|
||||
|
||||
@@ -15,9 +15,9 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
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">
|
||||
<div className="max-w-md rounded-xl bg-white dark:bg-gray-700 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>
|
||||
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">{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">
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function FormulaVisualEditor({ phases: initialPhases, onSave }: P
|
||||
</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">
|
||||
className="rounded-lg bg-white dark:bg-gray-700 px-3 py-1 text-xs font-medium text-blue-600 shadow-sm hover:bg-blue-50">
|
||||
自动归一化
|
||||
</button>
|
||||
)}
|
||||
@@ -199,7 +199,7 @@ export default function FormulaVisualEditor({ phases: initialPhases, onSave }: P
|
||||
{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>
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">肤感子维度</h4>
|
||||
<ReactECharts
|
||||
option={{
|
||||
radar: {
|
||||
@@ -234,7 +234,7 @@ export default function FormulaVisualEditor({ phases: initialPhases, onSave }: P
|
||||
</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>
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">稳定性评分</h4>
|
||||
<ReactECharts
|
||||
option={{
|
||||
series: [{
|
||||
@@ -254,7 +254,7 @@ export default function FormulaVisualEditor({ phases: initialPhases, onSave }: P
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500">配方结构</h4>
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">配方结构</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allIngredients.map((ing, i) => (
|
||||
<div key={i} className="rounded px-2 py-0.5 text-xs"
|
||||
@@ -275,7 +275,7 @@ export default function FormulaVisualEditor({ phases: initialPhases, onSave }: P
|
||||
<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>
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">{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
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function InitConfig() {
|
||||
useEffect(() => {
|
||||
const openaiKey = localStorage.getItem('openai-key')
|
||||
const deepseekKey = localStorage.getItem('deepseek-key')
|
||||
const openaiBaseUrl = localStorage.getItem('openai-base-url')
|
||||
const deepseekBaseUrl = localStorage.getItem('deepseek-base-url')
|
||||
const aiMock = localStorage.getItem('ai-mock') ?? 'true'
|
||||
|
||||
const body: Record<string, string | undefined> = { aiMock }
|
||||
if (openaiKey) body.openaiKey = openaiKey
|
||||
if (deepseekKey) body.deepseekKey = deepseekKey
|
||||
if (openaiBaseUrl) body.openaiBaseUrl = openaiBaseUrl
|
||||
if (deepseekBaseUrl) body.deepseekBaseUrl = deepseekBaseUrl
|
||||
|
||||
fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1 +1,144 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-gray-50: #F5E6E6;
|
||||
--color-gray-100: #F0E0E0;
|
||||
--color-gray-200: #E8D0D0;
|
||||
--color-gray-300: #D4B5B5;
|
||||
--color-gray-400: #C0A0A0;
|
||||
--color-gray-500: #999999;
|
||||
--color-gray-600: #8C6C6C;
|
||||
--color-gray-700: #6C5050;
|
||||
--color-gray-800: #4C3434;
|
||||
--color-gray-900: #333333;
|
||||
--color-blue-50: #FDF5F0;
|
||||
--color-blue-100: #FAF0E8;
|
||||
--color-blue-500: #C9A96E;
|
||||
--color-blue-600: #D4A5A5;
|
||||
--color-blue-700: #C9A96E;
|
||||
}
|
||||
|
||||
.dark .bg-blue-600,
|
||||
.dark .bg-blue-700:hover {
|
||||
background-color: #9B8EC4 !important;
|
||||
border-color: #9B8EC4 !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.dark .bg-blue-500 {
|
||||
background-color: #4CB8A7 !important;
|
||||
}
|
||||
|
||||
.dark {
|
||||
background-color: #1A1830 !important;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: #1A1830 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-50,
|
||||
.dark .bg-white,
|
||||
.dark aside,
|
||||
.dark header {
|
||||
background-color: #282540 !important;
|
||||
}
|
||||
|
||||
.dark .dark\:bg-gray-900 {
|
||||
background-color: #1A1830 !important;
|
||||
}
|
||||
|
||||
.dark .dark\:bg-gray-800 {
|
||||
background-color: #2C2840 !important;
|
||||
}
|
||||
|
||||
.dark .dark\:bg-gray-700,
|
||||
.dark .dark\:hover\:bg-gray-100:hover {
|
||||
background-color: #3A3650 !important;
|
||||
}
|
||||
|
||||
.dark input,
|
||||
.dark select {
|
||||
background-color: #2C2840 !important;
|
||||
border-color: #3A3650 !important;
|
||||
color: #E8E4F0 !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-200,
|
||||
.dark .dark\:border-gray-700 {
|
||||
border-color: #3A3650 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-900,
|
||||
.dark .text-gray-800,
|
||||
.dark .text-gray-700,
|
||||
.dark h2,
|
||||
.dark .font-bold,
|
||||
.dark .font-semibold {
|
||||
color: #E8E4F0 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-600,
|
||||
.dark .dark\:text-gray-400 {
|
||||
color: #C8C0D8 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-500 {
|
||||
color: #A8A0C0 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-400,
|
||||
.dark .dark\:text-gray-300 {
|
||||
color: #9B8EC4 !important;
|
||||
}
|
||||
|
||||
.dark .text-blue-600 {
|
||||
color: #9B8EC4 !important;
|
||||
}
|
||||
|
||||
.dark .text-blue-700 {
|
||||
color: #4CB8A7 !important;
|
||||
}
|
||||
|
||||
.dark .dark\:bg-gray-50 {
|
||||
background-color: #1A1830 !important;
|
||||
}
|
||||
|
||||
button.rounded-lg:not([class*="bg-blue"]),
|
||||
button.rounded-md:not([class*="bg-blue"]),
|
||||
.btn-ghost {
|
||||
background-color: rgba(255,255,255,0.85) !important;
|
||||
}
|
||||
|
||||
button.rounded-lg:not([class*="bg-blue"]):hover,
|
||||
button.rounded-md:not([class*="bg-blue"]):hover,
|
||||
.btn-ghost:hover {
|
||||
background-color: var(--color-gray-100) !important;
|
||||
}
|
||||
|
||||
.dark button.rounded-lg:not([class*="bg-blue"]),
|
||||
.dark button.rounded-md:not([class*="bg-blue"]),
|
||||
.dark .btn-ghost {
|
||||
background-color: #2C2840 !important;
|
||||
border-color: #3A3650 !important;
|
||||
color: #E8E4F0 !important;
|
||||
}
|
||||
|
||||
.dark button.rounded-lg:not([class*="bg-blue"]):hover,
|
||||
.dark button.rounded-md:not([class*="bg-blue"]):hover,
|
||||
.dark .btn-ghost:hover {
|
||||
background-color: #3A3650 !important;
|
||||
}
|
||||
|
||||
.bg-blue-600,
|
||||
.bg-blue-700:hover {
|
||||
background-color: #D4A5A5 !important;
|
||||
border: 1px solid #D4A5A5;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.bg-blue-600:hover,
|
||||
.bg-blue-700 {
|
||||
background-color: #C9A96E !important;
|
||||
border-color: #C9A96E !important;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function AppLayout() {
|
||||
<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"
|
||||
className="rounded-md p-1 text-gray-500 dark:text-gray-400 hover:bg-gray-100 lg:hidden dark:hover:bg-gray-800"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
@@ -112,7 +112,7 @@ export default function AppLayout() {
|
||||
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"
|
||||
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-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,26 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
try {
|
||||
const raw = localStorage.getItem('auth-storage')
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as { state?: { token?: string } }
|
||||
const token = parsed?.state?.token
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, options)
|
||||
const headers = new Headers(options?.headers)
|
||||
const authHeaders = getAuthHeaders()
|
||||
for (const [key, value] of Object.entries(authHeaders)) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...options, headers })
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const text = await res.text()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import InitConfig from './components/InitConfig'
|
||||
import { ToastProvider } from './shared/components/Toast'
|
||||
import { router } from './router'
|
||||
import { queryClient } from './lib/queryClient'
|
||||
import './index.css'
|
||||
@@ -11,10 +11,11 @@ import './index.css'
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<InitConfig />
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
<ToastProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
81
frontend/src/modules/formulas/components/ParetoChart.tsx
Normal file
81
frontend/src/modules/formulas/components/ParetoChart.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from 'react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
interface ParetoPoint {
|
||||
name: string
|
||||
costEstimate: number
|
||||
index: number
|
||||
}
|
||||
|
||||
interface ParetoChartProps {
|
||||
options: ParetoPoint[]
|
||||
}
|
||||
|
||||
export default function ParetoChart({ options }: ParetoChartProps) {
|
||||
const chartData = useMemo(() => {
|
||||
return options.map((opt, i) => ({
|
||||
value: [opt.costEstimate, options.length - i],
|
||||
name: opt.name,
|
||||
}))
|
||||
}, [options])
|
||||
|
||||
const paretoIndices = useMemo(() => {
|
||||
if (options.length < 2) return []
|
||||
const sorted = [...options].sort((a, b) => a.costEstimate - b.costEstimate)
|
||||
return sorted.map(s => options.indexOf(s))
|
||||
}, [options])
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { data: { value: number[]; name: string } }) => {
|
||||
const [cost] = params.data.value
|
||||
return `${params.data.name}<br/>成本: ${cost} 元/kg`
|
||||
},
|
||||
},
|
||||
grid: { left: 60, right: 30, top: 30, bottom: 50 },
|
||||
xAxis: {
|
||||
name: '成本 (元/kg)',
|
||||
nameLocation: 'center',
|
||||
nameGap: 30,
|
||||
type: 'value',
|
||||
},
|
||||
yAxis: {
|
||||
name: '方案序号',
|
||||
type: 'value',
|
||||
splitLine: { show: true },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'scatter',
|
||||
data: chartData,
|
||||
symbolSize: 14,
|
||||
itemStyle: {
|
||||
color: (params: { dataIndex: number }) =>
|
||||
paretoIndices.includes(params.dataIndex) ? '#ef4444' : '#3b82f6',
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { data: { name: string } }) => params.data.name,
|
||||
position: 'right',
|
||||
fontSize: 11,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if (options.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-700 dark:border-gray-700 dark:bg-gray-700 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Pareto 前沿</h3>
|
||||
<p className="mb-2 text-xs text-gray-400">
|
||||
红色点表示 Pareto 最优方案(在成本和指标上都无法改进)
|
||||
</p>
|
||||
<ReactECharts option={option} style={{ height: 300 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
frontend/src/modules/formulas/formulas.service.ts
Normal file
108
frontend/src/modules/formulas/formulas.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { apiFetch } from '@/shared/services/api'
|
||||
|
||||
export interface FormulaListItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
currentVersion: number
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
project: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
export interface FormulaDetail {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
currentVersion: number
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
project: { id: string; name: string } | null
|
||||
versions: FormulaVersion[]
|
||||
}
|
||||
|
||||
export interface FormulaVersion {
|
||||
id: string
|
||||
versionNumber: number
|
||||
description: string | null
|
||||
snapshotData: unknown
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
phases: Phase[]
|
||||
}
|
||||
|
||||
export interface Phase {
|
||||
id: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
ingredients: FormulaIngredient[]
|
||||
}
|
||||
|
||||
export interface FormulaIngredient {
|
||||
id: string
|
||||
ingredientId: string
|
||||
percentage: number
|
||||
processNotes: string | null
|
||||
ingredient: { id: string; inciName: string; chineseName: string }
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
pagination: { page: number; limit: number; total: number; totalPages: number }
|
||||
}
|
||||
|
||||
export interface CreateFormulaInput {
|
||||
name: string
|
||||
description?: string
|
||||
projectId?: string
|
||||
phases: {
|
||||
name: string
|
||||
sortOrder?: number
|
||||
ingredients: { ingredientId: string; percentage: number; processNotes?: string }[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export const formulaService = {
|
||||
list(params?: { page?: number; limit?: number; search?: string }) {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.page) qs.set('page', String(params.page))
|
||||
if (params?.limit) qs.set('limit', String(params.limit))
|
||||
if (params?.search) qs.set('search', params.search)
|
||||
const query = qs.toString() ? `?${qs.toString()}` : ''
|
||||
return apiFetch<PaginatedResponse<FormulaListItem>>(`/api/formulas${query}`)
|
||||
},
|
||||
|
||||
getById(id: string) {
|
||||
return apiFetch<{ data: FormulaDetail }>(`/api/formulas/${id}`)
|
||||
},
|
||||
|
||||
create(input: CreateFormulaInput) {
|
||||
return apiFetch<{ data: FormulaDetail }>('/api/formulas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
},
|
||||
|
||||
updateMeta(id: string, data: { name?: string; description?: string }) {
|
||||
return apiFetch<{ data: { id: string } }>(`/api/formulas/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
updateComposition(id: string, phases: CreateFormulaInput['phases']) {
|
||||
return apiFetch<{ data: FormulaDetail }>(`/api/formulas/${id}/composition`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phases }),
|
||||
})
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
return apiFetch(`/api/formulas/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export default function ColorLabPage() {
|
||||
<div className={`flex w-full items-center gap-3 rounded-lg border p-3 ${deltaBg}`}>
|
||||
<div className="h-10 w-10 flex-shrink-0 rounded border" style={{ backgroundColor: labToHex(target.lab.L, target.lab.a, target.lab.b) }} />
|
||||
<div className="text-xs">
|
||||
<p className="text-gray-500">{target.label}</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">{target.label}</p>
|
||||
{delta !== null && <p className={`font-bold ${deltaColor}`}>ΔE = {delta.toFixed(2)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,19 +112,19 @@ export default function ColorLabPage() {
|
||||
|
||||
<div className="w-full space-y-1 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">HEX</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">HEX</span>
|
||||
<span className="font-mono">{hex}</span>
|
||||
<button onClick={() => navigator.clipboard.writeText(hex)} className="rounded p-0.5 text-gray-300 hover:text-gray-500"><Copy size={12} /></button>
|
||||
<button onClick={() => navigator.clipboard.writeText(hex)} className="rounded p-0.5 text-gray-300 hover:text-gray-500 dark:text-gray-400"><Copy size={12} /></button>
|
||||
</div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">RGB</span><span className="font-mono">{rgb.r}, {rgb.g}, {rgb.b}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Lab</span><span className="font-mono">L:{currentLab.L.toFixed(1)} a:{currentLab.a.toFixed(1)} b:{currentLab.b.toFixed(1)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">RGB</span><span className="font-mono">{rgb.r}, {rgb.g}, {rgb.b}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Lab</span><span className="font-mono">L:{currentLab.L.toFixed(1)} a:{currentLab.a.toFixed(1)} b:{currentLab.b.toFixed(1)}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex gap-1">
|
||||
{(['hex', 'rgb', 'lab', 'pantone'] as const).map(f => (
|
||||
<button key={f} onClick={() => setInputFormat(f)}
|
||||
className={`rounded px-2 py-0.5 text-xs ${inputFormat === f ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
className={`rounded px-2 py-0.5 text-xs ${inputFormat === f ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500 dark:text-gray-400'}`}>
|
||||
{f === 'pantone' ? '潘通' : f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
@@ -145,12 +145,12 @@ export default function ColorLabPage() {
|
||||
<div className="flex gap-1">
|
||||
{(['lab', 'rgb'] as const).map(m => (
|
||||
<button key={m} onClick={() => setMode(m)}
|
||||
className={`rounded px-2 py-0.5 text-xs ${mode === m ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
className={`rounded px-2 py-0.5 text-xs ${mode === m ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500 dark:text-gray-400'}`}>
|
||||
{m.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setTarget(null)}
|
||||
className="ml-1 rounded p-0.5 text-gray-300 hover:text-gray-500"><RotateCcw size={14} /></button>
|
||||
className="ml-1 rounded p-0.5 text-gray-300 hover:text-gray-500 dark:text-gray-400"><RotateCcw size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +199,7 @@ function SliderRow({ label, value, min, max, step, onChange, trackStyle }: {
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 text-xs text-gray-500">{label}</span>
|
||||
<span className="w-6 text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
||||
<input type="range" min={min} max={max} step={step} value={value}
|
||||
onChange={e => onChange(parseFloat(e.target.value))}
|
||||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full"
|
||||
|
||||
@@ -34,36 +34,36 @@ export default function DashboardPage() {
|
||||
<h2 className="mb-6 text-2xl font-bold">仪表盘</h2>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<Link to="/formulas" className="rounded-xl border bg-white p-5 transition-shadow hover:shadow-md">
|
||||
<Link to="/formulas" className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5 transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-blue-50 p-3"><FlaskConical size={24} className="text-blue-600" /></div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats?.formulaCount ?? '-'}</div>
|
||||
<div className="text-sm text-gray-500">配方总数</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">配方总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/ingredients" className="rounded-xl border bg-white p-5 transition-shadow hover:shadow-md">
|
||||
<Link to="/ingredients" className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5 transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-green-50 p-3"><Leaf size={24} className="text-green-600" /></div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats?.ingredientCount ?? '-'}</div>
|
||||
<div className="text-sm text-gray-500">成分总数</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">成分总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/projects" className="rounded-xl border bg-white p-5 transition-shadow hover:shadow-md">
|
||||
<Link to="/projects" className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5 transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-purple-50 p-3"><FolderKanban size={24} className="text-purple-600" /></div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats?.projectCount ?? '-'}</div>
|
||||
<div className="text-sm text-gray-500">项目数量</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">项目数量</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-white p-5">
|
||||
<div className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="font-semibold">最近更新</h3>
|
||||
<Link to="/formulas" className="text-sm text-blue-600 hover:underline">查看全部</Link>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function FormulaDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formula.description ? <p className="mb-4 text-sm text-gray-500">{String(formula.description)}</p> : null}
|
||||
{formula.description ? <p className="mb-4 text-sm text-gray-500 dark:text-gray-400">{String(formula.description)}</p> : null}
|
||||
<div className="mb-4 flex items-center gap-4 text-xs text-gray-400">
|
||||
<span>v{Number(formula.currentVersion)}</span>
|
||||
<span>更新于 {new Date(String(formula.updatedAt)).toLocaleString('zh-CN')}</span>
|
||||
@@ -64,11 +64,11 @@ export default function FormulaDetailPage() {
|
||||
|
||||
<div className="mb-4 flex gap-1 rounded-lg bg-gray-100 p-1 w-fit">
|
||||
<button onClick={() => setParams({ tab: 'detail' })}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm ${tab === 'detail' ? 'bg-white font-medium text-gray-900 shadow-sm' : 'text-gray-500'}`}>
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm ${tab === 'detail' ? 'bg-white dark:bg-gray-700 font-medium text-gray-900 shadow-sm' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
<Eye size={14} /> 详情
|
||||
</button>
|
||||
<button onClick={() => setParams({ tab: 'visual' })}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm ${tab === 'visual' ? 'bg-white font-medium text-gray-900 shadow-sm' : 'text-gray-500'}`}>
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm ${tab === 'visual' ? 'bg-white dark:bg-gray-700 font-medium text-gray-900 shadow-sm' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
<BarChart3 size={14} /> 可视化编辑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -51,30 +51,30 @@ export default function FormulaListPage() {
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">配方记录</h2>
|
||||
<button onClick={() => navigate('/formulas/new')}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||
<Plus size={16} /> 新建配方
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6 w-72">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input type="text" placeholder="搜索配方名称..."
|
||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-9 pr-3 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
className="w-full rounded-lg border border-slate-300 py-2 pl-9 pr-3 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-xl border border-gray-200 p-5">
|
||||
<div className="mb-3 h-5 w-3/4 rounded bg-gray-200" />
|
||||
<div className="mb-2 h-4 w-full rounded bg-gray-100" />
|
||||
<div className="h-3 w-1/2 rounded bg-gray-100" />
|
||||
<div key={i} className="animate-pulse rounded-xl border border-slate-200 p-5">
|
||||
<div className="mb-3 h-5 w-3/4 rounded bg-slate-200" />
|
||||
<div className="mb-2 h-4 w-full rounded bg-slate-100" />
|
||||
<div className="h-3 w-1/2 rounded bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : formulas.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<div className="flex flex-col items-center justify-center py-20 text-slate-400">
|
||||
<FlaskConical size={48} className="mb-3" />
|
||||
<p className="text-lg">暂无配方</p>
|
||||
<p className="mt-1 text-sm">点击"新建配方"开始创建</p>
|
||||
@@ -84,18 +84,18 @@ export default function FormulaListPage() {
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{formulas.map((f) => (
|
||||
<Link key={f.id} to={`/formulas/${f.id}`}
|
||||
className="group rounded-xl border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md">
|
||||
<h3 className="mb-1 font-semibold text-gray-900 group-hover:text-blue-600">{f.name}</h3>
|
||||
className="group rounded-xl border border-slate-200 bg-white dark:bg-gray-700 p-5 transition-shadow hover:shadow-card-hover">
|
||||
<h3 className="mb-1 font-semibold text-slate-900 group-hover:text-brand-600">{f.name}</h3>
|
||||
{f.description && (
|
||||
<p className="mb-3 line-clamp-2 text-sm text-gray-500">{f.description}</p>
|
||||
<p className="mb-3 line-clamp-2 text-sm text-slate-500">{f.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400">
|
||||
<div className="flex items-center gap-4 text-xs text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock size={12} /> v{f.currentVersion}
|
||||
</span>
|
||||
<span>{new Date(f.updatedAt).toLocaleDateString('zh-CN')}</span>
|
||||
{f.project && (
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-500">{f.project.name}</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-slate-500">{f.project.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -105,10 +105,10 @@ export default function FormulaListPage() {
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-center gap-3 text-sm">
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
className="rounded-lg border px-3 py-1.5 hover:bg-gray-50 disabled:opacity-30">上一页</button>
|
||||
<span className="text-gray-500">{pagination.page} / {pagination.totalPages}</span>
|
||||
className="rounded-lg border px-3 py-1.5 hover:bg-slate-50 disabled:opacity-30">上一页</button>
|
||||
<span className="text-slate-500">{pagination.page} / {pagination.totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(pagination.totalPages, p + 1))} disabled={page >= pagination.totalPages}
|
||||
className="rounded-lg border px-3 py-1.5 hover:bg-gray-50 disabled:opacity-30">下一页</button>
|
||||
className="rounded-lg border px-3 py-1.5 hover:bg-slate-50 disabled:opacity-30">下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -178,7 +178,7 @@ export default function IngredientsPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">{ing.unitPrice != null ? `¥${Number(ing.unitPrice).toFixed(2)}` : '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{ing.supplier ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{ing.supplier ?? '-'}</td>
|
||||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => { setSelected(ing); setDialogMode('edit'); setFormError('') }}
|
||||
@@ -196,7 +196,7 @@ export default function IngredientsPage() {
|
||||
</div>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>共 {pagination.total} 条</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
@@ -230,7 +230,7 @@ export default function IngredientsPage() {
|
||||
['单价', selected.unitPrice != null ? `¥${Number(selected.unitPrice).toFixed(2)}` : '-'],
|
||||
['描述', selected.description ?? '-'],
|
||||
].map(([l, v]) => (
|
||||
<div key={l}><span className="text-sm text-gray-500">{l}</span><p className="text-sm">{v}</p></div>
|
||||
<div key={l}><span className="text-sm text-gray-500 dark:text-gray-400">{l}</span><p className="text-sm">{v}</p></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -284,7 +284,7 @@ export default function IngredientsPage() {
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
||||
<AlertDialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl">
|
||||
<AlertDialog.Title className="mb-2 text-lg font-bold">确认删除</AlertDialog.Title>
|
||||
<AlertDialog.Description className="mb-4 text-sm text-gray-500">
|
||||
<AlertDialog.Description className="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
确定要删除成分「{deleteTarget?.chineseName}」吗?此操作不可撤销。
|
||||
</AlertDialog.Description>
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -25,29 +25,29 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-sm rounded-xl bg-white p-8 shadow-lg">
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="w-full max-w-sm rounded-xl bg-white dark:bg-gray-700 p-8 shadow-lg">
|
||||
<div className="mb-6 text-center">
|
||||
<FlaskConical size={32} className="mx-auto mb-2 text-blue-600" />
|
||||
<FlaskConical size={32} className="mx-auto mb-2 text-brand-600" />
|
||||
<h1 className="text-xl font-bold">配方研发平台</h1>
|
||||
<p className="text-sm text-gray-500">登录您的账户</p>
|
||||
<p className="text-sm text-slate-500">登录您的账户</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input type="text" placeholder="用户名" value={username} onChange={e => setUsername(e.target.value)}
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<input type="password" placeholder="密码" value={password} onChange={e => setPassword(e.target.value)}
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
className="w-full rounded-lg bg-brand-600 py-2.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
还没有账户?<Link to="/register" className="text-blue-600 hover:underline">注册</Link>
|
||||
<p className="mt-4 text-center text-sm text-slate-500">
|
||||
还没有账户?<Link to="/register" className="text-brand-600 hover:underline">注册</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Plus, Trash2, FolderKanban } from 'lucide-react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface Project {
|
||||
@@ -33,35 +32,47 @@ export default function ProjectsPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h2 className="mb-6 text-2xl font-bold">项目管理</h2>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-800 dark:text-slate-100">项目管理</h2>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">管理研发项目,组织配方分类</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex gap-2">
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="项目名称"
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
<input value={desc} onChange={e => setDesc(e.target.value)} placeholder="描述(可选)"
|
||||
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
<div className="mb-8 flex gap-2">
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="项目名称" className="w-40" />
|
||||
<input value={desc} onChange={e => setDesc(e.target.value)} placeholder="描述(可选)" className="flex-1" />
|
||||
<button onClick={() => createMut.mutate()} disabled={!name.trim()}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
className="inline-flex items-center gap-1.5 rounded-btn bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-200 hover:bg-brand-700 active:scale-[0.98] disabled:opacity-50">
|
||||
<Plus size={14} /> 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="rounded-xl border bg-white p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-semibold">{p.name}</h3>
|
||||
<button onClick={() => deleteMut.mutate(p.id)} className="rounded p-1 text-gray-400 hover:text-red-500"><Trash2 size={14} /></button>
|
||||
{projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-400 dark:text-slate-500">
|
||||
<FolderKanban size={48} className="mb-3 opacity-50" />
|
||||
<p className="text-lg font-medium">暂无项目</p>
|
||||
<p className="mt-1 text-sm">在上方输入项目名称创建第一个项目</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="rounded-2xl border border-slate-100 bg-white dark:bg-gray-700 p-5 shadow-card transition-all duration-300 hover:shadow-card-hover hover:-translate-y-0.5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-slate-900/30">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-semibold text-slate-800 dark:text-slate-100">{p.name}</h3>
|
||||
<button onClick={() => deleteMut.mutate(p.id)} className="rounded-btn p-1 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-500 dark:hover:bg-rose-900/20">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{p.description && <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{p.description}</p>}
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
|
||||
{p._count.formulas} 个配方
|
||||
</span>
|
||||
<span>{new Date(p.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{p.description && <p className="mt-1 text-sm text-gray-500">{p.description}</p>}
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>{p._count.formulas} 个配方</span>
|
||||
<span>·</span>
|
||||
<span>{new Date(p.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,24 +25,24 @@ export default function RegisterPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-sm rounded-xl bg-white p-8 shadow-lg">
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="w-full max-w-sm rounded-xl bg-white dark:bg-gray-700 p-8 shadow-lg">
|
||||
<h1 className="mb-6 text-center text-xl font-bold">创建账户</h1>
|
||||
{error && <div className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input type="text" placeholder="用户名" value={username} onChange={e => setUsername(e.target.value)}
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<input type="password" placeholder="密码(至少4位)" value={password} onChange={e => setPassword(e.target.value)}
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<input type="password" placeholder="确认密码" value={confirm} onChange={e => setConfirm(e.target.value)}
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
className="w-full rounded-lg bg-brand-600 py-2.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
已有账户?<Link to="/login" className="text-blue-600 hover:underline">登录</Link>
|
||||
<p className="mt-4 text-center text-sm text-slate-500">
|
||||
已有账户?<Link to="/login" className="text-brand-600 hover:underline">登录</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function SearchPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h2 className="mb-2 text-2xl font-bold">搜索结果</h2>
|
||||
{q && <p className="mb-4 text-sm text-gray-500">「{q}」{keywords.length > 0 && ` → AI 理解: ${keywords.join(', ')}`}</p>}
|
||||
{q && <p className="mb-4 text-sm text-gray-500 dark:text-gray-400">「{q}」{keywords.length > 0 && ` → AI 理解: ${keywords.join(', ')}`}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-gray-400">搜索中...</div>
|
||||
@@ -42,7 +42,7 @@ export default function SearchPage() {
|
||||
<Link key={f.id as string} to={`/formulas/${f.id}`}
|
||||
className="rounded-xl border bg-white p-5 transition-shadow hover:shadow-md">
|
||||
<h3 className="font-semibold text-gray-900">{f.name}</h3>
|
||||
{f.description && <p className="mt-1 line-clamp-2 text-sm text-gray-500">{f.description}</p>}
|
||||
{f.description && <p className="mt-1 line-clamp-2 text-sm text-gray-500 dark:text-gray-400">{f.description}</p>}
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
{f.project && <span>{f.project.name}</span>}
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function SettingsPage() {
|
||||
<h2 className="mb-6 text-2xl font-bold">设置</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border bg-white p-5">
|
||||
<div className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5">
|
||||
<h3 className="mb-3 font-semibold">外观</h3>
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
@@ -89,11 +89,11 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-white p-5">
|
||||
<div className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5">
|
||||
<h3 className="mb-3 font-semibold">AI 配置</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-sm text-gray-500">运行模式</p>
|
||||
<p className="mb-2 text-sm text-gray-500 dark:text-gray-400">运行模式</p>
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ value: true, label: 'Mock 模拟(无需 Key)' },
|
||||
@@ -125,7 +125,7 @@ export default function SettingsPage() {
|
||||
<input type="text" value={openaiBaseUrl}
|
||||
onChange={e => setOpenaiBaseUrl(e.target.value)}
|
||||
placeholder="Base URL(可选,默认 api.openai.com)"
|
||||
className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 focus:border-blue-500 focus:outline-none" />
|
||||
className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 dark:text-gray-400 focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function SettingsPage() {
|
||||
<input type="text" value={deepseekBaseUrl}
|
||||
onChange={e => setDeepseekBaseUrl(e.target.value)}
|
||||
placeholder="Base URL(可选,默认 api.deepseek.com)"
|
||||
className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 focus:border-blue-500 focus:outline-none" />
|
||||
className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 dark:text-gray-400 focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
@@ -170,9 +170,9 @@ export default function SettingsPage() {
|
||||
<p className="mt-2 text-xs text-gray-400">API Key 仅存储在本地浏览器和后端内存中,不会上传到第三方</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-white p-5">
|
||||
<div className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5">
|
||||
<h3 className="mb-3 font-semibold">关于</h3>
|
||||
<div className="space-y-1 text-sm text-gray-500">
|
||||
<div className="space-y-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>配方研发智能平台 v0.1.0</p>
|
||||
<p>AI 驱动的化妆品配方研发辅助工具</p>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function VersionComparePage() {
|
||||
<span className="font-medium">{d.inciName}</span>
|
||||
<span className="ml-1 text-xs text-gray-400">{d.chineseName}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{d.phaseName}</td>
|
||||
<td className="px-4 py-2 text-gray-500 dark:text-gray-400">{d.phaseName}</td>
|
||||
<td className="px-4 py-2 text-right">{d.oldPercentage != null ? `${d.oldPercentage.toFixed(2)}%` : '-'}</td>
|
||||
<td className="px-4 py-2 text-right">{d.newPercentage != null ? `${d.newPercentage.toFixed(2)}%` : '-'}</td>
|
||||
<td className={`px-4 py-2 text-right font-medium ${d.change > 0.01 ? 'text-green-600' : d.change < -0.01 ? 'text-red-600' : 'text-gray-400'}`}>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function VersionHistoryPage() {
|
||||
|
||||
{versions.map((v, i) => (
|
||||
<div key={v.id} className="relative mb-6">
|
||||
<div className={`absolute -left-[1.65rem] top-1.5 h-3 w-3 rounded-full border-2 ${i === 0 ? 'border-blue-500 bg-blue-100' : 'border-gray-300 bg-white'}`} />
|
||||
<div className={`absolute -left-[1.65rem] top-1.5 h-3 w-3 rounded-full border-2 ${i === 0 ? 'border-blue-500 bg-blue-100' : 'border-gray-300 bg-white dark:bg-gray-700'}`} />
|
||||
|
||||
<button onClick={() => setExpanded(expanded === i ? null : i)}
|
||||
className="w-full text-left">
|
||||
@@ -67,7 +67,7 @@ export default function VersionHistoryPage() {
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{new Date(v.createdAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
{v.description && <p className="mt-0.5 text-sm text-gray-500">{v.description}</p>}
|
||||
{v.description && <p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{v.description}</p>}
|
||||
<ChevronDown size={14} className={`absolute right-0 top-1.5 text-gray-300 transition-transform ${expanded === i ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
@@ -75,10 +75,10 @@ export default function VersionHistoryPage() {
|
||||
<div className="mt-3 space-y-2 rounded-lg border bg-gray-50 p-3">
|
||||
{v.phases.map(p => (
|
||||
<div key={p.id}>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{p.name}</p>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">{p.name}</p>
|
||||
<div className="space-y-0.5">
|
||||
{p.ingredients.map((ing, j) => (
|
||||
<div key={j} className="flex items-center justify-between rounded bg-white px-2 py-1 text-sm">
|
||||
<div key={j} className="flex items-center justify-between rounded bg-white dark:bg-gray-700 px-2 py-1 text-sm">
|
||||
<span>{ing.ingredient.inciName} <span className="text-xs text-gray-400">{ing.ingredient.chineseName}</span></span>
|
||||
<span className="font-medium text-gray-600">{Number(ing.percentage).toFixed(2)}%</span>
|
||||
</div>
|
||||
|
||||
39
frontend/src/shared/components/Alert.tsx
Normal file
39
frontend/src/shared/components/Alert.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx'
|
||||
import { AlertCircle, CheckCircle, Info, XCircle } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type AlertVariant = 'error' | 'success' | 'info' | 'warning'
|
||||
|
||||
interface AlertProps {
|
||||
variant?: AlertVariant
|
||||
title?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const config: Record<AlertVariant, { icon: typeof AlertCircle; style: string }> = {
|
||||
error: { icon: XCircle, style: 'border-red-200 bg-red-50 text-red-800' },
|
||||
success: { icon: CheckCircle, style: 'border-green-200 bg-green-50 text-green-800' },
|
||||
warning: { icon: AlertCircle, style: 'border-yellow-200 bg-yellow-50 text-yellow-800' },
|
||||
info: { icon: Info, style: 'border-blue-200 bg-blue-50 text-blue-800' },
|
||||
}
|
||||
|
||||
export function Alert({ variant = 'info', title, children, className, onClose }: AlertProps) {
|
||||
const { icon: Icon, style } = config[variant]
|
||||
|
||||
return (
|
||||
<div className={clsx('flex gap-3 rounded-lg border px-4 py-3', style, className)}>
|
||||
<Icon size={18} className="shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && <p className="font-medium text-sm">{title}</p>}
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="shrink-0 opacity-50 hover:opacity-100">
|
||||
<XCircle size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
frontend/src/shared/components/Skeleton.tsx
Normal file
53
frontend/src/shared/components/Skeleton.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string
|
||||
lines?: number
|
||||
rounded?: boolean
|
||||
}
|
||||
|
||||
export function Skeleton({ className, lines = 1, rounded }: SkeletonProps) {
|
||||
if (lines > 1) {
|
||||
return (
|
||||
<div className={clsx('space-y-2', className)}>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
'animate-pulse bg-gray-200 dark:bg-gray-700',
|
||||
rounded ? 'rounded-full' : 'rounded',
|
||||
i === lines - 1 ? 'w-3/4' : 'w-full',
|
||||
'h-4',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-pulse bg-gray-200',
|
||||
rounded ? 'rounded-full' : 'rounded-md',
|
||||
className ?? 'h-4 w-full',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 px-4 py-8">
|
||||
<Skeleton className="h-8 w-48 rounded" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Skeleton className="h-24 rounded-xl" />
|
||||
<Skeleton className="h-24 rounded-xl" />
|
||||
<Skeleton className="h-24 rounded-xl" />
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-700 dark:border-gray-700 dark:bg-gray-700 p-5">
|
||||
<Skeleton lines={4} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
frontend/src/shared/components/Toast.tsx
Normal file
87
frontend/src/shared/components/Toast.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState, useCallback, createContext, useContext, type ReactNode } from 'react'
|
||||
import { X, CheckCircle, AlertCircle, Info, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'loading'
|
||||
|
||||
interface ToastItem {
|
||||
id: number
|
||||
type: ToastType
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (message: string, type?: ToastType, duration?: number) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
const icons: Record<ToastType, typeof CheckCircle> = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
info: Info,
|
||||
loading: Loader2,
|
||||
}
|
||||
|
||||
const colors: Record<ToastType, string> = {
|
||||
success: 'border-green-200 bg-green-50 text-green-800',
|
||||
error: 'border-red-200 bg-red-50 text-red-800',
|
||||
info: 'border-blue-200 bg-blue-50 text-blue-800',
|
||||
loading: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-700',
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
|
||||
const add = useCallback((message: string, type: ToastType = 'info', duration = 4000) => {
|
||||
const id = nextId++
|
||||
setToasts(prev => [...prev, { id, type, message, duration }])
|
||||
if (duration > 0 && type !== 'loading') {
|
||||
setTimeout(() => remove(id), duration)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remove = useCallback((id: number) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const toast = useCallback((message: string, type?: ToastType, duration?: number) => {
|
||||
add(message, type, duration)
|
||||
}, [add])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map(t => {
|
||||
const Icon = icons[t.type]
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded-lg border px-4 py-3 shadow-lg min-w-[280px] max-w-[400px] animate-in',
|
||||
colors[t.type],
|
||||
t.type === 'loading' && 'animate-pulse',
|
||||
)}
|
||||
>
|
||||
<Icon size={16} className={t.type === 'loading' ? 'animate-spin' : ''} />
|
||||
<span className="text-sm flex-1">{t.message}</span>
|
||||
<button onClick={() => remove(t.id)} className="shrink-0 opacity-50 hover:opacity-100">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
52
frontend/src/shared/services/api.ts
Normal file
52
frontend/src/shared/services/api.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
code?: string
|
||||
|
||||
constructor(status: number, message: string, code?: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
try {
|
||||
const raw = localStorage.getItem('auth-storage')
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as { state?: { token?: string } }
|
||||
const token = parsed?.state?.token
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(url: string, options?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(options?.headers)
|
||||
const authHeaders = getAuthHeaders()
|
||||
for (const [key, value] of Object.entries(authHeaders)) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...options, headers })
|
||||
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 & { error?: string; code?: string }
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, data.error ?? `请求失败 (${res.status})`, data.code)
|
||||
}
|
||||
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, '响应格式错误')
|
||||
}
|
||||
}
|
||||
24
scripts/backup-db.sh
Executable file
24
scripts/backup-db.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/var/backups/colorfull}"
|
||||
RETENTION_DAYS="${RETENTION_DAYS:-7}"
|
||||
DB_CONTAINER="${DB_CONTAINER:-colorfull-db}"
|
||||
DB_NAME="${DB_NAME:-colorfull}"
|
||||
DB_USER="${DB_USER:-colorfull}"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="$BACKUP_DIR/colorfull_${TIMESTAMP}.sql.gz"
|
||||
|
||||
echo "[$(date)] Starting backup to $BACKUP_FILE"
|
||||
|
||||
docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$BACKUP_FILE"
|
||||
|
||||
echo "[$(date)] Backup complete: $(ls -lh "$BACKUP_FILE" | awk '{print $5}')"
|
||||
|
||||
find "$BACKUP_DIR" -name "colorfull_*.sql.gz" -mtime +"$RETENTION_DAYS" -delete
|
||||
|
||||
echo "[$(date)] Cleaned up backups older than $RETENTION_DAYS days"
|
||||
echo "[$(date)] Current backups: $(ls "$BACKUP_DIR" | wc -l) files"
|
||||
Reference in New Issue
Block a user