企业级重构:四层模块化架构 + RBAC授权 + 安全加固 + 颜色引擎/配方推演增强
架构 - 后端从 flat routes/ 重构为 modules/<domain>/ 模块化结构(8个模块) - 四层架构:Route -> Service -> Repository -> Prisma - 新增 shared/ 基础设施(AppError 异常体系、ALS 上下文、prom-client 指标) - 前端 Toast/Skeleton/Alert 组件基建 + formulaService 模板 安全 - JWT 签名算法修复(HS256 用 createHmac 而非 createHash) - 密码哈希 async scrypt + timingSafeEqual - API Key 从 localStorage 迁移至服务端 runtime/config.json - Helmet 安全头 + rate-limit 全局限流 100 req/min - 全局 auth preHandler + RBAC + Ownership 中间件 颜色引擎 - 色匹配切换为 cube 粗筛 + CIEDE2000 精排 - PantoneColor 表 + 种子数据 + 搜索端点 - AI 配色 Prompt 注入成分库 colorant 列表 配方推演 - 本地优化引擎(同 category 替换 + 成本排序) - baseFormulaId 支持 + Pareto 散点图 文档 - ADR-0003 四层架构、ADR-0004 RBAC 授权模型 - 更新 ADR-0001/0002 - api-reference.md(29端点)、project-overview.md 部署 - Dockerfile * 2 + nginx.conf + docker-compose.prod.yml - 健康探针 + 优雅关闭 + pg_dump 备份脚本 - ESLint + Prettier + tsconfig strict
This commit is contained in:
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
RUN pnpm fetch
|
||||
RUN pnpm install --frozen-lockfile --offline
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"api:gen": "tsx scripts/generate-types.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -49,6 +50,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
231
frontend/pnpm-lock.yaml
generated
231
frontend/pnpm-lock.yaml
generated
@@ -55,10 +55,10 @@ importers:
|
||||
version: 7.9.0
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
version: 6.1.0
|
||||
echarts-for-react:
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6(echarts@6.0.0)(react@19.2.6)
|
||||
version: 3.0.6(echarts@6.1.0)(react@19.2.6)
|
||||
lucide-react:
|
||||
specifier: ^1.16.0
|
||||
version: 1.16.0(react@19.2.6)
|
||||
@@ -114,6 +114,9 @@ importers:
|
||||
globals:
|
||||
specifier: ^17.6.0
|
||||
version: 17.6.0
|
||||
openapi-typescript:
|
||||
specifier: ^7.13.0
|
||||
version: 7.13.0(typescript@6.0.3)
|
||||
prettier:
|
||||
specifier: ^3.8.3
|
||||
version: 3.8.3
|
||||
@@ -720,6 +723,16 @@ packages:
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@redocly/ajv@8.11.2':
|
||||
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
|
||||
|
||||
'@redocly/config@0.22.0':
|
||||
resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
|
||||
|
||||
'@redocly/openapi-core@1.34.14':
|
||||
resolution: {integrity: sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA==}
|
||||
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.1':
|
||||
resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1075,9 +1088,20 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
agent-base@7.1.2:
|
||||
resolution: {integrity: sha512-JVzqkCNRT+VfqzzgPWDPnwvDheSAUdiMUn3NoLXpDJF5lRqeJqyC9iGsAxIOAW+mzIdq+uP1TvcX6bMtrH0agg==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ajv@6.15.0:
|
||||
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
|
||||
|
||||
ansi-colors@4.1.3:
|
||||
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1089,6 +1113,9 @@ packages:
|
||||
ast-v8-to-istanbul@1.0.0:
|
||||
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
|
||||
|
||||
balanced-match@1.0.0:
|
||||
resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==}
|
||||
|
||||
balanced-match@4.0.4:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -1098,6 +1125,9 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
brace-expansion@2.1.0:
|
||||
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -1114,10 +1144,16 @@ packages:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
change-case@5.4.4:
|
||||
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
colorette@1.4.0:
|
||||
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
|
||||
|
||||
colorjs.io@0.6.1:
|
||||
resolution: {integrity: sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==}
|
||||
|
||||
@@ -1294,8 +1330,8 @@ packages:
|
||||
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
|
||||
react: ^15.0.0 || >=16.0.0
|
||||
|
||||
echarts@6.0.0:
|
||||
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||
echarts@6.1.0:
|
||||
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||
|
||||
electron-to-chromium@1.5.358:
|
||||
resolution: {integrity: sha512-EO7tKm3QxRqTs1lSuPXzl6yRAwznehp0AH9OoMOIC+4mQzTFday8FJCO5KU6J/TFSQXEOahNq4vTKpz1jmCVOA==}
|
||||
@@ -1445,6 +1481,10 @@ packages:
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1461,6 +1501,10 @@ packages:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
|
||||
index-to-position@1.2.0:
|
||||
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1492,12 +1536,20 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-levenshtein@1.1.6:
|
||||
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1509,6 +1561,9 @@ packages:
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
@@ -1624,6 +1679,10 @@ packages:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@5.1.9:
|
||||
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1641,6 +1700,12 @@ packages:
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
openapi-typescript@7.13.0:
|
||||
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^5.x
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1653,6 +1718,10 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
parse-json@8.3.0:
|
||||
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1671,6 +1740,10 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pluralize@8.0.0:
|
||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.5.14:
|
||||
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1805,6 +1878,10 @@ packages:
|
||||
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
robust-predicates@3.0.3:
|
||||
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
|
||||
|
||||
@@ -1858,6 +1935,10 @@ packages:
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
supports-color@10.2.2:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1900,6 +1981,10 @@ packages:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
type-fest@4.39.1:
|
||||
resolution: {integrity: sha512-uW9qzd66uyHYxwyVBYiwS4Oi0qZyUqwjU+Oevr6ZogYiXt99EOYtwvzMSLw1c3lYo2HzJsep/NB23iEVEgjG/w==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
typescript-eslint@8.59.4:
|
||||
resolution: {integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1921,6 +2006,9 @@ packages:
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
uri-js-replace@1.0.1:
|
||||
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
@@ -2045,6 +2133,13 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yaml-ast-parser@0.0.43:
|
||||
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2058,8 +2153,8 @@ packages:
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
zrender@6.0.0:
|
||||
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||
zrender@6.1.0:
|
||||
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||
|
||||
zustand@5.0.13:
|
||||
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
|
||||
@@ -2102,7 +2197,7 @@ snapshots:
|
||||
'@babel/types': 7.29.0
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -2172,7 +2267,7 @@ snapshots:
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.29.0
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2234,7 +2329,7 @@ snapshots:
|
||||
'@eslint/config-array@0.23.5':
|
||||
dependencies:
|
||||
'@eslint/object-schema': 3.0.5
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
minimatch: 10.2.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -2708,6 +2803,29 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@redocly/ajv@8.11.2':
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
uri-js-replace: 1.0.1
|
||||
|
||||
'@redocly/config@0.22.0': {}
|
||||
|
||||
'@redocly/openapi-core@1.34.14(supports-color@10.2.2)':
|
||||
dependencies:
|
||||
'@redocly/ajv': 8.11.2
|
||||
'@redocly/config': 0.22.0
|
||||
colorette: 1.4.0
|
||||
https-proxy-agent: 7.0.6(supports-color@10.2.2)
|
||||
js-levenshtein: 1.1.6
|
||||
js-yaml: 4.1.1
|
||||
minimatch: 5.1.9
|
||||
pluralize: 8.0.0
|
||||
yaml-ast-parser: 0.0.43
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.1':
|
||||
optional: true
|
||||
|
||||
@@ -2890,7 +3008,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.59.4
|
||||
'@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3)
|
||||
'@typescript-eslint/visitor-keys': 8.59.4
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
eslint: 10.4.0(jiti@2.7.0)
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
@@ -2900,7 +3018,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.4
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -2919,7 +3037,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.59.4
|
||||
'@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3)
|
||||
'@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
eslint: 10.4.0(jiti@2.7.0)
|
||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||
typescript: 6.0.3
|
||||
@@ -2934,7 +3052,7 @@ snapshots:
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.4
|
||||
'@typescript-eslint/visitor-keys': 8.59.4
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.0
|
||||
tinyglobby: 0.2.16
|
||||
@@ -3025,6 +3143,12 @@ snapshots:
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
agent-base@7.1.2(supports-color@10.2.2):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ajv@6.15.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
@@ -3032,6 +3156,10 @@ snapshots:
|
||||
json-schema-traverse: 0.4.1
|
||||
uri-js: 4.4.1
|
||||
|
||||
ansi-colors@4.1.3: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3044,10 +3172,16 @@ snapshots:
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
balanced-match@1.0.0: {}
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
baseline-browser-mapping@2.10.31: {}
|
||||
|
||||
brace-expansion@2.1.0:
|
||||
dependencies:
|
||||
balanced-match: 1.0.0
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
@@ -3064,8 +3198,12 @@ snapshots:
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
change-case@5.4.4: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
colorette@1.4.0: {}
|
||||
|
||||
colorjs.io@0.6.1: {}
|
||||
|
||||
commander@7.2.0: {}
|
||||
@@ -3234,9 +3372,11 @@ snapshots:
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
debug@4.4.3:
|
||||
debug@4.4.3(supports-color@10.2.2):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optionalDependencies:
|
||||
supports-color: 10.2.2
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
@@ -3248,17 +3388,17 @@ snapshots:
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
||||
echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.6):
|
||||
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.6):
|
||||
dependencies:
|
||||
echarts: 6.0.0
|
||||
echarts: 6.1.0
|
||||
fast-deep-equal: 3.1.3
|
||||
react: 19.2.6
|
||||
size-sensor: 1.0.3
|
||||
|
||||
echarts@6.0.0:
|
||||
echarts@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.0.0
|
||||
zrender: 6.1.0
|
||||
|
||||
electron-to-chromium@1.5.358: {}
|
||||
|
||||
@@ -3313,7 +3453,7 @@ snapshots:
|
||||
'@types/estree': 1.0.9
|
||||
ajv: 6.15.0
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 9.1.2
|
||||
eslint-visitor-keys: 5.0.1
|
||||
@@ -3411,6 +3551,13 @@ snapshots:
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
https-proxy-agent@7.0.6(supports-color@10.2.2):
|
||||
dependencies:
|
||||
agent-base: 7.1.2(supports-color@10.2.2)
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -3421,6 +3568,8 @@ snapshots:
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
index-to-position@1.2.0: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
@@ -3446,16 +3595,24 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-levenshtein@1.1.6: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
@@ -3548,6 +3705,10 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.6
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.0
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
@@ -3558,6 +3719,16 @@ snapshots:
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
openapi-typescript@7.13.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@redocly/openapi-core': 1.34.14(supports-color@10.2.2)
|
||||
ansi-colors: 4.1.3
|
||||
change-case: 5.4.4
|
||||
parse-json: 8.3.0
|
||||
supports-color: 10.2.2
|
||||
typescript: 6.0.3
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -3575,6 +3746,12 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
parse-json@8.3.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
index-to-position: 1.2.0
|
||||
type-fest: 4.39.1
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
@@ -3585,6 +3762,8 @@ snapshots:
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
postcss@8.5.14:
|
||||
dependencies:
|
||||
nanoid: 3.3.12
|
||||
@@ -3653,6 +3832,8 @@ snapshots:
|
||||
|
||||
react@19.2.6: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
robust-predicates@3.0.3: {}
|
||||
|
||||
rolldown@1.0.1:
|
||||
@@ -3704,6 +3885,8 @@ snapshots:
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
@@ -3735,6 +3918,8 @@ snapshots:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
type-fest@4.39.1: {}
|
||||
|
||||
typescript-eslint@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
@@ -3756,6 +3941,8 @@ snapshots:
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
uri-js-replace@1.0.1: {}
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -3828,6 +4015,10 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml-ast-parser@0.0.43: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.4.3):
|
||||
@@ -3836,7 +4027,7 @@ snapshots:
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
zrender@6.0.0:
|
||||
zrender@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
|
||||
34
frontend/scripts/generate-types.ts
Normal file
34
frontend/scripts/generate-types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..')
|
||||
const BACKEND = join(ROOT, '..', 'backend')
|
||||
const SPEC_FILE = join(BACKEND, 'generated', 'openapi.json')
|
||||
const OUT_DIR = join(ROOT, 'src', 'generated')
|
||||
const OUT_FILE = join(OUT_DIR, 'api.ts')
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(SPEC_FILE)) {
|
||||
console.log('Generating OpenAPI spec from backend...')
|
||||
execSync('pnpm api:gen', { cwd: BACKEND, stdio: 'inherit' })
|
||||
}
|
||||
|
||||
console.log('Generating TypeScript types from OpenAPI spec...')
|
||||
const spec = JSON.parse(readFileSync(SPEC_FILE, 'utf-8'))
|
||||
|
||||
const openapiTS = await import('openapi-typescript')
|
||||
const types = await openapiTS.default(new URL(`file://${SPEC_FILE}`), {
|
||||
exportType: true,
|
||||
})
|
||||
|
||||
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true })
|
||||
writeFileSync(OUT_FILE, types)
|
||||
|
||||
console.log(`Types written to src/generated/api.ts (${types.length} bytes)`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -55,7 +55,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl">
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white dark:bg-gray-700 p-6 shadow-xl">
|
||||
<Dialog.Title className="mb-4 text-lg font-bold">AI 配色推荐</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<button className="absolute right-4 top-4 rounded p-1 text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
||||
@@ -72,7 +72,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 text-xs text-gray-500">
|
||||
<div className="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
目标色:{targetLab ? `Lab(${targetLab.L.toFixed(0)},${targetLab.a.toFixed(0)},${targetLab.b.toFixed(0)})` : '当前色'}
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco
|
||||
</div>
|
||||
) : matchedFormulas.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500">AI 未返回推荐,以下是历史匹配配方:</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">AI 未返回推荐,以下是历史匹配配方:</p>
|
||||
{matchedFormulas.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border p-2 text-sm">
|
||||
<span>{f.name as string}</span>
|
||||
@@ -121,7 +121,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-lg border p-3">
|
||||
<p className="mb-2 text-xs font-medium text-gray-500">皮肤预览</p>
|
||||
<p className="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">皮肤预览</p>
|
||||
<div className="h-20 rounded-lg"
|
||||
style={{
|
||||
background: selectedColor
|
||||
|
||||
@@ -15,9 +15,9 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
|
||||
<div className="max-w-md rounded-xl bg-white dark:bg-gray-700 p-8 text-center shadow-lg">
|
||||
<h1 className="mb-2 text-2xl font-bold text-red-600">出错了</h1>
|
||||
<p className="mb-4 text-sm text-gray-500">{this.state.error?.message ?? '发生了意外错误'}</p>
|
||||
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">{this.state.error?.message ?? '发生了意外错误'}</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={() => { this.setState({ hasError: false, error: null }); window.location.reload() }}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700">
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function FormulaVisualEditor({ phases: initialPhases, onSave }: P
|
||||
</div>
|
||||
{!isValid && total > 0 && (
|
||||
<button onClick={normalize}
|
||||
className="rounded-lg bg-white px-3 py-1 text-xs font-medium text-blue-600 shadow-sm hover:bg-blue-50">
|
||||
className="rounded-lg bg-white dark:bg-gray-700 px-3 py-1 text-xs font-medium text-blue-600 shadow-sm hover:bg-blue-50">
|
||||
自动归一化
|
||||
</button>
|
||||
)}
|
||||
@@ -199,7 +199,7 @@ export default function FormulaVisualEditor({ phases: initialPhases, onSave }: P
|
||||
{hasPrediction && (
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-500">肤感子维度</h4>
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">肤感子维度</h4>
|
||||
<ReactECharts
|
||||
option={{
|
||||
radar: {
|
||||
@@ -234,7 +234,7 @@ export default function FormulaVisualEditor({ phases: initialPhases, onSave }: P
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500">稳定性评分</h4>
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">稳定性评分</h4>
|
||||
<ReactECharts
|
||||
option={{
|
||||
series: [{
|
||||
@@ -254,7 +254,7 @@ export default function FormulaVisualEditor({ phases: initialPhases, onSave }: P
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500">配方结构</h4>
|
||||
<h4 className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">配方结构</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allIngredients.map((ing, i) => (
|
||||
<div key={i} className="rounded px-2 py-0.5 text-xs"
|
||||
@@ -275,7 +275,7 @@ export default function FormulaVisualEditor({ phases: initialPhases, onSave }: P
|
||||
<div className="flex-1 space-y-3 overflow-y-auto">
|
||||
{phases.map((phase, pi) => (
|
||||
<div key={pi}>
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-500">{phase.name}</h4>
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">{phase.name}</h4>
|
||||
<div className="space-y-1.5">
|
||||
{phase.ingredients.map((ing, ii) => {
|
||||
const flatIdx = phases.slice(0, pi).reduce((s, p) => s + p.ingredients.length, 0) + ii
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function InitConfig() {
|
||||
useEffect(() => {
|
||||
const openaiKey = localStorage.getItem('openai-key')
|
||||
const deepseekKey = localStorage.getItem('deepseek-key')
|
||||
const openaiBaseUrl = localStorage.getItem('openai-base-url')
|
||||
const deepseekBaseUrl = localStorage.getItem('deepseek-base-url')
|
||||
const aiMock = localStorage.getItem('ai-mock') ?? 'true'
|
||||
|
||||
const body: Record<string, string | undefined> = { aiMock }
|
||||
if (openaiKey) body.openaiKey = openaiKey
|
||||
if (deepseekKey) body.deepseekKey = deepseekKey
|
||||
if (openaiBaseUrl) body.openaiBaseUrl = openaiBaseUrl
|
||||
if (deepseekBaseUrl) body.deepseekBaseUrl = deepseekBaseUrl
|
||||
|
||||
fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1 +1,144 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-gray-50: #F5E6E6;
|
||||
--color-gray-100: #F0E0E0;
|
||||
--color-gray-200: #E8D0D0;
|
||||
--color-gray-300: #D4B5B5;
|
||||
--color-gray-400: #C0A0A0;
|
||||
--color-gray-500: #999999;
|
||||
--color-gray-600: #8C6C6C;
|
||||
--color-gray-700: #6C5050;
|
||||
--color-gray-800: #4C3434;
|
||||
--color-gray-900: #333333;
|
||||
--color-blue-50: #FDF5F0;
|
||||
--color-blue-100: #FAF0E8;
|
||||
--color-blue-500: #C9A96E;
|
||||
--color-blue-600: #D4A5A5;
|
||||
--color-blue-700: #C9A96E;
|
||||
}
|
||||
|
||||
.dark .bg-blue-600,
|
||||
.dark .bg-blue-700:hover {
|
||||
background-color: #9B8EC4 !important;
|
||||
border-color: #9B8EC4 !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.dark .bg-blue-500 {
|
||||
background-color: #4CB8A7 !important;
|
||||
}
|
||||
|
||||
.dark {
|
||||
background-color: #1A1830 !important;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: #1A1830 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-50,
|
||||
.dark .bg-white,
|
||||
.dark aside,
|
||||
.dark header {
|
||||
background-color: #282540 !important;
|
||||
}
|
||||
|
||||
.dark .dark\:bg-gray-900 {
|
||||
background-color: #1A1830 !important;
|
||||
}
|
||||
|
||||
.dark .dark\:bg-gray-800 {
|
||||
background-color: #2C2840 !important;
|
||||
}
|
||||
|
||||
.dark .dark\:bg-gray-700,
|
||||
.dark .dark\:hover\:bg-gray-100:hover {
|
||||
background-color: #3A3650 !important;
|
||||
}
|
||||
|
||||
.dark input,
|
||||
.dark select {
|
||||
background-color: #2C2840 !important;
|
||||
border-color: #3A3650 !important;
|
||||
color: #E8E4F0 !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-200,
|
||||
.dark .dark\:border-gray-700 {
|
||||
border-color: #3A3650 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-900,
|
||||
.dark .text-gray-800,
|
||||
.dark .text-gray-700,
|
||||
.dark h2,
|
||||
.dark .font-bold,
|
||||
.dark .font-semibold {
|
||||
color: #E8E4F0 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-600,
|
||||
.dark .dark\:text-gray-400 {
|
||||
color: #C8C0D8 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-500 {
|
||||
color: #A8A0C0 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-400,
|
||||
.dark .dark\:text-gray-300 {
|
||||
color: #9B8EC4 !important;
|
||||
}
|
||||
|
||||
.dark .text-blue-600 {
|
||||
color: #9B8EC4 !important;
|
||||
}
|
||||
|
||||
.dark .text-blue-700 {
|
||||
color: #4CB8A7 !important;
|
||||
}
|
||||
|
||||
.dark .dark\:bg-gray-50 {
|
||||
background-color: #1A1830 !important;
|
||||
}
|
||||
|
||||
button.rounded-lg:not([class*="bg-blue"]),
|
||||
button.rounded-md:not([class*="bg-blue"]),
|
||||
.btn-ghost {
|
||||
background-color: rgba(255,255,255,0.85) !important;
|
||||
}
|
||||
|
||||
button.rounded-lg:not([class*="bg-blue"]):hover,
|
||||
button.rounded-md:not([class*="bg-blue"]):hover,
|
||||
.btn-ghost:hover {
|
||||
background-color: var(--color-gray-100) !important;
|
||||
}
|
||||
|
||||
.dark button.rounded-lg:not([class*="bg-blue"]),
|
||||
.dark button.rounded-md:not([class*="bg-blue"]),
|
||||
.dark .btn-ghost {
|
||||
background-color: #2C2840 !important;
|
||||
border-color: #3A3650 !important;
|
||||
color: #E8E4F0 !important;
|
||||
}
|
||||
|
||||
.dark button.rounded-lg:not([class*="bg-blue"]):hover,
|
||||
.dark button.rounded-md:not([class*="bg-blue"]):hover,
|
||||
.dark .btn-ghost:hover {
|
||||
background-color: #3A3650 !important;
|
||||
}
|
||||
|
||||
.bg-blue-600,
|
||||
.bg-blue-700:hover {
|
||||
background-color: #D4A5A5 !important;
|
||||
border: 1px solid #D4A5A5;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.bg-blue-600:hover,
|
||||
.bg-blue-700 {
|
||||
background-color: #C9A96E !important;
|
||||
border-color: #C9A96E !important;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function AppLayout() {
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-900">
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="rounded-md p-1 text-gray-500 hover:bg-gray-100 lg:hidden dark:hover:bg-gray-800"
|
||||
className="rounded-md p-1 text-gray-500 dark:text-gray-400 hover:bg-gray-100 lg:hidden dark:hover:bg-gray-800"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
@@ -112,7 +112,7 @@ export default function AppLayout() {
|
||||
type="text" placeholder="搜索配方...(支持自然语言)"
|
||||
value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearch}
|
||||
className="w-full rounded-lg border border-gray-200 py-1.5 pl-8 pr-3 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800"
|
||||
className="w-full rounded-lg border border-gray-200 py-1.5 pl-8 pr-3 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,26 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
try {
|
||||
const raw = localStorage.getItem('auth-storage')
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as { state?: { token?: string } }
|
||||
const token = parsed?.state?.token
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, options)
|
||||
const headers = new Headers(options?.headers)
|
||||
const authHeaders = getAuthHeaders()
|
||||
for (const [key, value] of Object.entries(authHeaders)) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...options, headers })
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const text = await res.text()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import InitConfig from './components/InitConfig'
|
||||
import { ToastProvider } from './shared/components/Toast'
|
||||
import { router } from './router'
|
||||
import { queryClient } from './lib/queryClient'
|
||||
import './index.css'
|
||||
@@ -11,10 +11,11 @@ import './index.css'
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<InitConfig />
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
<ToastProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
81
frontend/src/modules/formulas/components/ParetoChart.tsx
Normal file
81
frontend/src/modules/formulas/components/ParetoChart.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from 'react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
interface ParetoPoint {
|
||||
name: string
|
||||
costEstimate: number
|
||||
index: number
|
||||
}
|
||||
|
||||
interface ParetoChartProps {
|
||||
options: ParetoPoint[]
|
||||
}
|
||||
|
||||
export default function ParetoChart({ options }: ParetoChartProps) {
|
||||
const chartData = useMemo(() => {
|
||||
return options.map((opt, i) => ({
|
||||
value: [opt.costEstimate, options.length - i],
|
||||
name: opt.name,
|
||||
}))
|
||||
}, [options])
|
||||
|
||||
const paretoIndices = useMemo(() => {
|
||||
if (options.length < 2) return []
|
||||
const sorted = [...options].sort((a, b) => a.costEstimate - b.costEstimate)
|
||||
return sorted.map(s => options.indexOf(s))
|
||||
}, [options])
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { data: { value: number[]; name: string } }) => {
|
||||
const [cost] = params.data.value
|
||||
return `${params.data.name}<br/>成本: ${cost} 元/kg`
|
||||
},
|
||||
},
|
||||
grid: { left: 60, right: 30, top: 30, bottom: 50 },
|
||||
xAxis: {
|
||||
name: '成本 (元/kg)',
|
||||
nameLocation: 'center',
|
||||
nameGap: 30,
|
||||
type: 'value',
|
||||
},
|
||||
yAxis: {
|
||||
name: '方案序号',
|
||||
type: 'value',
|
||||
splitLine: { show: true },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'scatter',
|
||||
data: chartData,
|
||||
symbolSize: 14,
|
||||
itemStyle: {
|
||||
color: (params: { dataIndex: number }) =>
|
||||
paretoIndices.includes(params.dataIndex) ? '#ef4444' : '#3b82f6',
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { data: { name: string } }) => params.data.name,
|
||||
position: 'right',
|
||||
fontSize: 11,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if (options.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-700 dark:border-gray-700 dark:bg-gray-700 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Pareto 前沿</h3>
|
||||
<p className="mb-2 text-xs text-gray-400">
|
||||
红色点表示 Pareto 最优方案(在成本和指标上都无法改进)
|
||||
</p>
|
||||
<ReactECharts option={option} style={{ height: 300 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
frontend/src/modules/formulas/formulas.service.ts
Normal file
108
frontend/src/modules/formulas/formulas.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { apiFetch } from '@/shared/services/api'
|
||||
|
||||
export interface FormulaListItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
currentVersion: number
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
project: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
export interface FormulaDetail {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
currentVersion: number
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
project: { id: string; name: string } | null
|
||||
versions: FormulaVersion[]
|
||||
}
|
||||
|
||||
export interface FormulaVersion {
|
||||
id: string
|
||||
versionNumber: number
|
||||
description: string | null
|
||||
snapshotData: unknown
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
phases: Phase[]
|
||||
}
|
||||
|
||||
export interface Phase {
|
||||
id: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
ingredients: FormulaIngredient[]
|
||||
}
|
||||
|
||||
export interface FormulaIngredient {
|
||||
id: string
|
||||
ingredientId: string
|
||||
percentage: number
|
||||
processNotes: string | null
|
||||
ingredient: { id: string; inciName: string; chineseName: string }
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
pagination: { page: number; limit: number; total: number; totalPages: number }
|
||||
}
|
||||
|
||||
export interface CreateFormulaInput {
|
||||
name: string
|
||||
description?: string
|
||||
projectId?: string
|
||||
phases: {
|
||||
name: string
|
||||
sortOrder?: number
|
||||
ingredients: { ingredientId: string; percentage: number; processNotes?: string }[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export const formulaService = {
|
||||
list(params?: { page?: number; limit?: number; search?: string }) {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.page) qs.set('page', String(params.page))
|
||||
if (params?.limit) qs.set('limit', String(params.limit))
|
||||
if (params?.search) qs.set('search', params.search)
|
||||
const query = qs.toString() ? `?${qs.toString()}` : ''
|
||||
return apiFetch<PaginatedResponse<FormulaListItem>>(`/api/formulas${query}`)
|
||||
},
|
||||
|
||||
getById(id: string) {
|
||||
return apiFetch<{ data: FormulaDetail }>(`/api/formulas/${id}`)
|
||||
},
|
||||
|
||||
create(input: CreateFormulaInput) {
|
||||
return apiFetch<{ data: FormulaDetail }>('/api/formulas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
},
|
||||
|
||||
updateMeta(id: string, data: { name?: string; description?: string }) {
|
||||
return apiFetch<{ data: { id: string } }>(`/api/formulas/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
updateComposition(id: string, phases: CreateFormulaInput['phases']) {
|
||||
return apiFetch<{ data: FormulaDetail }>(`/api/formulas/${id}/composition`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phases }),
|
||||
})
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
return apiFetch(`/api/formulas/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export default function ColorLabPage() {
|
||||
<div className={`flex w-full items-center gap-3 rounded-lg border p-3 ${deltaBg}`}>
|
||||
<div className="h-10 w-10 flex-shrink-0 rounded border" style={{ backgroundColor: labToHex(target.lab.L, target.lab.a, target.lab.b) }} />
|
||||
<div className="text-xs">
|
||||
<p className="text-gray-500">{target.label}</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">{target.label}</p>
|
||||
{delta !== null && <p className={`font-bold ${deltaColor}`}>ΔE = {delta.toFixed(2)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,19 +112,19 @@ export default function ColorLabPage() {
|
||||
|
||||
<div className="w-full space-y-1 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">HEX</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">HEX</span>
|
||||
<span className="font-mono">{hex}</span>
|
||||
<button onClick={() => navigator.clipboard.writeText(hex)} className="rounded p-0.5 text-gray-300 hover:text-gray-500"><Copy size={12} /></button>
|
||||
<button onClick={() => navigator.clipboard.writeText(hex)} className="rounded p-0.5 text-gray-300 hover:text-gray-500 dark:text-gray-400"><Copy size={12} /></button>
|
||||
</div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">RGB</span><span className="font-mono">{rgb.r}, {rgb.g}, {rgb.b}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Lab</span><span className="font-mono">L:{currentLab.L.toFixed(1)} a:{currentLab.a.toFixed(1)} b:{currentLab.b.toFixed(1)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">RGB</span><span className="font-mono">{rgb.r}, {rgb.g}, {rgb.b}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Lab</span><span className="font-mono">L:{currentLab.L.toFixed(1)} a:{currentLab.a.toFixed(1)} b:{currentLab.b.toFixed(1)}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex gap-1">
|
||||
{(['hex', 'rgb', 'lab', 'pantone'] as const).map(f => (
|
||||
<button key={f} onClick={() => setInputFormat(f)}
|
||||
className={`rounded px-2 py-0.5 text-xs ${inputFormat === f ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
className={`rounded px-2 py-0.5 text-xs ${inputFormat === f ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500 dark:text-gray-400'}`}>
|
||||
{f === 'pantone' ? '潘通' : f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
@@ -145,12 +145,12 @@ export default function ColorLabPage() {
|
||||
<div className="flex gap-1">
|
||||
{(['lab', 'rgb'] as const).map(m => (
|
||||
<button key={m} onClick={() => setMode(m)}
|
||||
className={`rounded px-2 py-0.5 text-xs ${mode === m ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
className={`rounded px-2 py-0.5 text-xs ${mode === m ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500 dark:text-gray-400'}`}>
|
||||
{m.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setTarget(null)}
|
||||
className="ml-1 rounded p-0.5 text-gray-300 hover:text-gray-500"><RotateCcw size={14} /></button>
|
||||
className="ml-1 rounded p-0.5 text-gray-300 hover:text-gray-500 dark:text-gray-400"><RotateCcw size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +199,7 @@ function SliderRow({ label, value, min, max, step, onChange, trackStyle }: {
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 text-xs text-gray-500">{label}</span>
|
||||
<span className="w-6 text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
||||
<input type="range" min={min} max={max} step={step} value={value}
|
||||
onChange={e => onChange(parseFloat(e.target.value))}
|
||||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full"
|
||||
|
||||
@@ -34,36 +34,36 @@ export default function DashboardPage() {
|
||||
<h2 className="mb-6 text-2xl font-bold">仪表盘</h2>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<Link to="/formulas" className="rounded-xl border bg-white p-5 transition-shadow hover:shadow-md">
|
||||
<Link to="/formulas" className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5 transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-blue-50 p-3"><FlaskConical size={24} className="text-blue-600" /></div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats?.formulaCount ?? '-'}</div>
|
||||
<div className="text-sm text-gray-500">配方总数</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">配方总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/ingredients" className="rounded-xl border bg-white p-5 transition-shadow hover:shadow-md">
|
||||
<Link to="/ingredients" className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5 transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-green-50 p-3"><Leaf size={24} className="text-green-600" /></div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats?.ingredientCount ?? '-'}</div>
|
||||
<div className="text-sm text-gray-500">成分总数</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">成分总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/projects" className="rounded-xl border bg-white p-5 transition-shadow hover:shadow-md">
|
||||
<Link to="/projects" className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5 transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-purple-50 p-3"><FolderKanban size={24} className="text-purple-600" /></div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats?.projectCount ?? '-'}</div>
|
||||
<div className="text-sm text-gray-500">项目数量</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">项目数量</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-white p-5">
|
||||
<div className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="font-semibold">最近更新</h3>
|
||||
<Link to="/formulas" className="text-sm text-blue-600 hover:underline">查看全部</Link>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function FormulaDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formula.description ? <p className="mb-4 text-sm text-gray-500">{String(formula.description)}</p> : null}
|
||||
{formula.description ? <p className="mb-4 text-sm text-gray-500 dark:text-gray-400">{String(formula.description)}</p> : null}
|
||||
<div className="mb-4 flex items-center gap-4 text-xs text-gray-400">
|
||||
<span>v{Number(formula.currentVersion)}</span>
|
||||
<span>更新于 {new Date(String(formula.updatedAt)).toLocaleString('zh-CN')}</span>
|
||||
@@ -64,11 +64,11 @@ export default function FormulaDetailPage() {
|
||||
|
||||
<div className="mb-4 flex gap-1 rounded-lg bg-gray-100 p-1 w-fit">
|
||||
<button onClick={() => setParams({ tab: 'detail' })}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm ${tab === 'detail' ? 'bg-white font-medium text-gray-900 shadow-sm' : 'text-gray-500'}`}>
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm ${tab === 'detail' ? 'bg-white dark:bg-gray-700 font-medium text-gray-900 shadow-sm' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
<Eye size={14} /> 详情
|
||||
</button>
|
||||
<button onClick={() => setParams({ tab: 'visual' })}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm ${tab === 'visual' ? 'bg-white font-medium text-gray-900 shadow-sm' : 'text-gray-500'}`}>
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm ${tab === 'visual' ? 'bg-white dark:bg-gray-700 font-medium text-gray-900 shadow-sm' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
<BarChart3 size={14} /> 可视化编辑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -51,30 +51,30 @@ export default function FormulaListPage() {
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">配方记录</h2>
|
||||
<button onClick={() => navigate('/formulas/new')}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||
<Plus size={16} /> 新建配方
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6 w-72">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input type="text" placeholder="搜索配方名称..."
|
||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-9 pr-3 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
className="w-full rounded-lg border border-slate-300 py-2 pl-9 pr-3 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-xl border border-gray-200 p-5">
|
||||
<div className="mb-3 h-5 w-3/4 rounded bg-gray-200" />
|
||||
<div className="mb-2 h-4 w-full rounded bg-gray-100" />
|
||||
<div className="h-3 w-1/2 rounded bg-gray-100" />
|
||||
<div key={i} className="animate-pulse rounded-xl border border-slate-200 p-5">
|
||||
<div className="mb-3 h-5 w-3/4 rounded bg-slate-200" />
|
||||
<div className="mb-2 h-4 w-full rounded bg-slate-100" />
|
||||
<div className="h-3 w-1/2 rounded bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : formulas.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<div className="flex flex-col items-center justify-center py-20 text-slate-400">
|
||||
<FlaskConical size={48} className="mb-3" />
|
||||
<p className="text-lg">暂无配方</p>
|
||||
<p className="mt-1 text-sm">点击"新建配方"开始创建</p>
|
||||
@@ -84,18 +84,18 @@ export default function FormulaListPage() {
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{formulas.map((f) => (
|
||||
<Link key={f.id} to={`/formulas/${f.id}`}
|
||||
className="group rounded-xl border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md">
|
||||
<h3 className="mb-1 font-semibold text-gray-900 group-hover:text-blue-600">{f.name}</h3>
|
||||
className="group rounded-xl border border-slate-200 bg-white dark:bg-gray-700 p-5 transition-shadow hover:shadow-card-hover">
|
||||
<h3 className="mb-1 font-semibold text-slate-900 group-hover:text-brand-600">{f.name}</h3>
|
||||
{f.description && (
|
||||
<p className="mb-3 line-clamp-2 text-sm text-gray-500">{f.description}</p>
|
||||
<p className="mb-3 line-clamp-2 text-sm text-slate-500">{f.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400">
|
||||
<div className="flex items-center gap-4 text-xs text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock size={12} /> v{f.currentVersion}
|
||||
</span>
|
||||
<span>{new Date(f.updatedAt).toLocaleDateString('zh-CN')}</span>
|
||||
{f.project && (
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-500">{f.project.name}</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-slate-500">{f.project.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -105,10 +105,10 @@ export default function FormulaListPage() {
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-center gap-3 text-sm">
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
className="rounded-lg border px-3 py-1.5 hover:bg-gray-50 disabled:opacity-30">上一页</button>
|
||||
<span className="text-gray-500">{pagination.page} / {pagination.totalPages}</span>
|
||||
className="rounded-lg border px-3 py-1.5 hover:bg-slate-50 disabled:opacity-30">上一页</button>
|
||||
<span className="text-slate-500">{pagination.page} / {pagination.totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(pagination.totalPages, p + 1))} disabled={page >= pagination.totalPages}
|
||||
className="rounded-lg border px-3 py-1.5 hover:bg-gray-50 disabled:opacity-30">下一页</button>
|
||||
className="rounded-lg border px-3 py-1.5 hover:bg-slate-50 disabled:opacity-30">下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -178,7 +178,7 @@ export default function IngredientsPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">{ing.unitPrice != null ? `¥${Number(ing.unitPrice).toFixed(2)}` : '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{ing.supplier ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{ing.supplier ?? '-'}</td>
|
||||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => { setSelected(ing); setDialogMode('edit'); setFormError('') }}
|
||||
@@ -196,7 +196,7 @@ export default function IngredientsPage() {
|
||||
</div>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>共 {pagination.total} 条</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
@@ -230,7 +230,7 @@ export default function IngredientsPage() {
|
||||
['单价', selected.unitPrice != null ? `¥${Number(selected.unitPrice).toFixed(2)}` : '-'],
|
||||
['描述', selected.description ?? '-'],
|
||||
].map(([l, v]) => (
|
||||
<div key={l}><span className="text-sm text-gray-500">{l}</span><p className="text-sm">{v}</p></div>
|
||||
<div key={l}><span className="text-sm text-gray-500 dark:text-gray-400">{l}</span><p className="text-sm">{v}</p></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -284,7 +284,7 @@ export default function IngredientsPage() {
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
||||
<AlertDialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl">
|
||||
<AlertDialog.Title className="mb-2 text-lg font-bold">确认删除</AlertDialog.Title>
|
||||
<AlertDialog.Description className="mb-4 text-sm text-gray-500">
|
||||
<AlertDialog.Description className="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
确定要删除成分「{deleteTarget?.chineseName}」吗?此操作不可撤销。
|
||||
</AlertDialog.Description>
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -25,29 +25,29 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-sm rounded-xl bg-white p-8 shadow-lg">
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="w-full max-w-sm rounded-xl bg-white dark:bg-gray-700 p-8 shadow-lg">
|
||||
<div className="mb-6 text-center">
|
||||
<FlaskConical size={32} className="mx-auto mb-2 text-blue-600" />
|
||||
<FlaskConical size={32} className="mx-auto mb-2 text-brand-600" />
|
||||
<h1 className="text-xl font-bold">配方研发平台</h1>
|
||||
<p className="text-sm text-gray-500">登录您的账户</p>
|
||||
<p className="text-sm text-slate-500">登录您的账户</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input type="text" placeholder="用户名" value={username} onChange={e => setUsername(e.target.value)}
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<input type="password" placeholder="密码" value={password} onChange={e => setPassword(e.target.value)}
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
className="w-full rounded-lg bg-brand-600 py-2.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
还没有账户?<Link to="/register" className="text-blue-600 hover:underline">注册</Link>
|
||||
<p className="mt-4 text-center text-sm text-slate-500">
|
||||
还没有账户?<Link to="/register" className="text-brand-600 hover:underline">注册</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Plus, Trash2, FolderKanban } from 'lucide-react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface Project {
|
||||
@@ -33,35 +32,47 @@ export default function ProjectsPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h2 className="mb-6 text-2xl font-bold">项目管理</h2>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-800 dark:text-slate-100">项目管理</h2>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">管理研发项目,组织配方分类</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex gap-2">
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="项目名称"
|
||||
className="rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
<input value={desc} onChange={e => setDesc(e.target.value)} placeholder="描述(可选)"
|
||||
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
<div className="mb-8 flex gap-2">
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="项目名称" className="w-40" />
|
||||
<input value={desc} onChange={e => setDesc(e.target.value)} placeholder="描述(可选)" className="flex-1" />
|
||||
<button onClick={() => createMut.mutate()} disabled={!name.trim()}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
className="inline-flex items-center gap-1.5 rounded-btn bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-200 hover:bg-brand-700 active:scale-[0.98] disabled:opacity-50">
|
||||
<Plus size={14} /> 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="rounded-xl border bg-white p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-semibold">{p.name}</h3>
|
||||
<button onClick={() => deleteMut.mutate(p.id)} className="rounded p-1 text-gray-400 hover:text-red-500"><Trash2 size={14} /></button>
|
||||
{projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-400 dark:text-slate-500">
|
||||
<FolderKanban size={48} className="mb-3 opacity-50" />
|
||||
<p className="text-lg font-medium">暂无项目</p>
|
||||
<p className="mt-1 text-sm">在上方输入项目名称创建第一个项目</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="rounded-2xl border border-slate-100 bg-white dark:bg-gray-700 p-5 shadow-card transition-all duration-300 hover:shadow-card-hover hover:-translate-y-0.5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-slate-900/30">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-semibold text-slate-800 dark:text-slate-100">{p.name}</h3>
|
||||
<button onClick={() => deleteMut.mutate(p.id)} className="rounded-btn p-1 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-500 dark:hover:bg-rose-900/20">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{p.description && <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{p.description}</p>}
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
|
||||
{p._count.formulas} 个配方
|
||||
</span>
|
||||
<span>{new Date(p.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{p.description && <p className="mt-1 text-sm text-gray-500">{p.description}</p>}
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>{p._count.formulas} 个配方</span>
|
||||
<span>·</span>
|
||||
<span>{new Date(p.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,24 +25,24 @@ export default function RegisterPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-sm rounded-xl bg-white p-8 shadow-lg">
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="w-full max-w-sm rounded-xl bg-white dark:bg-gray-700 p-8 shadow-lg">
|
||||
<h1 className="mb-6 text-center text-xl font-bold">创建账户</h1>
|
||||
{error && <div className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input type="text" placeholder="用户名" value={username} onChange={e => setUsername(e.target.value)}
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<input type="password" placeholder="密码(至少4位)" value={password} onChange={e => setPassword(e.target.value)}
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<input type="password" placeholder="确认密码" value={confirm} onChange={e => setConfirm(e.target.value)}
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
className="w-full rounded-lg bg-brand-600 py-2.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
已有账户?<Link to="/login" className="text-blue-600 hover:underline">登录</Link>
|
||||
<p className="mt-4 text-center text-sm text-slate-500">
|
||||
已有账户?<Link to="/login" className="text-brand-600 hover:underline">登录</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function SearchPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h2 className="mb-2 text-2xl font-bold">搜索结果</h2>
|
||||
{q && <p className="mb-4 text-sm text-gray-500">「{q}」{keywords.length > 0 && ` → AI 理解: ${keywords.join(', ')}`}</p>}
|
||||
{q && <p className="mb-4 text-sm text-gray-500 dark:text-gray-400">「{q}」{keywords.length > 0 && ` → AI 理解: ${keywords.join(', ')}`}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-gray-400">搜索中...</div>
|
||||
@@ -42,7 +42,7 @@ export default function SearchPage() {
|
||||
<Link key={f.id as string} to={`/formulas/${f.id}`}
|
||||
className="rounded-xl border bg-white p-5 transition-shadow hover:shadow-md">
|
||||
<h3 className="font-semibold text-gray-900">{f.name}</h3>
|
||||
{f.description && <p className="mt-1 line-clamp-2 text-sm text-gray-500">{f.description}</p>}
|
||||
{f.description && <p className="mt-1 line-clamp-2 text-sm text-gray-500 dark:text-gray-400">{f.description}</p>}
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
{f.project && <span>{f.project.name}</span>}
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function SettingsPage() {
|
||||
<h2 className="mb-6 text-2xl font-bold">设置</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border bg-white p-5">
|
||||
<div className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5">
|
||||
<h3 className="mb-3 font-semibold">外观</h3>
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
@@ -89,11 +89,11 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-white p-5">
|
||||
<div className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5">
|
||||
<h3 className="mb-3 font-semibold">AI 配置</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-sm text-gray-500">运行模式</p>
|
||||
<p className="mb-2 text-sm text-gray-500 dark:text-gray-400">运行模式</p>
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ value: true, label: 'Mock 模拟(无需 Key)' },
|
||||
@@ -125,7 +125,7 @@ export default function SettingsPage() {
|
||||
<input type="text" value={openaiBaseUrl}
|
||||
onChange={e => setOpenaiBaseUrl(e.target.value)}
|
||||
placeholder="Base URL(可选,默认 api.openai.com)"
|
||||
className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 focus:border-blue-500 focus:outline-none" />
|
||||
className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 dark:text-gray-400 focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function SettingsPage() {
|
||||
<input type="text" value={deepseekBaseUrl}
|
||||
onChange={e => setDeepseekBaseUrl(e.target.value)}
|
||||
placeholder="Base URL(可选,默认 api.deepseek.com)"
|
||||
className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 focus:border-blue-500 focus:outline-none" />
|
||||
className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 dark:text-gray-400 focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
@@ -170,9 +170,9 @@ export default function SettingsPage() {
|
||||
<p className="mt-2 text-xs text-gray-400">API Key 仅存储在本地浏览器和后端内存中,不会上传到第三方</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-white p-5">
|
||||
<div className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 p-5">
|
||||
<h3 className="mb-3 font-semibold">关于</h3>
|
||||
<div className="space-y-1 text-sm text-gray-500">
|
||||
<div className="space-y-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>配方研发智能平台 v0.1.0</p>
|
||||
<p>AI 驱动的化妆品配方研发辅助工具</p>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function VersionComparePage() {
|
||||
<span className="font-medium">{d.inciName}</span>
|
||||
<span className="ml-1 text-xs text-gray-400">{d.chineseName}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{d.phaseName}</td>
|
||||
<td className="px-4 py-2 text-gray-500 dark:text-gray-400">{d.phaseName}</td>
|
||||
<td className="px-4 py-2 text-right">{d.oldPercentage != null ? `${d.oldPercentage.toFixed(2)}%` : '-'}</td>
|
||||
<td className="px-4 py-2 text-right">{d.newPercentage != null ? `${d.newPercentage.toFixed(2)}%` : '-'}</td>
|
||||
<td className={`px-4 py-2 text-right font-medium ${d.change > 0.01 ? 'text-green-600' : d.change < -0.01 ? 'text-red-600' : 'text-gray-400'}`}>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function VersionHistoryPage() {
|
||||
|
||||
{versions.map((v, i) => (
|
||||
<div key={v.id} className="relative mb-6">
|
||||
<div className={`absolute -left-[1.65rem] top-1.5 h-3 w-3 rounded-full border-2 ${i === 0 ? 'border-blue-500 bg-blue-100' : 'border-gray-300 bg-white'}`} />
|
||||
<div className={`absolute -left-[1.65rem] top-1.5 h-3 w-3 rounded-full border-2 ${i === 0 ? 'border-blue-500 bg-blue-100' : 'border-gray-300 bg-white dark:bg-gray-700'}`} />
|
||||
|
||||
<button onClick={() => setExpanded(expanded === i ? null : i)}
|
||||
className="w-full text-left">
|
||||
@@ -67,7 +67,7 @@ export default function VersionHistoryPage() {
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{new Date(v.createdAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
{v.description && <p className="mt-0.5 text-sm text-gray-500">{v.description}</p>}
|
||||
{v.description && <p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{v.description}</p>}
|
||||
<ChevronDown size={14} className={`absolute right-0 top-1.5 text-gray-300 transition-transform ${expanded === i ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
@@ -75,10 +75,10 @@ export default function VersionHistoryPage() {
|
||||
<div className="mt-3 space-y-2 rounded-lg border bg-gray-50 p-3">
|
||||
{v.phases.map(p => (
|
||||
<div key={p.id}>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{p.name}</p>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">{p.name}</p>
|
||||
<div className="space-y-0.5">
|
||||
{p.ingredients.map((ing, j) => (
|
||||
<div key={j} className="flex items-center justify-between rounded bg-white px-2 py-1 text-sm">
|
||||
<div key={j} className="flex items-center justify-between rounded bg-white dark:bg-gray-700 px-2 py-1 text-sm">
|
||||
<span>{ing.ingredient.inciName} <span className="text-xs text-gray-400">{ing.ingredient.chineseName}</span></span>
|
||||
<span className="font-medium text-gray-600">{Number(ing.percentage).toFixed(2)}%</span>
|
||||
</div>
|
||||
|
||||
39
frontend/src/shared/components/Alert.tsx
Normal file
39
frontend/src/shared/components/Alert.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx'
|
||||
import { AlertCircle, CheckCircle, Info, XCircle } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type AlertVariant = 'error' | 'success' | 'info' | 'warning'
|
||||
|
||||
interface AlertProps {
|
||||
variant?: AlertVariant
|
||||
title?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const config: Record<AlertVariant, { icon: typeof AlertCircle; style: string }> = {
|
||||
error: { icon: XCircle, style: 'border-red-200 bg-red-50 text-red-800' },
|
||||
success: { icon: CheckCircle, style: 'border-green-200 bg-green-50 text-green-800' },
|
||||
warning: { icon: AlertCircle, style: 'border-yellow-200 bg-yellow-50 text-yellow-800' },
|
||||
info: { icon: Info, style: 'border-blue-200 bg-blue-50 text-blue-800' },
|
||||
}
|
||||
|
||||
export function Alert({ variant = 'info', title, children, className, onClose }: AlertProps) {
|
||||
const { icon: Icon, style } = config[variant]
|
||||
|
||||
return (
|
||||
<div className={clsx('flex gap-3 rounded-lg border px-4 py-3', style, className)}>
|
||||
<Icon size={18} className="shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && <p className="font-medium text-sm">{title}</p>}
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="shrink-0 opacity-50 hover:opacity-100">
|
||||
<XCircle size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
frontend/src/shared/components/Skeleton.tsx
Normal file
53
frontend/src/shared/components/Skeleton.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string
|
||||
lines?: number
|
||||
rounded?: boolean
|
||||
}
|
||||
|
||||
export function Skeleton({ className, lines = 1, rounded }: SkeletonProps) {
|
||||
if (lines > 1) {
|
||||
return (
|
||||
<div className={clsx('space-y-2', className)}>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
'animate-pulse bg-gray-200 dark:bg-gray-700',
|
||||
rounded ? 'rounded-full' : 'rounded',
|
||||
i === lines - 1 ? 'w-3/4' : 'w-full',
|
||||
'h-4',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-pulse bg-gray-200',
|
||||
rounded ? 'rounded-full' : 'rounded-md',
|
||||
className ?? 'h-4 w-full',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 px-4 py-8">
|
||||
<Skeleton className="h-8 w-48 rounded" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Skeleton className="h-24 rounded-xl" />
|
||||
<Skeleton className="h-24 rounded-xl" />
|
||||
<Skeleton className="h-24 rounded-xl" />
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-700 dark:border-gray-700 dark:bg-gray-700 p-5">
|
||||
<Skeleton lines={4} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
frontend/src/shared/components/Toast.tsx
Normal file
87
frontend/src/shared/components/Toast.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState, useCallback, createContext, useContext, type ReactNode } from 'react'
|
||||
import { X, CheckCircle, AlertCircle, Info, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'loading'
|
||||
|
||||
interface ToastItem {
|
||||
id: number
|
||||
type: ToastType
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (message: string, type?: ToastType, duration?: number) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
const icons: Record<ToastType, typeof CheckCircle> = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
info: Info,
|
||||
loading: Loader2,
|
||||
}
|
||||
|
||||
const colors: Record<ToastType, string> = {
|
||||
success: 'border-green-200 bg-green-50 text-green-800',
|
||||
error: 'border-red-200 bg-red-50 text-red-800',
|
||||
info: 'border-blue-200 bg-blue-50 text-blue-800',
|
||||
loading: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-700',
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
|
||||
const add = useCallback((message: string, type: ToastType = 'info', duration = 4000) => {
|
||||
const id = nextId++
|
||||
setToasts(prev => [...prev, { id, type, message, duration }])
|
||||
if (duration > 0 && type !== 'loading') {
|
||||
setTimeout(() => remove(id), duration)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remove = useCallback((id: number) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const toast = useCallback((message: string, type?: ToastType, duration?: number) => {
|
||||
add(message, type, duration)
|
||||
}, [add])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map(t => {
|
||||
const Icon = icons[t.type]
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded-lg border px-4 py-3 shadow-lg min-w-[280px] max-w-[400px] animate-in',
|
||||
colors[t.type],
|
||||
t.type === 'loading' && 'animate-pulse',
|
||||
)}
|
||||
>
|
||||
<Icon size={16} className={t.type === 'loading' ? 'animate-spin' : ''} />
|
||||
<span className="text-sm flex-1">{t.message}</span>
|
||||
<button onClick={() => remove(t.id)} className="shrink-0 opacity-50 hover:opacity-100">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
52
frontend/src/shared/services/api.ts
Normal file
52
frontend/src/shared/services/api.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
code?: string
|
||||
|
||||
constructor(status: number, message: string, code?: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
try {
|
||||
const raw = localStorage.getItem('auth-storage')
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as { state?: { token?: string } }
|
||||
const token = parsed?.state?.token
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(url: string, options?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(options?.headers)
|
||||
const authHeaders = getAuthHeaders()
|
||||
for (const [key, value] of Object.entries(authHeaders)) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...options, headers })
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const text = await res.text()
|
||||
if (!text) {
|
||||
if (!res.ok) throw new ApiError(res.status, `请求失败 (${res.status})`)
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(text) as T & { error?: string; code?: string }
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, data.error ?? `请求失败 (${res.status})`, data.code)
|
||||
}
|
||||
return data
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) throw e
|
||||
if (!res.ok) throw new ApiError(res.status, `请求失败 (${res.status})`)
|
||||
throw new ApiError(res.status, '响应格式错误')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user