企业级重构:四层模块化架构 + 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:
qichi.liang
2026-05-21 17:29:52 +08:00
parent 5240505a2e
commit c58ca26969
99 changed files with 6275 additions and 1353 deletions

View File

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

@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"tabWidth": 2
}

19
backend/Dockerfile Normal file
View 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
View 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',
},
},
)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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");

View File

@@ -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")
}

View File

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

View File

@@ -0,0 +1,7 @@
{
"AI_MOCK": "true",
"OPENAI_API_KEY": "",
"DEEPSEEK_API_KEY": "",
"OPENAI_BASE_URL": "",
"DEEPSEEK_BASE_URL": ""
}

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

View File

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

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

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

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

View File

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

View 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, '配方文本不能为空'),
})

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

View 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, '密码不能为空'),
})

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

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

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

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

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

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

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

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

View File

@@ -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: {},

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

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

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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})` },
]
}

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

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

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

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

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

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

View 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, '只能操作自己创建的配方')
}
}
}

View 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, '权限不足')
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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 | 性能优于 ReactAPI 相似 | 社区太小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 6SPA 模式。平台为内部工具,无需 SEO/SSR。
**决策**Vite 8SPA 模式。平台为内部工具,无需 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 | 自由度最高;定制性极强 | 命令式 APIReact 集成需大量封装;开发效率低 | ❌ |
| Recharts | React 声明式;组件化 | 图表类型有限;大数据集性能差 | ❌ |
**决策**TanStack Query v5。推荐列表、配方搜索、AI 预测等所有异步请求均通过它管理
**决策**ECharts 作为主图表库D3.js 作为辅助(颜色空间可视化等高度定制场景)
---
### 11. 后端 → FastifyBFF 单体)+ 外部 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 RESTAI 预测 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 验证AJVTS 支持日志pino | 社区较 Express 小 | ✅ 推荐 |
| Express | 生态最大;中间件极多 | 性能较低;无内置验证;回调风格 | ❌ |
| Hono | 极快;边缘部署 | 主要用于 Cloudflare/边缘场景 | ❌ |
**AI API 调用设计**(详见 ADR-0002
BFF 作为 AI API 的统一网关,负责:
- **编排**:组合多次 API 调用(如"先解析 NL 查询 → 再向量搜索 → 组合 prompt → 生成推荐"
- **降级**API 超时或失败时返回缓存结果或降级提示
- **限流**:保护 API 额度,控制并发
- **缓存**:相似查询复用结果,减少 API 调用
- **SSE 流式**配方推演等长耗时场景BFF 转发 AI API 的 streaming 响应
| AI 能力 | 调用方式 | 说明 |
| :--- | :--- | :--- |
| 配方指标预测 | Prompt + 历史数据few-shot | 将成分列表和比例作为 contextLLM 预测肤感/稳定性/成本 |
| NL 搜索解析 | LLM → 结构化查询 → pgvector | LLM 将自然语言转为过滤条件 + 向量搜索 |
| 配方生成/推演 | LLM with constraints | 给定约束条件LLM 生成候选配方方案 |
| 颜色推荐 | LLM + 色彩库 | 结合色差计算和 LLM 推理推荐色浆组合 |
| 成分标签提取 | LLM 结构化提取 | 从配方文本中提取 INCI 名称、比例、工艺参数 |
**决策**colorjs.io。
---
### 12. 数据库 → PostgreSQL 16 + pgvector
### 9. 后端框架 → Fastify
| 候选 | 结构化 | 向量搜索 | JSONB | 全文搜索 | 运维 | 结论 |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| **PostgreSQL + pgvector** | ✅ | ✅ IVFFlat/HNSW | ✅ | ✅ | 成熟 | ✅ 推荐 |
| MongoDB | ⚠️ 文档型 | ✅ Atlas Vector | ✅ | ⚠️ | 成熟 | ❌ 配方强关系 |
| Elasticsearch | ❌ | ⚠️ 需插件 | ❌ | ✅ | 复杂 | ❌ 过度 |
| Neo4j | ❌ 图 | ⚠️ | ❌ | ❌ | 小众 | ❌ 过度 |
| 候选 | 优势 | 劣势 | 结论 |
| :--- | :--- | :--- | :--- |
| **Fastify** | 性能最高(~60k req/s插件生态完善CORS/Swagger/HelmetTypeScript 原生支持schema 验证 | 社区较 Express 小 | ✅ 推荐 |
| Express | 最广泛使用;中间件生态极丰富 | 性能较差(~15k req/sTS 支持需额外配置;回调风格 | ❌ |
| Hono | 极轻量(< 10KB运行时无关Node/Deno/Bun/Edge | 生态较小;企业级插件不成熟 | ❌ |
| NestJS | 开箱即用架构Module/Controller/ServiceDI 容器 | 过度工程化;装饰器侵入性强;学习曲线陡峭;冷启动慢 | ❌ |
**决策**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→IngredientPrisma 的关联查询和事务支持良好。
---
### 12. 包管理 → pnpm workspace
| 候选 | 优势 | 劣势 | 结论 |
| :--- | :--- | :--- | :--- |
| **pnpm** | 硬盘高效硬链接严格依赖monorepo workspace 支持;速度快 | lockfile 格式与 npm 不兼容 | ✅ 推荐 |
| npm | 默认工具 | 依赖扁平化导致幽灵依赖;磁盘占用大;慢 | ❌ |
| Yarn | Plug'n'Play 模式 | PnP 兼容性问题多;社区分裂 | ❌ |
**决策**pnpm。前后端独立 package共享 workspace 协议。
---
@@ -213,56 +173,31 @@ BFF 作为 AI API 的统一网关,负责:
**决策**MinIOS3 兼容存储参考图片、导出文件。本地部署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 仍为 Betav0.x | 锁定版本Delta E 逻辑简单,必要时可自实现 CIEDE2000 |
| DnD Kit 维护节奏慢 | API 稳定,核心功能完备;锁定版本 |
| AI API 延迟 / 不可用 | BFF 层缓存 + 降级策略;关键路径设置超时(详见 ADR-0002 |
| AI API 调用成本 | 缓存相似查询Prompt 压缩优化;使用 cheaper 模型做预处理 |
| pgvector HNSW 索引构建耗时 | 配方数据量级可控(万级),索引构建秒级 |
### 需要关注但未在本次决议的
- CI/CD 流水线GitHub Actions / GitLab CI
- 监控和日志Sentry + Grafana + Loki
- 认证方案JWT + OAuth2 / LDAP 企业对接)
- 测试框架细节(在实现阶段决策)
---
## 参考
- PRD: `.scratch/formula-rd-platform/PRD.md`
- CSS Color Level 4: https://www.w3.org/TR/css-color-4/
- color.js: https://github.com/color-js/color.js
- pgvector: https://github.com/pgvector/pgvector
- 所有用户必须熟悉 React + Fastify + Prisma 三件套
- 数据库变更必须通过 Prisma Migrate不可手动修改 Schema
- OpenAPI spec 作为服务契约,前后端类型同步
- 本地开发需 Docker 运行 PostgreSQLpgvector 扩展)

View File

@@ -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 hashTTL 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 APIstreaming 模式)
4. BFF 转发 SSE 流到前端,逐步展示生成的候选方案
5. 每个候选方案附带置信度和变更说明
```
### 4. 颜色推荐
```
POST /api/color/recommend
BFF 流程:
1. 前端传入目标颜色 Lab 值
2. 在 PostgreSQL 中检索 ΔE < 3.0 的历史颜色配方pgvector
3. 将目标色 + 最近匹配配方作为 context调用 AI API 推荐色浆组合
4. 返回推荐色浆 + 预测比例 + 预测 ΔE
```
### 5. 配方结构化提取
```
POST /api/formulas/extract
BFF 流程:
1. 用户粘贴配方文本(或上传 Excel
2. 调用 AI API with function calling / structured output
3. 提取:成分 INCI 名、中文名、比例、所属相、工艺备注
4. 与成分目录ingredients 表)模糊匹配校验
5. 返回结构化 JSON前端展示确认
```
| 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 / DeepSeekAPI 不可用时自动降级为 Mock 模式
- AI 响应格式严格约束为 JSON前端不解析自然语言输出
- 每次 AI 调用记录审计日志,用于成本核算和问题排查
- 新增 AI Provider 只需实现 `AIProvider` 接口(`chat` + `chatStream`

View 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 preHandlerapp.ts 全局) | onRequest |
| **授权** | `requireRole()` / `requireFormulaOwnership()` | Route preHandler |
| **错误处理** | AppError 子类ValidationError/NotFoundError 等) | 全局 `setErrorHandler` |
| **日志** | pino 结构化日志 + AsyncLocalStorage context | `app.log.child()` |
| **审计** | AuditServicepino 输出action/resource/userId | Service 层显式调用 |
| **指标** | prom-clienthttp_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']` 自动发现

View 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 中间件 | ✅ |
| 纯 RBACadmin/formulator/viewer | 无需资源级检查 | 无法表达"只能编辑自己的配方"(需要更多角色或 ABAC 条件) | ❌ |
| ABAC属性动态判断 | 最灵活 | Admin UI 复杂;规则定义成本高;当前 2 角色场景下过度 | ❌ |
| 无授权(仅认证) | 最简单 | 任何认证用户可改 AI Key、删他人配方不可接受 | ❌ |
---
## 认证机制
| 组件 | 实现 |
|------|------|
| Token 格式 | JWT HS256HMAC-SHA256 |
| 签名密钥 | `JWT_SECRET` 环境变量 |
| 有效期 | 24 小时 |
| 有效载荷 | `{ userId, exp }` |
| 密码哈希 | scryptsalt 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

File diff suppressed because it is too large Load Diff

522
docs/project-overview.md Normal file
View 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 # ★ AIServiceProvider 抽象 + 缓存 + 限流 + 回退)
│ │ ├── 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
View 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
View 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;
}
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'}`}>

View File

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

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

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

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

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