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:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
frontend/eslint.config.js
Normal file
22
frontend/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
frontend/package.json
Normal file
59
frontend/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"clsx": "^2.1.1",
|
||||
"colorjs.io": "^0.6.1",
|
||||
"d3": "^7.9.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
3846
frontend/pnpm-lock.yaml
generated
Normal file
3846
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
6
frontend/src/App.tsx
Normal file
6
frontend/src/App.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import AppLayout from '@/layouts/AppLayout'
|
||||
|
||||
export default function App() {
|
||||
return <AppLayout />
|
||||
}
|
||||
|
||||
8
frontend/src/components/AuthGuard.tsx
Normal file
8
frontend/src/components/AuthGuard.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
export default function AuthGuard() {
|
||||
const token = useAuthStore(s => s.token)
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
150
frontend/src/components/ColorRecommendPanel.tsx
Normal file
150
frontend/src/components/ColorRecommendPanel.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { Sparkles, X, Save } from 'lucide-react'
|
||||
import type { LABColor } from '@/lib/color/types'
|
||||
import { labToHex } from '@/lib/color/convert'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface ColorRecommendPanelProps {
|
||||
currentLab: LABColor
|
||||
targetLab: LABColor | null
|
||||
}
|
||||
|
||||
export default function ColorRecommendPanel({ currentLab, targetLab }: ColorRecommendPanelProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [recommendations, setRecommendations] = useState<Array<Record<string, unknown>>>([])
|
||||
const [matchedFormulas, setMatchedFormulas] = useState<Array<Record<string, unknown>>>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchRecommend = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const lab = targetLab ?? currentLab
|
||||
const json = await apiFetch<{ recommendations: Array<Record<string, unknown>>; matchedFormulas: Array<Record<string, unknown>> }>(
|
||||
'/api/color/recommend',
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetLab: lab }) },
|
||||
)
|
||||
setRecommendations(json?.recommendations ?? [])
|
||||
setMatchedFormulas(json?.matchedFormulas ?? [])
|
||||
} catch { }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (selectedIndex === null) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const target = targetLab ?? currentLab
|
||||
await apiFetch('/api/color/recommend', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetLab: target }) })
|
||||
setOpen(false)
|
||||
} catch { }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const selectedColor = selectedIndex !== null ? (recommendations[selectedIndex]?.predictedLab as { L: number; a: number; b: number }) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => { setOpen(true); fetchRecommend() }}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700">
|
||||
<Sparkles size={14} /> AI 推荐配色
|
||||
</button>
|
||||
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl">
|
||||
<Dialog.Title className="mb-4 text-lg font-bold">AI 配色推荐</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<button className="absolute right-4 top-4 rounded p-1 text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
||||
</Dialog.Close>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3 py-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse flex items-center gap-4 rounded-lg border p-3">
|
||||
<div className="h-10 w-10 rounded bg-gray-200" />
|
||||
<div className="flex-1"><div className="mb-1 h-4 w-1/3 rounded bg-gray-200" /><div className="h-3 w-2/3 rounded bg-gray-100" /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 text-xs text-gray-500">
|
||||
目标色:{targetLab ? `Lab(${targetLab.L.toFixed(0)},${targetLab.a.toFixed(0)},${targetLab.b.toFixed(0)})` : '当前色'}
|
||||
</div>
|
||||
|
||||
{recommendations.length > 0 ? (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{recommendations.map((rec, i) => {
|
||||
const colorants = rec.colorants as Array<{ name: string; ratio: number }> | undefined
|
||||
const predictedDeltaE = rec.predictedDeltaE as number | undefined
|
||||
const predictedLab = rec.predictedLab as { L: number; a: number; b: number } | undefined
|
||||
const hex = predictedLab ? (() => { try { return labToHex(predictedLab.L, predictedLab.a, predictedLab.b) } catch { return '#ccc' } })() : '#ccc'
|
||||
|
||||
return (
|
||||
<button key={i} onClick={() => setSelectedIndex(i)}
|
||||
className={`flex w-full items-center gap-4 rounded-lg border p-3 text-left transition-colors ${selectedIndex === i ? 'border-purple-400 bg-purple-50' : 'hover:bg-gray-50'}`}>
|
||||
<div className="h-10 w-10 flex-shrink-0 rounded border" style={{ backgroundColor: hex }} />
|
||||
<div className="flex-1 text-sm">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{colorants?.map((c, j) => (
|
||||
<span key={j} className="rounded bg-gray-100 px-1.5 py-0.5 text-xs">{c.name} {(c.ratio * 100).toFixed(0)}%</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs">
|
||||
{predictedDeltaE !== undefined && (
|
||||
<span className={predictedDeltaE <= 1 ? 'text-green-600' : predictedDeltaE <= 3 ? 'text-yellow-600' : 'text-red-600'}>
|
||||
ΔE {predictedDeltaE.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : matchedFormulas.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500">AI 未返回推荐,以下是历史匹配配方:</p>
|
||||
{matchedFormulas.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border p-2 text-sm">
|
||||
<span>{f.name as string}</span>
|
||||
<span className="text-xs text-gray-400">ΔE {(f.deltaE as number)?.toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-gray-400">暂无推荐结果</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-lg border p-3">
|
||||
<p className="mb-2 text-xs font-medium text-gray-500">皮肤预览</p>
|
||||
<div className="h-20 rounded-lg"
|
||||
style={{
|
||||
background: selectedColor
|
||||
? `linear-gradient(135deg, #f5d0c0, #e8b8a0), ${labToHex(selectedColor.L, selectedColor.a, selectedColor.b)}`
|
||||
: 'linear-gradient(135deg, #f5d0c0, #e8b8a0)',
|
||||
backgroundBlendMode: 'multiply',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Dialog.Close asChild>
|
||||
<button className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50">关闭</button>
|
||||
</Dialog.Close>
|
||||
<button onClick={handleSave} disabled={selectedIndex === null || saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
<Save size={14} /> {saving ? '保存中...' : '保存为颜色配方'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</>
|
||||
)
|
||||
}
|
||||
147
frontend/src/components/ColorWheel.tsx
Normal file
147
frontend/src/components/ColorWheel.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { rgbToLab, labToRGB } from '@/lib/color/convert'
|
||||
|
||||
interface ColorWheelProps {
|
||||
size?: number
|
||||
onColorChange?: (lab: { L: number; a: number; b: number }) => void
|
||||
selectedLab?: { L: number; a: number; b: number } | null
|
||||
}
|
||||
|
||||
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
||||
const i = Math.floor(h * 6)
|
||||
const f = h * 6 - i
|
||||
const p = v * (1 - s)
|
||||
const q = v * (1 - f * s)
|
||||
const t = v * (1 - (1 - f) * s)
|
||||
switch (i % 6) {
|
||||
case 0: return [v, t, p]
|
||||
case 1: return [q, v, p]
|
||||
case 2: return [p, v, t]
|
||||
case 3: return [p, q, v]
|
||||
case 4: return [t, p, v]
|
||||
case 5: return [v, p, q]
|
||||
}
|
||||
return [0, 0, 0]
|
||||
}
|
||||
|
||||
export default function ColorWheel({ size = 400, onColorChange, selectedLab }: ColorWheelProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const selectPosRef = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
const render = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
let ctx = canvas.getContext('2d', { colorSpace: 'display-p3' })
|
||||
if (!ctx) ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = size * dpr
|
||||
canvas.height = size * dpr
|
||||
canvas.style.width = `${size}px`
|
||||
canvas.style.height = `${size}px`
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
const outerR = size / 2 - 4
|
||||
const innerR = outerR * 0.25
|
||||
const imageData = ctx.createImageData(size, size)
|
||||
|
||||
for (let py = 0; py < size; py++) {
|
||||
for (let px = 0; px < size; px++) {
|
||||
const dx = px - cx
|
||||
const dy = py - cy
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const idx = (py * size + px) * 4
|
||||
|
||||
if (dist >= innerR && dist <= outerR) {
|
||||
const angle = (Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI)
|
||||
const sat = (dist - innerR) / (outerR - innerR)
|
||||
const [r, g, b] = hsvToRgb(angle, sat, 1)
|
||||
imageData.data[idx] = Math.round(r * 255)
|
||||
imageData.data[idx + 1] = Math.round(g * 255)
|
||||
imageData.data[idx + 2] = Math.round(b * 255)
|
||||
imageData.data[idx + 3] = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
|
||||
const pos = selectPosRef.current
|
||||
if (pos) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(pos.x, pos.y, 6, 0, Math.PI * 2)
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.9)'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = '#374151'
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
}
|
||||
}, [size])
|
||||
|
||||
useEffect(() => { render() }, [render])
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
const dx = x - cx
|
||||
const dy = y - cy
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const outerR = size / 2 - 4
|
||||
const innerR = outerR * 0.25
|
||||
|
||||
if (dist < innerR || dist > outerR) return
|
||||
|
||||
const angle = (Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI)
|
||||
const sat = (dist - innerR) / (outerR - innerR)
|
||||
const [r, g, b] = hsvToRgb(angle, sat, 1)
|
||||
const lab = rgbToLab(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
|
||||
|
||||
selectPosRef.current = { x, y }
|
||||
render()
|
||||
onColorChange?.(lab)
|
||||
}, [size, onColorChange, render])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedLab) {
|
||||
const rgb = labToRGB(selectedLab.L, selectedLab.a, selectedLab.b)
|
||||
const hsv = (() => {
|
||||
const r = rgb.r / 255, g = rgb.g / 255, b = rgb.b / 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
const d = max - min
|
||||
let h = 0
|
||||
if (d === 0) h = 0
|
||||
else if (max === r) h = ((g - b) / d) % 6
|
||||
else if (max === g) h = (b - r) / d + 2
|
||||
else h = (r - g) / d + 4
|
||||
h = h / 6
|
||||
if (h < 0) h += 1
|
||||
const s = max === 0 ? 0 : d / max
|
||||
return { h, s }
|
||||
})()
|
||||
|
||||
const outerR = size / 2 - 4
|
||||
const innerR = outerR * 0.25
|
||||
const dist = innerR + hsv.s * (outerR - innerR)
|
||||
const ang = hsv.h * 2 * Math.PI - Math.PI
|
||||
selectPosRef.current = {
|
||||
x: size / 2 + Math.cos(ang) * dist,
|
||||
y: size / 2 + Math.sin(ang) * dist,
|
||||
}
|
||||
}
|
||||
render()
|
||||
}, [selectedLab, size, render])
|
||||
|
||||
return (
|
||||
<canvas ref={canvasRef} onClick={handleClick}
|
||||
className="cursor-crosshair rounded-full" />
|
||||
)
|
||||
}
|
||||
36
frontend/src/components/ErrorBoundary.tsx
Normal file
36
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface Props { children: ReactNode }
|
||||
interface State { hasError: boolean; error: Error | null }
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
|
||||
<h1 className="mb-2 text-2xl font-bold text-red-600">出错了</h1>
|
||||
<p className="mb-4 text-sm text-gray-500">{this.state.error?.message ?? '发生了意外错误'}</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={() => { this.setState({ hasError: false, error: null }); window.location.reload() }}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700">
|
||||
刷新页面
|
||||
</button>
|
||||
<Link to="/" className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50">
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
169
frontend/src/components/EyedropperPanel.tsx
Normal file
169
frontend/src/components/EyedropperPanel.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { rgbToLab } from '@/lib/color/convert'
|
||||
import type { LABColor } from '@/lib/color/types'
|
||||
import { Pipette, Upload } from 'lucide-react'
|
||||
|
||||
interface EyedropperPanelProps {
|
||||
onColorPick: (lab: LABColor) => void
|
||||
}
|
||||
|
||||
function avgColor(data: Uint8ClampedArray): [number, number, number] {
|
||||
return [Math.round(data[0]!), Math.round(data[1]!), Math.round(data[2]!)]
|
||||
}
|
||||
|
||||
export default function EyedropperPanel({ onColorPick }: EyedropperPanelProps) {
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null)
|
||||
const [scale, setScale] = useState(1)
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [history, setHistory] = useState<LABColor[]>(() => {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem('eyedropper-history') ?? '[]') as unknown[]
|
||||
return raw.filter((h): h is LABColor =>
|
||||
typeof h === 'object' && h !== null &&
|
||||
typeof (h as LABColor).L === 'number' &&
|
||||
typeof (h as LABColor).a === 'number' &&
|
||||
typeof (h as LABColor).b === 'number'
|
||||
)
|
||||
}
|
||||
catch { return [] }
|
||||
})
|
||||
|
||||
const renderImage = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !image) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
ctx.drawImage(image, 0, 0)
|
||||
}, [image])
|
||||
|
||||
useEffect(() => { if (image) renderImage() }, [image, renderImage])
|
||||
|
||||
const addHistory = (lab: LABColor) => {
|
||||
const updated = [lab, ...history.filter(h => h.L !== lab.L || h.a !== lab.a || h.b !== lab.b)].slice(0, 10)
|
||||
setHistory(updated)
|
||||
localStorage.setItem('eyedropper-history', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
const handleFileUpload = (file: File) => {
|
||||
if (!file.type.startsWith('image/')) return
|
||||
const img = new Image()
|
||||
img.onload = () => { setImage(img); setScale(1); setOffset({ x: 0, y: 0 }) }
|
||||
img.src = URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = (e.clientX - rect.left) / scale
|
||||
const y = (e.clientY - rect.top) / scale
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const w = Math.min(3, canvas.width - Math.round(x))
|
||||
const h = Math.min(3, canvas.height - Math.round(y))
|
||||
const imgData = ctx.getImageData(Math.round(x) - 1, Math.round(y) - 1, w, h)
|
||||
let r = 0, g = 0, b = 0, count = 0
|
||||
for (let i = 0; i < imgData.data.length; i += 4) {
|
||||
r += imgData.data[i]!; g += imgData.data[i + 1]!; b += imgData.data[i + 2]!; count++
|
||||
}
|
||||
const [avgR, avgG, avgB] = avgColor(new Uint8ClampedArray([Math.round(r / count), Math.round(g / count), Math.round(b / count)]))
|
||||
|
||||
try {
|
||||
const lab = rgbToLab(avgR, avgG, avgB)
|
||||
onColorPick(lab)
|
||||
addHistory(lab)
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const handleEyedropper = async () => {
|
||||
try {
|
||||
const dropper = new (window as unknown as { EyeDropper: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper()
|
||||
const result = await dropper.open()
|
||||
const hex = result.sRGBHex
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
const lab = rgbToLab(r, g, b)
|
||||
onColorPick(lab)
|
||||
addHistory(lab)
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault()
|
||||
setScale(s => Math.min(3, Math.max(0.5, s - e.deltaY * 0.001)))
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (scale <= 1) return
|
||||
setDragging(true)
|
||||
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y })
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!dragging) return
|
||||
setOffset({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y })
|
||||
}
|
||||
|
||||
const handleMouseUp = () => setDragging(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm hover:bg-gray-50">
|
||||
<Upload size={14} /> 上传图片
|
||||
<input type="file" accept="image/*" className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileUpload(f) }} />
|
||||
</label>
|
||||
<button onClick={handleEyedropper}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-purple-300 px-3 py-1.5 text-sm text-purple-600 hover:bg-purple-50">
|
||||
<Pipette size={14} /> 取色棒
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{image && (
|
||||
<div className="overflow-hidden rounded-lg border" style={{ maxHeight: scale > 1 ? '400px' : 'auto' }}>
|
||||
<canvas ref={canvasRef}
|
||||
onClick={handleCanvasClick}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{
|
||||
transform: `scale(${scale}) translate(${offset.x / scale}px, ${offset.y / scale}px)`,
|
||||
transformOrigin: 'top left',
|
||||
cursor: 'crosshair',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
className="block" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-400">取色历史:</span>
|
||||
{history.map((lab, i) => {
|
||||
if (typeof lab.L !== 'number' || typeof lab.a !== 'number' || typeof lab.b !== 'number') return null
|
||||
const r = Math.min(255, Math.max(0, Math.round(lab.L * 2.55)))
|
||||
const g = Math.min(255, Math.max(0, Math.round((lab.a + 128) * 0.8)))
|
||||
const b = Math.min(255, Math.max(0, Math.round((lab.b + 128) * 0.8)))
|
||||
const color = `rgb(${Math.min(255, r)},${Math.min(255, g)},${Math.min(255, b)})`
|
||||
return (
|
||||
<button key={i} onClick={() => onColorPick(lab)}
|
||||
className="h-5 w-5 rounded border border-gray-300" style={{ backgroundColor: color }}
|
||||
title={`Lab(${lab.L.toFixed(0)},${lab.a.toFixed(0)},${lab.b.toFixed(0)})`} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
304
frontend/src/components/FormulaVisualEditor.tsx
Normal file
304
frontend/src/components/FormulaVisualEditor.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { Minus, Plus, Save, Sparkles, Loader2 } from 'lucide-react'
|
||||
import { useAIPredict } from '@/hooks/useAIPredict'
|
||||
|
||||
const COLORS = ['#0ea5e9','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899','#06b6d4','#84cc16','#f97316','#6366f1','#14b8a6','#e11d48']
|
||||
|
||||
interface IngredientData {
|
||||
ingredientId: string
|
||||
inciName: string
|
||||
chineseName: string
|
||||
percentage: number
|
||||
ingredient?: { inciName: string; chineseName: string }
|
||||
}
|
||||
|
||||
interface PhaseData {
|
||||
name: string
|
||||
ingredients: IngredientData[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
phases: PhaseData[]
|
||||
onSave: (phases: Array<{ name: string; ingredients: Array<{ ingredientId: string; percentage: number }> }>) => Promise<void>
|
||||
}
|
||||
|
||||
export default function FormulaVisualEditor({ phases: initialPhases, onSave }: Props) {
|
||||
const [phases, setPhases] = useState<PhaseData[]>(() =>
|
||||
initialPhases.map(p => ({
|
||||
...p,
|
||||
ingredients: p.ingredients.map(i => ({
|
||||
...i,
|
||||
percentage: Number(i.percentage),
|
||||
ingredient: i.ingredient,
|
||||
})),
|
||||
}))
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const allIngredients = useMemo(() =>
|
||||
phases.flatMap(p => p.ingredients.map(i => ({
|
||||
...i,
|
||||
phaseName: p.name,
|
||||
inciName: i.ingredient?.inciName ?? i.inciName,
|
||||
chineseName: i.ingredient?.chineseName ?? i.chineseName,
|
||||
})))
|
||||
, [phases])
|
||||
|
||||
const total = useMemo(() => allIngredients.reduce((s, i) => s + i.percentage, 0), [allIngredients])
|
||||
const isValid = total >= 99.5 && total <= 100.5
|
||||
|
||||
const adjustPct = (idx: number, delta: number) => {
|
||||
setPhases(prev => {
|
||||
const flat: { phaseIdx: number; ingIdx: number; ing: IngredientData }[] = []
|
||||
prev.forEach((p, pi) => p.ingredients.forEach((ing, ii) => flat.push({ phaseIdx: pi, ingIdx: ii, ing })))
|
||||
if (idx >= flat.length) return prev
|
||||
|
||||
const next = prev.map(p => ({ ...p, ingredients: p.ingredients.map(i => ({ ...i })) }))
|
||||
const target = flat[idx]!
|
||||
const current = next[target.phaseIdx]!.ingredients[target.ingIdx]!
|
||||
const newPct = Math.max(0.01, Math.min(100, current.percentage + delta))
|
||||
current.percentage = Math.round(newPct * 100) / 100
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setPct = (idx: number, value: number) => {
|
||||
setPhases(prev => {
|
||||
const flat: { phaseIdx: number; ingIdx: number }[] = []
|
||||
prev.forEach((p, pi) => p.ingredients.forEach((_, ii) => flat.push({ phaseIdx: pi, ingIdx: ii })))
|
||||
if (idx >= flat.length) return prev
|
||||
|
||||
const next = prev.map(p => ({ ...p, ingredients: p.ingredients.map(i => ({ ...i })) }))
|
||||
const target = flat[idx]!
|
||||
next[target.phaseIdx]!.ingredients[target.ingIdx]!.percentage = Math.round(value * 100) / 100
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const normalize = () => {
|
||||
if (total <= 0) return
|
||||
setPhases(prev =>
|
||||
prev.map(p => ({
|
||||
...p,
|
||||
ingredients: p.ingredients.map(i => ({
|
||||
...i,
|
||||
percentage: Math.round((i.percentage / total) * 100 * 100) / 100,
|
||||
})),
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const chartOption = useMemo(() => ({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c}%' },
|
||||
legend: { bottom: 0, textStyle: { fontSize: 11 } },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, formatter: '{b}\n{c}%', fontSize: 11 },
|
||||
data: allIngredients.map((ing, i) => ({
|
||||
name: ing.inciName.length > 12 ? ing.inciName.slice(0, 12) + '…' : ing.inciName,
|
||||
value: ing.percentage,
|
||||
itemStyle: { color: COLORS[i % COLORS.length] },
|
||||
})),
|
||||
}],
|
||||
}), [allIngredients])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = phases.map(p => ({
|
||||
name: p.name,
|
||||
ingredients: p.ingredients.map(i => ({
|
||||
ingredientId: i.ingredientId,
|
||||
percentage: i.percentage,
|
||||
})),
|
||||
}))
|
||||
await onSave(payload)
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const { prediction, loading: predicting, predict } = useAIPredict()
|
||||
|
||||
const handlePredict = () => {
|
||||
predict(allIngredients.map(i => ({
|
||||
name: i.inciName,
|
||||
percentage: i.percentage,
|
||||
category: '',
|
||||
})))
|
||||
}
|
||||
|
||||
const hasPrediction = prediction !== null
|
||||
|
||||
return (
|
||||
<div ref={editorRef} className="flex flex-col gap-6 lg:flex-row">
|
||||
<div className="flex-shrink-0 lg:w-[380px]">
|
||||
<ReactECharts option={chartOption} style={{ height: 380 }} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className={`mb-4 rounded-lg border p-3 ${isValid ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600">总比例</span>
|
||||
<span className={`text-xl font-bold ${isValid ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{total.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
{!isValid && total > 0 && (
|
||||
<button onClick={normalize}
|
||||
className="rounded-lg bg-white px-3 py-1 text-xs font-medium text-blue-600 shadow-sm hover:bg-blue-50">
|
||||
自动归一化
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isValid && (
|
||||
<div className="mt-1 h-1.5 w-full rounded-full bg-gray-200">
|
||||
<div className="h-full rounded-full bg-green-500 transition-all" style={{ width: `${total}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<button onClick={handlePredict} disabled={predicting}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-purple-600 px-3 py-1.5 text-sm text-white hover:bg-purple-700 disabled:opacity-50">
|
||||
{predicting ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
|
||||
{predicting ? '预测中...' : 'AI 预测指标'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasPrediction && (
|
||||
<div className="mb-4 grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-center">
|
||||
<div className="text-xs text-blue-600">肤感指数</div>
|
||||
<div className="text-xl font-bold text-blue-700">{prediction!.sensoryIndex.overall}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-green-50 p-3 text-center">
|
||||
<div className="text-xs text-green-600">稳定性</div>
|
||||
<div className="text-xl font-bold text-green-700">{prediction!.stabilityScore}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-amber-50 p-3 text-center">
|
||||
<div className="text-xs text-amber-600">成本估算</div>
|
||||
<div className="text-xl font-bold text-amber-700">¥{prediction!.costEstimate}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPrediction && (
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-500">肤感子维度</h4>
|
||||
<ReactECharts
|
||||
option={{
|
||||
radar: {
|
||||
indicator: [
|
||||
{ name: '铺展性', max: 100 },
|
||||
{ name: '吸收速度', max: 100 },
|
||||
{ name: '黏腻度', max: 100 },
|
||||
{ name: '综合', max: 100 },
|
||||
],
|
||||
shape: 'circle',
|
||||
center: ['50%', '55%'],
|
||||
radius: '70%',
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
data: [{
|
||||
value: [
|
||||
prediction!.sensoryIndex.spreadability,
|
||||
prediction!.sensoryIndex.absorption,
|
||||
prediction!.sensoryIndex.stickiness,
|
||||
prediction!.sensoryIndex.overall,
|
||||
],
|
||||
name: '当前配方',
|
||||
areaStyle: { color: 'rgba(59,130,246,0.2)' },
|
||||
lineStyle: { color: '#3b82f6' },
|
||||
itemStyle: { color: '#3b82f6' },
|
||||
}],
|
||||
}],
|
||||
}}
|
||||
style={{ height: 220 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500">稳定性评分</h4>
|
||||
<ReactECharts
|
||||
option={{
|
||||
series: [{
|
||||
type: 'gauge',
|
||||
startAngle: 210, endAngle: -30,
|
||||
min: 0, max: 100,
|
||||
progress: { show: true, width: 12, itemStyle: { color: '#10b981' } },
|
||||
axisLine: { lineStyle: { width: 12, color: [[0.6, '#ef4444'], [0.8, '#f59e0b'], [1, '#10b981']] } },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
detail: { valueAnimation: true, fontSize: 20, offsetCenter: [0, '60%'] },
|
||||
data: [{ value: prediction!.stabilityScore }],
|
||||
}],
|
||||
}}
|
||||
style={{ height: 140 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500">配方结构</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allIngredients.map((ing, i) => (
|
||||
<div key={i} className="rounded px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: COLORS[i % COLORS.length] + '20',
|
||||
color: COLORS[i % COLORS.length],
|
||||
fontSize: `${Math.max(10, 10 + ing.percentage / 5)}px`,
|
||||
}}>
|
||||
{ing.inciName} {ing.percentage.toFixed(1)}%
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto">
|
||||
{phases.map((phase, pi) => (
|
||||
<div key={pi}>
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-500">{phase.name}</h4>
|
||||
<div className="space-y-1.5">
|
||||
{phase.ingredients.map((ing, ii) => {
|
||||
const flatIdx = phases.slice(0, pi).reduce((s, p) => s + p.ingredients.length, 0) + ii
|
||||
return (
|
||||
<div key={ii} className="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium truncate block">{ing.ingredient?.inciName ?? ing.inciName}</span>
|
||||
<span className="text-xs text-gray-400">{ing.ingredient?.chineseName ?? ing.chineseName}</span>
|
||||
</div>
|
||||
<button onClick={() => adjustPct(flatIdx, -0.1)}
|
||||
className="rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600"><Minus size={12} /></button>
|
||||
<input type="number" step="0.01" min="0.01" max="100"
|
||||
value={ing.percentage} onChange={e => setPct(flatIdx, parseFloat(e.target.value) || 0)}
|
||||
className="w-20 rounded border px-2 py-0.5 text-right text-sm focus:border-blue-500 focus:outline-none" />
|
||||
<span className="text-xs text-gray-400">%</span>
|
||||
<button onClick={() => adjustPct(flatIdx, 0.1)}
|
||||
className="rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600"><Plus size={12} /></button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={handleSave} disabled={saving || !isValid}
|
||||
className="mt-4 inline-flex items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
title={!isValid ? '比例总和需在 99.5%~100.5% 之间' : undefined}>
|
||||
<Save size={14} /> {saving ? '保存中...' : '保存配方'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
frontend/src/hooks/useAIPredict.ts
Normal file
77
frontend/src/hooks/useAIPredict.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
|
||||
interface PredictionResult {
|
||||
sensoryIndex: {
|
||||
spreadability: number; absorption: number; stickiness: number; overall: number
|
||||
}
|
||||
stabilityScore: number
|
||||
costEstimate: number
|
||||
confidence: number
|
||||
reasoning?: string
|
||||
}
|
||||
|
||||
interface IngredientInput {
|
||||
name: string; percentage: number; category: string
|
||||
}
|
||||
|
||||
export function useAIPredict() {
|
||||
const [prediction, setPrediction] = useState<PredictionResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const predict = useCallback(async (ingredients: IngredientInput[]) => {
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai/predict-formula', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ingredients }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('预测失败')
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) throw new Error('No stream')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = JSON.parse(line.slice(6)) as { type: string; content: string }
|
||||
if (data.type === 'result') {
|
||||
const parsed = JSON.parse(data.content) as PredictionResult
|
||||
setPrediction(parsed)
|
||||
} else if (data.type === 'error') {
|
||||
setError('AI 预测失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
setError('预测请求失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { prediction, loading, error, predict }
|
||||
}
|
||||
1
frontend/src/index.css
Normal file
1
frontend/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
134
frontend/src/layouts/AppLayout.tsx
Normal file
134
frontend/src/layouts/AppLayout.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, FlaskConical, Palette, Compass, Leaf, FolderKanban, Settings,
|
||||
ChevronLeft, ChevronRight, Sun, Moon, Menu, Search,
|
||||
} from 'lucide-react'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import clsx from 'clsx'
|
||||
import { LogOut } from 'lucide-react'
|
||||
|
||||
const menuItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: '仪表盘' },
|
||||
{ to: '/formulas', icon: FlaskConical, label: '配方记录' },
|
||||
{ to: '/color-lab', icon: Palette, label: '颜色引擎' },
|
||||
{ to: '/formula-explorer', icon: Compass, label: '配方推演' },
|
||||
{ to: '/ingredients', icon: Leaf, label: '成分目录' },
|
||||
{ to: '/projects', icon: FolderKanban, label: '项目管理' },
|
||||
{ to: '/settings', icon: Settings, label: '设置' },
|
||||
]
|
||||
|
||||
export default function AppLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const { theme, toggleTheme } = useThemeStore()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const logout = useAuthStore(s => s.logout)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const handleSearch = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('flex min-h-screen bg-gray-50', theme === 'dark' && 'dark')}>
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed inset-y-0 left-0 z-50 flex flex-col border-r border-gray-200 bg-white transition-all duration-300 dark:border-gray-700 dark:bg-gray-900',
|
||||
collapsed && !mobileOpen ? 'w-16' : 'w-60',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between border-b border-gray-200 px-3 dark:border-gray-700">
|
||||
{!(collapsed && !mobileOpen) && (
|
||||
<span className="text-sm font-bold text-gray-900 dark:text-white">配方研发平台</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="hidden rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 lg:block dark:hover:bg-gray-800"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
||||
{menuItems.map((item) => {
|
||||
const isActive =
|
||||
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to)
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100',
|
||||
)}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
{!(collapsed && !mobileOpen) && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-200 p-2 dark:border-gray-700">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={20} /> : <Moon size={20} />}
|
||||
{!(collapsed && !mobileOpen) && <span>{theme === 'dark' ? '浅色模式' : '深色模式'}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={clsx('flex flex-1 flex-col transition-all duration-300', collapsed ? 'lg:ml-16' : 'lg:ml-60')}>
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-900">
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="rounded-md p-1 text-gray-500 hover:bg-gray-100 lg:hidden dark:hover:bg-gray-800"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="mx-4 hidden flex-1 sm:block">
|
||||
<div className="relative max-w-md">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text" placeholder="搜索配方...(支持自然语言)"
|
||||
value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearch}
|
||||
className="w-full rounded-lg border border-gray-200 py-1.5 pl-8 pr-3 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 lg:hidden" />
|
||||
<div className="flex items-center gap-3">
|
||||
{user && <span className="text-sm text-gray-600">{user.username}</span>}
|
||||
<button onClick={logout} className="rounded p-1 text-gray-400 hover:text-red-500" title="退出登录">
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
frontend/src/lib/api.ts
Normal file
32
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, options)
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const text = await res.text()
|
||||
if (!text) {
|
||||
if (!res.ok) throw new ApiError(res.status, `请求失败 (${res.status})`)
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(text) as T
|
||||
if (!res.ok) {
|
||||
const msg = (data as Record<string, unknown>)?.error as string ?? `请求失败 (${res.status})`
|
||||
throw new ApiError(res.status, msg)
|
||||
}
|
||||
return data
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) throw e
|
||||
if (!res.ok) throw new ApiError(res.status, `请求失败 (${res.status})`)
|
||||
throw new ApiError(res.status, '响应格式错误')
|
||||
}
|
||||
}
|
||||
131
frontend/src/lib/color/color.test.ts
Normal file
131
frontend/src/lib/color/color.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
hexToLab,
|
||||
labToHex,
|
||||
rgbToLab,
|
||||
labToRGB,
|
||||
labToLCH,
|
||||
lchToLab,
|
||||
displayP3ToLab,
|
||||
labToDisplayP3,
|
||||
} from './convert'
|
||||
import { deltaE2000, deltaE76, deltaECMC } from './deltaE'
|
||||
|
||||
describe('色空间转换', () => {
|
||||
describe('hexToLab / labToHex', () => {
|
||||
it('白色 #FFFFFF → Lab(100, 0, 0)', () => {
|
||||
const lab = hexToLab('#FFFFFF')
|
||||
expect(lab.L).toBeCloseTo(100, 0)
|
||||
expect(Math.abs(lab.a)).toBeLessThan(0.1)
|
||||
expect(Math.abs(lab.b)).toBeLessThan(0.1)
|
||||
})
|
||||
|
||||
it('黑色 #000000 → Lab(0, 0, 0)', () => {
|
||||
const lab = hexToLab('#000000')
|
||||
expect(lab.L).toBeCloseTo(0, 0)
|
||||
})
|
||||
|
||||
it('纯红 #FF0000 → Lab 值合理', () => {
|
||||
const lab = hexToLab('#FF0000')
|
||||
expect(lab.L).toBeGreaterThan(40)
|
||||
expect(lab.L).toBeLessThan(60)
|
||||
expect(lab.a).toBeGreaterThan(30)
|
||||
})
|
||||
|
||||
it('Lab → hex 往返转换:白色', () => {
|
||||
const hex = labToHex(100, 0, 0)
|
||||
expect(hex.toLowerCase()).toBe('#ffffff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rgbToLab / labToRGB', () => {
|
||||
it('RGB(255, 255, 255) → Lab(100, 0, 0) 附近', () => {
|
||||
const lab = rgbToLab(255, 255, 255)
|
||||
expect(lab.L).toBeCloseTo(100, 0)
|
||||
})
|
||||
|
||||
it('RGB 往返转换 |ΔL| < 0.1', () => {
|
||||
const original: [number, number, number] = [128, 64, 192]
|
||||
const lab = rgbToLab(...original)
|
||||
const rgb = labToRGB(lab.L, lab.a, lab.b)
|
||||
const lab2 = rgbToLab(rgb.r, rgb.g, rgb.b)
|
||||
expect(Math.abs(lab.L - lab2.L)).toBeLessThan(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('labToLCH / lchToLab', () => {
|
||||
it('Lab(50, 0, 0) → LCH(L=50, C≈0)', () => {
|
||||
const lch = labToLCH(50, 0, 0)
|
||||
expect(lch.L).toBeCloseTo(50, 0)
|
||||
expect(lch.C).toBeLessThan(1)
|
||||
})
|
||||
|
||||
it('LCH 往返转换误差 < 0.01', () => {
|
||||
const lab: [number, number, number] = [60, 20, -15]
|
||||
const lch = labToLCH(...lab)
|
||||
const lab2 = lchToLab(lch.L, lch.C, lch.h)
|
||||
expect(Math.abs(lab[0] - lab2.L)).toBeLessThan(0.01)
|
||||
expect(Math.abs(lab[1] - lab2.a)).toBeLessThan(0.01)
|
||||
expect(Math.abs(lab[2] - lab2.b)).toBeLessThan(0.01)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Display P3 转换', () => {
|
||||
it('P3(0, 0, 0) → Lab 接近黑色', () => {
|
||||
const lab = displayP3ToLab(0, 0, 0)
|
||||
expect(lab.L).toBeCloseTo(0, 0)
|
||||
})
|
||||
|
||||
it('P3(1, 0, 0) → Lab 红色区域', () => {
|
||||
const lab = displayP3ToLab(1, 0, 0)
|
||||
expect(lab.L).toBeGreaterThan(40)
|
||||
expect(lab.a).toBeGreaterThan(30)
|
||||
})
|
||||
|
||||
it('Lab → P3 → Lab 往返', () => {
|
||||
const original: [number, number, number] = [50, 30, -20]
|
||||
const p3 = labToDisplayP3(...original)
|
||||
const lab = displayP3ToLab(p3.r, p3.g, p3.b)
|
||||
expect(Math.abs(original[0] - lab.L)).toBeLessThan(0.1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('色差计算', () => {
|
||||
it('相同颜色的 ΔE = 0', () => {
|
||||
const lab = { L: 50, a: 10, b: -10 }
|
||||
expect(deltaE2000(lab, lab)).toBe(0)
|
||||
expect(deltaE76(lab, lab)).toBe(0)
|
||||
expect(deltaECMC(lab, lab)).toBe(0)
|
||||
})
|
||||
|
||||
it('纯红 vs 纯绿的 ΔE > 10', () => {
|
||||
const red = hexToLab('#FF0000')
|
||||
const green = hexToLab('#00FF00')
|
||||
expect(deltaE2000(red, green)).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('白色 vs 黑色的 ΔE 较大', () => {
|
||||
const white = hexToLab('#FFFFFF')
|
||||
const black = hexToLab('#000000')
|
||||
const d = deltaE2000(white, black)
|
||||
expect(d).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('CIEDE2000 对接近颜色的敏感度', () => {
|
||||
const color1 = hexToLab('#FF0000')
|
||||
const color2 = hexToLab('#FE0000')
|
||||
const d = deltaE2000(color1, color2)
|
||||
expect(d).toBeGreaterThan(0)
|
||||
expect(d).toBeLessThan(5)
|
||||
})
|
||||
|
||||
it('ΔE76 ≥ ΔE2000 对接近白颜色(CIEDE2000 更精确)', () => {
|
||||
const nearWhite1 = hexToLab('#FEFEFE')
|
||||
const nearWhite2 = hexToLab('#FDFDFD')
|
||||
const d76 = deltaE76(nearWhite1, nearWhite2)
|
||||
const d2000 = deltaE2000(nearWhite1, nearWhite2)
|
||||
expect(d2000).toBeGreaterThanOrEqual(0)
|
||||
expect(d76).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
62
frontend/src/lib/color/convert.ts
Normal file
62
frontend/src/lib/color/convert.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import Color from 'colorjs.io'
|
||||
import type { LABColor, RGBColor, LCHColor, DisplayP3Color } from './types'
|
||||
|
||||
function getLab(color: Color): [number, number, number] {
|
||||
const lab = color.lab
|
||||
return [lab[0]!, lab[1]!, lab[2]!]
|
||||
}
|
||||
|
||||
function getSrgb(color: Color): [number, number, number] {
|
||||
const srgb = color.srgb
|
||||
return [srgb[0]!, srgb[1]!, srgb[2]!]
|
||||
}
|
||||
|
||||
function getLch(color: Color): [number, number, number] {
|
||||
const lch = color.lch
|
||||
return [lch[0]!, lch[1]!, lch[2]!]
|
||||
}
|
||||
|
||||
function getP3(color: Color): [number, number, number] {
|
||||
const p3 = color.p3
|
||||
return [p3[0]!, p3[1]!, p3[2]!]
|
||||
}
|
||||
|
||||
export function hexToLab(hex: string): LABColor {
|
||||
const [L, a, b] = getLab(new Color(hex))
|
||||
return { L, a, b }
|
||||
}
|
||||
|
||||
export function labToHex(L: number, a: number, b: number): string {
|
||||
const color = new Color('lab', [L, a, b])
|
||||
return color.to('srgb').toString({ format: 'hex', collapse: false })
|
||||
}
|
||||
|
||||
export function rgbToLab(r: number, g: number, b: number): LABColor {
|
||||
const [L, aVal, bVal] = getLab(new Color('srgb', [r / 255, g / 255, b / 255]))
|
||||
return { L, a: aVal, b: bVal }
|
||||
}
|
||||
|
||||
export function labToRGB(L: number, a: number, b: number): RGBColor {
|
||||
const [r, g, bVal] = getSrgb(new Color('lab', [L, a, b]))
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(bVal * 255) }
|
||||
}
|
||||
|
||||
export function labToLCH(L: number, a: number, b: number): LCHColor {
|
||||
const [lchL, C, h] = getLch(new Color('lab', [L, a, b]))
|
||||
return { L: lchL, C, h }
|
||||
}
|
||||
|
||||
export function lchToLab(L: number, C: number, h: number): LABColor {
|
||||
const [labL, a, b] = getLab(new Color('lch', [L, C, h]))
|
||||
return { L: labL, a, b }
|
||||
}
|
||||
|
||||
export function displayP3ToLab(r: number, g: number, b: number): LABColor {
|
||||
const [L, a, bVal] = getLab(new Color('p3', [r, g, b]))
|
||||
return { L, a, b: bVal }
|
||||
}
|
||||
|
||||
export function labToDisplayP3(L: number, a: number, b: number): DisplayP3Color {
|
||||
const [r, g, bVal] = getP3(new Color('lab', [L, a, b]))
|
||||
return { r, g, b: bVal }
|
||||
}
|
||||
20
frontend/src/lib/color/deltaE.ts
Normal file
20
frontend/src/lib/color/deltaE.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import Color from 'colorjs.io'
|
||||
import type { LABColor } from './types'
|
||||
|
||||
export function deltaE2000(lab1: LABColor, lab2: LABColor): number {
|
||||
const color1 = new Color('lab', [lab1.L, lab1.a, lab1.b])
|
||||
const color2 = new Color('lab', [lab2.L, lab2.a, lab2.b])
|
||||
return color1.deltaE(color2, '2000')
|
||||
}
|
||||
|
||||
export function deltaECMC(lab1: LABColor, lab2: LABColor, l = 2, c = 1): number {
|
||||
const color1 = new Color('lab', [lab1.L, lab1.a, lab1.b])
|
||||
const color2 = new Color('lab', [lab2.L, lab2.a, lab2.b])
|
||||
return color1.deltaE(color2, { method: 'CMC', l, c })
|
||||
}
|
||||
|
||||
export function deltaE76(lab1: LABColor, lab2: LABColor): number {
|
||||
const color1 = new Color('lab', [lab1.L, lab1.a, lab1.b])
|
||||
const color2 = new Color('lab', [lab2.L, lab2.a, lab2.b])
|
||||
return color1.deltaE(color2, '76')
|
||||
}
|
||||
23
frontend/src/lib/color/types.ts
Normal file
23
frontend/src/lib/color/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface LABColor {
|
||||
L: number
|
||||
a: number
|
||||
b: number
|
||||
}
|
||||
|
||||
export interface RGBColor {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}
|
||||
|
||||
export interface LCHColor {
|
||||
L: number
|
||||
C: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface DisplayP3Color {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}
|
||||
11
frontend/src/lib/queryClient.ts
Normal file
11
frontend/src/lib/queryClient.ts
Normal 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
18
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
210
frontend/src/pages/ColorLabPage.tsx
Normal file
210
frontend/src/pages/ColorLabPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
frontend/src/pages/DashboardPage.tsx
Normal file
92
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
frontend/src/pages/FormulaDetailPage.tsx
Normal file
100
frontend/src/pages/FormulaDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
346
frontend/src/pages/FormulaEditorPage.tsx
Normal file
346
frontend/src/pages/FormulaEditorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
190
frontend/src/pages/FormulaExplorerPage.tsx
Normal file
190
frontend/src/pages/FormulaExplorerPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
frontend/src/pages/FormulaListPage.tsx
Normal file
118
frontend/src/pages/FormulaListPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
304
frontend/src/pages/IngredientsPage.tsx
Normal file
304
frontend/src/pages/IngredientsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
frontend/src/pages/LoginPage.tsx
Normal file
55
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
frontend/src/pages/ProjectsPage.tsx
Normal file
67
frontend/src/pages/ProjectsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
frontend/src/pages/RegisterPage.tsx
Normal file
50
frontend/src/pages/RegisterPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
frontend/src/pages/SearchPage.tsx
Normal file
55
frontend/src/pages/SearchPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
183
frontend/src/pages/SettingsPage.tsx
Normal file
183
frontend/src/pages/SettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
179
frontend/src/pages/VersionComparePage.tsx
Normal file
179
frontend/src/pages/VersionComparePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
frontend/src/pages/VersionHistoryPage.tsx
Normal file
97
frontend/src/pages/VersionHistoryPage.tsx
Normal 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
44
frontend/src/router.tsx
Normal 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 /> },
|
||||
],
|
||||
}],
|
||||
},
|
||||
])
|
||||
58
frontend/src/stores/authStore.ts
Normal file
58
frontend/src/stores/authStore.ts
Normal 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' }
|
||||
)
|
||||
)
|
||||
26
frontend/src/stores/themeStore.ts
Normal file
26
frontend/src/stores/themeStore.ts
Normal 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',
|
||||
},
|
||||
),
|
||||
)
|
||||
36
frontend/tsconfig.app.json
Normal file
36
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal 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
23
frontend/vite.config.ts
Normal 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
13
frontend/vitest.config.ts
Normal 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}'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user