feat: AI 驱动的配方研发智能平台 v0.1

核心功能:
- M3 配方记录: 创建/编辑/详情/可视化编辑/AI提取/版本历史/版本对比
- M1 颜色引擎: D3.js 色相环/滑条微调/ΔE计算/取色棒/AI配色推荐
- M2 可视化编辑器: ECharts饼图/成分滑条/AI预测/雷达图/仪表盘
- M4 配方推演: 约束设置/SSE推演/方案对比/散点图
- 平台: NL智能搜索/项目管理/CSV导出/JWT认证/全局搜索

技术栈:
- 前端: React + Vite + Tailwind CSS 4 + Zustand + TanStack Query
- 后端: Fastify 5 + Prisma 7 + PostgreSQL + pgvector
- AI: OpenAI/DeepSeek API 调用 + Prompt模板 + 缓存/降级/限流
- 测试: Vitest 42 tests (26 API集成 + 16 色彩模块)
This commit is contained in:
qichi.liang
2026-05-20 17:50:37 +08:00
commit 23e5cb4006
125 changed files with 14454 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
frontend/.npmrc Normal file
View File

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

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

22
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

59
frontend/package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.100.11",
"clsx": "^2.1.1",
"colorjs.io": "^0.6.1",
"d3": "^7.9.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.76.0",
"react-router-dom": "^7.15.1",
"zod": "^4.4.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.6",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.8.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.6"
}
}

3846
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

6
frontend/src/App.tsx Normal file
View File

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

View File

@@ -0,0 +1,8 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
export default function AuthGuard() {
const token = useAuthStore(s => s.token)
if (!token) return <Navigate to="/login" replace />
return <Outlet />
}

View File

@@ -0,0 +1,150 @@
import { useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { Sparkles, X, Save } from 'lucide-react'
import type { LABColor } from '@/lib/color/types'
import { labToHex } from '@/lib/color/convert'
import { apiFetch } from '@/lib/api'
interface ColorRecommendPanelProps {
currentLab: LABColor
targetLab: LABColor | null
}
export default function ColorRecommendPanel({ currentLab, targetLab }: ColorRecommendPanelProps) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [recommendations, setRecommendations] = useState<Array<Record<string, unknown>>>([])
const [matchedFormulas, setMatchedFormulas] = useState<Array<Record<string, unknown>>>([])
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [saving, setSaving] = useState(false)
const fetchRecommend = async () => {
setLoading(true)
try {
const lab = targetLab ?? currentLab
const json = await apiFetch<{ recommendations: Array<Record<string, unknown>>; matchedFormulas: Array<Record<string, unknown>> }>(
'/api/color/recommend',
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetLab: lab }) },
)
setRecommendations(json?.recommendations ?? [])
setMatchedFormulas(json?.matchedFormulas ?? [])
} catch { }
finally { setLoading(false) }
}
const handleSave = async () => {
if (selectedIndex === null) return
setSaving(true)
try {
const target = targetLab ?? currentLab
await apiFetch('/api/color/recommend', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetLab: target }) })
setOpen(false)
} catch { }
finally { setSaving(false) }
}
const selectedColor = selectedIndex !== null ? (recommendations[selectedIndex]?.predictedLab as { L: number; a: number; b: number }) : null
return (
<>
<button onClick={() => { setOpen(true); fetchRecommend() }}
className="inline-flex items-center gap-1.5 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700">
<Sparkles size={14} /> AI
</button>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl">
<Dialog.Title className="mb-4 text-lg font-bold">AI </Dialog.Title>
<Dialog.Close asChild>
<button className="absolute right-4 top-4 rounded p-1 text-gray-400 hover:text-gray-600"><X size={18} /></button>
</Dialog.Close>
{loading ? (
<div className="space-y-3 py-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="animate-pulse flex items-center gap-4 rounded-lg border p-3">
<div className="h-10 w-10 rounded bg-gray-200" />
<div className="flex-1"><div className="mb-1 h-4 w-1/3 rounded bg-gray-200" /><div className="h-3 w-2/3 rounded bg-gray-100" /></div>
</div>
))}
</div>
) : (
<>
<div className="mb-3 text-xs text-gray-500">
{targetLab ? `Lab(${targetLab.L.toFixed(0)},${targetLab.a.toFixed(0)},${targetLab.b.toFixed(0)})` : '当前色'}
</div>
{recommendations.length > 0 ? (
<div className="space-y-2 max-h-64 overflow-y-auto">
{recommendations.map((rec, i) => {
const colorants = rec.colorants as Array<{ name: string; ratio: number }> | undefined
const predictedDeltaE = rec.predictedDeltaE as number | undefined
const predictedLab = rec.predictedLab as { L: number; a: number; b: number } | undefined
const hex = predictedLab ? (() => { try { return labToHex(predictedLab.L, predictedLab.a, predictedLab.b) } catch { return '#ccc' } })() : '#ccc'
return (
<button key={i} onClick={() => setSelectedIndex(i)}
className={`flex w-full items-center gap-4 rounded-lg border p-3 text-left transition-colors ${selectedIndex === i ? 'border-purple-400 bg-purple-50' : 'hover:bg-gray-50'}`}>
<div className="h-10 w-10 flex-shrink-0 rounded border" style={{ backgroundColor: hex }} />
<div className="flex-1 text-sm">
<div className="flex flex-wrap gap-1">
{colorants?.map((c, j) => (
<span key={j} className="rounded bg-gray-100 px-1.5 py-0.5 text-xs">{c.name} {(c.ratio * 100).toFixed(0)}%</span>
))}
</div>
</div>
<div className="text-right text-xs">
{predictedDeltaE !== undefined && (
<span className={predictedDeltaE <= 1 ? 'text-green-600' : predictedDeltaE <= 3 ? 'text-yellow-600' : 'text-red-600'}>
ΔE {predictedDeltaE.toFixed(2)}
</span>
)}
</div>
</button>
)
})}
</div>
) : matchedFormulas.length > 0 ? (
<div className="space-y-2">
<p className="text-sm text-gray-500">AI </p>
{matchedFormulas.map((f, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border p-2 text-sm">
<span>{f.name as string}</span>
<span className="text-xs text-gray-400">ΔE {(f.deltaE as number)?.toFixed(2)}</span>
</div>
))}
</div>
) : (
<p className="py-6 text-center text-sm text-gray-400"></p>
)}
<div className="mt-4 rounded-lg border p-3">
<p className="mb-2 text-xs font-medium text-gray-500"></p>
<div className="h-20 rounded-lg"
style={{
background: selectedColor
? `linear-gradient(135deg, #f5d0c0, #e8b8a0), ${labToHex(selectedColor.L, selectedColor.a, selectedColor.b)}`
: 'linear-gradient(135deg, #f5d0c0, #e8b8a0)',
backgroundBlendMode: 'multiply',
}} />
</div>
<div className="mt-4 flex justify-end gap-2">
<Dialog.Close asChild>
<button className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50"></button>
</Dialog.Close>
<button onClick={handleSave} disabled={selectedIndex === null || saving}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50">
<Save size={14} /> {saving ? '保存中...' : '保存为颜色配方'}
</button>
</div>
</>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</>
)
}

View File

@@ -0,0 +1,147 @@
import { useEffect, useRef, useCallback } from 'react'
import { rgbToLab, labToRGB } from '@/lib/color/convert'
interface ColorWheelProps {
size?: number
onColorChange?: (lab: { L: number; a: number; b: number }) => void
selectedLab?: { L: number; a: number; b: number } | null
}
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
const i = Math.floor(h * 6)
const f = h * 6 - i
const p = v * (1 - s)
const q = v * (1 - f * s)
const t = v * (1 - (1 - f) * s)
switch (i % 6) {
case 0: return [v, t, p]
case 1: return [q, v, p]
case 2: return [p, v, t]
case 3: return [p, q, v]
case 4: return [t, p, v]
case 5: return [v, p, q]
}
return [0, 0, 0]
}
export default function ColorWheel({ size = 400, onColorChange, selectedLab }: ColorWheelProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const selectPosRef = useRef<{ x: number; y: number } | null>(null)
const render = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
let ctx = canvas.getContext('2d', { colorSpace: 'display-p3' })
if (!ctx) ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
canvas.width = size * dpr
canvas.height = size * dpr
canvas.style.width = `${size}px`
canvas.style.height = `${size}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const cx = size / 2
const cy = size / 2
const outerR = size / 2 - 4
const innerR = outerR * 0.25
const imageData = ctx.createImageData(size, size)
for (let py = 0; py < size; py++) {
for (let px = 0; px < size; px++) {
const dx = px - cx
const dy = py - cy
const dist = Math.sqrt(dx * dx + dy * dy)
const idx = (py * size + px) * 4
if (dist >= innerR && dist <= outerR) {
const angle = (Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI)
const sat = (dist - innerR) / (outerR - innerR)
const [r, g, b] = hsvToRgb(angle, sat, 1)
imageData.data[idx] = Math.round(r * 255)
imageData.data[idx + 1] = Math.round(g * 255)
imageData.data[idx + 2] = Math.round(b * 255)
imageData.data[idx + 3] = 255
}
}
}
ctx.putImageData(imageData, 0, 0)
const pos = selectPosRef.current
if (pos) {
ctx.beginPath()
ctx.arc(pos.x, pos.y, 6, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.fill()
ctx.strokeStyle = '#374151'
ctx.lineWidth = 2
ctx.stroke()
}
}, [size])
useEffect(() => { render() }, [render])
const handleClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const cx = size / 2
const cy = size / 2
const dx = x - cx
const dy = y - cy
const dist = Math.sqrt(dx * dx + dy * dy)
const outerR = size / 2 - 4
const innerR = outerR * 0.25
if (dist < innerR || dist > outerR) return
const angle = (Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI)
const sat = (dist - innerR) / (outerR - innerR)
const [r, g, b] = hsvToRgb(angle, sat, 1)
const lab = rgbToLab(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
selectPosRef.current = { x, y }
render()
onColorChange?.(lab)
}, [size, onColorChange, render])
useEffect(() => {
if (selectedLab) {
const rgb = labToRGB(selectedLab.L, selectedLab.a, selectedLab.b)
const hsv = (() => {
const r = rgb.r / 255, g = rgb.g / 255, b = rgb.b / 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
const d = max - min
let h = 0
if (d === 0) h = 0
else if (max === r) h = ((g - b) / d) % 6
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h = h / 6
if (h < 0) h += 1
const s = max === 0 ? 0 : d / max
return { h, s }
})()
const outerR = size / 2 - 4
const innerR = outerR * 0.25
const dist = innerR + hsv.s * (outerR - innerR)
const ang = hsv.h * 2 * Math.PI - Math.PI
selectPosRef.current = {
x: size / 2 + Math.cos(ang) * dist,
y: size / 2 + Math.sin(ang) * dist,
}
}
render()
}, [selectedLab, size, render])
return (
<canvas ref={canvasRef} onClick={handleClick}
className="cursor-crosshair rounded-full" />
)
}

View File

@@ -0,0 +1,36 @@
import { Component, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
interface Props { children: ReactNode }
interface State { hasError: boolean; error: Error | null }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
render() {
if (this.state.hasError) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
<h1 className="mb-2 text-2xl font-bold text-red-600"></h1>
<p className="mb-4 text-sm text-gray-500">{this.state.error?.message ?? '发生了意外错误'}</p>
<div className="flex gap-3 justify-center">
<button onClick={() => { this.setState({ hasError: false, error: null }); window.location.reload() }}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700">
</button>
<Link to="/" className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50">
</Link>
</div>
</div>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,169 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { rgbToLab } from '@/lib/color/convert'
import type { LABColor } from '@/lib/color/types'
import { Pipette, Upload } from 'lucide-react'
interface EyedropperPanelProps {
onColorPick: (lab: LABColor) => void
}
function avgColor(data: Uint8ClampedArray): [number, number, number] {
return [Math.round(data[0]!), Math.round(data[1]!), Math.round(data[2]!)]
}
export default function EyedropperPanel({ onColorPick }: EyedropperPanelProps) {
const [image, setImage] = useState<HTMLImageElement | null>(null)
const [scale, setScale] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const [dragging, setDragging] = useState(false)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const canvasRef = useRef<HTMLCanvasElement>(null)
const [history, setHistory] = useState<LABColor[]>(() => {
try {
const raw = JSON.parse(localStorage.getItem('eyedropper-history') ?? '[]') as unknown[]
return raw.filter((h): h is LABColor =>
typeof h === 'object' && h !== null &&
typeof (h as LABColor).L === 'number' &&
typeof (h as LABColor).a === 'number' &&
typeof (h as LABColor).b === 'number'
)
}
catch { return [] }
})
const renderImage = useCallback(() => {
const canvas = canvasRef.current
if (!canvas || !image) return
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
ctx.drawImage(image, 0, 0)
}, [image])
useEffect(() => { if (image) renderImage() }, [image, renderImage])
const addHistory = (lab: LABColor) => {
const updated = [lab, ...history.filter(h => h.L !== lab.L || h.a !== lab.a || h.b !== lab.b)].slice(0, 10)
setHistory(updated)
localStorage.setItem('eyedropper-history', JSON.stringify(updated))
}
const handleFileUpload = (file: File) => {
if (!file.type.startsWith('image/')) return
const img = new Image()
img.onload = () => { setImage(img); setScale(1); setOffset({ x: 0, y: 0 }) }
img.src = URL.createObjectURL(file)
}
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = (e.clientX - rect.left) / scale
const y = (e.clientY - rect.top) / scale
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = Math.min(3, canvas.width - Math.round(x))
const h = Math.min(3, canvas.height - Math.round(y))
const imgData = ctx.getImageData(Math.round(x) - 1, Math.round(y) - 1, w, h)
let r = 0, g = 0, b = 0, count = 0
for (let i = 0; i < imgData.data.length; i += 4) {
r += imgData.data[i]!; g += imgData.data[i + 1]!; b += imgData.data[i + 2]!; count++
}
const [avgR, avgG, avgB] = avgColor(new Uint8ClampedArray([Math.round(r / count), Math.round(g / count), Math.round(b / count)]))
try {
const lab = rgbToLab(avgR, avgG, avgB)
onColorPick(lab)
addHistory(lab)
} catch { }
}
const handleEyedropper = async () => {
try {
const dropper = new (window as unknown as { EyeDropper: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper()
const result = await dropper.open()
const hex = result.sRGBHex
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
const lab = rgbToLab(r, g, b)
onColorPick(lab)
addHistory(lab)
} catch { }
}
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault()
setScale(s => Math.min(3, Math.max(0.5, s - e.deltaY * 0.001)))
}
const handleMouseDown = (e: React.MouseEvent) => {
if (scale <= 1) return
setDragging(true)
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y })
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!dragging) return
setOffset({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y })
}
const handleMouseUp = () => setDragging(false)
return (
<div className="space-y-3">
<div className="flex gap-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm hover:bg-gray-50">
<Upload size={14} />
<input type="file" accept="image/*" className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileUpload(f) }} />
</label>
<button onClick={handleEyedropper}
className="inline-flex items-center gap-1.5 rounded-lg border border-purple-300 px-3 py-1.5 text-sm text-purple-600 hover:bg-purple-50">
<Pipette size={14} />
</button>
</div>
{image && (
<div className="overflow-hidden rounded-lg border" style={{ maxHeight: scale > 1 ? '400px' : 'auto' }}>
<canvas ref={canvasRef}
onClick={handleCanvasClick}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{
transform: `scale(${scale}) translate(${offset.x / scale}px, ${offset.y / scale}px)`,
transformOrigin: 'top left',
cursor: 'crosshair',
maxWidth: '100%',
}}
className="block" />
</div>
)}
{history.length > 0 && (
<div className="flex items-center gap-1">
<span className="text-xs text-gray-400"></span>
{history.map((lab, i) => {
if (typeof lab.L !== 'number' || typeof lab.a !== 'number' || typeof lab.b !== 'number') return null
const r = Math.min(255, Math.max(0, Math.round(lab.L * 2.55)))
const g = Math.min(255, Math.max(0, Math.round((lab.a + 128) * 0.8)))
const b = Math.min(255, Math.max(0, Math.round((lab.b + 128) * 0.8)))
const color = `rgb(${Math.min(255, r)},${Math.min(255, g)},${Math.min(255, b)})`
return (
<button key={i} onClick={() => onColorPick(lab)}
className="h-5 w-5 rounded border border-gray-300" style={{ backgroundColor: color }}
title={`Lab(${lab.L.toFixed(0)},${lab.a.toFixed(0)},${lab.b.toFixed(0)})`} />
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,304 @@
import { useState, useMemo, useRef } from 'react'
import ReactECharts from 'echarts-for-react'
import { Minus, Plus, Save, Sparkles, Loader2 } from 'lucide-react'
import { useAIPredict } from '@/hooks/useAIPredict'
const COLORS = ['#0ea5e9','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899','#06b6d4','#84cc16','#f97316','#6366f1','#14b8a6','#e11d48']
interface IngredientData {
ingredientId: string
inciName: string
chineseName: string
percentage: number
ingredient?: { inciName: string; chineseName: string }
}
interface PhaseData {
name: string
ingredients: IngredientData[]
}
interface Props {
phases: PhaseData[]
onSave: (phases: Array<{ name: string; ingredients: Array<{ ingredientId: string; percentage: number }> }>) => Promise<void>
}
export default function FormulaVisualEditor({ phases: initialPhases, onSave }: Props) {
const [phases, setPhases] = useState<PhaseData[]>(() =>
initialPhases.map(p => ({
...p,
ingredients: p.ingredients.map(i => ({
...i,
percentage: Number(i.percentage),
ingredient: i.ingredient,
})),
}))
)
const [saving, setSaving] = useState(false)
const editorRef = useRef<HTMLDivElement>(null)
const allIngredients = useMemo(() =>
phases.flatMap(p => p.ingredients.map(i => ({
...i,
phaseName: p.name,
inciName: i.ingredient?.inciName ?? i.inciName,
chineseName: i.ingredient?.chineseName ?? i.chineseName,
})))
, [phases])
const total = useMemo(() => allIngredients.reduce((s, i) => s + i.percentage, 0), [allIngredients])
const isValid = total >= 99.5 && total <= 100.5
const adjustPct = (idx: number, delta: number) => {
setPhases(prev => {
const flat: { phaseIdx: number; ingIdx: number; ing: IngredientData }[] = []
prev.forEach((p, pi) => p.ingredients.forEach((ing, ii) => flat.push({ phaseIdx: pi, ingIdx: ii, ing })))
if (idx >= flat.length) return prev
const next = prev.map(p => ({ ...p, ingredients: p.ingredients.map(i => ({ ...i })) }))
const target = flat[idx]!
const current = next[target.phaseIdx]!.ingredients[target.ingIdx]!
const newPct = Math.max(0.01, Math.min(100, current.percentage + delta))
current.percentage = Math.round(newPct * 100) / 100
return next
})
}
const setPct = (idx: number, value: number) => {
setPhases(prev => {
const flat: { phaseIdx: number; ingIdx: number }[] = []
prev.forEach((p, pi) => p.ingredients.forEach((_, ii) => flat.push({ phaseIdx: pi, ingIdx: ii })))
if (idx >= flat.length) return prev
const next = prev.map(p => ({ ...p, ingredients: p.ingredients.map(i => ({ ...i })) }))
const target = flat[idx]!
next[target.phaseIdx]!.ingredients[target.ingIdx]!.percentage = Math.round(value * 100) / 100
return next
})
}
const normalize = () => {
if (total <= 0) return
setPhases(prev =>
prev.map(p => ({
...p,
ingredients: p.ingredients.map(i => ({
...i,
percentage: Math.round((i.percentage / total) * 100 * 100) / 100,
})),
}))
)
}
const chartOption = useMemo(() => ({
tooltip: { trigger: 'item', formatter: '{b}: {c}%' },
legend: { bottom: 0, textStyle: { fontSize: 11 } },
series: [{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: '{b}\n{c}%', fontSize: 11 },
data: allIngredients.map((ing, i) => ({
name: ing.inciName.length > 12 ? ing.inciName.slice(0, 12) + '…' : ing.inciName,
value: ing.percentage,
itemStyle: { color: COLORS[i % COLORS.length] },
})),
}],
}), [allIngredients])
const handleSave = async () => {
setSaving(true)
try {
const payload = phases.map(p => ({
name: p.name,
ingredients: p.ingredients.map(i => ({
ingredientId: i.ingredientId,
percentage: i.percentage,
})),
}))
await onSave(payload)
} finally { setSaving(false) }
}
const { prediction, loading: predicting, predict } = useAIPredict()
const handlePredict = () => {
predict(allIngredients.map(i => ({
name: i.inciName,
percentage: i.percentage,
category: '',
})))
}
const hasPrediction = prediction !== null
return (
<div ref={editorRef} className="flex flex-col gap-6 lg:flex-row">
<div className="flex-shrink-0 lg:w-[380px]">
<ReactECharts option={chartOption} style={{ height: 380 }} />
</div>
<div className="flex flex-1 flex-col">
<div className={`mb-4 rounded-lg border p-3 ${isValid ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600"></span>
<span className={`text-xl font-bold ${isValid ? 'text-green-600' : 'text-red-600'}`}>
{total.toFixed(2)}%
</span>
</div>
{!isValid && total > 0 && (
<button onClick={normalize}
className="rounded-lg bg-white px-3 py-1 text-xs font-medium text-blue-600 shadow-sm hover:bg-blue-50">
</button>
)}
</div>
{isValid && (
<div className="mt-1 h-1.5 w-full rounded-full bg-gray-200">
<div className="h-full rounded-full bg-green-500 transition-all" style={{ width: `${total}%` }} />
</div>
)}
</div>
<div className="mb-3 flex items-center gap-3">
<button onClick={handlePredict} disabled={predicting}
className="inline-flex items-center gap-1.5 rounded-lg bg-purple-600 px-3 py-1.5 text-sm text-white hover:bg-purple-700 disabled:opacity-50">
{predicting ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
{predicting ? '预测中...' : 'AI 预测指标'}
</button>
</div>
{hasPrediction && (
<div className="mb-4 grid grid-cols-3 gap-3">
<div className="rounded-lg bg-blue-50 p-3 text-center">
<div className="text-xs text-blue-600"></div>
<div className="text-xl font-bold text-blue-700">{prediction!.sensoryIndex.overall}</div>
</div>
<div className="rounded-lg bg-green-50 p-3 text-center">
<div className="text-xs text-green-600"></div>
<div className="text-xl font-bold text-green-700">{prediction!.stabilityScore}</div>
</div>
<div className="rounded-lg bg-amber-50 p-3 text-center">
<div className="text-xs text-amber-600"></div>
<div className="text-xl font-bold text-amber-700">¥{prediction!.costEstimate}</div>
</div>
</div>
)}
{hasPrediction && (
<div className="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-lg border p-3">
<h4 className="mb-2 text-xs font-medium text-gray-500"></h4>
<ReactECharts
option={{
radar: {
indicator: [
{ name: '铺展性', max: 100 },
{ name: '吸收速度', max: 100 },
{ name: '黏腻度', max: 100 },
{ name: '综合', max: 100 },
],
shape: 'circle',
center: ['50%', '55%'],
radius: '70%',
},
series: [{
type: 'radar',
data: [{
value: [
prediction!.sensoryIndex.spreadability,
prediction!.sensoryIndex.absorption,
prediction!.sensoryIndex.stickiness,
prediction!.sensoryIndex.overall,
],
name: '当前配方',
areaStyle: { color: 'rgba(59,130,246,0.2)' },
lineStyle: { color: '#3b82f6' },
itemStyle: { color: '#3b82f6' },
}],
}],
}}
style={{ height: 220 }}
/>
</div>
<div className="space-y-3">
<div className="rounded-lg border p-3">
<h4 className="mb-1 text-xs font-medium text-gray-500"></h4>
<ReactECharts
option={{
series: [{
type: 'gauge',
startAngle: 210, endAngle: -30,
min: 0, max: 100,
progress: { show: true, width: 12, itemStyle: { color: '#10b981' } },
axisLine: { lineStyle: { width: 12, color: [[0.6, '#ef4444'], [0.8, '#f59e0b'], [1, '#10b981']] } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
detail: { valueAnimation: true, fontSize: 20, offsetCenter: [0, '60%'] },
data: [{ value: prediction!.stabilityScore }],
}],
}}
style={{ height: 140 }}
/>
</div>
<div className="rounded-lg border p-3">
<h4 className="mb-1 text-xs font-medium text-gray-500"></h4>
<div className="flex flex-wrap gap-1">
{allIngredients.map((ing, i) => (
<div key={i} className="rounded px-2 py-0.5 text-xs"
style={{
backgroundColor: COLORS[i % COLORS.length] + '20',
color: COLORS[i % COLORS.length],
fontSize: `${Math.max(10, 10 + ing.percentage / 5)}px`,
}}>
{ing.inciName} {ing.percentage.toFixed(1)}%
</div>
))}
</div>
</div>
</div>
</div>
)}
<div className="flex-1 space-y-3 overflow-y-auto">
{phases.map((phase, pi) => (
<div key={pi}>
<h4 className="mb-2 text-xs font-medium text-gray-500">{phase.name}</h4>
<div className="space-y-1.5">
{phase.ingredients.map((ing, ii) => {
const flatIdx = phases.slice(0, pi).reduce((s, p) => s + p.ingredients.length, 0) + ii
return (
<div key={ii} className="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-2">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium truncate block">{ing.ingredient?.inciName ?? ing.inciName}</span>
<span className="text-xs text-gray-400">{ing.ingredient?.chineseName ?? ing.chineseName}</span>
</div>
<button onClick={() => adjustPct(flatIdx, -0.1)}
className="rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600"><Minus size={12} /></button>
<input type="number" step="0.01" min="0.01" max="100"
value={ing.percentage} onChange={e => setPct(flatIdx, parseFloat(e.target.value) || 0)}
className="w-20 rounded border px-2 py-0.5 text-right text-sm focus:border-blue-500 focus:outline-none" />
<span className="text-xs text-gray-400">%</span>
<button onClick={() => adjustPct(flatIdx, 0.1)}
className="rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600"><Plus size={12} /></button>
</div>
)
})}
</div>
</div>
))}
</div>
<button onClick={handleSave} disabled={saving || !isValid}
className="mt-4 inline-flex items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
title={!isValid ? '比例总和需在 99.5%~100.5% 之间' : undefined}>
<Save size={14} /> {saving ? '保存中...' : '保存配方'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { useState, useCallback, useRef } from 'react'
interface PredictionResult {
sensoryIndex: {
spreadability: number; absorption: number; stickiness: number; overall: number
}
stabilityScore: number
costEstimate: number
confidence: number
reasoning?: string
}
interface IngredientInput {
name: string; percentage: number; category: string
}
export function useAIPredict() {
const [prediction, setPrediction] = useState<PredictionResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
const predict = useCallback(async (ingredients: IngredientInput[]) => {
if (abortRef.current) abortRef.current.abort()
const controller = new AbortController()
abortRef.current = controller
setLoading(true)
setError(null)
try {
const res = await fetch('/api/ai/predict-formula', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ingredients }),
signal: controller.signal,
})
if (!res.ok) throw new Error('预测失败')
const reader = res.body?.getReader()
if (!reader) throw new Error('No stream')
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6)) as { type: string; content: string }
if (data.type === 'result') {
const parsed = JSON.parse(data.content) as PredictionResult
setPrediction(parsed)
} else if (data.type === 'error') {
setError('AI 预测失败')
}
}
}
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
setError('预测请求失败')
}
} finally {
setLoading(false)
}
}, [])
return { prediction, loading, error, predict }
}

1
frontend/src/index.css Normal file
View File

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

View File

@@ -0,0 +1,134 @@
import { useState } from 'react'
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom'
import {
LayoutDashboard, FlaskConical, Palette, Compass, Leaf, FolderKanban, Settings,
ChevronLeft, ChevronRight, Sun, Moon, Menu, Search,
} from 'lucide-react'
import { useThemeStore } from '@/stores/themeStore'
import { useAuthStore } from '@/stores/authStore'
import clsx from 'clsx'
import { LogOut } from 'lucide-react'
const menuItems = [
{ to: '/', icon: LayoutDashboard, label: '仪表盘' },
{ to: '/formulas', icon: FlaskConical, label: '配方记录' },
{ to: '/color-lab', icon: Palette, label: '颜色引擎' },
{ to: '/formula-explorer', icon: Compass, label: '配方推演' },
{ to: '/ingredients', icon: Leaf, label: '成分目录' },
{ to: '/projects', icon: FolderKanban, label: '项目管理' },
{ to: '/settings', icon: Settings, label: '设置' },
]
export default function AppLayout() {
const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const { theme, toggleTheme } = useThemeStore()
const user = useAuthStore(s => s.user)
const logout = useAuthStore(s => s.logout)
const location = useLocation()
const navigate = useNavigate()
const [searchQuery, setSearchQuery] = useState('')
const handleSearch = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
}
}
return (
<div className={clsx('flex min-h-screen bg-gray-50', theme === 'dark' && 'dark')}>
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
<aside
className={clsx(
'fixed inset-y-0 left-0 z-50 flex flex-col border-r border-gray-200 bg-white transition-all duration-300 dark:border-gray-700 dark:bg-gray-900',
collapsed && !mobileOpen ? 'w-16' : 'w-60',
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
)}
>
<div className="flex h-14 items-center justify-between border-b border-gray-200 px-3 dark:border-gray-700">
{!(collapsed && !mobileOpen) && (
<span className="text-sm font-bold text-gray-900 dark:text-white"></span>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="hidden rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 lg:block dark:hover:bg-gray-800"
>
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
{menuItems.map((item) => {
const isActive =
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to)
return (
<NavLink
key={item.to}
to={item.to}
onClick={() => setMobileOpen(false)}
className={clsx(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100',
)}
>
<item.icon size={20} />
{!(collapsed && !mobileOpen) && <span>{item.label}</span>}
</NavLink>
)
})}
</nav>
<div className="border-t border-gray-200 p-2 dark:border-gray-700">
<button
onClick={toggleTheme}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
>
{theme === 'dark' ? <Sun size={20} /> : <Moon size={20} />}
{!(collapsed && !mobileOpen) && <span>{theme === 'dark' ? '浅色模式' : '深色模式'}</span>}
</button>
</div>
</aside>
<div className={clsx('flex flex-1 flex-col transition-all duration-300', collapsed ? 'lg:ml-16' : 'lg:ml-60')}>
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-900">
<button
onClick={() => setMobileOpen(true)}
className="rounded-md p-1 text-gray-500 hover:bg-gray-100 lg:hidden dark:hover:bg-gray-800"
>
<Menu size={20} />
</button>
<div className="mx-4 hidden flex-1 sm:block">
<div className="relative max-w-md">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text" placeholder="搜索配方...(支持自然语言)"
value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearch}
className="w-full rounded-lg border border-gray-200 py-1.5 pl-8 pr-3 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800"
/>
</div>
</div>
<div className="flex-1 lg:hidden" />
<div className="flex items-center gap-3">
{user && <span className="text-sm text-gray-600">{user.username}</span>}
<button onClick={logout} className="rounded p-1 text-gray-400 hover:text-red-500" title="退出登录">
<LogOut size={16} />
</button>
</div>
</header>
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
</div>
)
}

32
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,32 @@
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
export async function apiFetch<T = unknown>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, options)
if (res.status === 204) return undefined as T
const text = await res.text()
if (!text) {
if (!res.ok) throw new ApiError(res.status, `请求失败 (${res.status})`)
return undefined as T
}
try {
const data = JSON.parse(text) as T
if (!res.ok) {
const msg = (data as Record<string, unknown>)?.error as string ?? `请求失败 (${res.status})`
throw new ApiError(res.status, msg)
}
return data
} catch (e) {
if (e instanceof ApiError) throw e
if (!res.ok) throw new ApiError(res.status, `请求失败 (${res.status})`)
throw new ApiError(res.status, '响应格式错误')
}
}

View File

@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest'
import {
hexToLab,
labToHex,
rgbToLab,
labToRGB,
labToLCH,
lchToLab,
displayP3ToLab,
labToDisplayP3,
} from './convert'
import { deltaE2000, deltaE76, deltaECMC } from './deltaE'
describe('色空间转换', () => {
describe('hexToLab / labToHex', () => {
it('白色 #FFFFFF → Lab(100, 0, 0)', () => {
const lab = hexToLab('#FFFFFF')
expect(lab.L).toBeCloseTo(100, 0)
expect(Math.abs(lab.a)).toBeLessThan(0.1)
expect(Math.abs(lab.b)).toBeLessThan(0.1)
})
it('黑色 #000000 → Lab(0, 0, 0)', () => {
const lab = hexToLab('#000000')
expect(lab.L).toBeCloseTo(0, 0)
})
it('纯红 #FF0000 → Lab 值合理', () => {
const lab = hexToLab('#FF0000')
expect(lab.L).toBeGreaterThan(40)
expect(lab.L).toBeLessThan(60)
expect(lab.a).toBeGreaterThan(30)
})
it('Lab → hex 往返转换:白色', () => {
const hex = labToHex(100, 0, 0)
expect(hex.toLowerCase()).toBe('#ffffff')
})
})
describe('rgbToLab / labToRGB', () => {
it('RGB(255, 255, 255) → Lab(100, 0, 0) 附近', () => {
const lab = rgbToLab(255, 255, 255)
expect(lab.L).toBeCloseTo(100, 0)
})
it('RGB 往返转换 |ΔL| < 0.1', () => {
const original: [number, number, number] = [128, 64, 192]
const lab = rgbToLab(...original)
const rgb = labToRGB(lab.L, lab.a, lab.b)
const lab2 = rgbToLab(rgb.r, rgb.g, rgb.b)
expect(Math.abs(lab.L - lab2.L)).toBeLessThan(2)
})
})
describe('labToLCH / lchToLab', () => {
it('Lab(50, 0, 0) → LCH(L=50, C≈0)', () => {
const lch = labToLCH(50, 0, 0)
expect(lch.L).toBeCloseTo(50, 0)
expect(lch.C).toBeLessThan(1)
})
it('LCH 往返转换误差 < 0.01', () => {
const lab: [number, number, number] = [60, 20, -15]
const lch = labToLCH(...lab)
const lab2 = lchToLab(lch.L, lch.C, lch.h)
expect(Math.abs(lab[0] - lab2.L)).toBeLessThan(0.01)
expect(Math.abs(lab[1] - lab2.a)).toBeLessThan(0.01)
expect(Math.abs(lab[2] - lab2.b)).toBeLessThan(0.01)
})
})
describe('Display P3 转换', () => {
it('P3(0, 0, 0) → Lab 接近黑色', () => {
const lab = displayP3ToLab(0, 0, 0)
expect(lab.L).toBeCloseTo(0, 0)
})
it('P3(1, 0, 0) → Lab 红色区域', () => {
const lab = displayP3ToLab(1, 0, 0)
expect(lab.L).toBeGreaterThan(40)
expect(lab.a).toBeGreaterThan(30)
})
it('Lab → P3 → Lab 往返', () => {
const original: [number, number, number] = [50, 30, -20]
const p3 = labToDisplayP3(...original)
const lab = displayP3ToLab(p3.r, p3.g, p3.b)
expect(Math.abs(original[0] - lab.L)).toBeLessThan(0.1)
})
})
})
describe('色差计算', () => {
it('相同颜色的 ΔE = 0', () => {
const lab = { L: 50, a: 10, b: -10 }
expect(deltaE2000(lab, lab)).toBe(0)
expect(deltaE76(lab, lab)).toBe(0)
expect(deltaECMC(lab, lab)).toBe(0)
})
it('纯红 vs 纯绿的 ΔE > 10', () => {
const red = hexToLab('#FF0000')
const green = hexToLab('#00FF00')
expect(deltaE2000(red, green)).toBeGreaterThan(10)
})
it('白色 vs 黑色的 ΔE 较大', () => {
const white = hexToLab('#FFFFFF')
const black = hexToLab('#000000')
const d = deltaE2000(white, black)
expect(d).toBeGreaterThan(50)
})
it('CIEDE2000 对接近颜色的敏感度', () => {
const color1 = hexToLab('#FF0000')
const color2 = hexToLab('#FE0000')
const d = deltaE2000(color1, color2)
expect(d).toBeGreaterThan(0)
expect(d).toBeLessThan(5)
})
it('ΔE76 ≥ ΔE2000 对接近白颜色CIEDE2000 更精确)', () => {
const nearWhite1 = hexToLab('#FEFEFE')
const nearWhite2 = hexToLab('#FDFDFD')
const d76 = deltaE76(nearWhite1, nearWhite2)
const d2000 = deltaE2000(nearWhite1, nearWhite2)
expect(d2000).toBeGreaterThanOrEqual(0)
expect(d76).toBeGreaterThanOrEqual(0)
})
})

View File

@@ -0,0 +1,62 @@
import Color from 'colorjs.io'
import type { LABColor, RGBColor, LCHColor, DisplayP3Color } from './types'
function getLab(color: Color): [number, number, number] {
const lab = color.lab
return [lab[0]!, lab[1]!, lab[2]!]
}
function getSrgb(color: Color): [number, number, number] {
const srgb = color.srgb
return [srgb[0]!, srgb[1]!, srgb[2]!]
}
function getLch(color: Color): [number, number, number] {
const lch = color.lch
return [lch[0]!, lch[1]!, lch[2]!]
}
function getP3(color: Color): [number, number, number] {
const p3 = color.p3
return [p3[0]!, p3[1]!, p3[2]!]
}
export function hexToLab(hex: string): LABColor {
const [L, a, b] = getLab(new Color(hex))
return { L, a, b }
}
export function labToHex(L: number, a: number, b: number): string {
const color = new Color('lab', [L, a, b])
return color.to('srgb').toString({ format: 'hex', collapse: false })
}
export function rgbToLab(r: number, g: number, b: number): LABColor {
const [L, aVal, bVal] = getLab(new Color('srgb', [r / 255, g / 255, b / 255]))
return { L, a: aVal, b: bVal }
}
export function labToRGB(L: number, a: number, b: number): RGBColor {
const [r, g, bVal] = getSrgb(new Color('lab', [L, a, b]))
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(bVal * 255) }
}
export function labToLCH(L: number, a: number, b: number): LCHColor {
const [lchL, C, h] = getLch(new Color('lab', [L, a, b]))
return { L: lchL, C, h }
}
export function lchToLab(L: number, C: number, h: number): LABColor {
const [labL, a, b] = getLab(new Color('lch', [L, C, h]))
return { L: labL, a, b }
}
export function displayP3ToLab(r: number, g: number, b: number): LABColor {
const [L, a, bVal] = getLab(new Color('p3', [r, g, b]))
return { L, a, b: bVal }
}
export function labToDisplayP3(L: number, a: number, b: number): DisplayP3Color {
const [r, g, bVal] = getP3(new Color('lab', [L, a, b]))
return { r, g, b: bVal }
}

View File

@@ -0,0 +1,20 @@
import Color from 'colorjs.io'
import type { LABColor } from './types'
export function deltaE2000(lab1: LABColor, lab2: LABColor): number {
const color1 = new Color('lab', [lab1.L, lab1.a, lab1.b])
const color2 = new Color('lab', [lab2.L, lab2.a, lab2.b])
return color1.deltaE(color2, '2000')
}
export function deltaECMC(lab1: LABColor, lab2: LABColor, l = 2, c = 1): number {
const color1 = new Color('lab', [lab1.L, lab1.a, lab1.b])
const color2 = new Color('lab', [lab2.L, lab2.a, lab2.b])
return color1.deltaE(color2, { method: 'CMC', l, c })
}
export function deltaE76(lab1: LABColor, lab2: LABColor): number {
const color1 = new Color('lab', [lab1.L, lab1.a, lab1.b])
const color2 = new Color('lab', [lab2.L, lab2.a, lab2.b])
return color1.deltaE(color2, '76')
}

View File

@@ -0,0 +1,23 @@
export interface LABColor {
L: number
a: number
b: number
}
export interface RGBColor {
r: number
g: number
b: number
}
export interface LCHColor {
L: number
C: number
h: number
}
export interface DisplayP3Color {
r: number
g: number
b: number
}

View File

@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
refetchOnWindowFocus: false,
},
},
})

18
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { QueryClientProvider } from '@tanstack/react-query'
import { ErrorBoundary } from './components/ErrorBoundary'
import { router } from './router'
import { queryClient } from './lib/queryClient'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>,
)

View File

@@ -0,0 +1,210 @@
import { useState, useMemo } from 'react'
import ColorWheel from '@/components/ColorWheel'
import { hexToLab, labToHex, labToRGB, rgbToLab } from '@/lib/color/convert'
import { deltaE2000 } from '@/lib/color/deltaE'
import type { LABColor } from '@/lib/color/types'
import { Copy, RotateCcw } from 'lucide-react'
import EyedropperPanel from '@/components/EyedropperPanel'
import ColorRecommendPanel from '@/components/ColorRecommendPanel'
const PANTONE_MAP: Array<{ code: string; lab: LABColor }> = [
{ code: '185 C', lab: { L: 48, a: 68, b: 48 } },
{ code: '286 C', lab: { L: 30, a: 12, b: -52 } },
{ code: '354 C', lab: { L: 55, a: -52, b: 28 } },
{ code: '109 C', lab: { L: 82, a: 8, b: 95 } },
{ code: '151 C', lab: { L: 65, a: 40, b: 70 } },
{ code: 'Process Black C', lab: { L: 18, a: 1, b: 0 } },
{ code: 'Warm Red C', lab: { L: 48, a: 65, b: 42 } },
{ code: 'Cool Gray 7 C', lab: { L: 58, a: 0, b: -2 } },
]
type AdjustMode = 'lab' | 'rgb'
interface TargetColor {
label: string
lab: LABColor
}
export default function ColorLabPage() {
const [currentLab, setCurrentLab] = useState<LABColor>({ L: 50, a: 0, b: 0 })
const [mode, setMode] = useState<AdjustMode>('lab')
const [inputValue, setInputValue] = useState('')
const [inputFormat, setInputFormat] = useState<'hex' | 'rgb' | 'lab' | 'pantone'>('hex')
const [target, setTarget] = useState<TargetColor | null>(null)
const hex = useMemo(() => {
try { return labToHex(currentLab.L, currentLab.a, currentLab.b) }
catch { return '#808080' }
}, [currentLab])
const rgb = useMemo(() => {
try { return labToRGB(currentLab.L, currentLab.a, currentLab.b) }
catch { return { r: 128, g: 128, b: 128 } }
}, [currentLab])
const delta = useMemo(() => {
if (!target) return null
return deltaE2000(currentLab, target.lab)
}, [currentLab, target])
const handleSlider = (channel: string, value: number) => {
if (mode === 'lab') {
setCurrentLab(prev => ({ ...prev, [channel]: value }))
} else {
const newRgb = { ...rgb, [channel]: value }
try { setCurrentLab(rgbToLab(newRgb.r, newRgb.g, newRgb.b)) }
catch { }
}
}
const handleInputSubmit = () => {
if (!inputValue.trim()) return
try {
let lab: LABColor
if (inputFormat === 'hex') {
lab = hexToLab(inputValue.trim())
setTarget({ label: inputValue.trim(), lab })
} else if (inputFormat === 'rgb') {
const parts = inputValue.replace(/[rgb()]/g, '').split(/[, ]+/).filter(Boolean).map(Number)
if (parts.length !== 3) return
lab = rgbToLab(parts[0]!, parts[1]!, parts[2]!)
setTarget({ label: `rgb(${parts.join(',')})`, lab })
} else if (inputFormat === 'lab') {
const parts = inputValue.split(/[, ]+/).filter(Boolean).map(Number)
if (parts.length !== 3) return
lab = { L: parts[0]!, a: parts[1]!, b: parts[2]! }
setTarget({ label: `Lab(${parts.join(',')})`, lab })
} else {
const match = PANTONE_MAP.find(p => p.code.toLowerCase() === inputValue.trim().toLowerCase())
if (!match) return
lab = match.lab
setTarget({ label: match.code, lab })
}
setCurrentLab(lab)
} catch { }
}
const deltaColor = delta !== null ? (delta <= 1 ? 'text-green-600' : delta <= 3 ? 'text-yellow-600' : 'text-red-600') : ''
const deltaBg = delta !== null ? (delta <= 1 ? 'bg-green-50 border-green-200' : delta <= 3 ? 'bg-yellow-50 border-yellow-200' : 'bg-red-50 border-red-200') : ''
return (
<div>
<h2 className="mb-6 text-2xl font-bold"></h2>
<div className="flex flex-wrap gap-8 lg:flex-nowrap">
<div className="flex-shrink-0">
<ColorWheel size={380} selectedLab={currentLab} onColorChange={setCurrentLab} />
</div>
<div className="flex min-w-[220px] flex-col items-center gap-4">
{target && (
<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>
{delta !== null && <p className={`font-bold ${deltaColor}`}>ΔE = {delta.toFixed(2)}</p>}
</div>
</div>
)}
<div className="h-32 w-32 rounded-xl border-2 border-gray-200 shadow-inner"
style={{ backgroundColor: hex }} />
<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="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>
</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>
<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'}`}>
{f === 'pantone' ? '潘通' : f.toUpperCase()}
</button>
))}
</div>
<div className="flex gap-1">
<input value={inputValue} onChange={e => setInputValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleInputSubmit() }}
placeholder={inputFormat === 'pantone' ? '185 C' : inputFormat === 'hex' ? '#FF0000' : (inputFormat === 'rgb' ? '255,0,0' : '50,50,0')}
className="flex-1 rounded-lg border px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none" />
<button onClick={handleInputSubmit} className="rounded-lg bg-blue-600 px-3 py-1.5 text-xs text-white hover:bg-blue-700"></button>
</div>
</div>
</div>
<div className="min-w-[200px] flex-1 space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700"></span>
<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'}`}>
{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>
</div>
</div>
{mode === 'lab' ? (
<>
<SliderRow label="L" value={currentLab.L} min={0} max={100} step={0.1}
onChange={v => handleSlider('L', v)}
trackStyle="linear-gradient(to right, #000, #fff)" />
<SliderRow label="a" value={currentLab.a} min={-128} max={127} step={0.1}
onChange={v => handleSlider('a', v)}
trackStyle="linear-gradient(to right, #00a060, #888, #ff4060)" />
<SliderRow label="b" value={currentLab.b} min={-128} max={127} step={0.1}
onChange={v => handleSlider('b', v)}
trackStyle="linear-gradient(to right, #2060ff, #888, #ffe020)" />
</>
) : (
<>
<SliderRow label="R" value={rgb.r} min={0} max={255} step={1}
onChange={v => handleSlider('r', v)}
trackStyle="linear-gradient(to right, #000, #ff0000)" />
<SliderRow label="G" value={rgb.g} min={0} max={255} step={1}
onChange={v => handleSlider('g', v)}
trackStyle="linear-gradient(to right, #000, #00ff00)" />
<SliderRow label="B" value={rgb.b} min={0} max={255} step={1}
onChange={v => handleSlider('b', v)}
trackStyle="linear-gradient(to right, #000, #0000ff)" />
</>
)}
</div>
</div>
<div className="mt-8 border-t pt-6">
<div className="flex items-center gap-4 mb-4">
<span className="text-sm font-medium text-gray-700"></span>
<ColorRecommendPanel currentLab={currentLab} targetLab={target?.lab ?? null} />
</div>
<EyedropperPanel onColorPick={setCurrentLab} />
</div>
</div>
)
}
function SliderRow({ label, value, min, max, step, onChange, trackStyle }: {
label: string; value: number; min: number; max: number; step: number
onChange: (v: number) => void; trackStyle: string
}) {
return (
<div className="flex items-center gap-3">
<span className="w-6 text-xs text-gray-500">{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"
style={{ background: trackStyle }} />
<span className="w-14 text-right text-xs font-mono text-gray-600">{value.toFixed(1)}</span>
</div>
)
}

View File

@@ -0,0 +1,92 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { FlaskConical, Leaf, FolderKanban } from 'lucide-react'
import { apiFetch } from '@/lib/api'
interface Stats {
formulaCount: number; ingredientCount: number; projectCount: number
}
interface RecentFormula {
id: string; name: string; currentVersion: number; updatedAt: string; project: { name: string } | null
}
async function fetchStats(): Promise<Stats> {
const [f, i, p] = await Promise.all([
apiFetch<{ pagination: { total: number } }>('/api/formulas?limit=1'),
apiFetch<{ pagination: { total: number } }>('/api/ingredients?limit=1'),
apiFetch<{ data: unknown[] }>('/api/projects'),
])
return { formulaCount: f?.pagination?.total ?? 0, ingredientCount: i?.pagination?.total ?? 0, projectCount: p?.data?.length ?? 0 }
}
async function fetchRecentFormulas(): Promise<RecentFormula[]> {
const res = await apiFetch<{ data: RecentFormula[] }>('/api/formulas?limit=5&sortBy=updatedAt&sortOrder=desc')
return res?.data ?? []
}
export default function DashboardPage() {
const { data: stats } = useQuery({ queryKey: ['stats'], queryFn: fetchStats })
const { data: recentFormulas = [] } = useQuery({ queryKey: ['recent-formulas'], queryFn: fetchRecentFormulas })
return (
<div className="mx-auto max-w-5xl">
<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">
<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>
</div>
</Link>
<Link to="/ingredients" className="rounded-xl border bg-white 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>
</div>
</Link>
<Link to="/projects" className="rounded-xl border bg-white 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>
</div>
</Link>
</div>
<div className="rounded-xl border bg-white 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>
</div>
{recentFormulas.length === 0 ? (
<p className="py-6 text-center text-sm text-gray-400">"新建配方"</p>
) : (
<div className="divide-y">
{recentFormulas.map((f) => (
<Link key={f.id} to={`/formulas/${f.id}`} className="flex items-center justify-between py-3 hover:bg-gray-50">
<div>
<span className="font-medium">{f.name}</span>
{f.project && <span className="ml-2 text-xs text-gray-400">{f.project.name}</span>}
</div>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>v{f.currentVersion}</span>
<span>{new Date(f.updatedAt).toLocaleDateString('zh-CN')}</span>
</div>
</Link>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import { useParams, Link, useSearchParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Pencil, History, Eye, BarChart3 } from 'lucide-react'
import FormulaVisualEditor from '@/components/FormulaVisualEditor'
import { apiFetch } from '@/lib/api'
interface IngData {
ingredientId: string; inciName: string; chineseName: string; percentage: number
ingredient?: { inciName: string; chineseName: string }
}
interface PhaseData {
name: string; ingredients: IngData[]
}
async function fetchFormula(id: string): Promise<Record<string, unknown> | undefined> {
const res = await apiFetch<{ data: Record<string, unknown> }>(`/api/formulas/${id}`)
return res?.data
}
export default function FormulaDetailPage() {
const { id } = useParams<{ id: string }>()
const [params, setParams] = useSearchParams()
const tab = params.get('tab') ?? 'detail'
const queryClient = useQueryClient()
const { data: formula, isLoading } = useQuery({
queryKey: ['formula', id], queryFn: () => fetchFormula(id!), enabled: !!id,
})
const saveMutation = useMutation({
mutationFn: (phases: Array<{ name: string; ingredients: Array<{ ingredientId: string; percentage: number }> }>) =>
apiFetch(`/api/formulas/${id}/composition`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phases }) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['formula', id] }),
})
if (isLoading) return <div className="py-12 text-center text-gray-400">...</div>
if (!formula) return <div className="py-12 text-center text-gray-400"></div>
const phases = ((formula.versions as Array<Record<string, unknown>>)?.[0]?.phases as PhaseData[]) ?? []
return (
<div className="mx-auto max-w-4xl">
<div className="mb-6 flex items-center gap-3">
<Link to="/formulas" className="rounded p-1 text-gray-400 hover:text-gray-600"><ArrowLeft size={18} /></Link>
<h2 className="text-2xl font-bold">{String(formula.name)}</h2>
<div className="ml-auto flex gap-2">
<Link to={`/formulas/${id}/history`}
className="inline-flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm hover:bg-gray-50">
<History size={14} />
</Link>
<Link to={`/formulas/${id}/edit`}
className="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700">
<Pencil size={14} />
</Link>
</div>
</div>
{formula.description ? <p className="mb-4 text-sm text-gray-500">{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>
</div>
<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'}`}>
<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'}`}>
<BarChart3 size={14} />
</button>
</div>
{tab === 'visual' ? (
<FormulaVisualEditor phases={phases} onSave={async (p) => { await saveMutation.mutateAsync(p) }} />
) : (
<div className="space-y-4">
{phases.map((phase, i) => (
<div key={i} className="rounded-lg border">
<div className="border-b bg-gray-50 px-4 py-2 text-sm font-medium">{phase.name}</div>
<div className="divide-y">
{phase.ingredients.map((ing, j) => (
<div key={j} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div>
<span className="font-medium">{ing.ingredient?.inciName ?? ing.inciName}</span>
<span className="ml-2 text-xs text-gray-400">{ing.ingredient?.chineseName ?? ing.chineseName ?? ''}</span>
</div>
<span className="font-medium text-gray-600">{Number(ing.percentage).toFixed(2)}%</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,346 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useMutation } from '@tanstack/react-query'
import * as Accordion from '@radix-ui/react-accordion'
import * as Popover from '@radix-ui/react-popover'
import * as Dialog from '@radix-ui/react-dialog'
import { Plus, Trash2, Search, ChevronDown, X, Sparkles } from 'lucide-react'
import { useForm, useFieldArray } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { apiFetch } from '@/lib/api'
interface Ingredient {
id: string; inciName: string; chineseName: string; functionCategory: string; unitPrice: number | null
}
const schema = z.object({
name: z.string().min(1, '配方名称为必填项'),
description: z.string().optional(),
phases: z.array(z.object({
name: z.string().min(1, '相名称为必填项'),
ingredients: z.array(z.object({
ingredientId: z.string(),
inciName: z.string(),
chineseName: z.string(),
percentage: z.number().min(0.01, '比例须 >0').max(100, '比例须 ≤100'),
})),
})).min(1, '至少需要一个相'),
})
type FormData = z.infer<typeof schema>
async function fetchIngredients(search: string): Promise<Ingredient[]> {
const res = await apiFetch<{ data: Ingredient[] }>(`/api/ingredients?search=${encodeURIComponent(search)}&limit=50`)
return res?.data ?? []
}
async function fetchFormula(id: string) {
const res = await apiFetch<{ data: Record<string, unknown> }>(`/api/formulas/${id}`)
return res?.data
}
async function createFormula(data: Record<string, unknown>) {
return apiFetch('/api/formulas', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
}
async function updateFormula(id: string, data: Record<string, unknown>) {
return apiFetch(`/api/formulas/${id}/composition`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
}
export default function FormulaEditorPage() {
const { id } = useParams<{ id: string }>()
const isEdit = !!id
const navigate = useNavigate()
const [ingredientSearch, setIngredientSearch] = useState('')
const [activePhaseIndex, setActivePhaseIndex] = useState<number | null>(null)
const { data: formula } = useQuery({
queryKey: ['formula', id],
queryFn: () => fetchFormula(id!),
enabled: isEdit,
})
const { data: searchResults = [] } = useQuery({
queryKey: ['ingredient-search', ingredientSearch],
queryFn: () => fetchIngredients(ingredientSearch),
enabled: ingredientSearch.length > 0,
})
const formulaData = isEdit && formula ? {
name: String(formula.name ?? ''),
description: String(formula.description ?? ''),
phases: ((formula.versions as Array<Record<string, unknown>>)?.[0]?.phases as Array<Record<string, unknown>>)?.map((p: Record<string, unknown>) => ({
name: String(p.name ?? ''),
ingredients: (p.ingredients as Array<Record<string, unknown>>).map((i: Record<string, unknown>) => {
const ing = i.ingredient as Record<string, unknown> | undefined
return {
ingredientId: String(ing?.id ?? i.ingredientId ?? ''),
inciName: String(ing?.inciName ?? ''),
chineseName: String(ing?.chineseName ?? ''),
percentage: Number(i.percentage ?? 0),
}
}),
})) ?? [{ name: '', ingredients: [] }],
} : undefined
const { register, control, handleSubmit, watch, setValue, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { name: '', description: '', phases: [{ name: '', ingredients: [] }] },
values: formulaData,
})
const { fields: phases, append: addPhase, remove: removePhase } = useFieldArray({ control, name: 'phases' })
const phasesData = watch('phases')
const totalPercentage = phasesData.reduce((sum: number, p) =>
sum + p.ingredients.reduce((s: number, i) => s + (Number(i.percentage) || 0), 0), 0)
const isTotalValid = totalPercentage >= 99.5 && totalPercentage <= 100.5
const createMut = useMutation({ mutationFn: createFormula, onSuccess: (d: unknown) => navigate(`/formulas/${(d as { data: { id: string } }).data.id}`) })
const updateMut = useMutation({ mutationFn: (data: Record<string, unknown>) => updateFormula(id!, data), onSuccess: () => navigate(`/formulas/${id}`) })
const onSubmit = (data: FormData) => {
const payload = { name: data.name, description: data.description, phases: data.phases.map(p => ({
name: p.name,
ingredients: p.ingredients.map(i => ({ ingredientId: i.ingredientId, percentage: i.percentage })),
}))}
if (isEdit) updateMut.mutate(payload)
else createMut.mutate(payload)
}
const addIngredient = (phaseIndex: number, ing: Ingredient) => {
const current = phasesData[phaseIndex]?.ingredients ?? []
if (current.some(i => i.ingredientId === ing.id)) return
setValue(`phases.${phaseIndex}.ingredients`, [
...current,
{ ingredientId: ing.id, inciName: ing.inciName, chineseName: ing.chineseName, percentage: 0 },
])
}
const removeIngredient = (phaseIndex: number, ingIndex: number) => {
const current = [...(phasesData[phaseIndex]?.ingredients ?? [])]
current.splice(ingIndex, 1)
setValue(`phases.${phaseIndex}.ingredients`, current)
}
const [aiOpen, setAiOpen] = useState(false)
const [aiText, setAiText] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiResult, setAiResult] = useState<Array<{ inciName: string; chineseName: string; percentage: number; phase: string }> | null>(null)
const handleAiExtract = async () => {
setAiLoading(true)
try {
const json = await apiFetch<{ data: Array<{ inciName: string; chineseName: string; percentage: number; phase: string }> }>(
'/api/ai/extract-formula',
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: aiText }) },
)
setAiResult(json?.data ?? [])
} catch { alert('AI 提取失败,请重试') }
finally { setAiLoading(false) }
}
const applyAiResult = () => {
if (!aiResult) return
const phaseMap = new Map<string, number>()
const newPhases = [...phasesData]
for (const ing of aiResult) {
const phaseName = ing.phase || '默认相'
let idx = phaseMap.get(phaseName)
if (idx === undefined) {
idx = newPhases.length
newPhases.push({ name: phaseName, ingredients: [] })
phaseMap.set(phaseName, idx)
}
newPhases[idx]!.ingredients.push({
ingredientId: '', inciName: ing.inciName, chineseName: ing.chineseName, percentage: ing.percentage,
})
}
setValue('phases', newPhases)
setAiOpen(false)
setAiText('')
setAiResult(null)
}
const isPending = createMut.isPending || updateMut.isPending
const canSave = Object.keys(errors).length === 0 && isTotalValid
return (
<div className="mx-auto max-w-3xl">
<h2 className="mb-6 text-2xl font-bold">
{isEdit ? '编辑配方' : '新建配方'}
{!isEdit && (
<button type="button" onClick={() => setAiOpen(true)}
className="ml-3 inline-flex items-center gap-1 rounded-lg border border-purple-300 px-3 py-1 text-sm font-normal text-purple-600 hover:bg-purple-50">
<Sparkles size={14} /> AI
</button>
)}
</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-gray-700"> *</span>
<input {...register('name')} className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name.message}</p>}
</label>
<label className="block">
<span className="text-sm font-medium text-gray-700"></span>
<textarea {...register('description')} rows={2} className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
</label>
</div>
<div>
<div className="mb-3 flex items-center justify-between">
<h3 className="font-medium"></h3>
<button type="button" onClick={() => addPhase({ name: '', ingredients: [] })}
className="inline-flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm hover:bg-gray-50">
<Plus size={14} />
</button>
</div>
<Accordion.Root type="single" collapsible className="space-y-2">
{phases.map((field, phaseIndex) => (
<Accordion.Item key={field.id} value={`phase-${phaseIndex}`}
className="rounded-lg border border-gray-200">
<Accordion.Header>
<Accordion.Trigger className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium hover:bg-gray-50">
<div className="flex items-center gap-2">
<ChevronDown size={14} className="transition-transform duration-200 [[data-state=open]_&]:rotate-180" />
<input
{...register(`phases.${phaseIndex}.name`)}
placeholder="相名称(如:水相)"
onClick={(e) => e.stopPropagation()}
className="rounded border px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
<button type="button" onClick={(e) => { e.stopPropagation(); removePhase(phaseIndex) }}
className="rounded p-1 text-gray-400 hover:text-red-500"><Trash2 size={14} /></button>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content className="px-4 pb-3">
<div className="space-y-1.5">
{phasesData[phaseIndex]?.ingredients.map((ing, ingIndex) => (
<div key={ingIndex} className="flex items-center gap-2 rounded bg-gray-50 px-3 py-2 text-sm">
<span className="flex-1 font-medium">{ing.inciName}</span>
<span className="text-xs text-gray-400">{ing.chineseName}</span>
<input
type="number" step="0.01" min="0" max="100"
{...register(`phases.${phaseIndex}.ingredients.${ingIndex}.percentage`, { valueAsNumber: true })}
className="w-20 rounded border px-2 py-0.5 text-right text-sm focus:border-blue-500 focus:outline-none"
/>
<span className="text-xs text-gray-400">%</span>
<button type="button" onClick={() => removeIngredient(phaseIndex, ingIndex)}
className="rounded p-0.5 text-gray-400 hover:text-red-500"><X size={12} /></button>
</div>
))}
<Popover.Root open={activePhaseIndex === phaseIndex} onOpenChange={(o) => setActivePhaseIndex(o ? phaseIndex : null)}>
<Popover.Trigger asChild>
<button type="button"
className="inline-flex items-center gap-1 rounded-lg border border-dashed px-3 py-1.5 text-sm text-gray-400 hover:border-gray-400 hover:text-gray-600">
<Plus size={12} />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="z-50 w-80 rounded-lg border bg-white p-3 shadow-lg" sideOffset={4}>
<div className="relative mb-2">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input type="text" placeholder="搜索成分..."
value={ingredientSearch} onChange={(e) => setIngredientSearch(e.target.value)}
className="w-full rounded border py-1.5 pl-8 pr-2 text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{searchResults.map((ing) => {
const alreadyAdded = phasesData[phaseIndex]?.ingredients.some(i => i.ingredientId === ing.id)
return (
<button key={ing.id} type="button" disabled={alreadyAdded}
onClick={() => { addIngredient(phaseIndex, ing); setActivePhaseIndex(null); setIngredientSearch('') }}
className={`w-full rounded px-2 py-1.5 text-left text-sm ${alreadyAdded ? 'cursor-not-allowed bg-gray-50 text-gray-300' : 'hover:bg-gray-50'}`}>
<span className="font-medium">{ing.inciName}</span>
<span className="ml-2 text-xs text-gray-400">{ing.chineseName}</span>
</button>
)
})}
{ingredientSearch && searchResults.length === 0 && (
<p className="py-2 text-center text-xs text-gray-400"></p>
)}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
</div>
<div className={`sticky bottom-0 rounded-lg border p-4 ${isTotalValid ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}`}>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium"></span>
<span className={`ml-1 text-lg font-bold ${isTotalValid ? 'text-green-600' : 'text-red-600'}`}>
{totalPercentage.toFixed(2)}%
</span>
{!isTotalValid && phasesData.some(p => p.ingredients.length > 0) && (
<p className="mt-0.5 text-xs text-red-500"> 99.5% ~ 100.5% </p>
)}
</div>
<button type="submit" disabled={!canSave || isPending}
className="rounded-lg bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
title={!canSave ? '请确保配方信息完整且比例总和在范围内' : undefined}>
{isPending ? '保存中...' : '保存配方'}
</button>
</div>
</div>
</form>
<Dialog.Root open={aiOpen} onOpenChange={setAiOpen}>
<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-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl">
<Dialog.Title className="mb-4 text-lg font-bold">AI </Dialog.Title>
<Dialog.Close asChild>
<button className="absolute right-4 top-4 rounded p-1 text-gray-400 hover:text-gray-600"><X size={18} /></button>
</Dialog.Close>
{!aiResult ? (
<>
<textarea rows={6} placeholder="粘贴配方文本AI 将自动提取成分、比例和相信息..."
value={aiText} onChange={(e) => setAiText(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
<button onClick={handleAiExtract} disabled={!aiText.trim() || aiLoading}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-purple-600 px-4 py-2 text-sm text-white hover:bg-purple-700 disabled:opacity-50">
<Sparkles size={14} /> {aiLoading ? '解析中...' : '开始提取'}
</button>
</>
) : (
<>
<div className="mb-4 max-h-48 space-y-1 overflow-y-auto">
{aiResult.map((ing, i) => (
<div key={i} className="flex items-center gap-2 rounded bg-gray-50 px-3 py-1.5 text-sm">
<span className="font-medium">{ing.inciName}</span>
<span className="text-xs text-gray-400">{ing.chineseName}</span>
<span className="ml-auto text-xs">{ing.percentage}%</span>
<span className="rounded bg-purple-50 px-1.5 py-0.5 text-xs text-purple-600">{ing.phase || '默认相'}</span>
</div>
))}
</div>
<div className="flex justify-end gap-2">
<button onClick={() => { setAiResult(null); setAiText('') }}
className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50"></button>
<button onClick={applyAiResult}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm text-white hover:bg-purple-700"></button>
</div>
</>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</div>
)
}

View File

@@ -0,0 +1,190 @@
import { useState, useRef, useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { Compass, Loader2 } from 'lucide-react'
interface FormulaOption {
name: string
changes?: Array<{ action: string; ingredient: string; oldPercentage: number | null; newPercentage: number }>
predictedMetrics?: Record<string, number>
reasoning?: string
}
export default function FormulaExplorerPage() {
const [costLimit, setCostLimit] = useState('')
const [excludeIngredient, setExcludeIngredient] = useState('')
const [excludeList, setExcludeList] = useState<string[]>([])
const [targetSensory, setTargetSensory] = useState('')
const [targetStability, setTargetStability] = useState('')
const [streaming, setStreaming] = useState(false)
const [options, setOptions] = useState<FormulaOption[]>([])
const abortRef = useRef<AbortController | null>(null)
const handleStart = async () => {
if (abortRef.current) abortRef.current.abort()
const controller = new AbortController()
abortRef.current = controller
setStreaming(true)
setOptions([])
const constraints: Record<string, unknown> = {}
if (costLimit) constraints.costLimit = parseFloat(costLimit)
if (excludeList.length) constraints.excludeIngredients = excludeList
const metrics: Record<string, number> = {}
if (targetSensory) metrics.sensoryIndex = parseFloat(targetSensory)
if (targetStability) metrics.stabilityScore = parseFloat(targetStability)
if (Object.keys(metrics).length) constraints.targetMetrics = metrics
try {
const res = await fetch('/api/ai/explore-formula', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(constraints), signal: controller.signal,
})
if (!res.ok) throw new Error('推演失败')
const reader = res.body?.getReader()
if (!reader) throw new Error('No stream')
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6)) as { type: string; option?: FormulaOption }
if (data.type === 'option' && data.option) setOptions(prev => [...prev, data.option!])
}
}
}
} catch (err) {
if ((err as Error).name !== 'AbortError') alert('推演失败')
} finally { setStreaming(false) }
}
const handleStop = () => { abortRef.current?.abort(); setStreaming(false) }
const addExclude = () => {
if (excludeIngredient.trim() && !excludeList.includes(excludeIngredient.trim())) {
setExcludeList([...excludeList, excludeIngredient.trim()])
setExcludeIngredient('')
}
}
const scatterData = useMemo(() =>
options.map((o, i) => ({
name: o.name,
value: [o.predictedMetrics?.costEstimate ?? 0, o.predictedMetrics?.sensoryIndex ?? 0],
itemStyle: { color: ['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899'][i % 6] },
})), [options])
return (
<div className="mx-auto max-w-5xl">
<h2 className="mb-6 text-2xl font-bold"></h2>
<div className="mb-8 grid gap-6 lg:grid-cols-2">
<div className="rounded-xl border p-5">
<h3 className="mb-4 font-semibold"></h3>
<div className="space-y-3">
<label className="block"><span className="text-sm text-gray-600"> (/kg)</span>
<input type="number" min="0" value={costLimit} onChange={e => setCostLimit(e.target.value)}
placeholder="如 50" className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></label>
<label className="block"><span className="text-sm text-gray-600"> (0-100)</span>
<input type="number" min="0" max="100" value={targetSensory} onChange={e => setTargetSensory(e.target.value)}
placeholder="如 80" className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></label>
<label className="block"><span className="text-sm text-gray-600"> (0-100)</span>
<input type="number" min="0" max="100" value={targetStability} onChange={e => setTargetStability(e.target.value)}
placeholder="如 85" className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></label>
<div><span className="text-sm text-gray-600"></span>
<div className="mt-0.5 flex gap-1">
<input value={excludeIngredient} onChange={e => setExcludeIngredient(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addExclude()}
placeholder="成分名" className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
<button onClick={addExclude} className="rounded-lg border px-3 py-2 text-sm hover:bg-gray-50"></button>
</div>
{excludeList.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{excludeList.map(ing => (
<span key={ing} className="inline-flex items-center gap-1 rounded-full bg-red-50 px-2 py-0.5 text-xs text-red-600">
{ing} <button onClick={() => setExcludeList(excludeList.filter(i => i !== ing))} className="text-red-400">×</button></span>))}
</div>)}
</div>
</div>
<div className="mt-4 flex gap-2">
<button onClick={handleStart} disabled={streaming}
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 disabled:opacity-50">
{streaming ? <Loader2 size={14} className="animate-spin" /> : <Compass size={14} />}
{streaming ? '推演中...' : '开始推演'}
</button>
{streaming && <button onClick={handleStop} className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50"></button>}
</div>
</div>
<div className="rounded-xl border p-5">
<h3 className="mb-3 font-semibold"> {options.length > 0 && `(${options.length})`}</h3>
{options.length === 0 && !streaming ? (
<p className="py-8 text-center text-sm text-gray-400">"开始推演"</p>
) : (
<div className="max-h-[300px] space-y-2 overflow-y-auto">
{options.map((opt, i) => (
<div key={i} className="rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between mb-1">
<span className="font-semibold">{opt.name}</span>
{opt.predictedMetrics && (
<span className="text-xs text-gray-400">
{opt.predictedMetrics.sensoryIndex} | {opt.predictedMetrics.stabilityScore} | ¥{opt.predictedMetrics.costEstimate}
</span>)}
</div>
{opt.changes && opt.changes.length > 0 && (
<div className="flex flex-wrap gap-1">
{opt.changes.map((c, j) => (
<span key={j} className={`rounded px-1.5 py-0.5 text-xs ${
c.action === 'add' ? 'bg-green-50 text-green-600' :
c.action === 'remove' ? 'bg-red-50 text-red-600' : 'bg-yellow-50 text-yellow-600'
}`}>
{c.action === 'add' ? '+' : c.action === 'remove' ? '-' : '~'} {c.ingredient}
{c.oldPercentage != null && c.newPercentage != null && (
<span className="ml-1 opacity-60">{c.oldPercentage}{c.newPercentage}%</span>)}
</span>))}
</div>)}
{opt.reasoning && <p className="mt-1 text-xs text-gray-400">{opt.reasoning}</p>}
</div>))}
</div>)}
</div>
</div>
{options.length > 1 && (
<div className="rounded-xl border p-5">
<h3 className="mb-3 font-semibold"></h3>
<div className="grid gap-6 lg:grid-cols-2">
<ReactECharts option={{
tooltip: { trigger: 'item' },
xAxis: { name: '成本 (元/kg)', type: 'value' },
yAxis: { name: '肤感指数', type: 'value', min: 0, max: 100 },
series: [{ type: 'scatter', data: scatterData, symbolSize: 16,
label: { show: true, formatter: '{b}', fontSize: 10, position: 'top' } }],
}} style={{ height: 300 }} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b text-gray-600"><tr>
<th className="py-2 pr-4 text-left font-medium"></th>
<th className="py-2 pr-4 text-right font-medium"></th>
<th className="py-2 pr-4 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr></thead>
<tbody className="divide-y">
{options.map((opt, i) => (
<tr key={i}><td className="py-2 pr-4 font-medium">{opt.name}</td>
<td className="py-2 pr-4 text-right">¥{opt.predictedMetrics?.costEstimate ?? '-'}</td>
<td className="py-2 pr-4 text-right">{opt.predictedMetrics?.sensoryIndex ?? '-'}</td>
<td className="py-2 text-right">{opt.predictedMetrics?.stabilityScore ?? '-'}</td></tr>))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,118 @@
import { useState, useEffect, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Search, Plus, FlaskConical, Clock } from 'lucide-react'
import { apiFetch } from '@/lib/api'
interface FormulaSummary {
id: string
name: string
description: string | null
currentVersion: number
updatedAt: string
createdAt: string
project: { id: string; name: string } | null
}
interface PaginatedResponse {
data: FormulaSummary[]
pagination: { page: number; limit: number; total: number; totalPages: number }
}
async function fetchFormulas(search: string, page: number): Promise<PaginatedResponse> {
const params = new URLSearchParams({ page: String(page), limit: '20' })
if (search) params.set('search', search)
return apiFetch(`/api/formulas?${params}`)
}
export default function FormulaListPage() {
const navigate = useNavigate()
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [page, setPage] = useState(1)
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => { setDebouncedSearch(search); setPage(1) }, 300)
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
}, [search])
const { data, isLoading } = useQuery({
queryKey: ['formulas', debouncedSearch, page],
queryFn: () => fetchFormulas(debouncedSearch, page),
})
const formulas = data?.data ?? []
const pagination = data?.pagination
return (
<div>
<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">
<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" />
<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" />
</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>
))}
</div>
) : formulas.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<FlaskConical size={48} className="mb-3" />
<p className="text-lg"></p>
<p className="mt-1 text-sm">"新建配方"</p>
</div>
) : (
<>
<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>
{f.description && (
<p className="mb-3 line-clamp-2 text-sm text-gray-500">{f.description}</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-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>
)}
</div>
</Link>
))}
</div>
{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>
<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>
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,304 @@
import { useState, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import * as Dialog from '@radix-ui/react-dialog'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import { Search, Plus, Pencil, Trash2, X, ChevronLeft, ChevronRight } from 'lucide-react'
import { apiFetch } from '@/lib/api'
interface Ingredient {
id: string
inciName: string
chineseName: string
functionCategory: string
supplier: string | null
unit: string
unitPrice: number | null
description: string | null
createdAt: string
}
interface PaginatedResponse {
data: Ingredient[]
pagination: { page: number; limit: number; total: number; totalPages: number }
}
const CATEGORY_LABELS: Record<string, string> = {
emulsifier: '乳化剂', humectant: '保湿剂', thickener: '增稠剂',
preservative: '防腐剂', antioxidant: '抗氧化剂', fragrance: '香精',
colorant: '着色剂', ph_adjuster: 'pH调节剂', sunscreen: '防晒剂',
surfactant: '表面活性剂', emollient: '润肤剂', other: '其他',
}
async function fetchIngredients(search: string, category: string, page: number): Promise<PaginatedResponse> {
const params = new URLSearchParams({ page: String(page), limit: '20' })
if (search) params.set('search', search)
if (category) params.set('category', category)
return apiFetch(`/api/ingredients?${params}`)
}
async function createIngredient(data: Record<string, unknown>): Promise<{ data: Ingredient }> {
return apiFetch('/api/ingredients', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
}
async function updateIngredient(id: string, data: Record<string, unknown>): Promise<{ data: Ingredient }> {
return apiFetch(`/api/ingredients/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
}
async function deleteIngredient(id: string): Promise<void> {
await apiFetch(`/api/ingredients/${id}`, { method: 'DELETE' })
}
export default function IngredientsPage() {
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [category, setCategory] = useState('')
const [page, setPage] = useState(1)
const [selected, setSelected] = useState<Ingredient | null>(null)
const [dialogMode, setDialogMode] = useState<'create' | 'edit' | 'view' | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Ingredient | null>(null)
const [formError, setFormError] = useState('')
const queryClient = useQueryClient()
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => { setDebouncedSearch(search); setPage(1) }, 300)
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
}, [search])
const { data, isLoading } = useQuery({
queryKey: ['ingredients', debouncedSearch, category, page],
queryFn: () => fetchIngredients(debouncedSearch, category, page),
})
const createMut = useMutation({
mutationFn: createIngredient,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ingredients'] }); setDialogMode(null); setSelected(null) },
})
const updateMut = useMutation({
mutationFn: ({ id, ...data }: { id: string } & Record<string, unknown>) => updateIngredient(id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ingredients'] }); setDialogMode(null); setSelected(null) },
})
const deleteMut = useMutation({
mutationFn: deleteIngredient,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ingredients'] }); setDeleteTarget(null) },
})
const ingredients = data?.data ?? []
const pagination = data?.pagination
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setFormError('')
const form = e.currentTarget
const fd = new FormData(form)
const inciName = fd.get('inciName') as string
const chineseName = fd.get('chineseName') as string
const functionCategory = fd.get('functionCategory') as string
const unitPrice = fd.get('unitPrice') as string
if (!inciName || !chineseName || !functionCategory) {
setFormError('INCI名称、中文名和功能分类为必填项'); return
}
if (unitPrice && Number(unitPrice) < 0) {
setFormError('单价不能为负数'); return
}
const payload: Record<string, unknown> = {
inciName, chineseName, functionCategory,
supplier: (fd.get('supplier') as string) || null,
unit: (fd.get('unit') as string) || 'kg',
unitPrice: unitPrice ? Number(unitPrice) : null,
description: (fd.get('description') as string) || null,
}
try {
if (dialogMode === 'create') await createMut.mutateAsync(payload)
else if (dialogMode === 'edit' && selected) await updateMut.mutateAsync({ id: selected.id, ...payload })
} catch (err) { setFormError((err as Error).message) }
}
const handleDelete = async () => {
if (!deleteTarget) return
try { await deleteMut.mutateAsync(deleteTarget.id) }
catch (err) { alert((err as Error).message) }
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-2xl font-bold"></h2>
<button onClick={() => { setDialogMode('create'); setSelected(null); setFormError('') }}
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">
<Plus size={16} />
</button>
</div>
<div className="mb-4 flex gap-3">
<div className="relative w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input type="text" placeholder="搜索 INCI 或中文名..."
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 focus:ring-1 focus:ring-blue-500" />
</div>
<select value={category} onChange={(e) => { setCategory(e.target.value); setPage(1) }}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none">
<option value=""></option>
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
{isLoading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : ingredients.length === 0 ? (
<div className="py-12 text-center text-gray-400"></div>
) : (
<>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full text-left text-sm">
<thead className="border-b bg-gray-50 text-gray-600">
<tr>
<th className="px-4 py-3 font-medium">INCI </th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"> (/kg)</th>
<th className="px-4 py-3 font-medium"></th>
<th className="w-24 px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{ingredients.map((ing) => (
<tr key={ing.id} className="cursor-pointer hover:bg-gray-50"
onClick={() => { setSelected(ing); setDialogMode('view') }}>
<td className="px-4 py-3 font-medium">{ing.inciName}</td>
<td className="px-4 py-3">{ing.chineseName}</td>
<td className="px-4 py-3">
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
{CATEGORY_LABELS[ing.functionCategory] ?? ing.functionCategory}
</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" onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1">
<button onClick={() => { setSelected(ing); setDialogMode('edit'); setFormError('') }}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-blue-600">
<Pencil size={14} /></button>
<button onClick={() => setDeleteTarget(ing)}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-red-600">
<Trash2 size={14} /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-sm text-gray-500">
<span> {pagination.total} </span>
<div className="flex items-center gap-2">
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
className="rounded p-1 hover:bg-gray-100 disabled:opacity-30"><ChevronLeft size={16} /></button>
<span>{pagination.page} / {pagination.totalPages}</span>
<button onClick={() => setPage(p => Math.min(pagination.totalPages, p + 1))} disabled={page >= pagination.totalPages}
className="rounded p-1 hover:bg-gray-100 disabled:opacity-30"><ChevronRight size={16} /></button>
</div>
</div>
)}
</>
)}
<Dialog.Root open={dialogMode !== null} onOpenChange={(open) => { if (!open) { setDialogMode(null); setSelected(null) } }}>
<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-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl">
<Dialog.Title className="mb-4 text-lg font-bold">
{dialogMode === 'create' ? '新建成分' : dialogMode === 'edit' ? '编辑成分' : '成分详情'}
</Dialog.Title>
<Dialog.Close asChild>
<button className="absolute right-4 top-4 rounded p-1 text-gray-400 hover:text-gray-600"><X size={18} /></button>
</Dialog.Close>
{dialogMode === 'view' && selected ? (
<div className="space-y-3">
{[
['INCI 名称', selected.inciName], ['中文名', selected.chineseName],
['功能分类', CATEGORY_LABELS[selected.functionCategory] ?? selected.functionCategory],
['供应商', selected.supplier ?? '-'], ['单位', selected.unit],
['单价', 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>
) : (
<form onSubmit={handleSave} className="space-y-3">
{formError && <div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{formError}</div>}
<div className="grid grid-cols-2 gap-3">
<label className="block"><span className="text-sm text-gray-600">INCI *</span>
<input name="inciName" required defaultValue={selected?.inciName ?? ''}
className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></label>
<label className="block"><span className="text-sm text-gray-600"> *</span>
<input name="chineseName" required defaultValue={selected?.chineseName ?? ''}
className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></label>
</div>
<label className="block"><span className="text-sm text-gray-600"> *</span>
<select name="functionCategory" required defaultValue={selected?.functionCategory ?? ''}
className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none">
<option value=""></option>
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select></label>
<div className="grid grid-cols-3 gap-3">
<label className="block"><span className="text-sm text-gray-600"></span>
<input name="supplier" defaultValue={selected?.supplier ?? ''}
className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></label>
<label className="block"><span className="text-sm text-gray-600"></span>
<input name="unit" defaultValue={selected?.unit ?? 'kg'}
className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></label>
<label className="block"><span className="text-sm text-gray-600"> (/kg)</span>
<input name="unitPrice" type="number" step="0.01" min="0" defaultValue={selected?.unitPrice != null ? String(selected.unitPrice) : ''}
className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></label>
</div>
<label className="block"><span className="text-sm text-gray-600"></span>
<textarea name="description" rows={2} defaultValue={selected?.description ?? ''}
className="mt-0.5 w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></label>
<div className="flex justify-end gap-2 pt-2">
<Dialog.Close asChild>
<button type="button" className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50"></button>
</Dialog.Close>
<button type="submit" className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
disabled={createMut.isPending || updateMut.isPending}>
{createMut.isPending || updateMut.isPending ? '保存中...' : '保存'}
</button>
</div>
</form>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<AlertDialog.Root open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}>
<AlertDialog.Portal>
<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">
{deleteTarget?.chineseName}
</AlertDialog.Description>
<div className="flex justify-end gap-2">
<AlertDialog.Cancel asChild>
<button className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50"></button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<button onClick={handleDelete} className="rounded-lg bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700"
disabled={deleteMut.isPending}>{deleteMut.isPending ? '删除中...' : '删除'}</button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
import { FlaskConical } from 'lucide-react'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const login = useAuthStore(s => s.login)
const navigate = useNavigate()
const location = useLocation()
const from = (location.state as { from?: string })?.from ?? '/'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
navigate(from, { replace: true })
} catch (err) { setError((err as Error).message) }
finally { setLoading(false) }
}
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="mb-6 text-center">
<FlaskConical size={32} className="mx-auto mb-2 text-blue-600" />
<h1 className="text-xl font-bold"></h1>
<p className="text-sm text-gray-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" />
<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" />
<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">
{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>
</div>
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { apiFetch } from '@/lib/api'
interface Project {
id: string; name: string; description: string | null
_count: { formulas: number }; createdAt: string
}
async function fetchProjects(): Promise<Project[]> {
const res = await apiFetch<{ data: Project[] }>('/api/projects')
return res?.data ?? []
}
export default function ProjectsPage() {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const { data: projects = [] } = useQuery({ queryKey: ['projects'], queryFn: fetchProjects })
const createMut = useMutation({
mutationFn: () => fetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description: desc }) }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); setName(''); setDesc('') },
})
const deleteMut = useMutation({
mutationFn: (id: string) => fetch(`/api/projects/${id}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
})
return (
<div className="mx-auto max-w-4xl">
<h2 className="mb-6 text-2xl font-bold"></h2>
<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" />
<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">
<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>
</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>
)
}

View File

@@ -0,0 +1,50 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
export default function RegisterPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const register = useAuthStore(s => s.register)
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password !== confirm) { setError('两次密码不一致'); return }
if (password.length < 4) { setError('密码至少4位'); return }
setLoading(true)
try {
await register(username, password)
navigate('/', { replace: true })
} catch (err) { setError((err as Error).message) }
finally { setLoading(false) }
}
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">
<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" />
<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" />
<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" />
<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">
{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>
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { useSearchParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Search } from 'lucide-react'
import { apiFetch } from '@/lib/api'
interface SearchResult {
id: string; name: string; description: string | null; project: { name: string } | null
}
async function searchFormulas(q: string) {
return apiFetch<{ data: SearchResult[]; keywords: string[] }>(`/api/ai/search?q=${encodeURIComponent(q)}`)
}
export default function SearchPage() {
const [params] = useSearchParams()
const q = params.get('q') ?? ''
const { data, isLoading } = useQuery({
queryKey: ['search', q],
queryFn: () => searchFormulas(q),
enabled: q.length > 0,
})
const results = data?.data ?? []
const keywords = data?.keywords ?? []
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>}
{isLoading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : results.length === 0 ? (
<div className="flex flex-col items-center py-20 text-gray-400">
<Search size={48} className="mb-3" />
<p></p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{results.map((f) => (
<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>}
<div className="mt-2 text-xs text-gray-400">
{f.project && <span>{f.project.name}</span>}
</div>
</Link>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,183 @@
import { useState, useEffect } from 'react'
import { useThemeStore } from '@/stores/themeStore'
import { Sun, Moon, Key, Save, Eye, EyeOff, CheckCircle, XCircle, Loader2 } from 'lucide-react'
export default function SettingsPage() {
const { theme, setTheme } = useThemeStore()
const [openaiKey, setOpenaiKey] = useState('')
const [deepseekKey, setDeepseekKey] = useState('')
const [openaiBaseUrl, setOpenaiBaseUrl] = useState('')
const [deepseekBaseUrl, setDeepseekBaseUrl] = useState('')
const [showOpenAI, setShowOpenAI] = useState(false)
const [showDeepseek, setShowDeepseek] = useState(false)
const [apiMode, setApiMode] = useState(() => localStorage.getItem('ai-mock') !== 'false')
const [saved, setSaved] = useState(false)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState('')
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null)
useEffect(() => {
setOpenaiKey(localStorage.getItem('openai-key') ?? '')
setDeepseekKey(localStorage.getItem('deepseek-key') ?? '')
setOpenaiBaseUrl(localStorage.getItem('openai-base-url') ?? '')
setDeepseekBaseUrl(localStorage.getItem('deepseek-base-url') ?? '')
}, [])
const handleSave = async () => {
setSaving(true)
setTestResult(null)
try {
await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
openaiKey: openaiKey || undefined,
deepseekKey: deepseekKey || undefined,
openaiBaseUrl: openaiBaseUrl || undefined,
deepseekBaseUrl: deepseekBaseUrl || undefined,
aiMock: apiMode ? 'true' : 'false',
}),
})
localStorage.setItem('ai-mock', String(apiMode))
if (openaiKey) localStorage.setItem('openai-key', openaiKey)
if (deepseekKey) localStorage.setItem('deepseek-key', deepseekKey)
if (openaiBaseUrl) localStorage.setItem('openai-base-url', openaiBaseUrl)
if (deepseekBaseUrl) localStorage.setItem('deepseek-base-url', deepseekBaseUrl)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch { }
finally { setSaving(false) }
}
const handleTest = async (provider: string) => {
setTesting(provider)
setTestResult(null)
try {
const res = await fetch('/api/config/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
const json = await res.json() as { ok: boolean; model?: string; error?: string }
setTestResult({
ok: json.ok,
msg: json.ok ? `连接成功 — ${json.model ?? 'OK'}` : (json.error ?? '连接失败'),
})
} catch {
setTestResult({ ok: false, msg: '请求失败,请检查后端是否运行' })
}
finally { setTesting('') }
}
return (
<div className="mx-auto max-w-2xl">
<h2 className="mb-6 text-2xl font-bold"></h2>
<div className="space-y-6">
<div className="rounded-xl border bg-white p-5">
<h3 className="mb-3 font-semibold"></h3>
<div className="flex gap-2">
{([
{ value: 'light' as const, icon: Sun, label: '浅色' },
{ value: 'dark' as const, icon: Moon, label: '深色' },
]).map(({ value, icon: Icon, label }) => (
<button key={value} onClick={() => setTheme(value)}
className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-sm transition-colors ${theme === value ? 'border-blue-400 bg-blue-50 text-blue-700' : 'hover:bg-gray-50'}`}>
<Icon size={18} /> {label}
</button>
))}
</div>
</div>
<div className="rounded-xl border bg-white p-5">
<h3 className="mb-3 font-semibold">AI </h3>
<div className="mb-4">
<p className="mb-2 text-sm text-gray-500"></p>
<div className="flex gap-2">
{([
{ value: true, label: 'Mock 模拟(无需 Key' },
{ value: false, label: 'Real 真实(需配置 Key' },
]).map(({ value, label }) => (
<button key={String(value)} onClick={() => setApiMode(value)}
className={`rounded-lg border px-4 py-2 text-sm transition-colors ${apiMode === value ? 'border-blue-400 bg-blue-50 text-blue-700' : 'hover:bg-gray-50'}`}>
{label}
</button>
))}
</div>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm text-gray-600">OpenAI API Key</label>
<div className="flex gap-1">
<div className="relative flex-1">
<Key size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input type={showOpenAI ? 'text' : 'password'} value={openaiKey}
onChange={e => setOpenaiKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-lg border py-2 pl-9 pr-9 text-sm focus:border-blue-500 focus:outline-none" />
<button onClick={() => setShowOpenAI(!showOpenAI)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
{showOpenAI ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<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" />
</div>
</div>
<div>
<label className="mb-1 block text-sm text-gray-600">DeepSeek API Key</label>
<div className="flex gap-1">
<div className="relative flex-1">
<Key size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input type={showDeepseek ? 'text' : 'password'} value={deepseekKey}
onChange={e => setDeepseekKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-lg border py-2 pl-9 pr-9 text-sm focus:border-blue-500 focus:outline-none" />
<button onClick={() => setShowDeepseek(!showDeepseek)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
{showDeepseek ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<button onClick={() => handleTest('deepseek')} disabled={testing === 'deepseek' || !deepseekKey}
className="rounded-lg border px-3 py-2 text-xs hover:bg-gray-50 disabled:opacity-50"
title="测试连接">
{testing === 'deepseek' ? <Loader2 size={14} className="animate-spin" /> : '测试'}
</button>
</div>
<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" />
</div>
{testResult && (
<div className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm ${testResult.ok ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
{testResult.ok ? <CheckCircle size={16} /> : <XCircle size={16} />}
{testResult.msg}
</div>
)}
</div>
<button onClick={handleSave} disabled={saving}
className={`mt-4 inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors ${saved ? 'bg-green-600' : 'bg-blue-600 hover:bg-blue-700'} disabled:opacity-50`}>
<Save size={14} /> {saved ? '已保存 ✓' : saving ? '保存中...' : '保存配置'}
</button>
<p className="mt-2 text-xs text-gray-400">API Key </p>
</div>
<div className="rounded-xl border bg-white p-5">
<h3 className="mb-3 font-semibold"></h3>
<div className="space-y-1 text-sm text-gray-500">
<p> v0.1.0</p>
<p>AI </p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,179 @@
import { useParams, Link, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeft, ArrowUp, ArrowDown, Plus, Minus } from 'lucide-react'
import { useMemo } from 'react'
import { apiFetch } from '@/lib/api'
interface VersionOption {
versionNumber: number; id: string; description: string | null; createdAt: string
}
interface IngDiff {
inciName: string; chineseName: string; phaseName: string
oldPercentage: number | null; newPercentage: number | null; change: number
type: 'added' | 'removed' | 'modified' | 'unchanged'
}
async function fetchVersions(formulaId: string): Promise<VersionOption[]> {
const res = await apiFetch<{ data: VersionOption[] }>(`/api/formulas/${formulaId}/versions`)
return res?.data ?? []
}
async function fetchVersionDetail(formulaId: string, versionNumber: number) {
const res = await apiFetch<{ data: Array<Record<string, unknown>> }>(`/api/formulas/${formulaId}/versions`)
return res?.data?.find((v: Record<string, unknown>) => v.versionNumber === versionNumber)
}
function computeDiff(oldSnapshot: Record<string, unknown> | null, newSnapshot: Record<string, unknown> | null): IngDiff[] {
if (!oldSnapshot || !newSnapshot) return []
const oldPhases = (oldSnapshot.phases as Array<Record<string, unknown>>) ?? []
const newPhases = (newSnapshot.phases as Array<Record<string, unknown>>) ?? []
const oldMap = new Map<string, { inciName: string; chineseName: string; phaseName: string; percentage: number }>()
const newMap = new Map<string, { inciName: string; chineseName: string; phaseName: string; percentage: number }>()
for (const phase of oldPhases) {
for (const ing of (phase.ingredients as Array<Record<string, unknown>>) ?? []) {
oldMap.set(ing.ingredientId as string, {
inciName: ing.inciName as string ?? '', chineseName: ing.chineseName as string ?? '',
phaseName: phase.name as string, percentage: Number(ing.percentage),
})
}
}
for (const phase of newPhases) {
for (const ing of (phase.ingredients as Array<Record<string, unknown>>) ?? []) {
newMap.set(ing.ingredientId as string, {
inciName: ing.inciName as string ?? '', chineseName: ing.chineseName as string ?? '',
phaseName: phase.name as string, percentage: Number(ing.percentage),
})
}
}
const diffs: IngDiff[] = []
for (const [id, oldIng] of oldMap) {
const newIng = newMap.get(id)
if (!newIng) {
diffs.push({ inciName: oldIng.inciName, chineseName: oldIng.chineseName, phaseName: oldIng.phaseName, oldPercentage: oldIng.percentage, newPercentage: null, change: -oldIng.percentage, type: 'removed' })
} else {
const change = newIng.percentage - oldIng.percentage
diffs.push({ inciName: oldIng.inciName, chineseName: oldIng.chineseName, phaseName: oldIng.phaseName, oldPercentage: oldIng.percentage, newPercentage: newIng.percentage, change, type: Math.abs(change) < 0.01 ? 'unchanged' : 'modified' })
}
}
for (const [id, newIng] of newMap) {
if (!oldMap.has(id)) {
diffs.push({ inciName: newIng.inciName, chineseName: newIng.chineseName, phaseName: newIng.phaseName, oldPercentage: null, newPercentage: newIng.percentage, change: newIng.percentage, type: 'added' })
}
}
return diffs
}
export default function VersionComparePage() {
const { id } = useParams<{ id: string }>()
const [params, setParams] = useSearchParams()
const v1 = Number(params.get('v1')) || 1
const v2 = Number(params.get('v2')) || 1
const { data: versions = [] } = useQuery({
queryKey: ['versions', id], queryFn: () => fetchVersions(id!), enabled: !!id,
})
const { data: detail1 } = useQuery({
queryKey: ['version', id, v1], queryFn: () => fetchVersionDetail(id!, v1), enabled: !!id,
})
const { data: detail2 } = useQuery({
queryKey: ['version', id, v2], queryFn: () => fetchVersionDetail(id!, v2), enabled: !!id,
})
const diffs = useMemo(() => {
if (!detail1 || !detail2) return []
return computeDiff(
(detail1 as Record<string, unknown>).snapshotData as Record<string, unknown>,
(detail2 as Record<string, unknown>).snapshotData as Record<string, unknown>,
)
}, [detail1, detail2])
const added = diffs.filter(d => d.type === 'added').length
const removed = diffs.filter(d => d.type === 'removed').length
const modified = diffs.filter(d => d.type === 'modified').length
return (
<div className="mx-auto max-w-3xl">
<div className="mb-6 flex items-center gap-3">
<Link to={`/formulas/${id}/history`} className="rounded p-1 text-gray-400 hover:text-gray-600">
<ArrowLeft size={18} /></Link>
<h2 className="text-2xl font-bold"></h2>
</div>
<div className="mb-4 flex items-center gap-3">
<select value={v1} onChange={(e) => setParams({ v1: e.target.value, v2: String(v2) })}
className="rounded-lg border px-3 py-2 text-sm">
{versions.map(v => <option key={v.id} value={v.versionNumber}>v{v.versionNumber} {v.description ? `- ${v.description}` : ''}</option>)}
</select>
<span className="text-gray-400">vs</span>
<select value={v2} onChange={(e) => setParams({ v1: String(v1), v2: e.target.value })}
className="rounded-lg border px-3 py-2 text-sm">
{versions.map(v => <option key={v.id} value={v.versionNumber}>v{v.versionNumber} {v.description ? `- ${v.description}` : ''}</option>)}
</select>
</div>
{diffs.length === 0 ? (
<div className="py-12 text-center text-gray-400"></div>
) : (
<>
<div className="mb-4 flex gap-4 text-sm">
{added > 0 && <span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-3 py-1 text-green-700"><Plus size={12} /> {added} </span>}
{removed > 0 && <span className="inline-flex items-center gap-1 rounded-full bg-red-50 px-3 py-1 text-red-700"><Minus size={12} /> {removed} </span>}
{modified > 0 && <span className="inline-flex items-center gap-1 rounded-full bg-yellow-50 px-3 py-1 text-yellow-700"> {modified} </span>}
</div>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-left text-sm">
<thead className="border-b bg-gray-50 text-gray-600">
<tr>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium text-right">v{v1}</th>
<th className="px-4 py-2 font-medium text-right">v{v2}</th>
<th className="px-4 py-2 font-medium text-right"></th>
</tr>
</thead>
<tbody className="divide-y">
{diffs.map((d, i) => (
<tr key={i} className={
d.type === 'added' ? 'bg-green-50' : d.type === 'removed' ? 'bg-red-50' : ''
}>
<td className="px-4 py-2">
<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-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'}`}>
{d.type === 'removed' ? (
<span className="text-red-500"></span>
) : d.type === 'added' ? (
<span className="text-green-500"></span>
) : Math.abs(d.change) < 0.01 ? (
'-'
) : (
<span className="inline-flex items-center gap-0.5">
{d.change > 0 ? <ArrowUp size={12} /> : <ArrowDown size={12} />}
{Math.abs(d.change).toFixed(2)}%
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { useParams, Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { ChevronDown, GitCompare, ArrowLeft } from 'lucide-react'
import { useState } from 'react'
interface VersionData {
id: string
versionNumber: number
description: string | null
createdAt: string
createdBy: string
phases: Array<{
id: string; name: string; sortOrder: number
ingredients: Array<{ ingredient: { inciName: string; chineseName: string }; percentage: number }>
}>
}
async function fetchVersions(formulaId: string): Promise<VersionData[]> {
const res = await apiFetch<{ data: VersionData[] }>(`/api/formulas/${formulaId}/versions`)
return res?.data ?? []
}
export default function VersionHistoryPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [expanded, setExpanded] = useState<number | null>(null)
const { data: versions = [], isLoading } = useQuery({
queryKey: ['versions', id],
queryFn: () => fetchVersions(id!),
enabled: !!id,
})
return (
<div className="mx-auto max-w-2xl">
<div className="mb-6 flex items-center gap-3">
<Link to={`/formulas/${id}`} className="rounded p-1 text-gray-400 hover:text-gray-600">
<ArrowLeft size={18} /></Link>
<h2 className="text-2xl font-bold"></h2>
{versions.length > 1 && (
<button onClick={() => navigate(`/formulas/${id}/compare?v1=${versions[versions.length - 1]?.versionNumber}&v2=${versions[0]?.versionNumber}`)}
className="ml-auto inline-flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm hover:bg-gray-50">
<GitCompare size={14} />
</button>
)}
</div>
{isLoading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : versions.length === 0 ? (
<div className="py-12 text-center text-gray-400"></div>
) : (
<div className="relative pl-8">
<div className="absolute left-3 top-2 bottom-2 w-0.5 bg-gray-200" />
{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'}`} />
<button onClick={() => setExpanded(expanded === i ? null : i)}
className="w-full text-left">
<div className="flex items-center gap-2">
<span className={`text-sm font-bold ${i === 0 ? 'text-blue-600' : 'text-gray-700'}`}>
v{v.versionNumber}
{i === 0 && <span className="ml-1 rounded bg-blue-50 px-1.5 py-0.5 text-xs font-normal text-blue-600"></span>}
</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>}
<ChevronDown size={14} className={`absolute right-0 top-1.5 text-gray-300 transition-transform ${expanded === i ? 'rotate-180' : ''}`} />
</button>
{expanded === i && (
<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>
<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">
<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>
))}
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}

44
frontend/src/router.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { createBrowserRouter } from 'react-router-dom'
import App from './App'
import AuthGuard from './components/AuthGuard'
import DashboardPage from './pages/DashboardPage'
import FormulaListPage from './pages/FormulaListPage'
import FormulaDetailPage from './pages/FormulaDetailPage'
import FormulaEditorPage from './pages/FormulaEditorPage'
import VersionHistoryPage from './pages/VersionHistoryPage'
import VersionComparePage from './pages/VersionComparePage'
import ColorLabPage from './pages/ColorLabPage'
import FormulaExplorerPage from './pages/FormulaExplorerPage'
import IngredientsPage from './pages/IngredientsPage'
import ProjectsPage from './pages/ProjectsPage'
import SettingsPage from './pages/SettingsPage'
import SearchPage from './pages/SearchPage'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
export const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{
element: <AuthGuard />,
children: [{
path: '/',
element: <App />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'formulas/new', element: <FormulaEditorPage /> },
{ path: 'formulas/:id/edit', element: <FormulaEditorPage /> },
{ path: 'formulas/:id/history', element: <VersionHistoryPage /> },
{ path: 'formulas/:id/compare', element: <VersionComparePage /> },
{ path: 'formulas/:id', element: <FormulaDetailPage /> },
{ path: 'formulas', element: <FormulaListPage /> },
{ path: 'color-lab', element: <ColorLabPage /> },
{ path: 'formula-explorer', element: <FormulaExplorerPage /> },
{ path: 'ingredients', element: <IngredientsPage /> },
{ path: 'projects', element: <ProjectsPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'search', element: <SearchPage /> },
],
}],
},
])

View File

@@ -0,0 +1,58 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface User {
id: string; username: string; role: string
}
interface AuthState {
user: User | null
token: string | null
login: (username: string, password: string) => Promise<void>
register: (username: string, password: string) => Promise<void>
logout: () => void
fetchMe: () => Promise<void>
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
login: async (username, password) => {
const res = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) { const err = await res.json(); throw new Error(err.error as string) }
const { data } = await res.json() as { data: { id: string; username: string; role: string; token: string } }
set({ user: { id: data.id, username: data.username, role: data.role }, token: data.token })
},
register: async (username, password) => {
const res = await fetch('/api/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) { const err = await res.json(); throw new Error(err.error as string) }
const { data } = await res.json() as { data: { id: string; username: string; role: string; token: string } }
set({ user: { id: data.id, username: data.username, role: data.role }, token: data.token })
},
logout: () => set({ user: null, token: null }),
fetchMe: async () => {
const token = get().token
if (!token) return
try {
const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) { set({ user: null, token: null }); return }
const { data } = await res.json() as { data: User }
set({ user: data })
} catch { set({ user: null, token: null }) }
},
}),
{ name: 'auth-storage' }
)
)

View File

@@ -0,0 +1,26 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type Theme = 'light' | 'dark'
interface ThemeState {
theme: Theme
setTheme: (theme: Theme) => void
toggleTheme: () => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
}),
{
name: 'theme-preference',
},
),
)

View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Strict */
"strict": true,
"noUncheckedIndexedAccess": true,
"ignoreDeprecations": "6.0",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

23
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})

13
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
test: {
include: ['src/**/*.test.{ts,tsx}'],
},
})