Compare commits

..

40 Commits
master ... dev

Author SHA1 Message Date
龟男日记\www 0fc2899f25 仍然有bug 2026-02-27 05:49:21 +08:00
龟男日记\www 1f03387619 订单查看 2026-02-25 21:57:32 +08:00
龟男日记\www 4fb29d9cb7 精简product 2026-02-25 19:43:02 +08:00
龟男日记\www e9947bdbdd 基础布局 2026-02-24 03:27:01 +08:00
龟男日记\www 482bcda16d 按钮精简 编辑 2026-02-24 00:05:26 +08:00
龟男日记\www 6e75c34faf 按钮精简 2026-02-23 21:23:40 +08:00
龟男日记\www c84eef485b 淘宝按钮填充 2026-02-23 06:32:42 +08:00
龟男日记\www b90005038f 组件库 2026-02-23 02:31:11 +08:00
龟男日记\www c6771a7098 计数更新 2026-02-23 00:22:12 +08:00
龟男日记\www 63620571a2 重新导入按钮 2026-02-22 22:28:21 +08:00
龟男日记\www c8de57af22 同步精简并优化 2026-02-22 19:29:15 +08:00
龟男日记\www a424a5c1a9 状态 2026-02-22 04:53:01 +08:00
龟男日记\www dc8c88e463 首页展示 2026-02-22 04:29:53 +08:00
龟男日记\www 14a2aaced0 同步优化 2026-02-21 22:52:41 +08:00
龟男日记\www 4def0e2c0b 批量同步 2026-02-21 18:17:39 +08:00
龟男日记\www 1860affd69 api 结构大精简 2026-02-21 03:28:58 +08:00
龟男日记\www b9cb60e3d0 精简api 2026-02-21 03:04:38 +08:00
龟男日记\www 41f3eb5adf 商品同步 2026-02-21 01:58:49 +08:00
龟男日记\www c29ee4d0c3 同步精简 2026-02-20 22:36:13 +08:00
龟男日记\www dae1b2704f 基类化 2026-02-20 22:01:23 +08:00
龟男日记\www 35928a6144 分类 preoder 和 order 2026-02-20 04:56:52 +08:00
龟男日记\www efb3f2c727 同步确定 2026-02-20 04:18:24 +08:00
龟男日记\www 9a47af76ce 精简同步按钮 2026-02-19 21:44:15 +08:00
龟男日记\www 1f78d88d10 同步优化 2026-02-18 03:17:19 +08:00
龟男日记\www f5621a3aa1 管理网站状态 2026-02-17 23:57:30 +08:00
龟男日记\www ee3ec61548 验证key 2026-02-16 04:39:32 +08:00
龟男日记\www 029c85f1a3 slider 优化 2026-02-15 18:56:35 +08:00
龟男日记\www 397dcb93ae 预购精简 2026-02-13 05:10:26 +08:00
龟男日记\www af1023c3d7 数据优化 2026-02-13 04:08:43 +08:00
龟男日记\www fa30986946 redis 2026-02-12 03:38:08 +08:00
龟男日记\www b26fe1e117 日志记录 2026-02-12 02:50:18 +08:00
龟男日记\www 07d1c2274b 清楚 2026-02-12 02:36:01 +08:00
龟男日记\www 249423d73d 暂时完结 2026-02-12 02:30:28 +08:00
龟男日记\www bdee359c4c 端口修正 2026-02-09 04:57:02 +08:00
龟男日记\www 84f94b53d9 重新布局 2026-02-09 04:52:14 +08:00
龟男日记\www b4991fcefd 富文本模式 2026-02-09 04:27:32 +08:00
龟男日记\www 4f14ac59f3 安装 LivePrew 2026-02-09 04:00:25 +08:00
龟男日记\www cccbe20aa0 删除 2026-02-09 03:52:44 +08:00
龟男日记\www 3ad86524d4 精简为网格展示 2026-02-09 03:14:16 +08:00
龟男日记\www 93f8261622 数据储存 2026-02-09 02:43:10 +08:00
116 changed files with 17013 additions and 30 deletions

View File

@ -1,2 +1,28 @@
DATABASE_URL=mongodb://127.0.0.1/your-database-name # Database
DATABASE_URL=postgresql://user:password@localhost:5432/database
# Payload
PAYLOAD_SECRET=YOUR_SECRET_HERE PAYLOAD_SECRET=YOUR_SECRET_HERE
# Onebound API淘宝商品数据https://open.onebound.cn
ONEBOUND_API_KEY=your-onebound-key
ONEBOUND_API_SECRET=your-onebound-secret
# Redis Configuration
REDIS_URL=redis://localhost:6379
# API Keys (统一使用 PAYLOAD_API_KEY)
PAYLOAD_API_KEY=your-payload-api-key-here
# Medusa 配置
MEDUSA_BACKEND_URL=http://localhost:9000
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=your-publishable-key-here
PAYLOAD_API_KEY=your-payload-api-key-here
# Cloudflare R2 配置
CLOUDFLARE_R2_BUCKET=your-bucket
CLOUDFLARE_R2_ACCESS_KEY_ID=your-access-key
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your-secret-key
CLOUDFLARE_R2_REGION=auto
CLOUDFLARE_R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
CLOUDFLARE_R2_PUBLIC_URL=https://your-public-domain.com

View File

@ -62,9 +62,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 1145
ENV PORT 3000 ENV PORT 1145
# server.js is created by next build from the standalone output # server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output # https://nextjs.org/docs/pages/api-reference/next-config-js/output

View File

@ -20,7 +20,7 @@ After you click the `Deploy` button above, you'll want to have standalone copy o
2. `cd my-project && cp .env.example .env` to copy the example environment variables. You'll need to add the `MONGODB_URL` from your Cloud project to your `.env` if you want to use S3 storage and the MongoDB database that was created for you. 2. `cd my-project && cp .env.example .env` to copy the example environment variables. You'll need to add the `MONGODB_URL` from your Cloud project to your `.env` if you want to use S3 storage and the MongoDB database that was created for you.
3. `pnpm install && pnpm dev` to install dependencies and start the dev server 3. `pnpm install && pnpm dev` to install dependencies and start the dev server
4. open `http://localhost:3000` to open the app in your browser 4. open `http://localhost:1145` to open the app in your browser
That's it! Changes made in `./src` will be reflected in your app. Follow the on-screen instructions to login and create your first admin user. Then check out [Production](#production) once you're ready to build and serve your app, and [Deployment](#deployment) when you're ready to go live. That's it! Changes made in `./src` will be reflected in your app. Follow the on-screen instructions to login and create your first admin user. Then check out [Production](#production) once you're ready to build and serve your app, and [Deployment](#deployment) when you're ready to go live.

View File

@ -4,7 +4,7 @@ services:
payload: payload:
image: node:18-alpine image: node:18-alpine
ports: ports:
- '3000:3000' - '1145:1145'
volumes: volumes:
- .:/home/node/app - .:/home/node/app
- node_modules:/home/node/app/node_modules - node_modules:/home/node/app/node_modules

View File

@ -6,24 +6,26 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build", "build": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev", "dev": "cross-env NODE_OPTIONS=--no-deprecation PORT=1145 next dev",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev", "devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation PORT=1145 next dev",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start", "start": "cross-env NODE_OPTIONS=--no-deprecation PORT=1145 next start",
"test": "pnpm run test:int && pnpm run test:e2e", "test": "pnpm run test:int && pnpm run test:e2e",
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts", "test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts" "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
}, },
"dependencies": { "dependencies": {
"@payloadcms/db-postgres": "3.75.0", "@payloadcms/db-postgres": "3.75.0",
"@payloadcms/live-preview-react": "^3.75.0",
"@payloadcms/next": "3.75.0", "@payloadcms/next": "3.75.0",
"@payloadcms/plugin-cloud": "^3.0.2", "@payloadcms/plugin-cloud": "^3.0.2",
"@payloadcms/plugin-cloud-storage": "^3.75.0", "@payloadcms/plugin-cloud-storage": "^3.75.0",
"@payloadcms/richtext-lexical": "3.75.0", "@payloadcms/richtext-lexical": "3.75.0",
"@payloadcms/storage-s3": "^3.75.0", "@payloadcms/storage-s3": "^3.75.0",
"@payloadcms/translations": "3.75.0",
"@payloadcms/ui": "3.75.0", "@payloadcms/ui": "3.75.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "16.4.7", "dotenv": "16.4.7",
@ -32,6 +34,7 @@
"payload": "3.75.0", "payload": "3.75.0",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"redis": "^5.10.0",
"sharp": "0.34.2" "sharp": "0.34.2"
}, },
"devDependencies": { "devDependencies": {

View File

@ -22,7 +22,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000', // baseURL: 'http://localhost:1145',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
@ -36,6 +36,6 @@ export default defineConfig({
webServer: { webServer: {
command: 'pnpm dev', command: 'pnpm dev',
reuseExistingServer: true, reuseExistingServer: true,
url: 'http://localhost:3000', url: 'http://localhost:1145',
}, },
}) })

View File

@ -11,6 +11,9 @@ importers:
'@payloadcms/db-postgres': '@payloadcms/db-postgres':
specifier: 3.75.0 specifier: 3.75.0
version: 3.75.0(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3)) version: 3.75.0(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))
'@payloadcms/live-preview-react':
specifier: ^3.75.0
version: 3.75.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@payloadcms/next': '@payloadcms/next':
specifier: 3.75.0 specifier: 3.75.0
version: 3.75.0(@types/react@19.2.9)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) version: 3.75.0(@types/react@19.2.9)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)
@ -26,6 +29,9 @@ importers:
'@payloadcms/storage-s3': '@payloadcms/storage-s3':
specifier: ^3.75.0 specifier: ^3.75.0
version: 3.75.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) version: 3.75.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)
'@payloadcms/translations':
specifier: 3.75.0
version: 3.75.0
'@payloadcms/ui': '@payloadcms/ui':
specifier: 3.75.0 specifier: 3.75.0
version: 3.75.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) version: 3.75.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)
@ -50,6 +56,9 @@ importers:
react-dom: react-dom:
specifier: 19.2.1 specifier: 19.2.1
version: 19.2.1(react@19.2.1) version: 19.2.1(react@19.2.1)
redis:
specifier: ^5.10.0
version: 5.10.0
sharp: sharp:
specifier: 0.34.2 specifier: 0.34.2
version: 0.34.2 version: 0.34.2
@ -1568,6 +1577,15 @@ packages:
graphql: ^16.8.1 graphql: ^16.8.1
payload: 3.75.0 payload: 3.75.0
'@payloadcms/live-preview-react@3.75.0':
resolution: {integrity: sha512-lPTsaE30YApPGkTbQNJe2vMoB6mG7FQ/JneqG75sPZ8orGuuo3BVW844Zf8MU7wlmQ8vR2g4eKZ78e2x3O1kmA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.1 || ^19.1.2 || ^19.2.1
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.1 || ^19.1.2 || ^19.2.1
'@payloadcms/live-preview@3.75.0':
resolution: {integrity: sha512-t5KMgQp8c4T+/s1Hy5crvsQlwr5vsw902JY/GYN9s1pAHzAdjS/3J8FwAcuR98zw17We1NXDAhhje7rrpgNxZg==}
'@payloadcms/next@3.75.0': '@payloadcms/next@3.75.0':
resolution: {integrity: sha512-fSw14T2NghaTRV+A8AswUgiKlLt2JoSFWeGnESw3DhMyk6nlKEmAXXiJx7jfMOojeLQsOJOZj/tbF+MQqJHNSQ==} resolution: {integrity: sha512-fSw14T2NghaTRV+A8AswUgiKlLt2JoSFWeGnESw3DhMyk6nlKEmAXXiJx7jfMOojeLQsOJOZj/tbF+MQqJHNSQ==}
engines: {node: ^18.20.2 || >=20.9.0} engines: {node: ^18.20.2 || >=20.9.0}
@ -1625,6 +1643,34 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
'@redis/bloom@5.10.0':
resolution: {integrity: sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.10.0
'@redis/client@5.10.0':
resolution: {integrity: sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==}
engines: {node: '>= 18'}
'@redis/json@5.10.0':
resolution: {integrity: sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.10.0
'@redis/search@5.10.0':
resolution: {integrity: sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.10.0
'@redis/time-series@5.10.0':
resolution: {integrity: sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg==}
engines: {node: '>= 18'}
peerDependencies:
'@redis/client': ^5.10.0
'@rolldown/pluginutils@1.0.0-beta.11': '@rolldown/pluginutils@1.0.0-beta.11':
resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==} resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==}
@ -2535,6 +2581,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@ -4118,6 +4168,10 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
redis@5.10.0:
resolution: {integrity: sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==}
engines: {node: '>= 18'}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -6564,6 +6618,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@payloadcms/live-preview-react@3.75.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
dependencies:
'@payloadcms/live-preview': 3.75.0
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
'@payloadcms/live-preview@3.75.0': {}
'@payloadcms/next@3.75.0(@types/react@19.2.9)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)': '@payloadcms/next@3.75.0(@types/react@19.2.9)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.56.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.75.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)':
dependencies: dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@dnd-kit/core': 6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@ -6727,6 +6789,26 @@ snapshots:
dependencies: dependencies:
playwright: 1.56.1 playwright: 1.56.1
'@redis/bloom@5.10.0(@redis/client@5.10.0)':
dependencies:
'@redis/client': 5.10.0
'@redis/client@5.10.0':
dependencies:
cluster-key-slot: 1.1.2
'@redis/json@5.10.0(@redis/client@5.10.0)':
dependencies:
'@redis/client': 5.10.0
'@redis/search@5.10.0(@redis/client@5.10.0)':
dependencies:
'@redis/client': 5.10.0
'@redis/time-series@5.10.0(@redis/client@5.10.0)':
dependencies:
'@redis/client': 5.10.0
'@rolldown/pluginutils@1.0.0-beta.11': {} '@rolldown/pluginutils@1.0.0-beta.11': {}
'@rollup/rollup-android-arm-eabi@4.57.1': '@rollup/rollup-android-arm-eabi@4.57.1':
@ -7736,6 +7818,8 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
cluster-key-slot@1.1.2: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@ -8139,8 +8223,8 @@ snapshots:
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.7.3) '@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.7.3)
eslint: 9.39.2 eslint: 9.39.2
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2)
eslint-plugin-react: 7.37.5(eslint@9.39.2) eslint-plugin-react: 7.37.5(eslint@9.39.2)
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2)
@ -8159,7 +8243,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3 debug: 4.4.3
@ -8170,22 +8254,22 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2): eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.7.3) '@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.7.3)
eslint: 9.39.2 eslint: 9.39.2
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@ -8196,7 +8280,7 @@ snapshots:
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 9.39.2 eslint: 9.39.2
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2)
hasown: 2.0.2 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
@ -9611,6 +9695,14 @@ snapshots:
real-require@0.2.0: {} real-require@0.2.0: {}
redis@5.10.0:
dependencies:
'@redis/bloom': 5.10.0(@redis/client@5.10.0)
'@redis/client': 5.10.0
'@redis/json': 5.10.0(@redis/client@5.10.0)
'@redis/search': 5.10.0(@redis/client@5.10.0)
'@redis/time-series': 5.10.0(@redis/client@5.10.0)
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8

View File

@ -0,0 +1,515 @@
import React, { useState, useCallback, useRef } from 'react';
import {
Cpu,
Settings,
Anchor,
ChevronUp,
ChevronDown,
Plus,
Trash2,
Image,
Layers,
FileText,
ChevronRight,
RotateCw,
Target,
Move
} from 'lucide-react';
/**
* Disassembly Edit/Preview Page
* 融合 DisassemblyPages视觉展示与 DisassemblyLinkedProductsPage交互编辑
*
* 编辑模式:左侧属性面板 + 点击选中导航组件 + 支持新增/删除/修改名称/代码/图片
* 预览模式:与 DisassemblyPages v7.2.0 一致的工业草稿风格展示
*/
const styles = `
@keyframes scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}
@keyframes node-pulse-kf {
0% { box-shadow: 0 0 0 0px rgba(234, 179, 8, 0.5); }
100% { box-shadow: 0 0 0 10px rgba(234, 179, 8, 0); }
}
.blueprint-grid {
background-color: #ffffff;
background-image:
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
background-size: 30px 30px;
}
.blueprint-grid::after {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0, 0, 0, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
background-size: 10px 10px;
pointer-events: none;
}
.assembly-view {
filter: drop-shadow(0 15px 30px rgba(0,0,0,0.1)) contrast(1.05);
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.assembly-view:hover {
filter: drop-shadow(0 20px 40px rgba(0,0,0,0.15)) contrast(1.1);
}
.leader-line-svg {
transition: stroke 0.4s ease, stroke-width 0.4s ease;
}
.text-label-container {
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
}
.scan-effect {
position: absolute;
top: 0; left: 0; right: 0; height: 100%;
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.01), transparent);
animation: scanline 15s linear infinite;
pointer-events: none;
}
.node-selected {
animation: node-pulse-kf 2s infinite;
outline: 2px solid #eab308;
outline-offset: 4px;
}
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #000; border-radius: 10px; }
.part-hover { transition: all 0.35s cubic-bezier(0.23, 1, 0.32, 1); }
`;
const DEFAULT_PARTS = [
{ id: 'p1', name: '环境监控', code: 'ENV-X', img: 'https://images.unsplash.com/photo-1555680202-c86f0e12f086?auto=format&fit=crop&q=80&w=200' },
{ id: 'p2', name: '能源模组', code: 'BATT-V8', img: 'https://images.unsplash.com/photo-1619641259501-c88f28c6e355?auto=format&fit=crop&q=80&w=200' },
{ id: 'p3', name: '雷达阵列', code: 'LDR-07', img: 'https://images.unsplash.com/photo-1555680202-c86f0e12f086?auto=format&fit=crop&q=80&w=200' },
{ id: 'p4', name: '核心总成', code: 'CORE-MAX', img: 'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&q=80&w=200' },
{ id: 'p5', name: '液压单元', code: 'HYD-02', img: 'https://images.unsplash.com/photo-1635350736475-c8cef4b21906?auto=format&fit=crop&q=80&w=200' },
{ id: 'p6', name: '散热单元', code: 'COOL-F2', img: 'https://images.unsplash.com/photo-1635350736475-c8cef4b21906?auto=format&fit=crop&q=80&w=200' },
{ id: 'p7', name: '存储阵列', code: 'DATA-2T', img: 'https://images.unsplash.com/photo-1544006659-f0b21f04cb1d?auto=format&fit=crop&q=80&w=200' },
];
const DEFAULT_ASSEMBLY = 'https://images.unsplash.com/photo-1581092160562-40aa08e78837?auto=format&fit=crop&q=80&w=1600';
const ICON_SIZE = 80;
const CENTER_AXIS_Y = 180;
const OFFSET_Y = 40;
// ─── 单个组件节点(共用于编辑 & 预览)─────────────────────────────────────
function PartNode({ part, index, isHovered, isSelected, isEditMode, onHover, onClick }) {
const isUp = index % 2 === 0;
const boxTop = isUp ? CENTER_AXIS_Y - OFFSET_Y : CENTER_AXIS_Y + OFFSET_Y;
const SVG_CENTER_X = 50;
const SVG_CENTER_Y = 40;
const lineColor = isSelected
? '#eab308'
: isHovered
? '#000'
: '#f5f5f5';
const lineWidth = isSelected ? '2.5' : isHovered ? '2.5' : '1';
return (
<div
onMouseEnter={() => onHover(part.id)}
onMouseLeave={() => onHover(null)}
onClick={() => isEditMode && onClick(part.id)}
className={`group flex flex-col items-center relative flex-1
${isEditMode ? 'cursor-pointer' : 'cursor-default'}
${isSelected ? 'z-30' : 'z-10'}`}
style={{ minWidth: 0 }}
>
{/* 垂直引导线 */}
<svg
className="absolute top-0 left-1/2 -translate-x-1/2 w-[100px] h-[400px] overflow-visible pointer-events-none z-0"
viewBox="0 0 100 400"
>
<path
d={`M ${SVG_CENTER_X} ${SVG_CENTER_Y} L ${SVG_CENTER_X} ${boxTop}`}
fill="none"
stroke={lineColor}
strokeWidth={lineWidth}
strokeDasharray={isSelected || isHovered ? 'none' : '3,3'}
className="leader-line-svg"
/>
<circle cx={SVG_CENTER_X} cy={SVG_CENTER_Y} r="2.5"
fill={isSelected ? '#eab308' : isHovered ? '#000' : '#e0e0e0'} />
<circle cx={SVG_CENTER_X} cy={boxTop} r={isSelected || isHovered ? '4' : '2'}
fill={isSelected ? '#eab308' : isHovered ? '#000' : '#e0e0e0'} />
</svg>
{/* 图标节点 */}
<div
className={`relative flex items-center justify-center transition-all duration-500 z-10 part-hover rounded-sm
${isHovered || isSelected ? 'scale-110 -translate-y-2' : ''}
${isSelected ? 'node-selected' : ''}`}
style={{ width: `${ICON_SIZE}px`, height: `${ICON_SIZE}px` }}
>
<img src={part.img} className="w-16 h-16 object-contain relative z-10" alt={part.name} />
{(isHovered || isSelected) && (
<div className="absolute inset-0 bg-neutral-900/5 blur-2xl rounded-full scale-125" />
)}
{isEditMode && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-neutral-800 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded-full z-20">
<Move size={9} />
</div>
)}
</div>
{/* 标签容器 */}
<div
className={`absolute flex flex-col items-center whitespace-nowrap text-label-container z-30
${isHovered || isSelected ? (isUp ? '-translate-y-1' : 'translate-y-1') : ''}`}
style={{ top: `${boxTop}px`, left: '50%', transform: 'translateX(-50%)' }}
>
{/* 接线端子 */}
<div className={`w-2.5 h-2.5 rounded-full border-2 border-white mb-3 transition-all
${isSelected ? 'bg-yellow-400 scale-150' : isHovered ? 'bg-neutral-900 scale-125' : 'bg-neutral-200'}`}
/>
{/* 指示器 */}
<div className="flex items-center gap-1.5 mb-1.5 opacity-60">
{isUp ? <ChevronUp size={8} /> : <div className="w-[8px]" />}
<div className={`text-[8px] font-black uppercase tracking-widest transition-colors
${isHovered || isSelected ? 'text-neutral-900' : 'text-neutral-300'}`}>
CODE.0{index + 1}
</div>
{!isUp ? <ChevronDown size={8} /> : <div className="w-[8px]" />}
</div>
{/* 大字名称 */}
<div className={`text-2xl font-black uppercase tracking-tighter transition-all duration-300
${isSelected ? 'text-yellow-500 scale-105' : isHovered ? 'text-neutral-900 scale-105' : 'text-neutral-400'}`}>
{part.name}
</div>
</div>
</div>
);
}
// ─── 主组件 ────────────────────────────────────────────────────────────────
export default function App() {
const [isEditMode, setIsEditMode] = useState(true);
const [parts, setParts] = useState(DEFAULT_PARTS);
const [assemblyImg, setAssemblyImg] = useState(DEFAULT_ASSEMBLY);
const [hoveredId, setHoveredId] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [imgInputVal, setImgInputVal] = useState('');
const selectedPart = parts.find(p => p.id === selectedId) || null;
const idxOf = (id) => parts.findIndex(p => p.id === id);
// ── 更新选中组件字段 ──
const updateField = useCallback((field, value) => {
if (!selectedId) return;
setParts(prev => prev.map(p => p.id === selectedId ? { ...p, [field]: value } : p));
}, [selectedId]);
// ── 新增组件 ──
const addPart = () => {
const newPart = {
id: 'p-' + Date.now(),
name: '新组件',
code: 'NEW-00',
img: 'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&q=80&w=200',
};
setParts(prev => [...prev, newPart]);
setSelectedId(newPart.id);
};
// ── 删除选中组件 ──
const deletePart = () => {
if (!selectedId) return;
setParts(prev => prev.filter(p => p.id !== selectedId));
setSelectedId(null);
};
// ── 上移 / 下移 ──
const movePart = (dir) => {
const idx = idxOf(selectedId);
if (idx < 0) return;
const next = idx + dir;
if (next < 0 || next >= parts.length) return;
setParts(prev => {
const arr = [...prev];
[arr[idx], arr[next]] = [arr[next], arr[idx]];
return arr;
});
};
// ── 切换模式 ──
const enterPreview = () => {
setIsEditMode(false);
setSelectedId(null);
};
const enterEdit = () => setIsEditMode(true);
return (
<div className="h-screen w-screen bg-[#ffffff] text-neutral-900 font-mono flex flex-col overflow-hidden select-none">
<style>{styles}</style>
{/* ── 顶部标题栏 ────────────────────────────────────── */}
<header className="h-14 border-b-2 border-neutral-900 bg-white flex items-center justify-between px-8 z-50 shadow-sm">
<div className="flex items-center gap-4">
<div className="p-1.5 bg-neutral-900 rounded-sm">
<Cpu size={18} className="text-white" />
</div>
<div className="flex flex-col">
<h1 className="text-[13px] font-black uppercase tracking-[0.35em] leading-none text-neutral-900">
Disassembly_Editor
</h1>
<p className="text-[9px] text-neutral-400 mt-0.5 font-bold tracking-tighter">
EDIT_PREVIEW_v1.0.0 // VISUAL_ENHANCED
</p>
</div>
</div>
{/* 模式切换 */}
<div className="flex items-center bg-neutral-100 p-1 border-2 border-neutral-900 rounded-sm">
<button
onClick={enterEdit}
className={`px-5 py-1.5 text-[10px] font-black transition-all uppercase tracking-widest
${isEditMode ? 'bg-black text-white shadow-inner' : 'text-neutral-500 hover:text-black'}`}
>
编辑模式
</button>
<button
onClick={enterPreview}
className={`px-5 py-1.5 text-[10px] font-black transition-all uppercase tracking-widest
${!isEditMode ? 'bg-black text-white shadow-inner' : 'text-neutral-500 hover:text-black'}`}
>
预览展示
</button>
</div>
<div className="flex items-center gap-6 text-neutral-400">
<div className="flex flex-col items-end text-[9px] font-black uppercase tracking-widest">
<span className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-neutral-900 rounded-full animate-pulse" />
{isEditMode ? 'MODE: EDIT' : 'MODE: PREVIEW'}
</span>
</div>
<div className="h-8 w-px bg-neutral-200" />
<Settings size={16} className="hover:text-neutral-900 cursor-pointer transition-colors" />
</div>
</header>
{/* ── 主体区域 ────────────────────────────────────── */}
<div className="flex-1 flex overflow-hidden">
{/* ── 左侧编辑面板(仅编辑模式)─────────────────── */}
{isEditMode && (
<aside className="w-72 bg-white border-r-2 border-neutral-900 z-40 flex flex-col overflow-y-auto custom-scrollbar shadow-xl">
{/* 中央图片设置 */}
<div className="p-5 border-b-2 border-neutral-100 space-y-3">
<h2 className="text-[10px] font-black uppercase tracking-widest text-neutral-500 flex items-center gap-2">
<Image size={12} /> 中央组装图
</h2>
<div className="relative w-full aspect-video border-2 border-neutral-200 overflow-hidden bg-neutral-50">
<img src={assemblyImg} className="w-full h-full object-cover filter grayscale" alt="Assembly" />
</div>
<input
type="text"
value={imgInputVal}
onChange={e => setImgInputVal(e.target.value)}
placeholder="粘贴图片 URL..."
className="w-full border-2 border-neutral-900 px-2 py-2 text-[10px] font-bold outline-none focus:bg-yellow-50 transition-colors"
/>
<button
onClick={() => { if (imgInputVal.trim()) { setAssemblyImg(imgInputVal.trim()); setImgInputVal(''); } }}
className="w-full py-2 bg-neutral-900 text-white text-[10px] font-black uppercase tracking-widest hover:bg-neutral-700 transition-colors flex items-center justify-center gap-2"
>
<RotateCw size={12} /> 替换图片
</button>
</div>
{/* 组件列表 */}
<div className="p-5 border-b-2 border-neutral-100 space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-[10px] font-black uppercase tracking-widest text-neutral-500 flex items-center gap-2">
<Layers size={12} /> 组件列表
<span className="bg-neutral-100 border border-neutral-300 text-[9px] px-1.5 py-0.5 rounded font-bold">{parts.length}</span>
</h2>
<button
onClick={addPart}
className="flex items-center gap-1 px-2 py-1 bg-black text-white text-[9px] font-black uppercase hover:bg-neutral-700 transition-colors shadow-[2px_2px_0px_#ccc]"
>
<Plus size={10} /> 添加
</button>
</div>
<div className="space-y-1 max-h-36 overflow-y-auto custom-scrollbar">
{parts.map((p, i) => (
<button
key={p.id}
onClick={() => setSelectedId(p.id)}
className={`w-full flex items-center gap-2 px-3 py-2 border text-left transition-all
${selectedId === p.id
? 'border-yellow-400 bg-yellow-50 shadow-[2px_2px_0px_#000]'
: 'border-neutral-200 hover:border-neutral-400 hover:bg-neutral-50'}`}
>
<img src={p.img} className="w-7 h-7 object-contain bg-neutral-100 border border-neutral-200 flex-none" alt="" />
<div className="flex-1 min-w-0">
<div className="text-[10px] font-black uppercase truncate">{p.name}</div>
<div className="text-[8px] text-neutral-400 font-bold">{p.code} · #{i + 1}</div>
</div>
{selectedId === p.id && <div className="w-1.5 h-1.5 rounded-full bg-yellow-400 flex-none" />}
</button>
))}
</div>
</div>
{/* 选中组件属性编辑 */}
{selectedPart ? (
<div className="flex-1 p-5 space-y-5">
<h2 className="text-[10px] font-black uppercase tracking-widest text-neutral-500 flex items-center justify-between">
<span className="flex items-center gap-2"><FileText size={12} /> 属性编辑</span>
<span className="text-[8px] bg-yellow-400 px-2 py-0.5 font-bold border border-yellow-500">{selectedPart.code}</span>
</h2>
<div className="space-y-4 p-4 border-2 border-neutral-900 bg-neutral-50 rounded-sm shadow-sm">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter">组件名称</label>
<input
type="text"
value={selectedPart.name}
onChange={e => updateField('name', e.target.value)}
className="w-full border-2 border-neutral-900 px-2 py-2 text-xs font-bold outline-none focus:bg-white transition-colors"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter">编号代码</label>
<input
type="text"
value={selectedPart.code}
onChange={e => updateField('code', e.target.value)}
className="w-full border-2 border-neutral-900 px-2 py-2 text-xs font-bold outline-none focus:bg-white transition-colors"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter">图片 URL</label>
<input
type="text"
value={selectedPart.img}
onChange={e => updateField('img', e.target.value)}
className="w-full border-2 border-neutral-900 px-2 py-2 text-[10px] font-bold outline-none focus:bg-white transition-colors"
/>
<img src={selectedPart.img} alt="" className="w-full h-20 object-contain bg-neutral-100 border border-neutral-200 mt-1" />
</div>
</div>
{/* 排序 & 删除 */}
<div className="flex gap-2">
<button onClick={() => movePart(-1)} className="flex-1 py-2 border-2 border-neutral-900 text-[10px] font-black uppercase hover:bg-neutral-50 transition-all flex items-center justify-center gap-1 shadow-[3px_3px_0px_#ddd]">
<ChevronUp size={12} /> 上移
</button>
<button onClick={() => movePart(1)} className="flex-1 py-2 border-2 border-neutral-900 text-[10px] font-black uppercase hover:bg-neutral-50 transition-all flex items-center justify-center gap-1 shadow-[3px_3px_0px_#ddd]">
<ChevronDown size={12} /> 下移
</button>
</div>
<button
onClick={deletePart}
className="w-full py-2.5 bg-red-50 text-red-600 border-2 border-red-200 text-[10px] font-black hover:bg-red-600 hover:text-white transition-all flex items-center justify-center gap-2 uppercase tracking-tighter"
>
<Trash2 size={12} /> Delete_Node
</button>
</div>
) : (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center text-neutral-300 text-[10px] font-bold uppercase italic border-2 border-dashed border-neutral-200 p-8 leading-relaxed">
<Target size={24} className="mx-auto mb-3 opacity-30" />
点击左侧列表或画布<br />节点以选中组件
</div>
</div>
)}
</aside>
)}
{/* ── 右侧主画布 ─────────────────────────────────── */}
<main
className="flex-1 relative flex flex-col items-center justify-center p-12 overflow-hidden"
onClick={() => isEditMode && setSelectedId(null)}
>
<div className="absolute inset-0 blueprint-grid" />
<div className="scan-effect" />
{/* 中心工业总成图 */}
<div className="relative w-full flex-1 max-w-4xl max-h-[35vh] flex items-center justify-center z-10">
{/* 角标 */}
<div className="absolute -inset-4 pointer-events-none opacity-40">
<div className="absolute top-0 left-0 w-12 h-12 border-t border-l border-neutral-600" />
<div className="absolute bottom-0 right-0 w-12 h-12 border-b border-r border-neutral-600" />
</div>
<img
src={assemblyImg}
className="max-w-full max-h-full object-contain assembly-view"
alt="Central Assembly System"
/>
{/* 编辑模式标注 */}
{isEditMode && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="border-2 border-dashed border-yellow-400/40 w-full h-full rounded-sm" />
<span className="absolute bottom-2 right-2 text-[8px] font-black text-yellow-500 uppercase bg-white/80 px-2 py-1 border border-yellow-300">
双击替换图片
</span>
</div>
)}
</div>
{/* 底部组件导航 */}
<div
className="w-full max-w-[1300px] mt-12 flex justify-between z-20 pb-72 px-10"
onClick={e => e.stopPropagation()}
>
{parts.map((part, index) => (
<PartNode
key={part.id}
part={part}
index={index}
isHovered={hoveredId === part.id}
isSelected={selectedId === part.id}
isEditMode={isEditMode}
onHover={setHoveredId}
onClick={(id) => setSelectedId(prev => prev === id ? null : id)}
/>
))}
</div>
{/* 预览模式按钮 */}
{!isEditMode && (
<div className="absolute bottom-12 flex flex-col items-center z-[120]">
<button
onClick={enterEdit}
className="bg-black text-white px-14 py-5 font-black uppercase text-[10px] tracking-[0.4em] shadow-[18px_18px_0px_#ccc] hover:shadow-none hover:translate-x-2.5 hover:translate-y-2.5 transition-all flex items-center gap-5 group border-2 border-neutral-900"
>
ENTER_EDIT_MODE <ChevronRight size={18} className="group-hover:translate-x-2 transition-transform" />
</button>
</div>
)}
</main>
</div>
{/* ── 页脚 ───────────────────────────────────────── */}
<footer className="h-10 bg-white border-t-2 border-neutral-900 flex items-center justify-between px-8 text-[9px] font-bold text-neutral-300 uppercase tracking-[0.4em]">
<div className="flex gap-8 items-center">
<div className="flex items-center gap-2 text-neutral-900">
<Anchor size={12} className="opacity-20" />
<span className="tracking-widest opacity-70 italic">Disassembly_EditPreview</span>
</div>
<span className="opacity-20">|</span>
<span className="opacity-40 tracking-tighter">
{parts.length} NODE{parts.length !== 1 ? 'S' : ''} // {isEditMode ? 'EDIT' : 'PREVIEW'}_LOCK
</span>
</div>
<div className="flex gap-3 items-center">
<span className="text-neutral-900 font-black px-3 py-1 bg-neutral-50 border border-neutral-200">v1.0.0</span>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,568 @@
import React, { useState, useRef, useEffect, useCallback, memo, useMemo } from 'react';
import {
Settings,
Plus,
Trash2,
Cpu,
Box,
BookOpen,
Move,
Target,
Ruler,
Zap,
Palette,
Spline,
ChevronRight,
Maximize2,
Wind,
RotateCw,
FileText,
DollarSign,
Activity,
GitBranch,
Layers,
Share2,
Battery,
HardDrive
} from 'lucide-react';
/**
* 工业蓝图风格样式系统
*/
const customStyles = `
.blueprint-grid {
background-image:
linear-gradient(#ccc 1px, transparent 1px),
linear-gradient(90deg, #ccc 1px, transparent 1px);
background-size: 40px 40px;
}
.drag-active {
cursor: grabbing !important;
}
.selection-box {
position: absolute;
border: 1px dashed #000;
background-color: rgba(0, 0, 0, 0.05);
pointer-events: none;
z-index: 200;
}
@keyframes line-flow {
from { stroke-dashoffset: 40; }
to { stroke-dashoffset: 0; }
}
@keyframes dash-flow-circle {
from { stroke-dashoffset: 40; }
to { stroke-dashoffset: 0; }
}
.leader-line {
animation: line-flow linear infinite;
pointer-events: none;
stroke-linecap: round;
transition: opacity 0.6s ease-out;
}
.rotating-origin {
animation: dash-flow-circle linear infinite;
pointer-events: none;
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #000;
border-radius: 10px;
}
.node-pulse {
animation: node-pulse-kf 2s infinite;
}
@keyframes node-pulse-kf {
0% { box-shadow: 0 0 0 0px rgba(234, 179, 8, 0.4); }
100% { box-shadow: 0 0 0 10px rgba(234, 179, 8, 0); }
}
.junction-node {
transition: opacity 0.6s ease-out, transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
`;
// 工业随机颜色池
const INDUSTRIAL_PALETTE = [
'#262626', '#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#06b6d4', '#64748b'
];
// 零件实体组件
const PartItem = memo(({ part, isSelected, showExploded, onMouseDown, isDraggingAny }) => {
const x = showExploded ? part.target.x : part.origin.x;
const y = showExploded ? part.target.y : part.origin.y;
const getIcon = () => {
const n = part.name.toLowerCase();
if (n.includes('处理器') || n.includes('cpu')) return <Cpu size={36} strokeWidth={1} />;
if (n.includes('电池') || n.includes('energy')) return <Battery size={36} strokeWidth={1} />;
if (n.includes('存储') || n.includes('ssd')) return <HardDrive size={36} strokeWidth={1} />;
if (n.includes('冷却') || n.includes('fan')) return <Wind size={36} strokeWidth={1} />;
return <Box size={36} strokeWidth={1} />;
};
return (
<div
onMouseDown={(e) => onMouseDown(e, part.id, 'target')}
className={`absolute w-60 h-32 -translate-x-1/2 -translate-y-1/2 cursor-move pointer-events-auto z-40
${isSelected ? 'z-[45]' : ''}
${!isDraggingAny ? 'transition-all duration-700 cubic-bezier(0.25, 1, 0.5, 1)' : ''}`}
style={{ left: `${x}%`, top: `${y}%` }}
>
<div className={`w-full h-full border-2 border-neutral-900 bg-white flex flex-col p-0 transition-all overflow-visible relative
${isSelected ? 'border-black shadow-[12px_12px_0px_#000] ring-4 ring-yellow-400/30 -translate-y-1' : 'shadow-lg opacity-100'}`}>
<div className="absolute -top-3 -right-2 px-3 py-1 bg-yellow-400 border-2 border-black shadow-[3px_3px_0px_#000] rotate-3 z-50 flex items-center gap-1">
<span className="text-[8px] font-black opacity-40">COST:</span>
<span className="text-[10px] font-black text-black antialiased tracking-tighter">
${part.price || '0.00'}
</span>
</div>
<div className="h-1.5 w-full shrink-0" style={{backgroundColor: part.color}}></div>
<div className="flex-1 flex items-stretch overflow-hidden bg-white text-left">
<div className="w-20 bg-neutral-50 border-r border-neutral-100 flex items-center justify-center shrink-0 text-neutral-800">
{getIcon()}
</div>
<div className="flex-1 p-3 flex flex-col justify-center overflow-hidden">
<div className="text-[11px] font-black uppercase leading-tight text-neutral-900 tracking-tighter w-full truncate mb-1">
{part.name}
</div>
<div className="text-[9px] font-bold text-neutral-400 line-clamp-3 leading-[1.3] uppercase italic">
{part.description || 'SPECIFICATION_NOT_DEFINED'}
</div>
</div>
</div>
</div>
</div>
);
});
const INITIAL_GROUPS = [
{
id: 'g1',
title: '系统架构拓扑 - 多样性物料',
image: 'https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?auto=format&fit=crop&q=80&w=1600',
parts: [
{ id: 'p1-1', name: '独立感应器', description: '环境监测模块\nIP67工业防护等级\n实时光敏反馈回路', price: '45.00', color: '#10b981', lineColor: '#059669', origin: { x: 20, y: 70 }, target: { x: 15, y: 35 }, waypoint: { x: 20, y: 55 }, pointRadius: 30, lineWidth: 2, lineType: 'straight', dashLength: 4, circleDash: 2, lineSpeed: 2.0, circleSpeed: 4.0 },
{ id: 'p1-2', name: '处理器 A (主)', description: '核心逻辑运算单元\n共享总线节点\n分布式指令架构', price: '299.00', color: '#ef4444', lineColor: '#b91c1c', origin: { x: 50, y: 50 }, target: { x: 35, y: 15 }, waypoint: { x: 45, y: 35 }, pointRadius: 40, lineWidth: 2.5, lineType: 'polyline', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 4.0 },
{ id: 'p1-3', name: '处理器 B (从)', description: '冗余热备模块\n自动继承父级配色\n共享起点布线逻辑', price: '299.00', color: '#ef4444', lineColor: '#b91c1c', origin: { x: 50, y: 50 }, target: { x: 65, y: 15 }, waypoint: { x: 55, y: 35 }, pointRadius: 40, lineWidth: 2.5, lineType: 'polyline', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 4.0 },
{ id: 'p1-4', name: '电池模组', description: '5000mAh 动力包\n共享能源转接点\n内置热管理网格', price: '120.00', color: '#3b82f6', lineColor: '#1d4ed8', origin: { x: 80, y: 60 }, target: { x: 75, y: 85 }, waypoint: { x: 85, y: 75 }, pointRadius: 35, lineWidth: 2, lineType: 'polyline', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 5.0 },
]
}
];
const App = () => {
const [isEditMode, setIsEditMode] = useState(true);
const [isExploded, setIsExploded] = useState(true);
const [topoVisible, setTopoVisible] = useState(true); // 核心:控制线条与节点的显示
const [groups, setGroups] = useState(INITIAL_GROUPS);
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const [selectedPartIds, setSelectedPartIds] = useState([INITIAL_GROUPS[0].parts[1].id]);
const [dragState, setDragState] = useState({ active: false, type: 'target', affectedIds: [] });
const [canvasSize, setCanvasSize] = useState({ width: 1, height: 1 });
const canvasRef = useRef(null);
const dragStartRef = useRef({ x: 0, y: 0 });
const initialPositionsRef = useRef({});
const currentGroup = groups[activeGroupIndex];
const lastSelectedPart = currentGroup.parts.find(p => p.id === selectedPartIds[selectedPartIds.length - 1]);
// 监听炸开状态,实现逻辑上的延迟显示
useEffect(() => {
if (isEditMode) {
setTopoVisible(true);
return;
}
if (isExploded) {
// 炸开:组件移动耗时 700ms我们延迟 800ms 后显示线条
const timer = setTimeout(() => setTopoVisible(true), 800);
return () => clearTimeout(timer);
} else {
// 收回:线条立即消失
setTopoVisible(false);
}
}, [isExploded, isEditMode]);
const uniqueOrigins = useMemo(() => {
const seen = new Set();
return currentGroup.parts.filter(p => {
const key = `${Math.round(p.origin.x)}-${Math.round(p.origin.y)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}, [currentGroup.parts]);
const uniqueWaypoints = useMemo(() => {
const seen = new Set();
return currentGroup.parts.filter(p => {
if (p.lineType !== 'polyline') return false;
const key = `${Math.round(p.waypoint.x)}-${Math.round(p.waypoint.y)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}, [currentGroup.parts]);
useEffect(() => {
const updateSize = () => {
if (canvasRef.current) {
setCanvasSize({ width: canvasRef.current.offsetWidth, height: canvasRef.current.offsetHeight });
}
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, []);
const updatePart = (partId, field, value) => {
setGroups(prev => {
const n = [...prev];
n[activeGroupIndex] = {
...n[activeGroupIndex],
parts: n[activeGroupIndex].parts.map(p => p.id === partId ? { ...p, [field]: value } : p)
};
return n;
});
};
const bulkUpdate = (field, value) => {
setGroups(prev => {
const n = [...prev];
n[activeGroupIndex] = {
...n[activeGroupIndex],
parts: n[activeGroupIndex].parts.map(p => selectedPartIds.includes(p.id) ? { ...p, [field]: value } : p)
};
return n;
});
};
const handleBranch = () => {
if (!lastSelectedPart) return;
const newId = 'p-branch-' + Date.now();
const newPart = {
...lastSelectedPart,
id: newId,
name: `${lastSelectedPart.name} 分支`,
target: { x: lastSelectedPart.target.x + 10, y: lastSelectedPart.target.y + 10 },
color: lastSelectedPart.color,
description: `基于 ${lastSelectedPart.name} 的拓扑分支\n自动同步父级配色与节点\n规格支持独立定义`
};
setGroups(prev => {
const n = [...prev];
n[activeGroupIndex].parts = [...n[activeGroupIndex].parts, newPart];
return n;
});
setSelectedPartIds([newId]);
};
const handleMouseDown = (e, partId, dragType) => {
if (!isEditMode) return;
e.stopPropagation();
const targetPart = currentGroup.parts.find(p => p.id === partId);
let affectedIds = [partId];
if (dragType === 'origin' || dragType === 'waypoint') {
affectedIds = currentGroup.parts.filter(p =>
Math.round(p[dragType].x) === Math.round(targetPart[dragType].x) &&
Math.round(p[dragType].y) === Math.round(targetPart[dragType].y)
).map(p => p.id);
}
setSelectedPartIds(affectedIds);
setDragState({ active: true, type: dragType, affectedIds });
const rect = canvasRef.current.getBoundingClientRect();
dragStartRef.current = {
x: ((e.clientX - rect.left) / rect.width) * 100,
y: ((e.clientY - rect.top) / rect.height) * 100
};
const initPos = {};
currentGroup.parts.forEach(p => {
if (affectedIds.includes(p.id)) {
initPos[p.id] = { ...p[dragType] };
}
});
initialPositionsRef.current = initPos;
};
const handleMouseMove = useCallback((e) => {
if (!isEditMode || !dragState.active || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const currentX = ((e.clientX - rect.left) / rect.width) * 100;
const currentY = ((e.clientY - rect.top) / rect.height) * 100;
const dx = currentX - dragStartRef.current.x;
const dy = currentY - dragStartRef.current.y;
const coordToUpdate = dragState.type;
setGroups(prev => {
const n = [...prev];
const g = n[activeGroupIndex];
n[activeGroupIndex] = {
...g,
parts: g.parts.map(p => {
if (dragState.affectedIds.includes(p.id)) {
const initPos = initialPositionsRef.current[p.id];
return {
...p,
[coordToUpdate]: {
x: Math.round((initPos.x + dx) * 10) / 10,
y: Math.round((initPos.y + dy) * 10) / 10
}
};
}
return p;
})
};
return n;
});
}, [dragState, isEditMode, activeGroupIndex]);
const handleMouseUp = useCallback(() => {
setDragState({ active: false, type: 'target', affectedIds: [] });
}, []);
useEffect(() => {
if (dragState.active) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragState.active, handleMouseMove, handleMouseUp]);
const getLeaderPath = (part) => {
if (part.lineType === 'polyline') {
return `M ${part.origin.x} ${part.origin.y} L ${part.waypoint.x} ${part.waypoint.y} L ${part.target.x} ${part.target.y}`;
}
return `M ${part.origin.x} ${part.origin.y} L ${part.target.x} ${part.target.y}`;
};
return (
<div className={`h-screen w-screen bg-[#f2f2f0] text-neutral-900 font-mono flex flex-col overflow-hidden relative selection:bg-black selection:text-white ${dragState.active ? 'drag-active' : ''}`}>
<style>{customStyles}</style>
<div className="fixed inset-0 pointer-events-none z-0 opacity-40 blueprint-grid"></div>
<header className="flex-none h-14 border-b-2 border-neutral-900 bg-white z-[110] flex items-center justify-between px-6 shadow-sm">
<div className="flex items-center gap-3">
<div className="bg-black text-white p-1 shadow-md"><Layers className="w-5 h-5" /></div>
<div>
<h1 className="text-lg font-black uppercase tracking-tighter leading-none text-neutral-900">Blueprint Topology</h1>
<p className="text-[10px] text-neutral-400 font-bold tracking-widest uppercase">Visual_Sync v15.0</p>
</div>
</div>
<div className="flex items-center bg-neutral-100 p-1 border-2 border-neutral-900 rounded-sm">
<button onClick={() => setIsEditMode(true)} className={`px-4 py-1 text-[10px] font-black transition-all ${isEditMode ? 'bg-black text-white shadow-inner' : 'text-neutral-500 hover:text-black'}`}>编辑模式</button>
<button onClick={() => { setIsEditMode(false); setIsExploded(false); setSelectedPartIds([]); }} className={`px-4 py-1 text-[10px] font-black transition-all ${!isEditMode ? 'bg-black text-white shadow-inner' : 'text-neutral-500 hover:text-black'}`}>预览展示</button>
</div>
</header>
<div className="flex-1 flex overflow-hidden relative">
{isEditMode && (
<aside className="w-80 bg-white border-r-2 border-neutral-900 z-[100] flex flex-col p-6 space-y-6 overflow-y-auto shadow-xl custom-scrollbar">
<div className="space-y-4 text-left">
<h2 className="text-sm font-black flex items-center justify-between italic text-neutral-800 border-b-2 border-neutral-100 pb-2">
<span className="flex items-center gap-2"><Settings size={16} /> 属性管理</span>
{selectedPartIds.length > 0 && <span className="text-[9px] bg-yellow-400 px-2 py-0.5 rounded-full font-bold shadow-sm uppercase">Linked: {selectedPartIds.length}</span>}
</h2>
{selectedPartIds.length > 0 ? (
<div className="space-y-5 animate-in fade-in slide-in-from-left-2 duration-300">
<button onClick={handleBranch} className="w-full py-3 bg-yellow-400 border-2 border-black text-[10px] font-black uppercase shadow-[4px_4px_0px_#000] active:translate-x-0.5 active:translate-y-0.5 transition-all flex items-center justify-center gap-2 hover:bg-yellow-300">
<GitBranch size={14} /> 基于当前节点分支
</button>
<div className="space-y-4 p-4 border-2 border-neutral-900 bg-neutral-50 rounded-sm shadow-sm text-left">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter">组件名称</label>
<input type="text" disabled={selectedPartIds.length > 1} value={lastSelectedPart?.name || ''} onChange={e => updatePart(lastSelectedPart.id, 'name', e.target.value)} className="w-full border-2 border-neutral-900 px-2 py-2 text-xs font-bold outline-none focus:bg-white transition-colors" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter flex items-center gap-1"><FileText size={10}/> 规格描述</label>
<textarea disabled={selectedPartIds.length > 1} value={lastSelectedPart?.description || ''} onChange={e => updatePart(lastSelectedPart.id, 'description', e.target.value)} rows="4" className="w-full border-2 border-neutral-900 px-2 py-2 text-[10px] font-bold outline-none focus:bg-white resize-none leading-relaxed" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter flex items-center gap-1"><Palette size={10}/> 顶部色块颜色</label>
<div className="flex items-center gap-2">
<input type="color" value={lastSelectedPart?.color || '#000000'} onChange={e => bulkUpdate('color', e.target.value)} className="w-full h-10 border-2 border-neutral-900 bg-white cursor-pointer p-1" />
<div className="w-10 h-10 border-2 border-neutral-900" style={{backgroundColor: lastSelectedPart?.color}}></div>
</div>
</div>
</div>
<div className="bg-neutral-900 p-4 rounded shadow-2xl space-y-4 text-white">
<label className="text-[9px] font-black text-yellow-400 uppercase tracking-widest flex items-center gap-2"><Zap size={12}/> Global_Sync</label>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase flex justify-between">
<span className="text-neutral-400 text-left">连线周期 (S)</span>
<span className="bg-white/10 px-1 rounded font-mono text-yellow-400">{lastSelectedPart?.lineSpeed || 2.0}s</span>
</label>
<input type="range" min="0.05" max="5.0" step="0.05" value={lastSelectedPart?.lineSpeed || 2.0} onChange={e => bulkUpdate('lineSpeed', parseFloat(e.target.value))} className="w-full accent-yellow-400 cursor-pointer h-1" />
</div>
</div>
<div className="space-y-3 pt-4 border-t-2 border-neutral-100">
<button onClick={() => setIsExploded(!isExploded)} className={`w-full py-4 border-2 border-neutral-900 text-[10px] font-black uppercase shadow-[6px_6px_0px_#000] active:translate-x-1 active:translate-y-1 transition-all ${isExploded ? 'bg-yellow-400 text-black' : 'bg-white'}`}>
{isExploded ? '视图: 炸开模式' : '视图: 组装模式'}
</button>
<button onClick={() => { setGroups(prev => { const n = [...prev]; n[activeGroupIndex].parts = currentGroup.parts.filter(p => !selectedPartIds.includes(p.id)); return n; }); setSelectedPartIds([]); }} className="w-full py-2.5 bg-red-50 text-red-600 border-2 border-red-200 text-[10px] font-black hover:bg-red-600 hover:text-white transition-all flex items-center justify-center gap-2 uppercase tracking-tighter"><Trash2 size={12} /> Delete_Nodes</button>
</div>
</div>
) : <div className="text-center py-12 text-neutral-300 text-[10px] font-bold uppercase italic border-2 border-dashed border-neutral-200 p-8 leading-relaxed">选择节点或物料卡<br/>进行拓扑逻辑管理</div>}
<button onClick={() => {
const randomColor = INDUSTRIAL_PALETTE[Math.floor(Math.random() * INDUSTRIAL_PALETTE.length)];
const newPart = { id: 'p' + Date.now(), name: '新独立物料', description: '待定义规格参数\n第二行规格\n第三行规格', price: '0.00', color: randomColor, lineColor: randomColor, origin: { x: Math.random()*20+40, y: Math.random()*20+40 }, target: { x: 50, y: 30 }, waypoint: { x: 50, y: 40 }, pointRadius: 40, lineWidth: 2.0, lineType: 'straight', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 4.0 };
setGroups(prev => { const n = [...prev]; n[activeGroupIndex].parts = [...n[activeGroupIndex].parts, newPart]; return n; });
setSelectedPartIds([newPart.id]);
}} className="w-full py-4 bg-white border-2 border-neutral-900 text-[10px] font-black shadow-[8px_8px_0px_#000] active:translate-x-1 active:translate-y-1 flex items-center justify-center gap-2 transition-all hover:bg-neutral-50"><Plus size={18} /> 添加独立物料</button>
</div>
</aside>
)}
<main className="flex-1 relative flex items-center justify-center p-20 overflow-visible" onMouseDown={() => isEditMode && setSelectedPartIds([])}>
<div ref={canvasRef} className={`relative w-full max-w-5xl aspect-video bg-white border-4 border-neutral-900 shadow-[48px_48px_0px_#ddd] transition-all duration-700`}>
<div className="absolute inset-0 z-10 pointer-events-none overflow-hidden">
<img src={currentGroup.image} alt={currentGroup.title} className="w-full h-full object-cover filter grayscale contrast-125" />
<div className="absolute inset-0 blueprint-grid opacity-20"></div>
</div>
{/* 线条渲染层 - 使用逻辑状态 topoVisible 控制显示 */}
<svg className="absolute inset-0 w-full h-full z-20 pointer-events-none overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none" shapeRendering="geometricPrecision">
{currentGroup.parts.map(part => {
const isSelected = selectedPartIds.includes(part.id);
const lw = (part.lineWidth || 2.5) * 0.35;
const actualLineColor = isSelected ? "#eab308" : (part.lineColor || part.color);
const dashArray = part.dashLength > 0 ? `${part.dashLength} ${lw * 4}` : "none";
return (
<path
key={`line-${part.id}`}
d={getLeaderPath(part)}
stroke={actualLineColor}
strokeWidth={isSelected ? lw * 1.8 : lw}
fill="none"
strokeDasharray={dashArray}
style={{
// 当 topoVisible 为 true 时才显示(炸开模式有 800ms 延迟,回收模式 0s 立即消失)
opacity: topoVisible ? (isSelected ? 1 : 0.7) : 0,
animationDuration: `${part.lineSpeed || 2.0}s`
}}
className="leader-line"
/>
);
})}
</svg>
{/* 节点圆环渲染层 - 同步 topoVisible */}
<svg className="absolute inset-0 w-full h-full z-30 pointer-events-none overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none" shapeRendering="geometricPrecision">
{uniqueOrigins.map(part => {
const isSelected = selectedPartIds.includes(part.id);
const rx = (part.pointRadius / (canvasSize.width || 1)) * 100;
const ry = (part.pointRadius / (canvasSize.height || 1)) * 100;
return (
<g key={`origin-unique-${part.id}`} style={{
opacity: topoVisible ? 1 : 0,
transition: 'opacity 0.6s ease-out'
}}>
<ellipse cx={part.origin.x} cy={part.origin.y} rx={rx} ry={ry} fill="none" stroke={part.color} strokeWidth="0.05" opacity="0.15" />
<ellipse
cx={part.origin.x} cy={part.origin.y} rx={rx} ry={ry} fill="none"
stroke={isSelected ? "#fbbf24" : part.color}
strokeWidth={isSelected ? 0.8 : 0.3}
strokeDasharray={`${part.circleDash || 1.5}, 2.5`}
style={{ animationDuration: `${part.circleSpeed || 4}s` }}
className="rotating-origin"
/>
</g>
);
})}
</svg>
{/* 物料卡片渲染层 */}
<div className="absolute inset-0 z-40 pointer-events-none">
{currentGroup.parts.map(part => (
<PartItem
key={part.id} part={part} isSelected={selectedPartIds.includes(part.id)}
showExploded={isExploded} onMouseDown={handleMouseDown} isDraggingAny={dragState.active}
/>
))}
</div>
{/* 编辑句柄 - 起点 */}
{isEditMode && uniqueOrigins.map(part => (
<div
key={`h-origin-${part.id}`}
onMouseDown={(e) => handleMouseDown(e, part.id, 'origin')}
className={`absolute -translate-x-1/2 -translate-y-1/2 cursor-move z-[55] rounded-full transition-all
${selectedPartIds.includes(part.id) ? 'bg-yellow-400/20 node-pulse' : 'hover:bg-black/10'}`}
style={{ left: `${part.origin.x}%`, top: `${part.origin.y}%`, width: `${part.pointRadius * 1.5}px`, height: `${part.pointRadius * 1.5}px` }}
/>
))}
{/* 编辑句柄 - 中继点 - 同步 topoVisible */}
{uniqueWaypoints.map(part => {
const isSelected = selectedPartIds.includes(part.id);
return (
<div
key={`h-waypoint-${part.id}`}
onMouseDown={(e) => handleMouseDown(e, part.id, 'waypoint')}
className={`absolute w-4 h-4 -translate-x-1/2 -translate-y-1/2 bg-white border-2 rounded-full cursor-move z-[70] shadow-md junction-node
${isSelected ? 'border-yellow-500 scale-125 bg-yellow-50 shadow-lg' : 'border-neutral-900'}`}
style={{
left: `${part.waypoint.x}%`,
top: `${part.waypoint.y}%`,
opacity: topoVisible ? 1 : 0,
pointerEvents: topoVisible ? 'auto' : 'none'
}}
>
{(isSelected && isEditMode) && <div className="absolute -top-7 bg-black text-white text-[8px] px-1.5 py-0.5 font-black rounded uppercase whitespace-nowrap left-1/2 -translate-x-1/2">Junction</div>}
</div>
);
})}
</div>
<div className="absolute top-12 left-12">
<div className="bg-black text-white px-6 py-2.5 text-xs font-black uppercase tracking-[0.3em] italic flex items-center gap-4 shadow-[12px_12px_0px_#ccc]">
<Target size={18} className="text-yellow-400 animate-pulse" /> {currentGroup.title}
</div>
</div>
{!isEditMode && (
<div className="absolute bottom-12 flex flex-col items-center gap-4 z-[120]">
<button onClick={() => setIsExploded(!isExploded)} className="bg-black text-white px-16 py-6 font-black uppercase text-xs tracking-[0.4em] shadow-[18px_18px_0px_#ccc] hover:shadow-none hover:translate-x-2.5 hover:translate-y-2.5 transition-all flex items-center gap-5 group border-2 border-neutral-900 text-left">
{isExploded ? 'RESET_ASSEMBLY' : 'START_AXIS_ANIMATION'} <ChevronRight size={20} className="group-hover:translate-x-2 transition-transform" />
</button>
</div>
)}
</main>
</div>
<footer className="flex-none h-10 border-t-2 border-neutral-900 bg-white flex items-center justify-between px-6 z-[110] shadow-sm text-neutral-500">
<div className="flex items-center gap-6 text-left">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-wider text-neutral-400"><Ruler size={12} /> Alignment: Corrected_Axis</div>
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-wider text-neutral-400 opacity-60"><GitBranch size={12}/> Anim_Sync: Logic_State_v15.0</div>
</div>
<div className="text-[10px] font-mono opacity-40 uppercase tracking-tighter font-bold italic text-right">System_Engine_Stable</div>
</footer>
</div>
);
};
export default App;

View File

@ -0,0 +1,240 @@
import React, { useState } from 'react';
import {
Layers,
Activity,
Settings,
Zap,
Cpu,
MousePointer2,
Anchor,
ChevronUp,
ChevronDown
} from 'lucide-react';
/**
* 工业草稿风格 v7.2.0 (视觉增强版)
* 核心优化:
* 1. 视觉聚焦:显著放大底部组件图标,提升细节可见度。
* 2. 极致简约:移除底部元数据标签,精简文字层次,聚焦核心名称。
* 3. 比例优化:维持 V 型交错布局,优化大尺寸图标下的物理连接感。
*/
const styles = `
@keyframes scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}
.blueprint-grid {
background-color: #ffffff;
background-image:
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
background-size: 30px 30px;
}
.blueprint-grid::after {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0, 0, 0, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
background-size: 10px 10px;
pointer-events: none;
}
.assembly-view {
filter: drop-shadow(0 15px 30px rgba(0,0,0,0.1)) contrast(1.05);
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.assembly-view:hover {
filter: drop-shadow(0 20px 40px rgba(0,0,0,0.15)) contrast(1.1);
}
.leader-line-svg {
transition: all 0.4s ease;
}
.text-label-container {
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
}
.scan-effect {
position: absolute;
top: 0; left: 0; right: 0; height: 100%;
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.01), transparent);
animation: scanline 15s linear infinite;
pointer-events: none;
}
`;
const SYSTEM_PARTS = [
{ id: 'p1', name: '环境监控', code: 'ENV-X', img: 'https://images.unsplash.com/photo-1555680202-c86f0e12f086?auto=format&fit=crop&q=80&w=200', link: '#' },
{ id: 'p2', name: '能源模组', code: 'BATT-V8', img: 'https://images.unsplash.com/photo-1619641259501-c88f28c6e355?auto=format&fit=crop&q=80&w=200', link: '#' },
{ id: 'p3', name: '雷达阵列', code: 'LDR-07', img: 'https://images.unsplash.com/photo-1555680202-c86f0e12f086?auto=format&fit=crop&q=80&w=200', link: '#' },
{ id: 'p4', name: '核心总成', code: 'CORE-MAX', img: 'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&q=80&w=200', link: '#' },
{ id: 'p5', name: '液压单元', code: 'HYD-02', img: 'https://images.unsplash.com/photo-1635350736475-c8cef4b21906?auto=format&fit=crop&q=80&w=200', link: '#' },
{ id: 'p6', name: '散热单元', code: 'COOL-F2', img: 'https://images.unsplash.com/photo-1635350736475-c8cef4b21906?auto=format&fit=crop&q=80&w=200', link: '#' },
{ id: 'p7', name: '存储阵列', code: 'DATA-2T', img: 'https://images.unsplash.com/photo-1544006659-f0b21f04cb1d?auto=format&fit=crop&q=80&w=200', link: '#' }
];
const ASSEMBLY_IMAGE = "https://images.unsplash.com/photo-1581092160562-40aa08e78837?auto=format&fit=crop&q=80&w=1600";
export default function App() {
const [hoveredId, setHoveredId] = useState(null);
return (
<div className="h-screen w-screen bg-[#ffffff] text-neutral-900 font-mono flex flex-col overflow-hidden select-none">
<style>{styles}</style>
{/* 顶部标题栏 */}
<header className="h-16 border-b border-neutral-200 bg-white flex items-center justify-between px-10 z-50">
<div className="flex items-center gap-4">
<div className="p-2 bg-neutral-900 rounded-sm">
<Cpu size={20} className="text-white" />
</div>
<div className="flex flex-col">
<h1 className="text-[13px] font-black uppercase tracking-[0.4em] leading-none text-neutral-900">Linear_Draft_Nav</h1>
<p className="text-[9px] text-neutral-400 mt-1 font-bold tracking-tighter">V7.2.0 // ENHANCED_VISUAL_ALIGN</p>
</div>
</div>
<div className="flex items-center gap-8 text-neutral-400">
<div className="flex flex-col items-end text-[9px] font-black uppercase tracking-widest">
<span className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-neutral-900 rounded-full animate-pulse"></div>
STATUS: NOMINAL
</span>
</div>
<div className="h-8 w-[1px] bg-neutral-100"></div>
<Settings size={18} className="hover:text-neutral-900 cursor-pointer transition-colors" />
</div>
</header>
{/* 主画布区域 */}
<main className="flex-1 relative flex flex-col items-center justify-center p-12 overflow-hidden">
<div className="absolute inset-0 blueprint-grid"></div>
<div className="scan-effect"></div>
{/* 中心工业总成图 */}
<div className="relative w-full flex-1 max-w-4xl max-h-[35vh] flex items-center justify-center z-10">
{/* 适配型角标 */}
<div className="absolute -inset-4 pointer-events-none opacity-40">
<div className="absolute top-0 left-0 w-12 h-12 border-t border-l border-neutral-600"></div>
<div className="absolute bottom-0 right-0 w-12 h-12 border-b border-r border-neutral-600"></div>
</div>
<img
src={ASSEMBLY_IMAGE}
className="max-w-full max-h-full object-contain assembly-view"
alt="Central Assembly System"
/>
</div>
{/* 底部组件导航集 */}
<div className="w-full max-w-[1300px] mt-12 flex justify-between z-20 pb-72 px-10">
{SYSTEM_PARTS.map((part, index) => {
const isHovered = hoveredId === part.id;
const isUp = index % 2 === 0;
// --- 物理常量:优化后的图标与交错位移 ---
const ICON_SIZE = 80; // 放大图标容器 (原 64)
const CENTER_AXIS_Y = 180;
const OFFSET_Y = 40;
const boxTop = isUp ? CENTER_AXIS_Y - OFFSET_Y : CENTER_AXIS_Y + OFFSET_Y;
// --- 垂直引导线逻辑 ---
const SVG_CENTER_X = 50;
const SVG_CENTER_Y = 40; // 适配大尺寸图标的起始点
return (
<div
key={part.id}
onMouseEnter={() => setHoveredId(part.id)}
onMouseLeave={() => setHoveredId(null)}
className="group flex flex-col items-center cursor-pointer relative flex-1"
style={{ minWidth: '0' }}
>
{/* 垂直引导线 */}
<svg
className="absolute top-0 left-1/2 -translate-x-1/2 w-[100px] h-[400px] overflow-visible pointer-events-none z-0"
viewBox={`0 0 100 400`}
>
<path
d={`M ${SVG_CENTER_X} ${SVG_CENTER_Y} L ${SVG_CENTER_X} ${boxTop}`}
fill="none"
stroke={isHovered ? "#000" : "#f5f5f5"}
strokeWidth={isHovered ? "2.5" : "1"}
strokeDasharray={isHovered ? "none" : "3,3"}
className="leader-line-svg"
/>
<circle cx={SVG_CENTER_X} cy={SVG_CENTER_Y} r="2.5" fill={isHovered ? "#000" : "#e0e0e0"} />
<circle cx={SVG_CENTER_X} cy={boxTop} r={isHovered ? "4" : "2"} fill={isHovered ? "#000" : "#e0e0e0"} />
</svg>
{/* 图标节点 - 已放大图片尺寸 */}
<div
className={`relative flex items-center justify-center transition-all duration-500 z-10
${isHovered ? 'scale-110 -translate-y-2' : 'opacity-100 hover:opacity-100'}`}
style={{ width: `${ICON_SIZE}px`, height: `${ICON_SIZE}px` }}
>
<img src={part.img} className="w-16 h-16 object-contain relative z-10" alt={part.name} />
{isHovered && (
<div className="absolute inset-0 bg-neutral-900/5 blur-2xl rounded-full scale-125" />
)}
</div>
{/* 标签容器 - 移除底部元数据 */}
<div
className={`absolute flex flex-col items-center whitespace-nowrap text-label-container z-30
${isHovered ? (isUp ? '-translate-y-1' : 'translate-y-1') : ''}`}
style={{
top: `${boxTop}px`,
left: '50%',
transform: 'translateX(-50%)'
}}
>
{/* 接线端子 */}
<div className={`w-2.5 h-2.5 rounded-full border-2 border-white mb-3 transition-all
${isHovered ? 'bg-neutral-900 scale-125' : 'bg-neutral-200'}`}
/>
{/* 顶部指示器 */}
<div className="flex items-center gap-1.5 mb-1.5 opacity-60">
{isUp ? <ChevronUp size={8} /> : <div className="w-[8px]" />}
<div className={`text-[8px] font-black uppercase tracking-widest transition-colors
${isHovered ? 'text-neutral-900' : 'text-neutral-300'}`}>
CODE.0{index + 1}
</div>
{!isUp ? <ChevronDown size={8} /> : <div className="w-[8px]" />}
</div>
{/* 大字名称 */}
<div className={`text-2xl font-black uppercase tracking-tighter transition-all duration-300
${isHovered ? 'text-neutral-900 scale-105' : 'text-neutral-400'}`}>
{part.name}
</div>
</div>
</div>
);
})}
</div>
</main>
{/* 页脚 */}
<footer className="h-10 bg-white border-t border-neutral-100 flex items-center justify-between px-10 text-[9px] font-bold text-neutral-300 uppercase tracking-[0.4em]">
<div className="flex gap-10 items-center">
<div className="flex items-center gap-2 text-neutral-900">
<Anchor size={14} className="opacity-10" />
<span className="tracking-widest opacity-80 italic">Visual_Enhanced_Array</span>
</div>
<span className="opacity-10">|</span>
<span className="opacity-40 tracking-tighter">DESIGN_LOCK_V7.2.0</span>
</div>
<div className="flex gap-4 items-center">
<span className="text-neutral-900 font-black px-3 py-1 bg-neutral-50 border border-neutral-100">B_7.2.0</span>
</div>
</footer>
</div>
);
}

View File

@ -1,7 +1,99 @@
import { ThumbnailCell as ThumbnailCell_c4ec43b3e74df5c75a3fb90c93e06b1d } from '../../../components/cells/ThumbnailCell'
import { ThumbnailField as ThumbnailField_0d2fbe11370060d58b3925e5dbbb79d6 } from '../../../components/fields/ThumbnailField'
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelatedProductsField as RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426 } from '../../../components/fields/RelatedProductsField'
import { ProductOrdersField as ProductOrdersField_d2dd8a14d02b2830d96686a022683d02 } from '../../../components/fields/ProductOrdersField'
import { SeedProjectStatusesButton as SeedProjectStatusesButton_2d6200f8d9c4e1d9630f4ca2e1ecad62 } from '../../../components/seed/SeedProjectStatusesButton'
import { TaobaoProductSync as TaobaoProductSync_c920a85a41a3caf5464668c331ea204a } from '../../../components/sync/taobao/TaobaoProductSync'
import { TaobaoFetchButton as TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7 } from '../../../components/fields/TaobaoFetchButton'
import { TaobaoLinkPreview as TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959 } from '../../../components/fields/TaobaoLinkPreview'
import { UnifiedSyncButton as UnifiedSyncButton_fc99b3f144909da232f9fd4ff7269523 } from '../../../components/sync/UnifiedSyncButton'
import { default as default_c2e3814fe427263135b1f5931c37f6f2 } from '../../../components/list/ProductGridStyler'
import { PreorderProgressCell as PreorderProgressCell_67df47753573233f0c83480de687f13b } from '../../../components/cells/PreorderProgressCell'
import { RefreshOrderCountField as RefreshOrderCountField_ef327f0ad449eac595b5e301044c0996 } from '../../../components/fields/RefreshOrderCountField'
import { PreorderProductGridStyler as PreorderProductGridStyler_e7f6f7c2233fc58ae87e992227bb80c5 } from '../../../components/list/PreorderProductGridStyler'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { DisassemblyEditorCell as DisassemblyEditorCell_9a55a4325c8609b6661772e6b79baf07 } from '../../../components/cells/DisassemblyEditorCell'
import { SeedDisassemblyButton as SeedDisassemblyButton_856cd11a2fe6ac8ad5696108a79fdfb9 } from '../../../components/seed/SeedDisassemblyButton'
import { default as default_8aadec319652639fb5e982d94aabed6c } from '../../../components/views/Disassembly/DisassemblyPageSaveArea'
import { SeedPrecautionsButton as SeedPrecautionsButton_768e87b00d261fe69a4b4731c1e8e2fb } from '../../../components/seed/SeedPrecautionsButton'
import { default as default_767734c8b7b095ea28d54c32abcf46e4 } from '../../../components/views/AdminPanel'
import { default as default_a766ef013722c08f9bb937940272cb5f } from '../../../components/views/LogsManagerView'
import { RestoreRecommendationsSeedButton as RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da } from '../../../components/seed/RestoreRecommendationsSeedButton'
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client' import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
import { default as default_3e6848ddbbb7b926ae9afb108e1f6856 } from '../../../components/views/Disassembly/editor'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
export const importMap = { export const importMap = {
"/components/cells/ThumbnailCell#ThumbnailCell": ThumbnailCell_c4ec43b3e74df5c75a3fb90c93e06b1d,
"/components/fields/ThumbnailField#ThumbnailField": ThumbnailField_0d2fbe11370060d58b3925e5dbbb79d6,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426,
"/components/fields/ProductOrdersField#ProductOrdersField": ProductOrdersField_d2dd8a14d02b2830d96686a022683d02,
"/components/seed/SeedProjectStatusesButton#SeedProjectStatusesButton": SeedProjectStatusesButton_2d6200f8d9c4e1d9630f4ca2e1ecad62,
"/components/sync/taobao/TaobaoProductSync#TaobaoProductSync": TaobaoProductSync_c920a85a41a3caf5464668c331ea204a,
"/components/fields/TaobaoFetchButton#TaobaoFetchButton": TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7,
"/components/fields/TaobaoLinkPreview#TaobaoLinkPreview": TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959,
"/components/sync/UnifiedSyncButton#UnifiedSyncButton": UnifiedSyncButton_fc99b3f144909da232f9fd4ff7269523,
"/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2,
"/components/cells/PreorderProgressCell#PreorderProgressCell": PreorderProgressCell_67df47753573233f0c83480de687f13b,
"/components/fields/RefreshOrderCountField#RefreshOrderCountField": RefreshOrderCountField_ef327f0ad449eac595b5e301044c0996,
"/components/list/PreorderProductGridStyler#PreorderProductGridStyler": PreorderProductGridStyler_e7f6f7c2233fc58ae87e992227bb80c5,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/components/cells/DisassemblyEditorCell#DisassemblyEditorCell": DisassemblyEditorCell_9a55a4325c8609b6661772e6b79baf07,
"/components/seed/SeedDisassemblyButton#SeedDisassemblyButton": SeedDisassemblyButton_856cd11a2fe6ac8ad5696108a79fdfb9,
"/components/views/Disassembly/DisassemblyPageSaveArea#default": default_8aadec319652639fb5e982d94aabed6c,
"/components/seed/SeedPrecautionsButton#SeedPrecautionsButton": SeedPrecautionsButton_768e87b00d261fe69a4b4731c1e8e2fb,
"/components/views/AdminPanel#default": default_767734c8b7b095ea28d54c32abcf46e4,
"/components/views/LogsManagerView#default": default_a766ef013722c08f9bb937940272cb5f,
"/components/seed/RestoreRecommendationsSeedButton#RestoreRecommendationsSeedButton": RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da,
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
"/components/views/Disassembly/editor#default": default_3e6848ddbbb7b926ae9afb108e1f6856,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
} }

View File

@ -0,0 +1,142 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getAllMedusaProducts, transformMedusaProductToPayload } from '@/lib/medusa'
/**
* Batch Sync Selected Products
* POST /api/admin/batch-sync-medusa
* Body: { ids: string[], collection: 'products' | 'preorder-products', forceUpdate?: boolean }
*/
export async function POST(request: Request) {
try {
const body = await request.json()
const { ids, collection, forceUpdate = false } = body
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return NextResponse.json(
{ success: false, error: 'No product IDs provided' },
{ status: 400 },
)
}
if (!collection || !['products', 'preorder-products'].includes(collection)) {
return NextResponse.json(
{ success: false, error: 'Invalid collection' },
{ status: 400 },
)
}
const payload = await getPayload({ config })
// Get all Medusa products once
const medusaProducts = await getAllMedusaProducts()
const medusaProductMap = new Map(medusaProducts.map(p => [p.id, p]))
const results = {
total: ids.length,
success: 0,
failed: 0,
skipped: 0,
details: [] as any[],
}
// Sync each selected product
for (const id of ids) {
try {
const product = await payload.findByID({
collection: collection as 'products' | 'preorder-products',
id,
})
if (!product || !product.medusaId) {
results.skipped++
results.details.push({
id,
title: product?.title || 'Unknown',
status: 'skipped',
reason: 'No Medusa ID',
})
continue
}
const medusaProduct = medusaProductMap.get(product.medusaId)
if (!medusaProduct) {
results.failed++
results.details.push({
id,
medusaId: product.medusaId,
title: product.title,
status: 'failed',
error: 'Product not found in Medusa',
})
continue
}
// 使用统一 transform保持与 sync/product 逻辑一致
const productData = transformMedusaProductToPayload(medusaProduct)
const updateData: any = {
lastSyncedAt: productData.lastSyncedAt,
medusaId: productData.medusaId,
seedId: productData.seedId,
// 始终从 Medusa 同步的字段
title: productData.title,
handle: productData.handle,
description: productData.description,
startPrice: productData.startPrice,
tags: productData.tags,
type: productData.type,
collection: productData.collection,
category: productData.category,
height: productData.height,
width: productData.width,
length: productData.length,
weight: productData.weight,
midCode: productData.midCode,
hsCode: productData.hsCode,
countryOfOrigin: productData.countryOfOrigin,
// thumbnail强制更新时覆盖否则只在为空时同步
...((forceUpdate || !product.thumbnail) && { thumbnail: productData.thumbnail }),
}
await payload.update({
collection: collection as 'products' | 'preorder-products',
id,
data: updateData,
})
results.success++
results.details.push({
id,
medusaId: product.medusaId,
title: product.title,
status: 'success',
})
} catch (error) {
results.failed++
results.details.push({
id,
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
return NextResponse.json({
success: true,
message: `Batch sync completed: ${results.success} success, ${results.failed} failed, ${results.skipped} skipped`,
results,
})
} catch (error) {
console.error('[batch-sync-medusa] Error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
)
}
}

View File

@ -0,0 +1,91 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest } from 'next/server'
/**
* API
* DELETE /api/admin/log?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
*/
export async function DELETE(req: NextRequest) {
try {
const payload = await getPayload({ config })
// 检查用户权限
const { user } = await payload.auth({ headers: req.headers })
if (!user || !user.roles?.includes('admin')) {
return Response.json({ error: '需要管理员权限' }, { status: 403 })
}
// 获取日期参数
const { searchParams } = new URL(req.url)
const startDate = searchParams.get('startDate')
const endDate = searchParams.get('endDate')
if (!startDate || !endDate) {
return Response.json({ error: '请提供开始和结束日期' }, { status: 400 })
}
// 验证日期格式
const start = new Date(startDate)
const end = new Date(endDate)
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return Response.json({ error: '无效的日期格式' }, { status: 400 })
}
// 设置时间范围
start.setHours(0, 0, 0, 0)
end.setHours(23, 59, 59, 999)
// 查询要删除的日志
const logsToDelete = await payload.find({
collection: 'logs',
where: {
and: [
{
createdAt: {
greater_than_equal: start.toISOString(),
},
},
{
createdAt: {
less_than_equal: end.toISOString(),
},
},
],
},
limit: 10000, // 限制一次删除数量
})
// 批量删除
let deletedCount = 0
for (const log of logsToDelete.docs) {
try {
await payload.delete({
collection: 'logs',
id: log.id,
context: { skipHooks: true }, // 跳过钩子
})
deletedCount++
} catch (error) {
console.error(`Failed to delete log ${log.id}:`, error)
}
}
return Response.json({
success: true,
message: `成功删除 ${deletedCount} 条日志记录`,
deletedCount,
totalFound: logsToDelete.totalDocs,
})
} catch (error) {
console.error('Delete logs error:', error)
return Response.json(
{
error: '删除失败',
message: error instanceof Error ? error.message : '未知错误',
},
{ status: 500 },
)
}
}

View File

@ -0,0 +1,76 @@
# 数据重置功能说明
## 概述
通过 Admin Settings 中的"数据重置"按钮,一键完成完整的数据重置流程。
## 功能
**一键重置所有数据**,包括:
1. 清理 Payload CMS 数据(保留用户)
2. 清理 Medusa 数据
3. 重新导入 Medusa seed 数据
## 使用方法
1. 登录 Payload CMS: `http://localhost:1145/admin`
2. 进入 **系统 → Admin Settings**
3. 在"数据管理"区域找到"🔄 数据重置Payload + Medusa"
4. 点击"🗑️ 重置所有数据"按钮
5. 确认操作后等待完成
## 技术实现
### Payload CMS 端
**API 端点:**
- `POST /api/admin/reset-data` - 数据重置主控端点
**UI 组件:**
- `ResetDataButton` - 重置按钮组件
- `AdminPanel` - 管理面板(包含重置按钮)
### Medusa 端
**API 端点:**
- `POST /admin/custom/clean` - 清理 Medusa 数据
- `POST /admin/custom/seed-pro` - 导入 seed 数据
## 执行流程
```
用户点击按钮
调用 /api/admin/reset-data
步骤 1: 清理 Payload 数据Products, PreorderProducts, Media, Announcements, Articles, Logs
步骤 2: 调用 Medusa /admin/custom/clean
步骤 3: 调用 Medusa /admin/custom/seed-pro
返回结果和详细信息
```
## 后续操作
数据重置完成后需要:
1. 同步 Medusa 商品到 Payload CMS
2. 设置 ProductRecommendations 商品推荐
3. 配置 PreorderProducts 的预购设置fundingGoal, preorderEndDate 等)
## 注意事项
⚠️ **危险操作** - 此操作不可撤销!
- 会删除所有商品、媒体、公告、文章和日志数据
- 保留用户账户和系统配置
- 整个过程可能需要 2-3 分钟
## 已移除的文件
精简脚本后移除了:
- `reset-data.bat` - 批处理脚本
- `gb-payload/src/scripts/clean-payload.ts` - 清理脚本
- `gb-payload/package.json` 中的 `clean` 命令
现在所有操作通过 Web UI 完成,无需命令行。

View File

@ -0,0 +1,196 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* API Route: Reset All Data
* POST /api/admin/reset-data
*
* Body: { mode?: 'full' | 'medusa-only' }
*
* full (): Payload + Medusa + Medusa seed
* medusa-only: 仅清理 Medusa + Medusa seed Payload
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}))
const mode: 'full' | 'medusa-only' = body.mode === 'medusa-only' ? 'medusa-only' : 'full'
const MEDUSA_BACKEND_URL = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
const results: any = {
steps: [],
success: true,
mode,
}
// ==================== 步骤 1: 清理 Payload 数据full 模式才执行)====================
if (mode === 'full') {
console.log('🧹 [1/3] 开始清理 Payload CMS 数据...')
const payloadResult = await cleanPayloadData()
results.steps.push({
step: 1,
name: 'Clean Payload',
success: payloadResult.success,
deleted: payloadResult.totalDeleted,
details: payloadResult.details,
})
if (!payloadResult.success) {
results.success = false
return NextResponse.json(results, { status: 500 })
}
} else {
console.log('⏭️ [1/3] medusa-only 模式,跳过 Payload 清理')
results.steps.push({ step: 1, name: 'Clean Payload', success: true, skipped: true })
}
// ==================== 步骤 2: 清理 Medusa 数据 ====================
console.log('🧹 [2/3] 开始清理 Medusa 数据...')
try {
const cleanResponse = await fetch(`${MEDUSA_BACKEND_URL}/hooks/clean`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-payload-api-key': process.env.PAYLOAD_API_KEY || '',
},
})
if (!cleanResponse.ok) {
const bodyText = await cleanResponse.text().catch(() => '')
throw new Error(`Medusa clean failed (${cleanResponse.status}): ${bodyText || cleanResponse.statusText}`)
}
const cleanData = await cleanResponse.json()
results.steps.push({
step: 2,
name: 'Clean Medusa',
success: true,
details: cleanData,
})
console.log('✅ Medusa 数据清理完成')
} catch (error) {
const errMsg = error instanceof Error ? error.message : 'Unknown error'
console.error('❌ Medusa 清理失败:', errMsg)
results.steps.push({
step: 2,
name: 'Clean Medusa',
success: false,
error: errMsg,
})
results.success = false
results.error = `[步骤2] ${errMsg}`
return NextResponse.json(results, { status: 500 })
}
// ==================== 步骤 3: Seed Medusa 数据 ====================
console.log('🌱 [3/3] 开始导入 Medusa 数据...')
try {
const seedResponse = await fetch(`${MEDUSA_BACKEND_URL}/hooks/seed-pro`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-payload-api-key': process.env.PAYLOAD_API_KEY || '',
},
// seed:pro 可能需要较长时间
})
if (!seedResponse.ok) {
const bodyText = await seedResponse.text().catch(() => '')
throw new Error(`Medusa seed failed (${seedResponse.status}): ${bodyText || seedResponse.statusText}`)
}
const seedData = await seedResponse.json()
results.steps.push({
step: 3,
name: 'Seed Medusa',
success: true,
details: seedData,
})
console.log('✅ Medusa 数据导入完成')
} catch (error) {
const errMsg = error instanceof Error ? error.message : 'Unknown error'
console.error('❌ Medusa seed 失败:', errMsg)
results.steps.push({
step: 3,
name: 'Seed Medusa',
success: false,
error: errMsg,
})
results.success = false
results.error = `[步骤3] ${errMsg}`
return NextResponse.json(results, { status: 500 })
}
// ==================== 完成 ====================
console.log('✨ 数据重置完成!')
results.message = mode === 'medusa-only'
? 'Medusa 数据重置完成!现在可以同步 Medusa 商品到 Payload CMS。'
: '数据重置完成!现在可以同步 Medusa 商品到 Payload CMS。'
return NextResponse.json(results, { status: 200 })
} catch (error) {
console.error('❌ 数据重置失败:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
},
{ status: 500 }
)
}
}
/**
* Payload CMS
*/
async function cleanPayloadData() {
const payload = await getPayload({ config })
const collections = [
'media',
'products',
'preorder-products',
'announcements',
'articles',
'logs'
]
const details: any = {}
let totalDeleted = 0
for (const collection of collections) {
try {
const result = await payload.find({
collection: collection as any,
limit: 1000,
})
if (result.totalDocs > 0) {
for (const doc of result.docs) {
await payload.delete({
collection: collection as any,
id: doc.id,
})
}
details[collection] = result.totalDocs
totalDeleted += result.totalDocs
console.log(`${collection}: 已删除 ${result.totalDocs} 条记录`)
} else {
details[collection] = 0
console.log(` ${collection}: 集合为空`)
}
} catch (error) {
console.error(`${collection}: 清理失败`, error)
details[collection] = { error: error instanceof Error ? error.message : 'Unknown error' }
}
}
console.log(`✅ Payload 数据清理完成,共删除 ${totalDeleted} 条记录`)
return {
success: true,
totalDeleted,
details,
}
}

View File

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* API Route: Restore Product Recommendations from Seed
* POST /api/admin/restore-recommendations-seed
*
* This server-side route uses Payload's local API to update the global config
* which requires proper authentication context that client-side fetch doesn't have.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { enabled, lists } = body
if (!lists || !Array.isArray(lists)) {
return NextResponse.json(
{ success: false, error: 'Invalid lists data' },
{ status: 400 }
)
}
// Get Payload instance with proper context
const payload = await getPayload({ config })
// Update the global using Payload's local API
const result = await payload.updateGlobal({
slug: 'product-recommendations',
data: {
enabled: enabled ?? true,
lists: lists,
},
})
return NextResponse.json({
success: true,
message: `Successfully restored ${lists.length} recommendation list(s)`,
data: result,
})
} catch (error) {
console.error('Error restoring recommendations seed:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
},
{ status: 500 }
)
}
}

109
src/app/api/admin/route.ts Normal file
View File

@ -0,0 +1,109 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
/**
* Admin Management API
* Combined endpoint for data clearing, stats, and diagnostics
*/
/**
* GET /api/admin?action=stats
* Get Payload collection statistics
*/
async function getStats() {
const payload = await getPayload({ config })
const [products, preorderProducts] = await Promise.all([
payload.find({ collection: 'products', limit: 0 }),
payload.find({ collection: 'preorder-products', limit: 0 }),
])
return {
products: products.totalDocs,
preorderProducts: preorderProducts.totalDocs,
total: products.totalDocs + preorderProducts.totalDocs,
}
}
/**
* DELETE /api/admin
* Clear Payload data (preserves Users and Media)
* Query params: ?collections=products,preorderProducts,announcements,articles
*/
async function clearData(searchParams: URLSearchParams) {
const payload = await getPayload({ config })
const collectionsParam = searchParams.get('collections')
const collections = collectionsParam
? collectionsParam.split(',')
: ['products', 'preorder-products', 'announcements', 'articles']
const results: Record<string, number> = {}
const errors: string[] = []
for (const collection of collections) {
// Protect critical collections
if (['users', 'media'].includes(collection)) {
errors.push(`Skipped protected collection: ${collection}`)
continue
}
try {
const deleted = await payload.delete({
collection: collection as any,
where: {},
})
results[collection] = deleted.docs?.length || 0
console.log(`✅ Cleared ${results[collection]} documents from ${collection}`)
} catch (error) {
const errorMsg = `Failed to clear ${collection}: ${error instanceof Error ? error.message : 'Unknown error'}`
console.error('❌', errorMsg)
errors.push(errorMsg)
}
}
return {
success: true,
message: 'Data cleared successfully',
results,
errors: errors.length > 0 ? errors : undefined,
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const action = searchParams.get('action')
if (action === 'stats') {
const stats = await getStats()
return NextResponse.json({ success: true, ...stats })
}
return NextResponse.json({
success: false,
error: 'Invalid action. Valid actions: stats',
}, { status: 400 })
} catch (error) {
console.error('[admin] GET error:', error)
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const result = await clearData(searchParams)
return NextResponse.json(result)
} catch (error) {
console.error('[admin] DELETE error:', error)
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}, { status: 500 })
}
}

View File

@ -0,0 +1,93 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { syncProductTaobaoLinks } from '@/lib/taobao'
import { addCorsHeaders, handleCorsOptions } from '@/lib/cors'
export async function OPTIONS(request: Request) {
return handleCorsOptions(request.headers.get('origin'))
}
/**
* POST /api/admin/taobao/sync-all
* title / thumbnail / price
*
* Body: { force?: boolean }
* : { success, total, updated, skipped, errors[] }
*/
export async function POST(request: Request) {
const origin = request.headers.get('origin')
try {
const body = await request.json().catch(() => ({}))
const force: boolean = body.force ?? false
const payload = await getPayload({ config })
const collections = ['products', 'preorder-products'] as const
let total = 0
let updated = 0
let skipped = 0
const errors: string[] = []
for (const collection of collections) {
let page = 1
let hasMore = true
while (hasMore) {
const result = await payload.find({
collection,
limit: 20,
page,
pagination: true,
})
for (const product of result.docs) {
const links: any[] = (product as any).taobaoLinks || []
if (links.length === 0) {
skipped++
continue
}
total++
try {
const r = await syncProductTaobaoLinks(
payload,
product.id,
collection,
force,
)
if (r.updated) updated++
else skipped++
} catch (err: any) {
errors.push(`${collection}/${product.id}: ${err?.message}`)
}
}
hasMore = result.hasNextPage ?? false
page++
}
}
const message = `共处理 ${total} 个产品,更新 ${updated} 个,跳过 ${skipped}${errors.length ? `${errors.length} 个错误` : ''}`
console.log(`[taobao/sync-all] ${message}`)
return addCorsHeaders(
NextResponse.json({
success: true,
message,
total,
updated,
skipped,
errors: errors.length ? errors : undefined,
}),
origin,
)
} catch (err: any) {
console.error('[taobao/sync-all]', err)
return addCorsHeaders(
NextResponse.json({ success: false, error: err?.message ?? 'Unknown error' }, { status: 500 }),
origin,
)
}
}

View File

@ -0,0 +1,55 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { syncProductTaobaoLinks } from '@/lib/taobao'
import { addCorsHeaders, handleCorsOptions } from '@/lib/cors'
export async function OPTIONS(request: Request) {
return handleCorsOptions(request.headers.get('origin'))
}
/**
* POST /api/admin/taobao/sync-product
* title / thumbnail / price
*
* Body: {
* productId: string
* collection: 'products' | 'preorder-products'
* force?: boolean // true = 覆盖已有字段false (默认) = 只填充空字段
* }
*/
export async function POST(request: Request) {
const origin = request.headers.get('origin')
try {
const { productId, collection, force = false } = await request.json()
if (!productId || !collection) {
return addCorsHeaders(
NextResponse.json({ success: false, error: 'productId 和 collection 必填' }, { status: 400 }),
origin,
)
}
if (!['products', 'preorder-products'].includes(collection)) {
return addCorsHeaders(
NextResponse.json({ success: false, error: '无效的 collection' }, { status: 400 }),
origin,
)
}
const payload = await getPayload({ config })
const result = await syncProductTaobaoLinks(payload, productId, collection, force)
return addCorsHeaders(
NextResponse.json({ success: true, ...result }),
origin,
)
} catch (err: any) {
console.error('[taobao/sync-product]', err)
return addCorsHeaders(
NextResponse.json({ success: false, error: err?.message ?? 'Unknown error' }, { status: 500 }),
origin,
)
}
}

98
src/app/api/cache/route.ts vendored Normal file
View File

@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCacheStats, clearAllCache, deleteCachePattern, connectRedis } from '@/lib/redis'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* GET /api/cache
*
*/
export async function GET(req: NextRequest) {
try {
// 验证用户权限
const payload = await getPayload({ config })
const headers = req.headers
const token = headers.get('authorization')?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
// 验证用户是否为管理员
const { user } = await payload.auth({ headers: req.headers })
if (!user || !user.roles?.includes('admin')) {
return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 })
}
// 获取统计信息
const stats = await getCacheStats()
return NextResponse.json({
success: true,
stats,
})
} catch (error) {
console.error('Cache stats error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get cache stats',
},
{ status: 500 },
)
}
}
/**
* DELETE /api/cache
*
* Query params:
* - pattern: 匹配模式 "products:*"
*/
export async function DELETE(req: NextRequest) {
try {
// 验证用户权限
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers })
if (!user || !user.roles?.includes('admin')) {
return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 })
}
// 确保 Redis 已连接
await connectRedis()
// 获取查询参数
const searchParams = req.nextUrl.searchParams
const pattern = searchParams.get('pattern')
let deletedCount = 0
let message = ''
if (pattern) {
// 删除匹配模式的缓存
deletedCount = await deleteCachePattern(pattern)
message = `已清除 ${deletedCount} 个匹配 "${pattern}" 的缓存键`
} else {
// 清除所有缓存
deletedCount = await clearAllCache()
message = `已清除所有缓存,共 ${deletedCount} 个键`
}
return NextResponse.json({
success: true,
message,
deletedCount,
})
} catch (error) {
console.error('Cache clear error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to clear cache',
},
{ status: 500 },
)
}
}

View File

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* GET /api/debug/preorder-products
* Debug endpoint to check PreorderProducts data
*/
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
// 直接查询 PreorderProducts 集合
const preorderProducts = await payload.find({
collection: 'preorder-products',
limit: 5,
depth: 0, // 不查询关联数据
})
console.log('=== PreorderProducts Debug ===')
console.log('Total docs:', preorderProducts.totalDocs)
console.log('First doc:', JSON.stringify(preorderProducts.docs[0], null, 2))
console.log('===============================')
return NextResponse.json({
total: preorderProducts.totalDocs,
products: preorderProducts.docs.map(doc => ({
id: doc.id,
title: doc.title,
medusaId: doc.medusaId,
thumbnail: doc.thumbnail,
description: doc.description,
preorderType: doc.preorderType,
fundingGoal: doc.fundingGoal,
orderCount: doc.orderCount,
preorderStartDate: doc.preorderStartDate,
preorderEndDate: doc.preorderEndDate,
allFields: Object.keys(doc),
})),
}, { status: 200 })
} catch (error: any) {
console.error('Error fetching preorder products:', error)
return NextResponse.json(
{
error: 'Failed to fetch preorder products',
message: error.message,
},
{ status: 500 }
)
}
}

133
src/app/api/home/route.ts Normal file
View File

@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* GET /api/home
* + Hero Slider +
*/
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
// 获取首页公告(已发布且在首页显示)
const announcements = await payload.find({
collection: 'announcements',
where: {
and: [
{
status: {
equals: 'published',
},
},
{
showOnHomepage: {
equals: true,
},
},
],
},
sort: '-priority',
limit: 10,
})
// 获取 Hero Slider
const heroSlider = await payload.findGlobal({
slug: 'hero-slider',
})
// 获取产品推荐depth: 2 足以拿到产品文档及其直接关联字段)
const productRecommendations = await payload.findGlobal({
slug: 'product-recommendations',
depth: 2,
})
// 构建响应数据
const response = {
announcements: announcements.docs.map((announcement) => ({
id: announcement.id,
title: announcement.title,
type: announcement.type,
summary: announcement.summary,
priority: announcement.priority,
publishedAt: announcement.publishedAt,
})),
heroSlider: {
slides: heroSlider.slides || [],
},
productRecommendations: {
enabled: productRecommendations.enabled || false,
lists: (productRecommendations.lists || []).map((list: any) => ({
title: list.title,
subtitle: list.subtitle,
preorder: list.preorder || false,
products: (list.products || []).map((productRef: any) => {
const product = productRef.value
// description 是纯文本(从 Medusa 同步)
const description = product.description || ''
// 基础产品信息
const baseInfo = {
id: product.id,
medusaId: product.medusaId,
handle: product.handle || null,
seedId: product.seedId,
title: product.title,
thumbnail: product.thumbnail,
status: product.status,
description,
content: product.content || null,
startPrice: product.startPrice ?? null,
}
// 如果是预购产品,添加预购特有字段
if (productRef.relationTo === 'preorder-products') {
const realOrderCount = product.orderCount || 0
const fakeOrderCount = product.fakeOrderCount || 0
const totalCount = realOrderCount + fakeOrderCount
return {
...baseInfo,
relationTo: 'preorder-products',
preorder: {
type: product.preorderType || 'standard',
fundingGoal: product.fundingGoal || 0,
orderCount: totalCount,
startDate: product.preorderStartDate,
endDate: product.preorderEndDate,
// 计算进度百分比(含 fakeOrderCount用于展示不限制 100 以支持超出显示)
progress: product.fundingGoal > 0
? Math.round((totalCount / product.fundingGoal) * 100)
: 0,
// 计算剩余天数
daysLeft: product.preorderEndDate
? Math.max(0, Math.ceil((new Date(product.preorderEndDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
: null,
// 支持者数量(含 fakeOrderCount用于展示
backers: totalCount,
},
}
}
// 普通产品
return {
...baseInfo,
relationTo: 'products',
}
}),
})),
},
}
return NextResponse.json(response, { status: 200 })
} catch (error: any) {
console.error('Error fetching homepage data:', error)
return NextResponse.json(
{
error: 'Failed to fetch homepage data',
message: error.message,
},
{ status: 500 }
)
}
}

View File

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
const MEDUSA_URL = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY || ''
/**
* Medusa /hooks/preorder-orders
* GET /api/preorders/:id/orders
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const payload = await getPayload({ config })
const { id } = await params
// 获取预购产品(先尝试 Payload ID再尝试 medusaId / seedId
let product: any = null
try {
product = await payload.findByID({
collection: 'preorder-products',
id,
depth: 0,
})
} catch {
const result = await payload.find({
collection: 'preorder-products',
where: {
or: [
{ medusaId: { equals: id } },
{ seedId: { equals: id } },
],
},
limit: 1,
depth: 0,
})
if (result.docs.length > 0) product = result.docs[0]
}
if (!product) {
return NextResponse.json({ error: 'Preorder product not found' }, { status: 404 })
}
// 构建查询参数:优先 medusaId其次 seedId
const queryParam = product.medusaId
? `product_id=${encodeURIComponent(product.medusaId)}`
: product.seedId
? `seed_id=${encodeURIComponent(product.seedId)}`
: null
if (!queryParam) {
return NextResponse.json(
{ error: 'Product has no Medusa ID or seed ID, cannot fetch orders' },
{ status: 400 }
)
}
// 调用 Medusa 内部 hook使用 x-payload-api-key无需 admin JWT
const medusaResponse = await fetch(
`${MEDUSA_URL}/hooks/preorder-orders?${queryParam}`,
{
headers: {
'Content-Type': 'application/json',
'x-payload-api-key': PAYLOAD_API_KEY,
},
}
)
if (!medusaResponse.ok) {
const errBody = await medusaResponse.json().catch(() => ({}))
console.error('[Payload Preorder Orders API] Medusa error:', errBody)
throw new Error((errBody as any).message || `Medusa responded ${medusaResponse.status}`)
}
// 直接透传 Medusa 的响应
const data = await medusaResponse.json()
return NextResponse.json(data)
} catch (error: any) {
console.error('[Payload Preorder Orders API] Error:', error?.message || error)
return NextResponse.json(
{ error: 'Failed to fetch preorder orders', message: error?.message },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* Medusa
* POST /api/preorders/:id/recalculate
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const payload = await getPayload({ config })
const { id } = await params
// 获取预购产品
let product: any = null
try {
product = await payload.findByID({
collection: 'preorder-products',
id,
depth: 2,
})
} catch (err) {
const result = await payload.find({
collection: 'preorder-products',
where: {
or: [
{ medusaId: { equals: id } },
{ seedId: { equals: id } },
],
},
limit: 1,
depth: 2,
})
if (result.docs.length > 0) {
product = result.docs[0]
}
}
if (!product) {
return NextResponse.json(
{ error: 'Preorder product not found' },
{ status: 404 }
)
}
if (!product.medusaId) {
return NextResponse.json(
{ error: 'Product has no Medusa ID, cannot recalculate' },
{ status: 400 }
)
}
// 从 Medusa 获取订单数据
const medusaUrl = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
const medusaResponse = await fetch(`${medusaUrl}/admin/orders`, {
headers: {
'Content-Type': 'application/json',
},
})
if (!medusaResponse.ok) {
throw new Error('Failed to fetch orders from Medusa')
}
const { orders } = await medusaResponse.json()
// 统计每个变体的订单数量
const variantCounts: Record<string, number> = {}
let totalCount = 0
for (const order of orders || []) {
if (!order?.items) continue
for (const item of order.items) {
if (!item || item.product_id !== product.medusaId) continue
const variantId = item.variant_id
if (variantId) {
variantCounts[variantId] = (variantCounts[variantId] || 0) + (item.quantity || 0)
totalCount += item.quantity || 0
}
}
}
// 从 Medusa 获取产品变体列表
const productResponse = await fetch(`${medusaUrl}/admin/products/${product.medusaId}`, {
headers: {
'Content-Type': 'application/json',
},
})
if (!productResponse.ok) {
throw new Error('Failed to fetch product from Medusa')
}
const { product: medusaProduct } = await productResponse.json()
const variants = medusaProduct.variants || []
// 更新每个变体的 metadata在 Medusa 中)
const updatePromises = variants.map(async (variant: any) => {
const count = variantCounts[variant.id] || 0
await fetch(`${medusaUrl}/admin/product-variants/${variant.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
metadata: {
...(variant.metadata || {}),
current_orders: String(count),
},
}),
})
return {
variant_id: variant.id,
count,
}
})
const updatedVariants = await Promise.all(updatePromises)
return NextResponse.json({
success: true,
product_id: product.id,
total_orders: totalCount,
variants: updatedVariants,
message: `Recalculated orders for ${variants.length} variant(s)`,
})
} catch (error: any) {
console.error('[Payload Preorder Recalculate API] Error:', error?.message || error)
return NextResponse.json(
{ error: 'Failed to recalculate orders', message: error?.message },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,318 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
*
* GET /api/preorders/:id
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const payload = await getPayload({ config })
const { id } = await params
// 尝试通过 Payload ID 查找
let product: any = null
try {
product = await payload.findByID({
collection: 'preorder-products',
id,
depth: 2,
})
} catch (err) {
// 如果不是 Payload ID尝试通过 medusaId 或 seedId 查找
const result = await payload.find({
collection: 'preorder-products',
where: {
or: [
{ medusaId: { equals: id } },
{ seedId: { equals: id } },
],
},
limit: 1,
depth: 2,
})
if (result.docs.length > 0) {
product = result.docs[0]
}
}
if (!product) {
return NextResponse.json(
{ error: 'Preorder product not found' },
{ status: 404 }
)
}
// 格式化变体数据
const variants = (product.variants || []).map((variant: any) => {
const currentOrders = parseInt(variant.currentOrders || '0', 10) || 0
const maxOrders = parseInt(variant.maxOrders || '0', 10) || 0
const availableSlots = maxOrders > 0 ? maxOrders - currentOrders : 0
const soldOut = maxOrders > 0 && currentOrders >= maxOrders
const utilization = maxOrders > 0 ? Math.round((currentOrders / maxOrders) * 100) : 0
return {
id: variant.id,
title: variant.title,
sku: variant.sku,
current_orders: currentOrders,
max_orders: maxOrders,
available_slots: availableSlots,
sold_out: soldOut,
utilization_percentage: utilization,
prices: variant.prices || [],
options: variant.options || {},
metadata: variant.metadata || {},
}
})
// 计算统计数据
const totalOrders = variants.reduce((sum: number, v: any) => sum + v.current_orders, 0)
const totalMaxOrders = variants.reduce((sum: number, v: any) => sum + v.max_orders, 0)
const totalAvailable = variants.reduce((sum: number, v: any) => sum + v.available_slots, 0)
const fundingGoal = parseInt(product.fundingGoal || '0', 10) || totalMaxOrders
const completionPercentage = fundingGoal > 0
? Math.round((totalOrders / fundingGoal) * 100)
: 0
const allSoldOut = variants.every((v: any) => v.sold_out)
const someSoldOut = variants.some((v: any) => v.sold_out)
return NextResponse.json({
preorder: {
id: product.id,
title: product.title,
description: product.description,
status: product._status,
thumbnail: product.thumbnail,
images: product.images || [],
// IDs
seed_id: product.seedId || product.medusaId,
medusa_id: product.medusaId,
// 预购元数据(从 Payload 管理)
is_preorder: true,
preorder_type: product.preorderType || 'standard',
preorder_end_date: product.preorderEndDate || null,
funding_goal: fundingGoal,
// 订单计数
order_count: parseInt(product.orderCount || '0', 10) || 0,
fake_order_count: parseInt(product.fakeOrderCount || '0', 10) || 0,
total_display_count: (parseInt(product.orderCount || '0', 10) || 0) + (parseInt(product.fakeOrderCount || '0', 10) || 0),
// 统计数据
current_orders: totalOrders,
total_max_orders: totalMaxOrders,
total_available_slots: totalAvailable,
completion_percentage: completionPercentage,
// 可用性状态
all_variants_sold_out: allSoldOut,
some_variants_sold_out: someSoldOut,
is_available: !allSoldOut && totalAvailable > 0,
// 详细信息
variants,
variants_count: variants.length,
categories: product.categories || [],
collection: product.collection || null,
metadata: product.metadata || {},
// 时间戳
created_at: product.createdAt,
updated_at: product.updatedAt,
last_synced_at: product.lastSyncedAt,
},
})
} catch (error: any) {
console.error('[Payload Preorder Detail API] Error:', error?.message || error)
return NextResponse.json(
{ error: 'Failed to fetch preorder product', message: error?.message },
{ status: 500 }
)
}
}
/**
*
* PATCH /api/preorders/:id
*
* Body:
* - variant_id?: string -
* - current_orders?: number -
* - max_orders?: number -
* - increment?: number -
* - decrement?: number -
*
* - preorder_end_date?: string -
* - funding_goal?: number -
* - preorder_type?: string -
* - fake_order_count?: number - Fake
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const payload = await getPayload({ config })
const { id } = await params
const body = await req.json()
const {
variant_id,
current_orders,
max_orders,
increment,
decrement,
preorder_end_date,
funding_goal,
preorder_type,
fake_order_count,
} = body
// 获取产品
let product: any = null
try {
product = await payload.findByID({
collection: 'preorder-products',
id,
depth: 2,
})
} catch (err) {
const result = await payload.find({
collection: 'preorder-products',
where: {
or: [
{ medusaId: { equals: id } },
{ seedId: { equals: id } },
],
},
limit: 1,
depth: 2,
})
if (result.docs.length > 0) {
product = result.docs[0]
}
}
if (!product) {
return NextResponse.json(
{ error: 'Preorder product not found' },
{ status: 404 }
)
}
// 模式1: 更新变体预购计数
if (variant_id) {
// 预购变体数据存储在 Medusa 中,直接更新 Medusa
const medusaUrl = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
// 获取当前变体数据
const variantResponse = await fetch(`${medusaUrl}/admin/product-variants/${variant_id}`, {
headers: {
'Content-Type': 'application/json',
},
})
if (!variantResponse.ok) {
return NextResponse.json(
{ error: 'Variant not found in Medusa' },
{ status: 404 }
)
}
const { variant } = await variantResponse.json()
const currentMeta = variant.metadata || {}
let newCurrentOrders = parseInt(currentMeta.current_orders || '0', 10) || 0
let newMaxOrders = parseInt(currentMeta.max_orders || '0', 10) || 0
// 处理更新逻辑
if (typeof current_orders === 'number') {
newCurrentOrders = Math.max(0, current_orders)
} else if (typeof increment === 'number') {
newCurrentOrders = Math.max(0, newCurrentOrders + increment)
} else if (typeof decrement === 'number') {
newCurrentOrders = Math.max(0, newCurrentOrders - decrement)
}
if (typeof max_orders === 'number') {
newMaxOrders = Math.max(0, max_orders)
}
// 更新 Medusa 变体 metadata
const updateResponse = await fetch(`${medusaUrl}/admin/product-variants/${variant_id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
metadata: {
...currentMeta,
current_orders: String(newCurrentOrders),
max_orders: String(newMaxOrders),
},
}),
})
if (!updateResponse.ok) {
throw new Error('Failed to update variant in Medusa')
}
return NextResponse.json({
success: true,
variant_id,
current_orders: newCurrentOrders,
max_orders: newMaxOrders,
available_slots: newMaxOrders - newCurrentOrders,
sold_out: newMaxOrders > 0 && newCurrentOrders >= newMaxOrders,
})
}
// 模式2: 更新产品级别预购元数据(在 Payload 中管理)
const updateData: any = {}
if (preorder_end_date !== undefined) {
updateData.preorderEndDate = preorder_end_date
}
if (funding_goal !== undefined) {
updateData.fundingGoal = String(funding_goal)
}
if (preorder_type !== undefined) {
updateData.preorderType = preorder_type
}
if (fake_order_count !== undefined) {
updateData.fakeOrderCount = Math.max(0, fake_order_count)
}
if (Object.keys(updateData).length > 0) {
await payload.update({
collection: 'preorder-products',
id: product.id,
data: updateData,
})
}
return NextResponse.json({
success: true,
message: 'Preorder product updated successfully',
updated_fields: Object.keys(updateData),
})
} catch (error: any) {
console.error('[Payload Preorder Update API] Error:', error?.message || error)
return NextResponse.json(
{ error: 'Failed to update preorder product', message: error?.message },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* GET /api/preorders/health-check
*
*
*
*/
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
// 获取所有预购产品
const { docs: products } = await payload.find({
collection: 'preorder-products',
limit: 1000,
depth: 2,
})
if (!products || products.length === 0) {
return NextResponse.json({
success: true,
summary: {
total: 0,
healthy: 0,
warnings: 0,
errors: 0,
},
products: [],
issues: [],
})
}
// 检查每个产品
const productChecks: any[] = []
const allIssues: string[] = []
let healthyCount = 0
let warningCount = 0
let errorCount = 0
for (const product of products) {
const issues: string[] = []
let severity: 'healthy' | 'warning' | 'error' = 'healthy'
// 检查必要字段
if (!product.medusaId) {
issues.push('缺少 Medusa ID')
severity = 'error'
}
if (!product.title) {
issues.push('缺少产品标题')
severity = 'error'
}
// 检查预购设置
if (product.fundingGoal === undefined || product.fundingGoal === null) {
issues.push('未设置众筹目标')
severity = severity === 'error' ? 'error' : 'warning'
} else if (product.fundingGoal === 0) {
issues.push('众筹目标为 0将使用变体总和')
}
// 检查日期
if (!product.preorderStartDate) {
issues.push('未设置预购开始日期')
severity = severity === 'error' ? 'error' : 'warning'
}
if (!product.preorderEndDate) {
issues.push('未设置预购结束日期')
severity = severity === 'error' ? 'error' : 'warning'
}
// 检查日期逻辑
if (product.preorderStartDate && product.preorderEndDate) {
const startDate = new Date(product.preorderStartDate)
const endDate = new Date(product.preorderEndDate)
if (startDate >= endDate) {
issues.push('预购开始日期晚于或等于结束日期')
severity = 'error'
}
const now = new Date()
if (endDate < now) {
issues.push('预购已结束')
} else if (startDate > now) {
issues.push('预购尚未开始')
}
}
// 检查订单计数
const orderCount = parseInt(String(product.orderCount || 0), 10)
const fakeOrderCount = parseInt(String(product.fakeOrderCount || 0), 10)
const totalDisplayCount = orderCount + fakeOrderCount
const fundingGoal = parseInt(String(product.fundingGoal || 0), 10)
if (fundingGoal > 0) {
const completionPercentage = Math.round((totalDisplayCount / fundingGoal) * 100)
if (completionPercentage >= 100) {
issues.push(`已达成目标 (${completionPercentage}%)`)
} else if (completionPercentage < 10) {
issues.push(`完成度较低 (${completionPercentage}%)`)
severity = severity === 'error' ? 'error' : 'warning'
}
}
// 检查状态
if (product.status !== 'published') {
issues.push(`产品状态为: ${product.status}`)
severity = severity === 'error' ? 'error' : 'warning'
}
// 更新统计
if (severity === 'error') {
errorCount++
} else if (severity === 'warning' || issues.length > 0) {
warningCount++
} else {
healthyCount++
}
// 记录产品检查结果
productChecks.push({
id: product.id,
title: product.title,
medusaId: product.medusaId,
seedId: product.seedId,
status: product.status,
severity,
issues,
stats: {
orderCount,
fakeOrderCount,
totalDisplayCount,
fundingGoal,
completionPercentage: fundingGoal > 0
? Math.round((totalDisplayCount / fundingGoal) * 100)
: 0,
},
dates: {
preorderStartDate: product.preorderStartDate,
preorderEndDate: product.preorderEndDate,
},
})
// 添加到全局问题列表
if (issues.length > 0) {
allIssues.push(`${product.title}: ${issues.join(', ')}`)
}
}
// 按严重程度排序
productChecks.sort((a, b) => {
const severityOrder: { [key: string]: number } = { error: 0, warning: 1, healthy: 2 }
return severityOrder[a.severity] - severityOrder[b.severity]
})
return NextResponse.json({
success: true,
timestamp: new Date().toISOString(),
summary: {
total: products.length,
healthy: healthyCount,
warnings: warningCount,
errors: errorCount,
},
products: productChecks,
issues: allIssues,
})
} catch (error: any) {
console.error('[Health Check API] Error:', error)
return NextResponse.json(
{
success: false,
error: 'Failed to check preorder products health',
message: error.message
},
{ status: 500 }
)
}
}

View File

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
* POST /api/preorders/increment-count
* orderCount Medusa subscriber
*
* Body: { medusaId: string; increment: number }
* Auth: x-payload-api-key header
*/
export async function POST(req: NextRequest) {
const apiKey = req.headers.get('x-payload-api-key')
if (!apiKey || apiKey !== process.env.PAYLOAD_API_KEY) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const { medusaId, increment } = await req.json()
if (!medusaId || typeof increment !== 'number') {
return NextResponse.json(
{ success: false, error: 'medusaId 和 increment (number) 为必填' },
{ status: 400 },
)
}
const payload = await getPayload({ config })
// 查找对应的预购商品
const result = await payload.find({
collection: 'preorder-products',
where: { medusaId: { equals: medusaId } },
limit: 1,
})
const product = result.docs[0]
if (!product) {
return NextResponse.json(
{ success: false, error: `未找到 medusaId=${medusaId} 的预购商品` },
{ status: 404 },
)
}
const currentCount = typeof product.orderCount === 'number' ? product.orderCount : 0
const newCount = Math.max(0, currentCount + increment)
await payload.update({
collection: 'preorder-products',
id: product.id,
data: { orderCount: newCount },
})
console.log(
`[increment-count] ✅ ${product.title}: ${currentCount}${newCount} (+${increment})`,
)
return NextResponse.json({
success: true,
title: product.title,
previousCount: currentCount,
newCount,
increment,
})
} catch (error: any) {
console.error('[increment-count] ❌', error?.message)
return NextResponse.json(
{ success: false, error: error?.message || 'Unknown error' },
{ status: 500 },
)
}
}

View File

@ -0,0 +1,190 @@
import { NextRequest, NextResponse } from 'next/server'
import payload from 'payload'
const MEDUSA_BACKEND_URL = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
const MEDUSA_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || ''
/**
*
* POST /api/preorders/refresh-order-counts
*
* Body:
* - productIds?: string[] - ID Payload ID
* - refreshAll?: boolean -
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { productIds, refreshAll } = body
if (!productIds && !refreshAll) {
return NextResponse.json(
{ success: false, error: '请提供 productIds 或设置 refreshAll' },
{ status: 400 }
)
}
let products: any[] = []
if (refreshAll) {
// 获取所有预购商品
const result = await payload.find({
collection: 'preorder-products',
limit: 1000,
where: {
medusaId: {
exists: true,
},
},
})
products = result.docs
} else {
// 获取指定的商品
const result = await payload.find({
collection: 'preorder-products',
limit: productIds.length,
where: {
id: {
in: productIds,
},
medusaId: {
exists: true,
},
},
})
products = result.docs
}
if (products.length === 0) {
return NextResponse.json(
{ success: false, error: '没有找到要刷新的商品' },
{ status: 404 }
)
}
// 统计更新结果
let successCount = 0
let failCount = 0
const errors: string[] = []
// 为每个商品刷新订单计数
for (const product of products) {
try {
const medusaId = product.medusaId
// 从 Medusa 获取订单数据
const response = await fetch(
`${MEDUSA_BACKEND_URL}/admin/orders?product_id=${medusaId}&limit=1000`,
{
headers: {
'x-publishable-api-key': MEDUSA_PUBLISHABLE_KEY,
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
throw new Error(`Medusa API 返回错误: ${response.status} ${response.statusText}`)
}
const data = await response.json()
const orders = data.orders || []
// 计算真实订单数
let realOrderCount = 0
// 筛选有效订单(排除取消的)
const validOrders = orders.filter((order: any) =>
order.status !== 'canceled' && order.payment_status !== 'not_paid'
)
// 遍历有效订单,统计该商品的数量
for (const order of validOrders) {
const items = order.items || []
for (const item of items) {
if (item.product_id === medusaId) {
realOrderCount += item.quantity || 1
}
}
}
// 更新 Payload 中的订单计数
await payload.update({
collection: 'preorder-products',
id: product.id,
data: {
orderCount: realOrderCount,
},
})
successCount++
} catch (error) {
console.error(`刷新商品 ${product.id} (${product.title}) 失败:`, error)
failCount++
errors.push(`${product.title}: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
return NextResponse.json({
success: true,
message: `刷新完成: ${successCount} 个成功, ${failCount} 个失败`,
successCount,
failCount,
errors,
})
} catch (error) {
console.error('刷新订单计数失败:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : '未知错误',
},
{ status: 500 }
)
}
}
/**
*
* GET /api/preorders/refresh-order-counts?productId=xxx
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const productId = searchParams.get('productId')
if (!productId) {
return NextResponse.json(
{ success: false, error: '请提供 productId' },
{ status: 400 }
)
}
const product = await payload.findByID({
collection: 'preorder-products',
id: productId,
})
if (!product || !product.medusaId) {
return NextResponse.json(
{ success: false, error: '商品不存在或没有 Medusa ID' },
{ status: 404 }
)
}
return NextResponse.json({
success: true,
currentCount: product.orderCount || 0,
medusaId: product.medusaId,
})
} catch (error) {
console.error('获取订单计数失败:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : '未知错误',
},
{ status: 500 }
)
}
}

View File

@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
/**
*
* GET /api/preorders
*
* Query params:
* - seed_id: seed_id
* - status: 按状态筛选 (draft|published)
*/
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
const { searchParams } = new URL(req.url)
const seed_id = searchParams.get('seed_id')
const status = searchParams.get('status')
// 构建查询条件
const where: any = {}
if (seed_id) {
where.seedId = { equals: seed_id }
}
if (status) {
where._status = { equals: status }
}
// 查询预购产品集合
const result = await payload.find({
collection: 'preorder-products',
where,
depth: 2,
limit: 100,
sort: '-createdAt',
})
// 格式化数据 - 以预购为主,展示完整变体信息
const formattedProducts = result.docs.map((product: any) => {
// 计算变体预购统计
const variants = (product.variants || []).map((variant: any) => {
const currentOrders = parseInt(variant.currentOrders || '0', 10) || 0
const maxOrders = parseInt(variant.maxOrders || '0', 10) || 0
const availableSlots = maxOrders > 0 ? maxOrders - currentOrders : 0
const soldOut = maxOrders > 0 && currentOrders >= maxOrders
const utilization = maxOrders > 0 ? Math.round((currentOrders / maxOrders) * 100) : 0
return {
id: variant.id,
title: variant.title,
sku: variant.sku,
current_orders: currentOrders,
max_orders: maxOrders,
available_slots: availableSlots,
sold_out: soldOut,
utilization_percentage: utilization,
prices: variant.prices || [],
}
})
// 产品级别统计
const totalOrders = variants.reduce((sum: number, v: any) => sum + v.current_orders, 0)
const totalMaxOrders = variants.reduce((sum: number, v: any) => sum + v.max_orders, 0)
const totalAvailable = variants.reduce((sum: number, v: any) => sum + v.available_slots, 0)
const fundingGoal = parseInt(product.fundingGoal || '0', 10) || totalMaxOrders
const completionPercentage = fundingGoal > 0
? Math.round((totalOrders / fundingGoal) * 100)
: 0
const allSoldOut = variants.every((v: any) => v.sold_out)
const someSoldOut = variants.some((v: any) => v.sold_out)
return {
id: product.id,
title: product.title,
status: product._status,
thumbnail: product.thumbnail,
description: product.description,
// Seed ID
seed_id: product.seedId || product.medusaId,
medusa_id: product.medusaId,
// 预购元数据
preorder_type: product.preorderType || 'standard',
funding_goal: fundingGoal,
// 订单计数
order_count: parseInt(product.orderCount || '0', 10) || 0,
fake_order_count: parseInt(product.fakeOrderCount || '0', 10) || 0,
total_display_count: (parseInt(product.orderCount || '0', 10) || 0) + (parseInt(product.fakeOrderCount || '0', 10) || 0),
// 统计数据
current_orders: totalOrders,
total_max_orders: totalMaxOrders,
total_available_slots: totalAvailable,
completion_percentage: completionPercentage,
// 可用性状态
all_variants_sold_out: allSoldOut,
some_variants_sold_out: someSoldOut,
is_available: !allSoldOut && totalAvailable > 0,
// 变体详情
variants,
variants_count: variants.length,
// 时间戳
created_at: product.createdAt,
updated_at: product.updatedAt,
}
})
// 排序:有库存优先 → 完成度高优先
formattedProducts.sort((a: any, b: any) => {
if (a.is_available !== b.is_available) {
return a.is_available ? -1 : 1
}
if (a.completion_percentage !== b.completion_percentage) {
return b.completion_percentage - a.completion_percentage
}
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
return NextResponse.json({
preorders: formattedProducts,
count: formattedProducts.length,
summary: {
total: formattedProducts.length,
available: formattedProducts.filter((p: any) => p.is_available).length,
sold_out: formattedProducts.filter((p: any) => p.all_variants_sold_out).length,
total_orders: formattedProducts.reduce((sum: number, p: any) => sum + p.current_orders, 0),
total_slots: formattedProducts.reduce((sum: number, p: any) => sum + p.total_max_orders, 0),
},
})
} catch (error: any) {
console.error('[Payload Preorders API] Error:', error?.message || error)
return NextResponse.json(
{ error: 'Failed to fetch preorder products', message: error?.message },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { addCorsHeaders, handleCorsOptions } from '@/lib/cors'
export async function OPTIONS(request: Request) {
const origin = request instanceof Request ? request.headers.get('origin') : null
return handleCorsOptions(origin)
}
/**
* Tab
* GET /api/products/:id/content[?collection=products|preorder-products]
*
* :id
* - Payload ID
* - Medusa ID (medusaId)
* - Seed ID (seedId)
* - Handle (handle)
*
* : { id, handle, content, projectStatuses[], precautions[], sharedPrecautions[] }
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const origin = req.headers.get('origin')
try {
const payload = await getPayload({ config })
const { id } = await params
const collectionParam = req.nextUrl.searchParams.get('collection')
// Determine which collections to search
const collections: Array<'products' | 'preorder-products'> =
collectionParam === 'preorder-products' ? ['preorder-products']
: collectionParam === 'products' ? ['products']
: ['products', 'preorder-products']
let doc: any = null
for (const collection of collections) {
// 1. Try as Payload document ID
try {
doc = await payload.findByID({ collection, id, depth: 2 })
if (doc) break
} catch {
// not a valid Payload ID — fall through
}
// 2. Try medusaId / seedId / handle
const result = await payload.find({
collection,
where: {
or: [
{ medusaId: { equals: id } },
{ seedId: { equals: id } },
{ handle: { equals: id } },
],
},
limit: 1,
depth: 2,
})
if (result.docs.length > 0) {
doc = result.docs[0]
break
}
}
if (!doc) {
const res = NextResponse.json({ error: 'Product not found' }, { status: 404 })
return addCorsHeaders(res, origin)
}
// Normalise sharedPrecautions: depth:2 resolves relationship to full Precaution docs
const sharedPrecautions = (doc.sharedPrecautions ?? [])
.map((item: any) => {
if (typeof item === 'object' && item !== null && item.id) {
return {
id: item.id as string,
title: item.title as string,
summary: (item.summary as string | undefined) ?? undefined,
}
}
return null
})
.filter(Boolean)
const data = {
id: doc.id,
handle: doc.handle ?? null,
content: doc.content ?? null,
projectStatuses: (doc.projectStatuses ?? []).map((s: any) => ({
id: s.id ?? String(Math.random()),
title: s.title,
badge: s.badge ?? undefined,
description: s.description ?? undefined,
order: s.order ?? 0,
})),
precautions: (doc.precautions ?? []).map((p: any) => ({
id: p.id ?? String(Math.random()),
title: p.title,
summary: p.summary ?? undefined,
order: p.order ?? 0,
})),
sharedPrecautions,
}
const res = NextResponse.json(data)
return addCorsHeaders(res, origin)
} catch (err: any) {
console.error('[products/[id]/content] Error:', err?.message ?? err)
const res = NextResponse.json({ error: 'Internal server error' }, { status: 500 })
return addCorsHeaders(res, origin)
}
}

View File

@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
const MEDUSA_URL = process.env.MEDUSA_BACKEND_URL || 'http://localhost:9000'
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY || ''
/**
* products preorder-products
* GET /api/products/:id/orders
*
* :id
* - Payload ID
* - Medusa ID (medusaId)
* - Seed ID (seedId)
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const payload = await getPayload({ config })
const { id } = await params
// 依次在两个集合中查找产品
let product: any = null
const collections = ['products', 'preorder-products'] as const
for (const collection of collections) {
try {
product = await payload.findByID({ collection, id, depth: 0 })
if (product) break
} catch {
// 不是 Payload ID通过 medusaId / seedId 再试
const result = await payload.find({
collection,
where: { or: [{ medusaId: { equals: id } }, { seedId: { equals: id } }] },
limit: 1,
depth: 0,
})
if (result.docs.length > 0) {
product = result.docs[0]
break
}
}
}
if (!product) {
return NextResponse.json({ error: 'Product not found' }, { status: 404 })
}
const queryParam = product.medusaId
? `product_id=${encodeURIComponent(product.medusaId)}`
: product.seedId
? `seed_id=${encodeURIComponent(product.seedId)}`
: null
if (!queryParam) {
return NextResponse.json(
{ error: 'Product has no Medusa ID or seed ID, cannot fetch orders' },
{ status: 400 }
)
}
const medusaResponse = await fetch(
`${MEDUSA_URL}/hooks/preorder-orders?${queryParam}`,
{
headers: {
'Content-Type': 'application/json',
'x-payload-api-key': PAYLOAD_API_KEY,
},
}
)
if (!medusaResponse.ok) {
const errBody = await medusaResponse.json().catch(() => ({}))
console.error('[Products Orders API] Medusa error:', errBody)
throw new Error((errBody as any).message || `Medusa responded ${medusaResponse.status}`)
}
const data = await medusaResponse.json()
// 在服务端注入 Medusa 后台订单链接(避免客户端暴露内部 URL
const medusaAdminBase = MEDUSA_URL
if (Array.isArray(data.orders)) {
data.orders = data.orders.map((order: any) => ({
...order,
medusa_url: `${medusaAdminBase}/app/orders/${order.id}`,
}))
}
return NextResponse.json(data)
} catch (error: any) {
console.error('[Products Orders API] Error:', error?.message || error)
return NextResponse.json(
{ error: 'Failed to fetch orders', message: error?.message },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,171 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import {
getAllMedusaProducts,
transformMedusaProductToPayload,
getProductCollection,
} from '@/lib/medusa'
import { addCorsHeaders, handleCorsOptions } from '@/lib/cors'
export async function OPTIONS(request: Request) {
const origin = request.headers.get('origin')
return handleCorsOptions(origin)
}
/**
* GET /api/sync/medusa
* Medusa Payload
*
* :
* ?forceUpdate=true/false ( false)
* ?medusaId=xxx Medusa subscriber
* ?collection=xxx collection medusaId 使
*/
export async function GET(request: NextRequest) {
const origin = request.headers.get('origin')
try {
const { searchParams } = new URL(request.url)
const forceUpdate = searchParams.get('forceUpdate') === 'true'
const singleMedusaId = searchParams.get('medusaId')
const preferredCollection = searchParams.get('collection') as
| 'products'
| 'preorder-products'
| null
const payload = await getPayload({ config })
const allMedusaProducts = await getAllMedusaProducts()
// 单商品模式(由 Medusa subscriber 调用)
const targetProducts = singleMedusaId
? allMedusaProducts.filter((p) => p.id === singleMedusaId)
: allMedusaProducts
if (singleMedusaId && targetProducts.length === 0) {
const response = NextResponse.json(
{ success: false, error: `Medusa 中未找到商品 ${singleMedusaId}` },
{ status: 404 },
)
return addCorsHeaders(response, origin)
}
const results = {
total: targetProducts.length,
created: 0,
updated: 0,
skipped: 0,
failed: 0,
}
for (const medusaProduct of targetProducts) {
try {
const productData = transformMedusaProductToPayload(medusaProduct)
const targetCollection =
preferredCollection || getProductCollection(medusaProduct)
// 查找现有产品seedId 优先,否则 medusaId
let existingProduct: any = null
let existingCollection: 'products' | 'preorder-products' | null = null
if (productData.seedId) {
for (const coll of ['products', 'preorder-products'] as const) {
const result = await payload.find({
collection: coll,
where: { seedId: { equals: productData.seedId } },
limit: 1,
})
if (result.docs[0]) {
existingProduct = result.docs[0]
existingCollection = coll
break
}
}
}
if (!existingProduct) {
for (const coll of ['products', 'preorder-products'] as const) {
const result = await payload.find({
collection: coll,
where: { medusaId: { equals: medusaProduct.id } },
limit: 1,
})
if (result.docs[0]) {
existingProduct = result.docs[0]
existingCollection = coll
break
}
}
}
if (existingProduct) {
// 构建更新数据forceUpdate 时覆盖所有字段,否则只更新 Medusa 来源字段(保留 Payload 编辑内容)
const updateData: any = {
lastSyncedAt: productData.lastSyncedAt,
medusaId: productData.medusaId,
seedId: productData.seedId,
title: productData.title,
handle: productData.handle,
description: productData.description,
startPrice: productData.startPrice,
tags: productData.tags,
type: productData.type,
collection: productData.collection,
category: productData.category,
height: productData.height,
width: productData.width,
length: productData.length,
weight: productData.weight,
midCode: productData.midCode,
hsCode: productData.hsCode,
countryOfOrigin: productData.countryOfOrigin,
// thumbnail: forceUpdate 时覆盖,否则保留 Payload 已有值
thumbnail: forceUpdate
? (productData.thumbnail || existingProduct.thumbnail)
: (existingProduct.thumbnail || productData.thumbnail),
}
// 如果需要跨 collection 移动
if (existingCollection && existingCollection !== targetCollection) {
await payload.delete({ collection: existingCollection, id: existingProduct.id })
await payload.create({ collection: targetCollection, data: updateData })
} else {
await payload.update({
collection: targetCollection,
id: existingProduct.id,
data: updateData,
})
}
results.updated++
} else {
// 新建
await payload.create({
collection: targetCollection,
data: {
...productData,
status: (productData.status as 'draft' | 'published') ?? 'draft',
},
})
results.created++
}
} catch (err) {
console.error(`[sync/medusa] ❌ 同步失败 ${medusaProduct.id}:`, err)
results.failed++
}
}
const response = NextResponse.json({
success: true,
message: `同步完成:新建 ${results.created},更新 ${results.updated},跳过 ${results.skipped},失败 ${results.failed}`,
results,
})
return addCorsHeaders(response, origin)
} catch (error: any) {
console.error('[sync/medusa] ❌ 错误:', error)
const response = NextResponse.json(
{ success: false, error: error.message || 'Unknown error' },
{ status: 500 },
)
return addCorsHeaders(response, origin)
}
}

View File

@ -0,0 +1,304 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextResponse } from 'next/server'
import {
getAllMedusaProducts,
transformMedusaProductToPayload,
getProductCollection,
} from '@/lib/medusa'
import { addCorsHeaders, handleCorsOptions } from '@/lib/cors'
/**
* CORS
*/
export async function OPTIONS(request: Request) {
const origin = request.headers.get('origin')
return handleCorsOptions(origin)
}
/**
*
* POST /api/sync/product
* Body: { medusaId: string, collection?: 'products' | 'preorder-products', forceUpdate?: boolean }
*
* : { success: true, product: {...}, action: 'created' | 'updated' | 'skipped' }
*/
export async function POST(request: Request) {
console.log('[Sync Product API] 📥 收到同步请求')
const origin = request.headers.get('origin')
try {
const body = await request.json()
const {
medusaId,
collection: preferredCollection,
forceUpdate = false,
// 预购相关字段(从 Medusa subscriber 传递)
fundingGoal,
preorderStartDate,
preorderEndDate,
} = body
if (!medusaId) {
const response = NextResponse.json(
{
success: false,
error: 'medusaId is required',
},
{ status: 400 },
)
return addCorsHeaders(response, origin)
}
console.log('[Sync Product API] 🎯 参数:', {
medusaId,
preferredCollection,
forceUpdate,
preorderData: { fundingGoal, preorderStartDate, preorderEndDate }
})
const payload = await getPayload({ config })
// 从 Medusa 获取商品数据
const medusaProducts = await getAllMedusaProducts()
const medusaProduct = medusaProducts.find((p) => p.id === medusaId)
if (!medusaProduct) {
console.error(`[Sync Product API] ❌ Medusa 中未找到商品: ${medusaId}`)
const response = NextResponse.json(
{
success: false,
error: `Medusa 中未找到商品 ${medusaId}`,
},
{ status: 404 },
)
return addCorsHeaders(response, origin)
}
console.log(`[Sync Product API] ✅ 找到 Medusa 产品: ${medusaProduct.title}`)
// 确定目标 collection
const targetCollection = preferredCollection || getProductCollection(medusaProduct)
console.log(`[Sync Product API] 🎯 目标 collection: ${targetCollection}`)
// 转换数据
const productData = transformMedusaProductToPayload(medusaProduct)
const seedId = productData.seedId
// 注意taobaoLinks 在此仅写入原始 URL不调用 Onebound API
// 标题/封面/价格 需要在 Payload 后台手动点击“更新淘宝信息”按鈕才会解析
// 查找现有产品(优先通过 seedId
let existingProduct: any = null
let existingCollection: 'products' | 'preorder-products' | null = null
// 先通过 seedId 查找
if (seedId) {
for (const coll of ['products', 'preorder-products'] as const) {
const result = await payload.find({
collection: coll,
where: { seedId: { equals: seedId } },
limit: 1,
})
if (result.docs[0]) {
existingProduct = result.docs[0]
existingCollection = coll
console.log(`[Sync Product API] 🔍 通过 seedId 找到产品 (${coll}): ${existingProduct.id}`)
break
}
}
}
// 如果没找到,通过 medusaId 查找
if (!existingProduct) {
for (const coll of ['products', 'preorder-products'] as const) {
const result = await payload.find({
collection: coll,
where: { medusaId: { equals: medusaId } },
limit: 1,
})
if (result.docs[0]) {
existingProduct = result.docs[0]
existingCollection = coll
console.log(`[Sync Product API] 🔍 通过 medusaId 找到产品 (${coll}): ${existingProduct.id}`)
break
}
}
}
let action: 'created' | 'updated' | 'updated_partial' | 'moved' = 'created'
let finalProduct: any
// 如果在错误的 collection 中,需要移动
if (existingProduct && existingCollection && existingCollection !== targetCollection) {
console.log(`[Sync Product API] 🚚 移动产品: ${existingCollection} -> ${targetCollection}`)
await payload.delete({
collection: existingCollection,
id: existingProduct.id,
})
// 准备移动数据description 已包含在 productData 中)
const moveData: any = { ...productData }
finalProduct = await payload.create({
collection: targetCollection,
data: moveData,
})
action = 'moved'
console.log(`[Sync Product API] ✅ 移动成功, 新 ID: ${finalProduct.id}`)
}
// 如果找到了并且在正确的 collection 中
else if (existingProduct) {
if (!forceUpdate) {
// 只更新空字段,但 Medusa 属性字段总是更新
const mergedData: any = {
lastSyncedAt: productData.lastSyncedAt,
medusaId: productData.medusaId,
}
// 基础字段Medusa 来源的字段总是更新
mergedData.seedId = productData.seedId
mergedData.title = productData.title
mergedData.handle = productData.handle
mergedData.status = productData.status
// thumbnail 只在为空时同步Payload 编辑优先;可能来自 Medusa/S3 或淘宝链接首图)
if (!existingProduct.thumbnail) mergedData.thumbnail = productData.thumbnail
// taobaoLinks 只在为空时同步Payload 编辑优先)
if ((!existingProduct.taobaoLinks || existingProduct.taobaoLinks.length === 0) && (productData as any).taobaoLinks) {
mergedData.taobaoLinks = (productData as any).taobaoLinks
}
// description 始终从 Medusa 同步(纯文本,只读字段)
mergedData.description = medusaProduct.description || null
// 价格:总是更新
mergedData.startPrice = productData.startPrice
// 如果是预购产品fundingGoal 也总是更新
if (targetCollection === 'preorder-products' && fundingGoal !== undefined) {
(mergedData as any).fundingGoal = fundingGoal
}
// Medusa 属性字段:总是更新(以 Medusa 为准)
mergedData.tags = productData.tags
mergedData.type = productData.type
mergedData.collection = productData.collection
mergedData.category = productData.category
// 物理属性:总是更新
mergedData.height = productData.height
mergedData.width = productData.width
mergedData.length = productData.length
mergedData.weight = productData.weight
// 海关与物流:总是更新
mergedData.midCode = productData.midCode
mergedData.hsCode = productData.hsCode
mergedData.countryOfOrigin = productData.countryOfOrigin
// 如果是预购产品,添加预购日期字段(只在为空时更新)
if (targetCollection === 'preorder-products') {
// Preorder Start Date - 只在为空时更新
if (!existingProduct.preorderStartDate && preorderStartDate) {
(mergedData as any).preorderStartDate = preorderStartDate
}
// Preorder End Date - 只在为空时更新
if (!existingProduct.preorderEndDate && preorderEndDate) {
(mergedData as any).preorderEndDate = preorderEndDate
}
}
console.log(`[Sync Product API] 📝 更新字段(含 Medusa 属性): ${Object.keys(mergedData).join(', ')}`)
finalProduct = await payload.update({
collection: targetCollection,
id: existingProduct.id,
data: mergedData,
})
action = 'updated_partial'
} else {
// 强制更新所有字段description 已包含在 productData 中)
console.log(`[Sync Product API] ⚡ 强制更新所有字段`)
const forceUpdateData: any = { ...productData }
finalProduct = await payload.update({
collection: targetCollection,
id: existingProduct.id,
data: forceUpdateData,
})
action = 'updated'
}
}
// 不存在,创建新产品
else {
console.log(`[Sync Product API] ✨ 创建新产品`)
// 如果是预购产品,添加预购相关字段
const createData: any = { ...productData }
// description 已包含在 productData 中(纯文本,从 Medusa 同步)
if (targetCollection === 'preorder-products') {
if (fundingGoal !== undefined) {
createData.fundingGoal = fundingGoal
}
if (preorderStartDate) {
createData.preorderStartDate = preorderStartDate
}
if (preorderEndDate) {
createData.preorderEndDate = preorderEndDate
}
console.log(`[Sync Product API] 📋 添加预购字段:`, {
fundingGoal: createData.fundingGoal,
preorderStartDate: createData.preorderStartDate,
preorderEndDate: createData.preorderEndDate,
})
}
finalProduct = await payload.create({
collection: targetCollection,
data: createData,
})
action = 'created'
}
console.log(`[Sync Product API] ✅ 同步完成: ${action}`)
// 返回完整的产品数据
const response = NextResponse.json({
success: true,
action,
collection: targetCollection,
product: {
id: finalProduct.id,
title: finalProduct.title,
medusaId: finalProduct.medusaId,
seedId: finalProduct.seedId,
thumbnail: finalProduct.thumbnail,
status: finalProduct.status,
lastSyncedAt: finalProduct.lastSyncedAt,
// 如果是预购产品,包含预购信息
...(targetCollection === 'preorder-products' && {
preorderType: finalProduct.preorderType,
preorderEndDate: finalProduct.preorderEndDate,
fundingGoal: finalProduct.fundingGoal,
orderCount: finalProduct.orderCount,
fakeOrderCount: finalProduct.fakeOrderCount,
}),
},
message: `产品已${action === 'created' ? '创建' : action === 'moved' ? '移动' : '更新'}${targetCollection}`,
})
return addCorsHeaders(response, origin)
} catch (error) {
console.error('[Sync Product API] ❌ 同步失败:', error)
const errorResponse = NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
)
return addCorsHeaders(errorResponse, origin)
}
}

View File

@ -0,0 +1,41 @@
import { NextResponse } from 'next/server'
import { parseTaobaoMeta } from '@/lib/taobao'
import { addCorsHeaders, handleCorsOptions } from '@/lib/cors'
export async function OPTIONS(request: Request) {
return handleCorsOptions(request.headers.get('origin'))
}
/**
* POST /api/taobao/parse
* Body: { url: string }
* Returns: { success: true, title, thumbnail, price }
*
* "解析"
*/
export async function POST(request: Request) {
const origin = request.headers.get('origin')
try {
const { url } = await request.json()
if (!url || typeof url !== 'string') {
return addCorsHeaders(
NextResponse.json({ success: false, error: 'url is required' }, { status: 400 }),
origin,
)
}
const meta = await parseTaobaoMeta(url)
return addCorsHeaders(
NextResponse.json({ success: true, ...meta }),
origin,
)
} catch (err: any) {
return addCorsHeaders(
NextResponse.json({ success: false, error: err?.message ?? 'Unknown error' }, { status: 500 }),
origin,
)
}
}

View File

@ -0,0 +1,77 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest } from 'next/server'
/**
* API
* POST /api/upload-media
*/
export async function POST(req: NextRequest) {
try {
const payload = await getPayload({ config })
// 检查用户认证
const { user } = await payload.auth({ headers: req.headers })
if (!user) {
return Response.json({ error: '未授权,请先登录' }, { status: 401 })
}
// 获取 FormData
const formData = await req.formData()
const file = formData.get('file') as File
const alt = formData.get('alt') as string
if (!file) {
return Response.json({ error: '请选择要上传的文件' }, { status: 400 })
}
// 验证文件类型
if (!file.type.startsWith('image/')) {
return Response.json({ error: '只能上传图片文件' }, { status: 400 })
}
// 验证文件大小 (最大 10MB)
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
return Response.json({ error: '文件大小不能超过 10MB' }, { status: 400 })
}
// 将 File 转换为 Buffer
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
// 上传到 Media collection
const media = await payload.create({
collection: 'media',
data: {
alt: alt || file.name,
},
file: {
data: buffer,
name: file.name,
mimetype: file.type,
size: file.size,
},
user,
})
return Response.json({
success: true,
doc: {
id: media.id,
url: media.url,
filename: media.filename,
alt: media.alt,
},
})
} catch (error) {
console.error('Media upload error:', error)
return Response.json(
{
error: '上传失败',
message: error instanceof Error ? error.message : '未知错误',
},
{ status: 500 },
)
}
}

View File

@ -0,0 +1,228 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation'
import {
BlocksFeature,
BoldFeature,
HeadingFeature,
InlineCodeFeature,
ItalicFeature,
lexicalEditor,
LinkFeature,
OrderedListFeature,
ParagraphFeature,
UnorderedListFeature,
FixedToolbarFeature,
InlineToolbarFeature,
HorizontalRuleFeature,
BlockquoteFeature,
AlignFeature,
} from '@payloadcms/richtext-lexical'
export const Announcements: CollectionConfig = {
slug: 'announcements',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'type', 'status', 'publishedAt', 'updatedAt'],
description: '管理系统公告和通知',
pagination: {
defaultLimit: 25,
},
},
access: {
read: ({ req: { user } }) => {
// 公开访问已发布的公告
if (!user) {
return {
status: { equals: 'published' },
}
}
// 认证用户可以查看所有
return true
},
create: ({ req: { user } }) => {
// 所有已认证用户都可以创建
return Boolean(user)
},
update: ({ req: { user } }) => {
// 所有已认证用户都可以更新
return Boolean(user)
},
delete: ({ req: { user } }) => {
// 只有 admin 可以删除
if (!user) return false
return user.roles?.includes('admin') || false
},
},
fields: [
{
type: 'row',
fields: [
{
name: 'title',
type: 'text',
required: true,
admin: {
description: '公告标题',
width: '70%',
},
},
{
name: 'type',
type: 'select',
required: true,
defaultValue: 'info',
options: [
{
label: '信息',
value: 'info',
},
{
label: '警告',
value: 'warning',
},
{
label: '重要',
value: 'important',
},
{
label: '紧急',
value: 'urgent',
},
],
admin: {
description: '公告类型',
width: '30%',
},
},
],
},
{
type: 'row',
fields: [
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
options: [
{
label: '草稿',
value: 'draft',
},
{
label: '已发布',
value: 'published',
},
{
label: '已归档',
value: 'archived',
},
],
admin: {
description: '发布状态',
width: '50%',
},
},
{
name: 'priority',
type: 'number',
defaultValue: 0,
admin: {
description: '优先级(数字越大越靠前)',
width: '50%',
},
},
],
},
{
name: 'summary',
type: 'textarea',
admin: {
description: '公告摘要(显示在列表页)',
},
},
{
name: 'content',
type: 'richText',
required: true,
admin: {
description: '公告详细内容',
},
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
BoldFeature(),
ItalicFeature(),
LinkFeature({}),
OrderedListFeature(),
UnorderedListFeature(),
BlockquoteFeature(),
AlignFeature(),
InlineCodeFeature(),
FixedToolbarFeature(),
InlineToolbarFeature(),
HorizontalRuleFeature(),
],
}),
},
{
type: 'row',
fields: [
{
name: 'publishedAt',
type: 'date',
admin: {
description: '发布时间',
width: '50%',
date: {
displayFormat: 'yyyy-MM-dd HH:mm',
},
},
},
{
name: 'expiresAt',
type: 'date',
admin: {
description: '过期时间(可选)',
width: '50%',
date: {
displayFormat: 'yyyy-MM-dd HH:mm',
},
},
},
],
},
{
name: 'showOnHomepage',
type: 'checkbox',
defaultValue: false,
admin: {
description: '在首页显示此公告',
},
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
admin: {
description: '发布者',
},
},
],
timestamps: true,
hooks: {
beforeChange: [
({ data, operation }) => {
// 自动设置发布时间
if (operation === 'create' && data.status === 'published' && !data.publishedAt) {
data.publishedAt = new Date()
}
return data
},
],
afterChange: [logAfterChange],
afterDelete: [logAfterDelete],
},
}

338
src/collections/Articles.ts Normal file
View File

@ -0,0 +1,338 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
import { cacheAfterChange, cacheAfterDelete } from '../hooks/cacheInvalidation'
import {
BlocksFeature,
BoldFeature,
HeadingFeature,
InlineCodeFeature,
ItalicFeature,
lexicalEditor,
LinkFeature,
OrderedListFeature,
ParagraphFeature,
UnorderedListFeature,
FixedToolbarFeature,
InlineToolbarFeature,
HorizontalRuleFeature,
BlockquoteFeature,
AlignFeature,
ChecklistFeature,
IndentFeature,
UploadFeature,
} from '@payloadcms/richtext-lexical'
export const Articles: CollectionConfig = {
slug: 'articles',
admin: {
useAsTitle: 'title',
defaultColumns: ['featuredImage', 'title', 'category', 'status', 'publishedAt', 'updatedAt'],
description: '管理文章内容',
listSearchableFields: ['title', 'slug', 'excerpt'],
pagination: {
defaultLimit: 25,
},
},
access: {
read: ({ req: { user } }) => {
// 公开访问已发布的文章
if (!user) {
return {
status: { equals: 'published' },
}
}
// 认证用户可以查看所有
return true
},
create: ({ req: { user } }) => {
// 所有已认证用户都可以创建
return Boolean(user)
},
update: ({ req: { user } }) => {
// 所有已认证用户都可以更新
return Boolean(user)
},
delete: ({ req: { user } }) => {
// 只有 admin 可以删除
if (!user) return false
return user.roles?.includes('admin') || false
},
},
versions: {
drafts: {
autosave: true,
schedulePublish: true,
},
maxPerDoc: 50,
},
fields: [
{
type: 'row',
fields: [
{
name: 'title',
type: 'text',
required: true,
admin: {
description: '文章标题',
width: '70%',
},
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
options: [
{
label: '草稿',
value: 'draft',
},
{
label: '已发布',
value: 'published',
},
],
admin: {
description: '发布状态',
width: '30%',
},
},
],
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
index: true,
admin: {
description: '文章 URL 路径(用于 SEO 友好的 URL',
},
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
admin: {
description: '文章特色图片(封面)',
},
},
{
name: 'excerpt',
type: 'textarea',
admin: {
description: '文章摘要(显示在列表页和 SEO',
},
},
{
name: 'content',
type: 'richText',
required: true,
admin: {
description: '文章详细内容',
},
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
BoldFeature(),
ItalicFeature(),
LinkFeature({
enabledCollections: ['articles', 'products'],
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document.',
},
},
],
}),
OrderedListFeature(),
UnorderedListFeature(),
ChecklistFeature(),
BlockquoteFeature(),
AlignFeature(),
IndentFeature(),
InlineCodeFeature(),
UploadFeature({
collections: {
media: {
fields: [
{
name: 'caption',
type: 'richText',
label: '图片说明',
editor: lexicalEditor(),
},
],
},
},
}),
FixedToolbarFeature(),
InlineToolbarFeature(),
HorizontalRuleFeature(),
],
}),
},
{
type: 'row',
fields: [
{
name: 'category',
type: 'select',
required: true,
options: [
{
label: '新闻',
value: 'news',
},
{
label: '教程',
value: 'tutorial',
},
{
label: '技术',
value: 'tech',
},
{
label: '产品评测',
value: 'review',
},
{
label: '行业动态',
value: 'industry',
},
{
label: '其他',
value: 'other',
},
],
admin: {
description: '文章分类',
width: '50%',
},
},
{
name: 'featured',
type: 'checkbox',
defaultValue: false,
admin: {
description: '标记为推荐文章',
width: '50%',
},
},
],
},
{
name: 'tags',
type: 'text',
hasMany: true,
admin: {
description: '文章标签(用于搜索和分类)',
},
},
{
type: 'row',
fields: [
{
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
admin: {
description: '文章作者',
width: '50%',
},
},
{
name: 'publishedAt',
type: 'date',
admin: {
description: '发布时间',
width: '50%',
date: {
displayFormat: 'yyyy-MM-dd HH:mm',
},
},
},
],
},
{
name: 'relatedArticles',
type: 'relationship',
relationTo: 'articles',
hasMany: true,
admin: {
description: '相关文章',
},
},
{
name: 'relatedProducts',
type: 'relationship',
relationTo: 'products',
hasMany: true,
admin: {
description: '相关商品',
},
},
{
type: 'collapsible',
label: 'SEO 设置',
fields: [
{
name: 'metaTitle',
type: 'text',
admin: {
description: 'SEO 标题(留空则使用文章标题)',
},
},
{
name: 'metaDescription',
type: 'textarea',
admin: {
description: 'SEO 描述(留空则使用摘要)',
},
},
{
name: 'metaKeywords',
type: 'text',
hasMany: true,
admin: {
description: 'SEO 关键词',
},
},
],
},
],
timestamps: true,
hooks: {
beforeChange: [
({ data, operation }) => {
// 自动生成 slug如果未提供
if (operation === 'create' && !data.slug && data.title) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/(^-|-$)/g, '')
}
// 自动设置发布时间
if (operation === 'create' && data.status === 'published' && !data.publishedAt) {
data.publishedAt = new Date()
}
return data
},
],
afterChange: [logAfterChange, cacheAfterChange],
afterDelete: [logAfterDelete, cacheAfterDelete],
},
}

104
src/collections/Logs.ts Normal file
View File

@ -0,0 +1,104 @@
import type { CollectionConfig } from 'payload'
export const Logs: CollectionConfig = {
slug: 'logs',
admin: {
useAsTitle: 'action',
defaultColumns: ['action', 'collection', 'user', 'createdAt'],
description: '系统操作日志记录',
group: '系统',
pagination: {
defaultLimit: 50,
},
listSearchableFields: ['action', 'collection', 'documentId'],
},
access: {
// 只有 admin 和 editor 可以查看日志
read: ({ req: { user } }) => {
if (!user) return false
return user.roles?.includes('admin') || user.roles?.includes('editor') || false
},
// 禁止手动创建、更新日志(只能通过系统钩子自动创建)
create: () => false,
update: () => false,
// 只有 admin 可以删除
delete: ({ req: { user } }) => {
if (!user) return false
return user.roles?.includes('admin') || false
},
},
fields: [
{
name: 'action',
type: 'select',
required: true,
options: [
{ label: '创建', value: 'create' },
{ label: '更新', value: 'update' },
{ label: '删除', value: 'delete' },
{ label: '同步', value: 'sync' },
{ label: '登录', value: 'login' },
{ label: '登出', value: 'logout' },
],
admin: {
description: '操作类型',
},
},
{
name: 'collection',
type: 'text',
required: true,
index: true,
admin: {
description: '操作的集合名称',
},
},
{
name: 'documentId',
type: 'text',
index: true,
admin: {
description: '文档 ID',
},
},
{
name: 'documentTitle',
type: 'text',
admin: {
description: '文档标题',
},
},
{
name: 'user',
type: 'relationship',
relationTo: 'users',
required: true,
index: true,
admin: {
description: '操作用户',
},
},
{
name: 'changes',
type: 'json',
admin: {
description: '变更内容JSON 格式)',
},
},
{
name: 'ip',
type: 'text',
admin: {
description: 'IP 地址',
},
},
{
name: 'userAgent',
type: 'text',
admin: {
description: '浏览器信息',
},
},
],
timestamps: true,
}

View File

@ -1,4 +1,5 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
export const Media: CollectionConfig = { export const Media: CollectionConfig = {
slug: 'media', slug: 'media',
@ -12,5 +13,9 @@ export const Media: CollectionConfig = {
required: true, required: true,
}, },
], ],
hooks: {
afterChange: [logAfterChange],
afterDelete: [logAfterDelete],
},
upload: true, upload: true,
} }

View File

@ -0,0 +1,32 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
import { PrecautionItemFields } from './base/ProductBase'
/**
*
* PrecautionItemFields title / summary / order
* Products / PreorderProducts sharedPrecautions
*/
export const Precautions: CollectionConfig = {
slug: 'precautions',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'summary', 'updatedAt'],
description: '管理通用注意事项,可被多个产品复用引用',
pagination: {
defaultLimit: 25,
},
},
access: {
read: () => true,
create: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => !!user,
delete: ({ req: { user } }) => !!user,
},
fields: PrecautionItemFields,
hooks: {
afterChange: [logAfterChange],
afterDelete: [logAfterDelete],
},
timestamps: true,
}

View File

@ -8,6 +8,104 @@ export const Users: CollectionConfig = {
auth: true, auth: true,
fields: [ fields: [
// Email added by default // Email added by default
// Add more fields as needed {
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
defaultValue: ['user'],
required: true,
saveToJWT: true, // Include in JWT for fast access checks
access: {
update: ({ req: { user } }) => {
// Only admins can update roles
if (!user) return false
return user.roles?.includes('admin') || false
},
},
},
], ],
hooks: {
beforeChange: [
async ({ data, req, operation }) => {
// 如果是创建操作,检查是否为第一个用户
if (operation === 'create') {
const { totalDocs } = await req.payload.count({
collection: 'users',
})
// 如果这是第一个用户,自动设置为 admin
if (totalDocs === 0) {
data.roles = ['admin']
console.log('🎉 第一个用户注册,自动设置为管理员')
}
}
// 如果是更新操作,检查是否为唯一用户且 roles 为空
if (operation === 'update') {
const { totalDocs } = await req.payload.count({
collection: 'users',
})
// 如果只有一个用户且 roles 为空或只有 user自动升级为 admin
if (
totalDocs === 1 &&
(!data.roles ||
data.roles.length === 0 ||
(data.roles.length === 1 && data.roles[0] === 'user'))
) {
data.roles = ['admin']
console.log('🔧 当前是唯一用户,自动升级为管理员')
}
}
return data
},
],
afterRead: [
async ({ doc, req, context }) => {
// 跳过已标记为处理过的请求
if (context?.skipAutoAdmin) return doc
// 检查是否为唯一用户且 roles 为空或不正确
if (
!doc.roles ||
doc.roles.length === 0 ||
(doc.roles.length === 1 && doc.roles[0] === 'user')
) {
const { totalDocs } = await req.payload.count({
collection: 'users',
})
// 如果只有一个用户,自动更新为 admin
if (totalDocs === 1) {
console.log('🔄 检测到唯一用户权限异常,正在修复...')
try {
// 使用 overrideAccess 绕过权限检查,标记 context 避免循环
await req.payload.update({
collection: 'users',
id: doc.id,
data: {
roles: ['admin'],
},
context: {
skipAutoAdmin: true,
},
overrideAccess: true,
})
// 更新当前文档的 roles
doc.roles = ['admin']
console.log('✅ 唯一用户权限已修复为管理员')
} catch (error) {
console.error('❌ 更新用户权限失败:', error)
}
}
}
return doc
},
],
},
} }

View File

@ -0,0 +1,432 @@
import type { Field, Tab } from 'payload'
import {
BoldFeature,
HeadingFeature,
ItalicFeature,
lexicalEditor,
LinkFeature,
OrderedListFeature,
ParagraphFeature,
UnorderedListFeature,
UploadFeature,
FixedToolbarFeature,
InlineToolbarFeature,
HorizontalRuleFeature,
} from '@payloadcms/richtext-lexical'
/**
*
* Products PreorderProducts
*/
export const ProductBaseFields: Field[] = [
// ========== 基本信息字段 ==========
{
type: 'row',
fields: [
{
name: 'medusaId',
type: 'text',
required: true,
unique: true,
index: true,
admin: {
description: 'Medusa 商品 ID',
readOnly: true,
width: '40%',
},
},
{
name: 'handle',
type: 'text',
index: true,
admin: {
description: 'Medusa 商品 Handle用于前台 URL从 Medusa 同步)',
readOnly: true,
width: '30%',
},
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
options: [
{ label: '草稿', value: 'draft' },
{ label: '已发布', value: 'published' },
],
admin: {
description: '商品详情状态',
width: '30%',
},
},
],
},
{
name: 'seedId',
type: 'text',
unique: true,
index: true,
admin: {
description: 'Seed ID (从 Medusa 同步,用于数据绑定)',
readOnly: true,
},
},
{
name: 'title',
type: 'text',
required: true,
admin: {
description: '商品标题(从 Medusa 同步)',
readOnly: true,
},
},
{
name: 'thumbnail',
type: 'text',
admin: {
description: '商品封面 URL支持上传或输入 URL',
components: {
Cell: '/components/cells/ThumbnailCell#ThumbnailCell',
Field: '/components/fields/ThumbnailField#ThumbnailField',
},
},
},
{
name: 'startPrice',
type: 'number',
admin: {
description: '起始价格(从 Medusa 同步,单位:美分)',
readOnly: true,
},
},
{
name: 'lastSyncedAt',
type: 'date',
admin: {
description: '最后同步时间',
readOnly: true,
date: {
displayFormat: 'yyyy-MM-dd HH:mm:ss',
},
},
},
{
name: 'description',
type: 'text',
admin: {
description: '产品简介(纯文本,从 Medusa 同步,显示在卡片和列表页)',
readOnly: true,
},
},
{
name: 'accessPassword',
type: 'text',
admin: {
description: '访问密码(用于保护特定产品页面或内容)',
placeholder: '留空则不限制访问',
},
},
]
/**
* Medusa
* Medusa
* Medusa Payload
*/
export const MedusaAttributesFields: Field[] = [
{
name: 'tags',
type: 'text',
admin: {
description: '产品标签(逗号分隔,从 Medusa 同步)',
placeholder: '例如: 热门, 新品, 限量',
readOnly: true,
},
},
{
name: 'type',
type: 'text',
admin: {
description: '产品类型(从 Medusa 同步)',
placeholder: '例如: 外壳, PCB, 工具',
readOnly: true,
},
},
{
name: 'collection',
type: 'text',
admin: {
description: '产品系列(从 Medusa 同步)',
placeholder: '例如: Shell, Cartridge',
readOnly: true,
},
},
{
name: 'category',
type: 'text',
admin: {
description: '产品分类(从 Medusa 同步)',
placeholder: '例如: GBA, GBC, GB',
readOnly: true,
},
},
{
type: 'collapsible',
label: '物理属性(只读)',
fields: [
{
type: 'row',
fields: [
{
name: 'height',
type: 'number',
admin: {
description: '高度 (cm),从 Medusa 同步',
width: '25%',
readOnly: true,
},
},
{
name: 'width',
type: 'number',
admin: {
description: '宽度 (cm),从 Medusa 同步',
width: '25%',
readOnly: true,
},
},
{
name: 'length',
type: 'number',
admin: {
description: '长度 (cm),从 Medusa 同步',
width: '25%',
readOnly: true,
},
},
{
name: 'weight',
type: 'number',
admin: {
description: '重量 (g),从 Medusa 同步',
width: '25%',
readOnly: true,
},
},
],
},
],
},
{
type: 'collapsible',
label: '海关与物流(只读)',
fields: [
{
type: 'row',
fields: [
{
name: 'midCode',
type: 'text',
admin: {
description: 'MID 代码(制造商识别码,从 Medusa 同步)',
placeholder: '例如: 1234567890',
width: '33%',
readOnly: true,
},
},
{
name: 'hsCode',
type: 'text',
admin: {
description: 'HS 代码(海关编码,从 Medusa 同步)',
placeholder: '例如: 8523.49.00',
width: '33%',
readOnly: true,
},
},
{
name: 'countryOfOrigin',
type: 'text',
admin: {
description: '原产国(从 Medusa 同步)',
placeholder: '例如: CN, US, JP',
width: '34%',
readOnly: true,
},
},
],
},
],
},
]
/**
* Medusa Tab
* Medusa
*/
export const MedusaAttributesTab: Tab = {
label: '🏷️ 属性',
fields: MedusaAttributesFields,
}
/**
*
* (products preorder-products)
*/
export const RelatedProductsField: Field = {
name: 'relatedProducts',
type: 'relationship',
relationTo: ['products', 'preorder-products'],
hasMany: true,
admin: {
description: '相关商品,支持搜索联想',
components: {
Field: '/components/fields/RelatedProductsField#RelatedProductsField',
},
},
filterOptions: ({ relationTo, data }) => {
// 过滤掉当前商品本身,避免自引用
if (data?.id) {
return {
id: {
not_equals: data.id,
},
}
}
return true
},
}
// TaobaoLinksField 已移至独立文件,包含自动解析功能
export { TaobaoLinksField } from './TaobaoLinksField'
// OrdersTab 定义于 collections/project/OrdersTab.ts此处统一导出供各集合使用
export { OrdersTab } from '../project/OrdersTab'
/**
* Tab
*
*/
export const ProjectStatusesTab: Tab = {
label: '📊 项目状态',
fields: [
{
name: 'seedProjectStatusesUI',
type: 'ui',
admin: {
components: {
Field: '/components/seed/SeedProjectStatusesButton#SeedProjectStatusesButton',
},
},
},
{
name: 'projectStatuses',
type: 'array',
admin: {
description: '产品项目状态列表,可直接在此添加和编辑',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
admin: {
description: '状态名称,例如:研发中、众筹中、量产中',
},
},
{
name: 'badge',
type: 'text',
admin: {
description: '状态徽章标签(简短文字,展示在产品卡片上)',
placeholder: '例如: 研发中',
},
},
{
name: 'description',
type: 'richText',
editor: lexicalEditor({
features: [
ParagraphFeature(),
BoldFeature(),
ItalicFeature(),
UnorderedListFeature(),
OrderedListFeature(),
FixedToolbarFeature(),
InlineToolbarFeature(),
],
}),
admin: {
description: '状态简介(富文本)',
},
},
{
name: 'order',
type: 'number',
defaultValue: 0,
admin: {
description: '排序权重(数值越小越靠前)',
},
},
],
},
],
}
/**
*
* Precautions
*/
export const PrecautionItemFields: Field[] = [
{
name: 'title',
type: 'text',
required: true,
admin: {
description: '注意事项标题,例如:安装注意事项、使用须知',
},
},
{
name: 'summary',
type: 'text',
admin: {
description: '注意事项摘要',
placeholder: '请输入注意事项摘要...',
},
},
{
name: 'order',
type: 'number',
defaultValue: 0,
admin: {
description: '排序权重(数值越小越靠前)',
},
},
]
/**
* Tab
*
*/
export const PrecautionsTab: Tab = {
label: '⚠️ 注意事项',
fields: [
{
name: 'sharedPrecautions',
type: 'relationship',
relationTo: 'precautions',
hasMany: true,
admin: {
description: '引用通用注意事项(在《通用注意事项》集合中统一管理,可被多个产品复用)',
},
},
{
name: 'precautions',
type: 'array',
admin: {
description: '产品专属注意事项,可直接在此添加和编辑',
},
fields: PrecautionItemFields,
},
],
}

View File

@ -0,0 +1,101 @@
import type { Field } from 'payload'
/**
*
*
*
* - API
* - "🔍 自动解析" /api/taobao/parse
* thumbnail URL
* - thumbnail
*
*
* Medusa seed metadata.taobao_links (JSON string[])
* Payload sync taobaoLinks[].url /api/taobao/parse
* Merging rule Payload
*/
export const TaobaoLinksField: Field = {
name: 'taobaoLinks',
type: 'array',
label: '淘宝采购链接列表',
admin: {
description: '💡 管理淘宝采购链接(仅后台显示,不通过 API 暴露)',
initCollapsed: false,
},
access: {
read: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => !!user,
},
fields: [
{
name: 'url',
type: 'text',
label: '🔗 淘宝链接',
required: true,
admin: {
placeholder: 'https://item.taobao.com/item.htm?id=...',
},
},
{
// 自动解析按钮 —— 读取 url填入 title / thumbnail / price
type: 'ui',
name: 'fetchButton',
admin: {
components: {
Field: '/components/fields/TaobaoFetchButton#TaobaoFetchButton',
},
},
},
{
name: 'title',
type: 'text',
label: '📝 标题',
admin: {
placeholder: '自动解析或手动填写',
description: '淘宝商品标题(解析后自动填入)',
},
},
{
name: 'thumbnail',
type: 'text',
label: '🖼️ 封面 URL',
admin: {
placeholder: 'https://...',
description: '淘宝商品图片地址(字符串 URL解析后自动填入首条链接的封面可作为产品封面备用来源',
components: {
Cell: '/components/cells/ThumbnailCell#ThumbnailCell',
Field: '/components/fields/ThumbnailField#ThumbnailField',
},
},
},
{
name: 'price',
type: 'number',
label: '💴 价格CNY',
admin: {
placeholder: '0.00',
description: '淘宝商品人民币价格(解析后自动填入)',
step: 0.01,
},
},
{
name: 'note',
type: 'textarea',
label: '📄 备注',
admin: {
placeholder: '其他备注信息…',
rows: 2,
},
},
{
// 已有的预览组件(展示缩略图 + 跳转按钮)
type: 'ui',
name: 'linkPreview',
admin: {
components: {
Field: '/components/fields/TaobaoLinkPreview#TaobaoLinkPreview',
},
},
},
],
}

View File

@ -0,0 +1,96 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
import { cacheAfterChange, cacheAfterDelete } from '../../hooks/cacheInvalidation'
/**
* -
*
* DisassemblyPagesDisassemblyComponents
*
*
*
* DisassemblyPages
* DisassemblyAreas
* DisassemblyComponents
* DisassemblyLinkedProducts
*
* DisassemblyAreaViewerui
* DisassemblyPages.html 稿 +
*/
export const DisassemblyAreas: CollectionConfig = {
slug: 'disassembly-areas',
admin: {
useAsTitle: 'name',
hidden: true,
description: '拆解页的区域层(仅通过可视化编辑器管理)',
defaultColumns: ['name', 'page', 'updatedAt'],
pagination: {
defaultLimit: 50,
},
},
access: {
read: () => true,
create: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => !!user,
delete: ({ req: { user } }) => !!user,
},
hooks: {
afterChange: [logAfterChange, cacheAfterChange],
afterDelete: [logAfterDelete, cacheAfterDelete],
},
fields: [
// 区域名称
{
name: 'name',
label: '区域名称',
type: 'text',
required: true,
admin: {
description: '例如:主板区域、上盖区域、按键组',
},
},
// 所属拆解页
{
name: 'page',
label: '所属拆解页',
type: 'relationship',
relationTo: 'disassembly-pages',
required: true,
admin: {
description: '该区域归属的顶层拆解页(如 GBA、GBC',
},
},
// 装配大图(在预览中显示于中央)
{
name: 'mainImage',
label: '装配大图',
type: 'upload',
relationTo: 'media',
admin: {
description: '该区域的整体装配图,在区域预览中显示于中央(大图)',
},
},
// 缩略小图(在列表预览卡片中使用)
{
name: 'thumbnailImage',
label: '缩略小图',
type: 'upload',
relationTo: 'media',
admin: {
description: '该区域的缩略图,用于列表预览卡片(小图)',
},
},
// 拆解组件列表(第三层)
{
name: 'components',
label: '拆解组件',
type: 'relationship',
relationTo: 'disassembly-components',
hasMany: true,
},
],
}

View File

@ -0,0 +1,114 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
import { cacheAfterChange, cacheAfterDelete } from '../../hooks/cacheInvalidation'
/**
* -
*
* DisassemblyPages
* components
*/
export const DisassemblyComponents: CollectionConfig = {
slug: 'disassembly-components',
admin: {
useAsTitle: 'label',
hidden: true,
description: '拆解页中的拆解组件(通过拆解页管理)',
defaultColumns: ['label', 'startRadius', 'updatedAt'],
},
access: {
read: () => true,
create: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => !!user,
delete: ({ req: { user } }) => !!user,
},
hooks: {
afterChange: [logAfterChange, cacheAfterChange],
afterDelete: [logAfterDelete, cacheAfterDelete],
},
fields: [
// 组件名称Admin 面板标识用)
{
name: 'label',
label: '组件名称',
type: 'text',
required: true,
admin: {
description: '用于 Admin 面板中标识该组件',
},
},
// 零件编号(如 GBC-001、BOARD-A
{
name: 'productCode',
label: '零件编号',
type: 'text',
admin: {
description: '该组件的型号/零件编号,显示在区域预览节点中',
},
},
// 组件图片(在区域预览中作为节点图标)
{
name: 'componentImage',
label: '组件图片',
type: 'upload',
relationTo: 'media',
admin: {
description: '该组件的外观图片,显示在区域预览的节点处',
},
},
// 起点坐标
{
name: 'startCoordinate',
label: '起点坐标',
type: 'group',
admin: {
description: '组件锚点在页面/图片上的坐标',
},
fields: [
{
name: 'x',
label: 'X',
type: 'number',
required: true,
defaultValue: 0,
admin: { description: '水平坐标' },
},
{
name: 'y',
label: 'Y',
type: 'number',
required: true,
defaultValue: 0,
admin: { description: '垂直坐标' },
},
],
},
// 起点坐标半径
{
name: 'startRadius',
label: '起点坐标半径',
type: 'number',
required: true,
defaultValue: 20,
admin: {
description: '锚点热区半径px',
},
},
// 第三层:关联商品信息列表
{
name: 'linkedProducts',
label: '关联商品信息',
type: 'relationship',
relationTo: 'disassembly-linked-products',
hasMany: true,
admin: {
description: '该组件下的关联商品信息条目',
},
},
],
}

View File

@ -0,0 +1,92 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
import { cacheAfterChange, cacheAfterDelete } from '../../hooks/cacheInvalidation'
/**
* -
*
* DisassemblyComponents/
* linkedProducts
*/
export const DisassemblyLinkedProducts: CollectionConfig = {
slug: 'disassembly-linked-products',
admin: {
useAsTitle: 'productName',
hidden: true,
description: '拆解组件中的关联商品信息(通过拆解组件管理)',
defaultColumns: ['productName', 'products', 'preorderProducts', 'updatedAt'],
},
access: {
read: () => true,
create: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => !!user,
delete: ({ req: { user } }) => !!user,
},
hooks: {
afterChange: [logAfterChange, cacheAfterChange],
afterDelete: [logAfterDelete, cacheAfterDelete],
},
fields: [
// 具体坐标(商品标注在图片上的坐标)
{
name: 'coordinate',
label: '具体坐标',
type: 'group',
admin: {
description: '商品标注在图片上的坐标',
},
fields: [
{
name: 'x',
label: 'X',
type: 'number',
required: true,
defaultValue: 0,
admin: { description: '水平坐标' },
},
{
name: 'y',
label: 'Y',
type: 'number',
required: true,
defaultValue: 0,
admin: { description: '垂直坐标' },
},
],
},
// 商品名称(显示用,留空则使用关联商品标题)
{
name: 'productName',
label: '商品名称',
type: 'text',
admin: {
description: '展示在标注上的商品名称,留空则使用关联商品标题',
},
},
// 关联普通商品列表
{
name: 'products',
label: '关联商品 (Products)',
type: 'relationship',
relationTo: 'products',
hasMany: true,
admin: {
description: '关联的普通商品',
},
},
// 关联预售商品列表
{
name: 'preorderProducts',
label: '关联预售商品 (PreorderProducts)',
type: 'relationship',
relationTo: 'preorder-products',
hasMany: true,
admin: {
description: '关联的预售商品',
},
},
],
}

View File

@ -0,0 +1,91 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
import { cacheAfterChange, cacheAfterDelete } from '../../hooks/cacheInvalidation'
/**
* -
*
* Collection
* URL DisassemblyComponents
*
*
* DisassemblyPages
* DisassemblyAreas
* DisassemblyComponents
* DisassemblyLinkedProducts
*
* DisassemblyVisualEditorui
*/
export const DisassemblyPages: CollectionConfig = {
slug: 'disassembly-pages',
admin: {
useAsTitle: 'name',
defaultColumns: ['mainImage', 'name', 'url', 'editorLink', 'updatedAt'],
description: '管理产品拆解页,包含拆解组件和关联商品信息',
pagination: {
defaultLimit: 25,
},
components: {
// 列表顶部显示「初始化默认数据」按钮
beforeListTable: ['/components/seed/SeedDisassemblyButton#SeedDisassemblyButton'],
edit: {
// 保存按钮旁边增加「可视化预览 ↓」滚动快捷按钮
SaveButton: '/components/views/Disassembly/DisassemblyPageSaveArea#default',
},
},
},
access: {
read: () => true,
create: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => !!user,
delete: ({ req: { user } }) => !!user,
},
hooks: {
afterChange: [logAfterChange, cacheAfterChange],
afterDelete: [logAfterDelete, cacheAfterDelete],
},
fields: [
// 主图
{
name: 'mainImage',
label: '主图',
type: 'upload',
relationTo: 'media',
},
// 名称
{
name: 'name',
label: '名称',
type: 'text',
required: true,
},
// URL 链接
{
name: 'url',
label: 'URL 链接',
type: 'text',
admin: {
description: '该拆解页对应的页面路径或外部链接',
},
},
// 列表视图:可视化编辑器跳转按钮(仅 Cell不在编辑表单中显示
{
name: 'editorLink',
label: '可视化编辑',
type: 'ui',
admin: {
components: {
Cell: '/components/cells/DisassemblyEditorCell#DisassemblyEditorCell',
},
},
},
// 第二层:拆解区域列表
{
name: 'areas',
label: '拆解区域',
type: 'relationship',
relationTo: 'disassembly-areas',
hasMany: true,
}
],
}

View File

@ -0,0 +1,255 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
import { cacheAfterChange, cacheAfterDelete } from '../../hooks/cacheInvalidation'
import { ProductBaseFields, RelatedProductsField, TaobaoLinksField, MedusaAttributesTab, ProjectStatusesTab, PrecautionsTab, OrdersTab } from '../base/ProductBase'
import {
AlignFeature,
BlocksFeature,
BoldFeature,
ChecklistFeature,
HeadingFeature,
IndentFeature,
InlineCodeFeature,
ItalicFeature,
lexicalEditor,
LinkFeature,
OrderedListFeature,
ParagraphFeature,
RelationshipFeature,
UnorderedListFeature,
UploadFeature,
FixedToolbarFeature,
InlineToolbarFeature,
HorizontalRuleFeature,
BlockquoteFeature,
} from '@payloadcms/richtext-lexical'
export const PreorderProducts: CollectionConfig = {
slug: 'preorder-products',
admin: {
useAsTitle: 'title',
defaultColumns: ['thumbnail', 'title', 'medusaId', 'progress', 'status', 'updatedAt'],
description: '管理预售商品的详细内容和描述',
listSearchableFields: ['title', 'medusaId'],
pagination: {
defaultLimit: 25,
},
components: {
beforeListTable: [
'/components/sync/UnifiedSyncButton#UnifiedSyncButton',
'/components/list/PreorderProductGridStyler#PreorderProductGridStyler',
],
},
},
access: {
read: () => true, // 公开可读
create: ({ req: { user } }) => !!user, // 登录用户可创建
update: ({ req: { user } }) => !!user, // 登录用户可更新
delete: ({ req: { user } }) => !!user, // 登录用户可删除
},
fields: [
{
type: 'tabs',
tabs: [
{
label: ' 基本信息',
fields: [
...ProductBaseFields,
{
name: 'progress',
type: 'ui',
admin: {
components: {
Cell: '/components/cells/PreorderProgressCell#PreorderProgressCell',
},
},
},
],
},
{
label: '⚙️ 预购设置',
fields: [
{
type: 'row',
fields: [
{
name: 'preorderType',
type: 'select',
required: true,
defaultValue: 'standard',
options: [
{ label: '标准预购', value: 'standard' },
{ label: '众筹预购', value: 'crowdfunding' },
{ label: '限量预购', value: 'limited' },
],
admin: {
description: '预购类型',
width: '50%',
},
},
{
name: 'fundingGoal',
type: 'number',
required: true,
defaultValue: 0,
admin: {
description: '众筹目标数量0 表示以变体 max_orders 总和为准)',
width: '50%',
},
},
],
},
{
type: 'row',
fields: [
{
name: 'preorderStartDate',
type: 'date',
admin: {
description: '预购开始日期(可选)',
width: '50%',
date: {
displayFormat: 'yyyy-MM-dd HH:mm',
},
},
},
{
name: 'preorderEndDate',
type: 'date',
admin: {
description: '预购结束日期(可选)',
width: '50%',
date: {
displayFormat: 'yyyy-MM-dd HH:mm',
},
},
},
],
},
{
type: 'ui',
name: 'refreshOrderCount',
admin: {
components: {
Field: '/components/fields/RefreshOrderCountField#RefreshOrderCountField',
},
},
},
{
type: 'row',
fields: [
{
name: 'orderCount',
type: 'number',
defaultValue: 0,
admin: {
description: '真实订单计数(从 Medusa 同步)',
readOnly: true,
width: '50%',
},
},
{
name: 'fakeOrderCount',
type: 'number',
defaultValue: 0,
admin: {
description: 'Fake 订单计数(手动设置)',
width: '50%',
},
},
],
},
],
},
{
label: '📝 商品详情',
fields: [
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: [
ParagraphFeature(),
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
BoldFeature(),
ItalicFeature(),
UnorderedListFeature(),
OrderedListFeature(),
LinkFeature(),
AlignFeature(),
BlockquoteFeature(),
HorizontalRuleFeature(),
InlineCodeFeature(),
IndentFeature(),
ChecklistFeature(),
FixedToolbarFeature(),
InlineToolbarFeature(),
BlocksFeature({
blocks: [
{
slug: 'image',
imageURL: '/api/media',
fields: [
{
name: 'caption',
type: 'text',
label: '图片说明',
},
],
},
],
}),
UploadFeature({
collections: {
media: {
fields: [
{
name: 'caption',
type: 'text',
label: '图片说明',
},
],
},
},
}),
RelationshipFeature(),
],
}),
admin: {
description: '预售商品的详细内容(富文本,由 Payload 编辑,展示在产品详情页)',
},
},
],
},
{
label: '🔗 相关商品',
fields: [RelatedProductsField],
},
OrdersTab,
MedusaAttributesTab,
ProjectStatusesTab,
PrecautionsTab,
{
label: '🛒 淘宝链接',
fields: [
{
type: 'ui',
name: 'taobaoSyncButtons',
admin: {
components: {
Field: '/components/sync/taobao/TaobaoProductSync#TaobaoProductSync',
},
},
},
TaobaoLinksField,
],
},
],
},
],
hooks: {
afterChange: [cacheAfterChange, logAfterChange],
afterDelete: [cacheAfterDelete, logAfterDelete],
},
timestamps: true,
}

View File

@ -0,0 +1,147 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
import { cacheAfterChange, cacheAfterDelete } from '../../hooks/cacheInvalidation'
import { ProductBaseFields, RelatedProductsField, MedusaAttributesTab, ProjectStatusesTab, PrecautionsTab, OrdersTab } from '../base/ProductBase'
import { TaobaoLinksField } from '../base/TaobaoLinksField'
import {
AlignFeature,
BlocksFeature,
BoldFeature,
ChecklistFeature,
HeadingFeature,
IndentFeature,
InlineCodeFeature,
ItalicFeature,
lexicalEditor,
LinkFeature,
OrderedListFeature,
ParagraphFeature,
RelationshipFeature,
UnorderedListFeature,
UploadFeature,
FixedToolbarFeature,
InlineToolbarFeature,
HorizontalRuleFeature,
BlockquoteFeature,
} from '@payloadcms/richtext-lexical'
export const Products: CollectionConfig = {
slug: 'products',
admin: {
useAsTitle: 'title',
defaultColumns: ['thumbnail', 'title', 'medusaId', 'status', 'updatedAt'],
description: '管理 Medusa 商品的详细内容和描述',
listSearchableFields: ['title', 'medusaId'],
pagination: {
defaultLimit: 25,
},
components: {
beforeListTable: [
'/components/sync/UnifiedSyncButton#UnifiedSyncButton',
'/components/list/ProductGridStyler',
],
},
},
access: {
read: () => true, // 公开可读
},
fields: [
{
type: 'tabs',
tabs: [
{
label: ' 基本信息',
fields: ProductBaseFields,
},
{
label: '📄 商品详情',
fields: [
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: [
ParagraphFeature(),
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
BoldFeature(),
ItalicFeature(),
UnorderedListFeature(),
OrderedListFeature(),
LinkFeature(),
AlignFeature(),
BlockquoteFeature(),
HorizontalRuleFeature(),
InlineCodeFeature(),
IndentFeature(),
ChecklistFeature(),
FixedToolbarFeature(),
InlineToolbarFeature(),
BlocksFeature({
blocks: [
{
slug: 'image',
imageURL: '/api/media',
fields: [
{
name: 'caption',
type: 'text',
label: '图片说明',
},
],
},
],
}),
UploadFeature({
collections: {
media: {
fields: [
{
name: 'caption',
type: 'text',
label: '图片说明',
},
],
},
},
}),
RelationshipFeature(),
],
}),
admin: {
description: '商品详细内容(富文本,由 Payload 编辑,展示在产品详情页)',
},
},
],
},
{
label: '🔗 关联信息',
fields: [RelatedProductsField],
},
OrdersTab,
MedusaAttributesTab,
ProjectStatusesTab,
PrecautionsTab,
{
label: '🛒 淘宝链接',
fields: [
{
type: 'ui',
name: 'taobaoSyncButtons',
admin: {
components: {
Field: '/components/sync/taobao/TaobaoProductSync#TaobaoProductSync',
},
},
},
TaobaoLinksField,
],
},
],
},
],
hooks: {
afterChange: [logAfterChange, cacheAfterChange],
afterDelete: [logAfterDelete, cacheAfterDelete],
},
timestamps: true,
}

View File

@ -0,0 +1,21 @@
import type { Tab } from 'payload'
/**
* Tab
* /api/products/:id/orders Medusa
* Products PreorderProducts 使
*/
export const OrdersTab: Tab = {
label: '📦 订单信息',
fields: [
{
name: 'ordersDisplay',
type: 'ui',
admin: {
components: {
Field: '/components/fields/ProductOrdersField#ProductOrdersField',
},
},
},
],
}

View File

@ -0,0 +1,37 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
import { PrecautionItemFields } from '../base/ProductBase'
/**
*
* PrecautionItemFields title / summary / order
* Products / PreorderProducts sharedPrecautions
*/
export const Precautions: CollectionConfig = {
slug: 'precautions',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'summary', 'updatedAt'],
description: '管理通用注意事项,可被多个产品复用引用',
pagination: {
defaultLimit: 25,
},
components: {
beforeListTable: [
'/components/seed/SeedPrecautionsButton#SeedPrecautionsButton',
],
},
},
access: {
read: () => true,
create: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => !!user,
delete: ({ req: { user } }) => !!user,
},
fields: PrecautionItemFields,
hooks: {
afterChange: [logAfterChange],
afterDelete: [logAfterDelete],
},
timestamps: true,
}

View File

@ -0,0 +1,139 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
import {
BoldFeature,
HeadingFeature,
ItalicFeature,
lexicalEditor,
LinkFeature,
OrderedListFeature,
ParagraphFeature,
UnorderedListFeature,
FixedToolbarFeature,
InlineToolbarFeature,
HorizontalRuleFeature,
} from '@payloadcms/richtext-lexical'
/**
*
*
* Products PreorderProducts
*/
export const ProjectStatuses: CollectionConfig = {
slug: 'project-statuses',
admin: {
hidden: true,
useAsTitle: 'title',
defaultColumns: ['title', 'badge', 'color', 'updatedAt'],
description: '管理产品项目状态,如研发中、量产中、已停产等',
pagination: {
defaultLimit: 25,
},
},
access: {
read: () => true,
create: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => !!user,
delete: ({ req: { user } }) => !!user,
},
fields: [
{
type: 'tabs',
tabs: [
{
label: ' 基本信息',
fields: [
{
type: 'row',
fields: [
{
name: 'title',
type: 'text',
required: true,
admin: {
description: '状态名称,例如:研发中、众筹中、量产中',
width: '50%',
},
},
{
name: 'badge',
type: 'text',
admin: {
description: '状态徽章标签(简短文字,展示在产品卡片上)',
placeholder: '例如: 研发中',
width: '25%',
},
},
{
name: 'color',
type: 'select',
defaultValue: 'gray',
admin: {
description: '徽章颜色',
width: '25%',
},
options: [
{ label: '灰色', value: 'gray' },
{ label: '蓝色', value: 'blue' },
{ label: '绿色', value: 'green' },
{ label: '黄色', value: 'yellow' },
{ label: '橙色', value: 'orange' },
{ label: '红色', value: 'red' },
{ label: '紫色', value: 'purple' },
],
},
],
},
{
name: 'description',
type: 'text',
admin: {
description: '状态简介(纯文本,用于列表展示)',
placeholder: '请输入状态简介...',
},
},
{
name: 'order',
type: 'number',
defaultValue: 0,
admin: {
description: '排序权重(数值越小越靠前)',
},
},
],
},
{
label: '📄 详细说明',
fields: [
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: [
ParagraphFeature(),
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
BoldFeature(),
ItalicFeature(),
UnorderedListFeature(),
OrderedListFeature(),
LinkFeature(),
HorizontalRuleFeature(),
FixedToolbarFeature(),
InlineToolbarFeature(),
],
}),
admin: {
description: '状态详细说明(富文本,可包含进度描述、注意事项等)',
},
},
],
},
],
},
],
hooks: {
afterChange: [logAfterChange],
afterDelete: [logAfterDelete],
},
timestamps: true,
}

View File

@ -0,0 +1,27 @@
'use client'
import React from 'react'
import { Button } from '@payloadcms/ui'
/**
*
* DisassemblyPages
* DisassemblyPages.ts fields[].admin.components.Cell
*/
export function DisassemblyEditorCell({ rowData }: any) {
const id = rowData?.id
if (!id) return null
return (
<Button
buttonStyle="secondary"
size="small"
onClick={(e) => {
e?.stopPropagation?.()
window.location.href = `/admin/disassembly-editor?id=${id}`
}}
>
</Button>
)
}

View File

@ -0,0 +1,68 @@
'use client'
import React from 'react'
/**
*
*
*/
export function PreorderProgressCell({ rowData }: any) {
const orderCount = parseInt(rowData?.orderCount || '0', 10) || 0
const fakeOrderCount = parseInt(rowData?.fakeOrderCount || '0', 10) || 0
const fundingGoal = parseInt(rowData?.fundingGoal || '0', 10) || 100
const totalCount = orderCount + fakeOrderCount
const percentage = fundingGoal > 0 ? Math.round((totalCount / fundingGoal) * 100) : 0
const isExceeded = percentage > 100
const barWidth = Math.min(percentage, 100)
return (
<div className="preorder-progress-info">
<div className="progress-label">
<span></span>
<span className="progress-count">
{totalCount} / {fundingGoal}
</span>
</div>
<div className="progress-bar" style={{ position: 'relative' }}>
<div
className="progress-fill"
style={{
width: `${barWidth}%`,
background: isExceeded ? 'var(--theme-success-600, #16a34a)' : undefined,
}}
/>
{isExceeded && (
<div style={{
position: 'absolute',
right: 0,
top: '50%',
transform: 'translateY(-50%)',
width: 6,
height: 6,
borderRadius: '50%',
background: '#16a34a',
boxShadow: '0 0 0 2px #fff',
}} />
)}
</div>
<div className="progress-stats">
<div className="stat-item">
<span className="stat-label"></span>
<span className="stat-value">{orderCount}</span>
</div>
<div className="stat-item">
<span className="stat-label">Fake</span>
<span className="stat-value">{fakeOrderCount}</span>
</div>
<div className="stat-item">
<span className="stat-label"></span>
<span className="stat-value" style={isExceeded ? { color: '#16a34a', fontWeight: 700 } : {}}>
{isExceeded ? `超出 ${percentage - 100}%` : `${percentage}%`}
</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,55 @@
'use client'
import Link from 'next/link'
export const ThumbnailCell = (props: any) => {
// 尝试从不同的 props 路径获取值
const value = props.value || props.cellData || props.data
const rowData = props.rowData || props.row
// 获取起始价格(已经是美元)
const startPrice = rowData?.startPrice
const formattedPrice = startPrice ? `$${startPrice.toFixed(2)}` : ''
// 优先从 props 中获取 collection 信息Payload Cell API
let collectionSlug = props.collectionConfig?.slug || props.field?.relationTo || props.collection
// 如果没有从 props 获取到,通过检查预购特有字段自动判断
if (!collectionSlug) {
// PreorderProducts 有 orderCount, preorderType 等特有字段
const isPreorder = rowData?.orderCount !== undefined || rowData?.preorderType !== undefined
collectionSlug = isPreorder ? 'preorder-products' : 'products'
}
const isImage = typeof value === 'string' && value.match(/^https?:\/\/.+/)
const editUrl = `/admin/collections/${collectionSlug}/${rowData?.id || ''}`
return (
<Link
href={editUrl}
style={{ display: 'block', width: '100%', textDecoration: 'none', position: 'relative' }}
>
<div style={{ position: 'relative', width: '100%', height: '200px' }}>
{isImage ? (
<img src={value} alt="商品缩略图" className="thumbnail-img" />
) : (
<div className="no-image">{value || '无图片'}</div>
)}
{formattedPrice && (
<div style={{
position: 'absolute',
bottom: '8px',
right: '8px',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 'bold',
}}>
{formattedPrice}
</div>
)}
</div>
</Link>
)
}

View File

@ -0,0 +1,268 @@
'use client'
import { useField, useFormFields } from '@payloadcms/ui'
import React, { useEffect, useState } from 'react'
interface PreorderItem {
id: string
title: string
variant_id: string
variant_sku?: string
variant_title?: string
quantity: number
unit_price: number
total: number
}
interface Order {
id: string
display_id: number
status: string
payment_status: string
fulfillment_status: string
email: string
total: number
preorder_amount: number
preorder_quantity: number
currency_code: string
created_at: string
preorder_items: PreorderItem[]
}
interface Statistics {
total_orders: number
total_quantity: number
total_amount: number
status_breakdown: Record<string, number>
}
export const PreorderOrdersField: React.FC = () => {
const { value: medusaId } = useField<string>({ path: 'medusaId' })
const { value: seedId } = useField<string>({ path: 'seedId' })
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [stats, setStats] = useState<Statistics | null>(null)
useEffect(() => {
if (medusaId || seedId) {
fetchOrders()
}
}, [medusaId, seedId])
const fetchOrders = async () => {
const productId = seedId || medusaId
if (!productId) return
try {
setLoading(true)
setError(null)
const response = await fetch(`/api/preorders/${productId}/orders`)
if (!response.ok) {
throw new Error('Failed to fetch orders')
}
const data = await response.json()
setOrders(data.orders || [])
setStats(data.statistics ?? null)
} catch (err: any) {
console.error('Failed to fetch orders:', err)
setError(err.message || 'Failed to load orders')
} finally {
setLoading(false)
}
}
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100)
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
if (!medusaId && !seedId) {
return (
<div style={{ padding: '1rem', background: '#f5f5f5', borderRadius: '4px', marginBottom: '1rem' }}>
<p style={{ margin: 0, color: '#666' }}>
Medusa
</p>
</div>
)
}
if (loading) {
return (
<div style={{ padding: '1rem', background: '#f5f5f5', borderRadius: '4px', marginBottom: '1rem' }}>
<p style={{ margin: 0 }}>...</p>
</div>
)
}
if (error) {
return (
<div style={{ padding: '1rem', background: '#fee', borderRadius: '4px', marginBottom: '1rem', border: '1px solid #fcc' }}>
<p style={{ margin: 0, color: '#c00' }}>: {error}</p>
<button
onClick={fetchOrders}
style={{
marginTop: '0.5rem',
padding: '0.25rem 0.5rem',
background: '#fff',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer',
}}
>
</button>
</div>
)
}
if (orders.length === 0) {
return (
<div style={{ padding: '1rem', background: '#f5f5f5', borderRadius: '4px', marginBottom: '1rem' }}>
<p style={{ margin: 0, color: '#666' }}></p>
<button
onClick={fetchOrders}
style={{
marginTop: '0.5rem',
padding: '0.25rem 0.5rem',
background: '#fff',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer',
}}
>
</button>
</div>
)
}
return (
<div style={{ marginBottom: '1rem' }}>
{/* 统计信息 */}
{stats && (
<div style={{
padding: '1rem',
background: '#e3f2fd',
borderRadius: '4px',
marginBottom: '1rem',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: '1rem',
}}>
<div>
<div style={{ fontSize: '0.875rem', color: '#666', marginBottom: '0.25rem' }}></div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#1976d2' }}>{stats.total_orders}</div>
</div>
<div>
<div style={{ fontSize: '0.875rem', color: '#666', marginBottom: '0.25rem' }}></div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#1976d2' }}>{stats.total_quantity}</div>
</div>
<div>
<div style={{ fontSize: '0.875rem', color: '#666', marginBottom: '0.25rem' }}></div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#1976d2' }}>
{formatCurrency(stats.total_amount, orders[0]?.currency_code || 'CNY')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<button
onClick={fetchOrders}
style={{
padding: '0.5rem 1rem',
background: '#fff',
border: '1px solid #1976d2',
borderRadius: '4px',
cursor: 'pointer',
color: '#1976d2',
fontSize: '0.875rem',
}}
>
🔄
</button>
</div>
</div>
)}
{/* 订单列表 */}
<div style={{
border: '1px solid #e0e0e0',
borderRadius: '4px',
overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f5f5f5', borderBottom: '2px solid #e0e0e0' }}>
<th style={{ padding: '0.75rem', textAlign: 'left', fontSize: '0.875rem', fontWeight: 600 }}></th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontSize: '0.875rem', fontWeight: 600 }}></th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontSize: '0.875rem', fontWeight: 600 }}></th>
<th style={{ padding: '0.75rem', textAlign: 'right', fontSize: '0.875rem', fontWeight: 600 }}></th>
<th style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.875rem', fontWeight: 600 }}></th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontSize: '0.875rem', fontWeight: 600 }}></th>
</tr>
</thead>
<tbody>
{orders.map((order, index) => (
<tr
key={order.id}
style={{
borderBottom: index < orders.length - 1 ? '1px solid #e0e0e0' : 'none',
background: index % 2 === 0 ? '#fff' : '#fafafa',
}}
>
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>
<div style={{ fontWeight: 600 }}>#{order.display_id}</div>
<div style={{ fontSize: '0.75rem', color: '#999' }}>{order.id.slice(0, 8)}</div>
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>
{order.email}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>
{(order.preorder_items || []).map((item, i) => (
<div key={i} style={{ marginBottom: i < order.preorder_items.length - 1 ? '0.25rem' : 0 }}>
{item.variant_title ? `${item.title} · ${item.variant_title}` : item.title} × {item.quantity}
</div>
))}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem', textAlign: 'right', fontWeight: 600 }}>
{formatCurrency(order.total, order.currency_code)}
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
<span style={{
display: 'inline-block',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 600,
background: order.status === 'completed' ? '#e8f5e9' : '#fff3e0',
color: order.status === 'completed' ? '#2e7d32' : '#f57c00',
}}>
{order.status}
</span>
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>
{formatDate(order.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -0,0 +1,360 @@
'use client'
import { Button, useField } from '@payloadcms/ui'
import React, { useEffect, useState } from 'react'
interface OrderItem {
id: string
title: string
variant_id: string
variant_sku?: string
variant_title?: string
quantity: number
unit_price: number
total: number
}
interface Order {
id: string
display_id: number
status: string
payment_status: string
fulfillment_status: string
email: string
total: number
preorder_amount: number
preorder_quantity: number
currency_code: string
created_at: string
preorder_items: OrderItem[]
medusa_url: string
}
interface Statistics {
total_orders: number
total_quantity: number
total_amount: number
status_breakdown: Record<string, number>
}
const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
completed: { bg: 'var(--theme-success-50)', text: 'var(--theme-success-900)' },
pending: { bg: 'var(--theme-warning-50)', text: 'var(--theme-warning-900)' },
cancelled: { bg: 'var(--theme-error-50)', text: 'var(--theme-error-900)' },
processing: { bg: 'var(--theme-elevation-100)', text: 'var(--theme-text)' },
}
const statusColor = (status: string) =>
STATUS_COLORS[status] ?? STATUS_COLORS.processing
// ─── shared styles ──────────────────────────────────────────────────────────
const card: React.CSSProperties = {
padding: '1rem',
border: '1px solid var(--theme-elevation-150)',
borderRadius: 'var(--style-radius-m)',
backgroundColor: 'var(--theme-elevation-50)',
marginBottom: '1rem',
}
const TH: React.CSSProperties = {
padding: '0.6rem 0.75rem',
textAlign: 'left',
fontWeight: 600,
color: 'var(--theme-elevation-600)',
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.04em',
whiteSpace: 'nowrap',
}
const TD: React.CSSProperties = {
padding: '0.6rem 0.75rem',
verticalAlign: 'middle',
}
// ─── sub-component ──────────────────────────────────────────────────────────
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div style={{
padding: '0.75rem 1rem',
border: '1px solid var(--theme-elevation-150)',
borderRadius: 'var(--style-radius-m)',
backgroundColor: 'var(--theme-elevation-50)',
}}>
<div style={{
fontSize: '0.75rem',
color: 'var(--theme-elevation-500)',
marginBottom: '0.25rem',
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}>
{label}
</div>
<div style={{ fontSize: '1.375rem', fontWeight: 700, color: 'var(--theme-text)' }}>
{value}
</div>
</div>
)
}
// ─── main component ─────────────────────────────────────────────────────────
/**
*
* Products PreorderProducts
* /api/products/:id/orders Medusa
*/
export const ProductOrdersField: React.FC = () => {
const { value: medusaId } = useField<string>({ path: 'medusaId' })
const { value: seedId } = useField<string>({ path: 'seedId' })
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [stats, setStats] = useState<Statistics | null>(null)
useEffect(() => {
if (medusaId || seedId) fetchOrders()
}, [medusaId, seedId])
const fetchOrders = async () => {
const productId = seedId || medusaId
if (!productId) return
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/products/${productId}/orders`)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as any).message || 'Failed to fetch orders')
}
const data = await res.json()
setOrders(data.orders || [])
setStats(data.statistics ?? null)
} catch (err: any) {
setError(err.message || 'Failed to load orders')
} finally {
setLoading(false)
}
}
const fmt = (amount: number, currency: string) =>
new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100)
const fmtDate = (d: string) =>
new Date(d).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
// ─── state views ─────────────────────────────────────────────────────────
if (!medusaId && !seedId) {
return (
<div style={card}>
<p style={{ margin: 0, color: 'var(--theme-elevation-500)', fontSize: '0.875rem' }}>
Medusa
</p>
</div>
)
}
if (loading) {
return (
<div style={card}>
<p style={{ margin: 0, fontSize: '0.875rem', color: 'var(--theme-elevation-500)' }}>
</p>
</div>
)
}
if (error) {
return (
<div style={{ ...card, backgroundColor: 'var(--theme-error-50)', borderColor: 'var(--theme-error-300)' }}>
<p style={{ margin: '0 0 0.75rem', color: 'var(--theme-error-900)', fontSize: '0.875rem' }}>
{error}
</p>
<Button buttonStyle="secondary" size="small" onClick={fetchOrders}></Button>
</div>
)
}
if (orders.length === 0) {
return (
<div style={card}>
<p style={{ margin: '0 0 0.75rem', color: 'var(--theme-elevation-500)', fontSize: '0.875rem' }}>
</p>
<Button buttonStyle="secondary" size="small" onClick={fetchOrders}></Button>
</div>
)
}
// ─── main view ──────────────────────────────────────────────────────────
const baseCurrency = orders[0]?.currency_code || 'CNY'
return (
<div style={{ marginBottom: '1.5rem' }}>
{/* 统计栏 */}
{stats && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: '0.75rem',
marginBottom: '1rem',
}}>
<StatCard label="订单总数" value={String(stats.total_orders)} />
<StatCard label="下单数量" value={String(stats.total_quantity)} />
<StatCard label="订单金额" value={fmt(stats.total_amount, baseCurrency)} />
<div style={{
padding: '0.75rem 1rem',
border: '1px solid var(--theme-elevation-150)',
borderRadius: 'var(--style-radius-m)',
backgroundColor: 'var(--theme-elevation-50)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}>
<div style={{ fontSize: '0.75rem', color: 'var(--theme-elevation-500)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
</div>
<Button buttonStyle="primary" onClick={fetchOrders} disabled={loading}>
🔄&nbsp;
</Button>
</div>
</div>
)}
{/* 订单表格 */}
<div style={{
border: '1px solid var(--theme-elevation-150)',
borderRadius: 'var(--style-radius-m)',
overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
<thead>
<tr style={{
background: 'var(--theme-elevation-50)',
borderBottom: '1px solid var(--theme-elevation-150)',
}}>
{['订单号', '客户', '商品', '金额', '状态', '时间', ''].map(h => (
<th key={h} style={TH}>{h}</th>
))}
</tr>
</thead>
<tbody>
{orders.map((order, idx) => {
const sc = statusColor(order.status)
return (
<tr
key={order.id}
style={{
borderBottom: idx < orders.length - 1
? '1px solid var(--theme-elevation-100)'
: 'none',
background: idx % 2 === 0
? 'var(--theme-bg)'
: 'var(--theme-elevation-50)',
}}
>
{/* 订单号 */}
<td style={TD}>
<span style={{ fontWeight: 600, color: 'var(--theme-text)' }}>
#{order.display_id}
</span>
<br />
<span style={{ fontSize: '0.7rem', color: 'var(--theme-elevation-400)' }}>
{order.id.slice(0, 8)}
</span>
</td>
{/* 客户 */}
<td style={TD}>
<span style={{ color: 'var(--theme-elevation-700)' }}>{order.email}</span>
</td>
{/* 商品 */}
<td style={TD}>
{(order.preorder_items || []).map((item, i) => (
<div
key={i}
style={{ marginBottom: i < order.preorder_items.length - 1 ? '0.2rem' : 0 }}
>
<span style={{ color: 'var(--theme-text)' }}>
{item.variant_title
? `${item.title} · ${item.variant_title}`
: item.title}
</span>
<span style={{ color: 'var(--theme-elevation-400)', marginLeft: '0.25rem' }}>
× {item.quantity}
</span>
</div>
))}
</td>
{/* 金额 */}
<td style={{ ...TD, textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap' }}>
{fmt(order.total, order.currency_code)}
</td>
{/* 状态 */}
<td style={{ ...TD, textAlign: 'center' }}>
<span style={{
display: 'inline-block',
padding: '0.2rem 0.6rem',
borderRadius: '999px',
fontSize: '0.75rem',
fontWeight: 600,
backgroundColor: sc.bg,
color: sc.text,
whiteSpace: 'nowrap',
}}>
{order.status}
</span>
</td>
{/* 时间 */}
<td style={{ ...TD, whiteSpace: 'nowrap', color: 'var(--theme-elevation-500)' }}>
{fmtDate(order.created_at)}
</td>
{/* 跳转 Medusa */}
<td style={{ ...TD, textAlign: 'center' }}>
<button
type="button"
onClick={() => window.open(order.medusa_url, '_blank', 'noopener,noreferrer')}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.5rem 0.875rem',
borderRadius: 'var(--style-radius-s)',
border: '1px solid var(--theme-elevation-300)',
backgroundColor: 'var(--theme-elevation-0, var(--theme-bg))',
color: 'var(--theme-text)',
fontSize: '0.8125rem',
fontWeight: 500,
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
onMouseEnter={e => (e.currentTarget.style.backgroundColor = 'var(--theme-elevation-100)')}
onMouseLeave={e => (e.currentTarget.style.backgroundColor = 'var(--theme-elevation-0, var(--theme-bg))')}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{ flexShrink: 0, opacity: 0.6 }}>
<path d="M1 11L11 1M11 1H4M11 1V8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -0,0 +1,130 @@
'use client'
import { useState } from 'react'
import { Button, useDocumentInfo } from '@payloadcms/ui'
import { useRouter } from 'next/navigation'
/**
*
*
*/
export function RefreshOrderCountField() {
const { id } = useDocumentInfo()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const router = useRouter()
const handleRefresh = async () => {
if (!id) {
setMessage('⚠️ 无法获取商品 ID')
return
}
setLoading(true)
setMessage('')
try {
const response = await fetch('/api/preorders/refresh-order-counts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
productIds: [id],
}),
})
const data = await response.json()
if (data.success) {
setMessage(`${data.message || '订单计数刷新成功!'}`)
// 刷新页面数据
setTimeout(() => {
router.refresh()
// 重新加载页面以更新显示
window.location.reload()
}, 1000)
} else {
setMessage(`❌ 刷新失败: ${data.error || '未知错误'}`)
}
} catch (error) {
setMessage('❌ 刷新出错: ' + (error instanceof Error ? error.message : '未知错误'))
} finally {
setLoading(false)
}
}
return (
<div
style={{
padding: '1rem',
border: '1px solid var(--theme-elevation-150)',
borderRadius: '4px',
backgroundColor: 'var(--theme-elevation-50)',
marginBottom: '1rem',
}}
>
<div style={{ marginBottom: '0.75rem' }}>
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '0.875rem', fontWeight: 600 }}>
📊
</h4>
<p style={{ margin: 0, fontSize: '0.8125rem', color: 'var(--theme-elevation-600)' }}>
Medusa
</p>
</div>
<Button
onClick={handleRefresh}
disabled={loading}
buttonStyle="primary"
size="small"
>
{loading ? '同步中...' : '🔄 刷新订单计数'}
</Button>
{message && (
<div
style={{
padding: '0.75rem',
marginTop: '0.75rem',
borderRadius: '4px',
backgroundColor: message.startsWith('✅')
? 'var(--theme-success-50)'
: message.startsWith('⚠️')
? 'var(--theme-warning-50)'
: 'var(--theme-error-50)',
color: message.startsWith('✅')
? 'var(--theme-success-900)'
: message.startsWith('⚠️')
? 'var(--theme-warning-900)'
: 'var(--theme-error-900)',
fontSize: '0.8125rem',
}}
>
{message}
</div>
)}
<div
style={{
marginTop: '0.75rem',
padding: '0.75rem',
backgroundColor: 'var(--theme-elevation-100)',
borderRadius: '4px',
fontSize: '0.8125rem',
color: 'var(--theme-elevation-600)',
}}
>
<p style={{ margin: '0.25rem 0', fontWeight: 600 }}>💡 </p>
<p style={{ margin: '0.25rem 0' }}>
<strong></strong> Medusa
</p>
<p style={{ margin: '0.25rem 0' }}>
<strong>Fake计数</strong>
</p>
<p style={{ margin: '0.25rem 0' }}>
<strong></strong> = + Fake计数
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,425 @@
'use client'
import { useField, useConfig, FieldLabel } from '@payloadcms/ui'
import { useState, useEffect, useCallback } from 'react'
import type { RelationshipFieldClientComponent } from 'payload'
/**
* - products preorder-products
*
*/
export const RelatedProductsField: RelationshipFieldClientComponent = ({ path, field }) => {
const hasMany = field.hasMany !== false // 默认多选
const relationTo = Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo]
const { value, setValue } = useField<string[] | string>({ path })
const { config } = useConfig()
const [inputValue, setInputValue] = useState('')
const [searchResults, setSearchResults] = useState<any[]>([])
const [selectedDetails, setSelectedDetails] = useState<any[]>([])
const [isLoading, setIsLoading] = useState(false)
// Fetch details for selected items
useEffect(() => {
const fetchSelectedDetails = async () => {
if (!value || (Array.isArray(value) && value.length === 0)) {
setSelectedDetails([])
return
}
const ids = Array.isArray(value) ? value : [value as unknown as string]
try {
// Fetch from both collections
const allDocs: any[] = []
for (const collection of relationTo) {
const searchParams = new URLSearchParams()
ids.forEach((id, index) => {
const idStr = typeof id === 'object' ? (id as any).value || (id as any).id : id
searchParams.append(`where[id][in][${index}]`, idStr)
})
const res = await fetch(
`${config.routes.api}/${collection}?${searchParams.toString()}&limit=${ids.length}`,
)
const data = await res.json()
if (data.docs) {
allDocs.push(...data.docs.map((d: any) => ({ ...d, _collection: collection })))
}
}
if (allDocs.length > 0) {
const docsMap = new Map(allDocs.map((d: any) => [d.id, d]))
const ordered = ids
.map((id) => {
const idStr = typeof id === 'object' ? (id as any).value || (id as any).id : id
return docsMap.get(idStr)
})
.filter(Boolean)
setSelectedDetails(ordered)
}
} catch (e) {
console.error('Error fetching selected products:', e)
}
}
fetchSelectedDetails()
}, [value, config.routes.api])
// Search function with debounce - search across all related collections
const searchProducts = useCallback(
async (term: string) => {
if (!term) {
setSearchResults([])
return
}
setIsLoading(true)
try {
const allResults: any[] = []
// Search in all relationTo collections
for (const collection of relationTo) {
const res = await fetch(
`${config.routes.api}/${collection}?where[title][like]=${encodeURIComponent(term)}&limit=5`,
)
const data = await res.json()
if (data.docs) {
allResults.push(...data.docs.map((d: any) => ({ ...d, _collection: collection })))
}
}
setSearchResults(allResults)
} catch (e) {
console.error('Search error:', e)
setSearchResults([])
} finally {
setIsLoading(false)
}
},
[config.routes.api, relationTo],
)
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
searchProducts(inputValue)
}, 300)
return () => clearTimeout(timer)
}, [inputValue, searchProducts])
const handleAdd = (product: any) => {
if (!hasMany) {
// Single select mode
const relationValue = {
relationTo: product._collection,
value: product.id,
}
setValue(relationValue as any)
setSelectedDetails([product])
setInputValue('')
setSearchResults([])
return
}
// Multiple select mode
const currentIds = Array.isArray(value) ? value : []
const exists = currentIds.some((id: any) => {
const idStr = typeof id === 'object' ? (id as any).value || (id as any).id : id
return idStr === product.id
})
if (!exists) {
const relationValue = {
relationTo: product._collection,
value: product.id,
}
setValue([...currentIds, relationValue] as any)
setSelectedDetails((prev) => [...prev, product])
}
setInputValue('')
}
const handleRemove = (idToRemove: string) => {
if (!hasMany) {
// Single select mode
setValue(null as any)
setSelectedDetails([])
return
}
// Multiple select mode
const currentIds = Array.isArray(value) ? value : []
const newValue = currentIds.filter((id: any) => {
const idStr = typeof id === 'object' ? (id as any).value || (id as any).id : id
return idStr !== idToRemove
})
setValue(newValue as any)
setSelectedDetails((prev) => prev.filter((p) => p.id !== idToRemove))
}
return (
<div style={{ marginBottom: 'var(--base)' }}>
<FieldLabel label={field.label} />
{/* Selected Items Grid - 网格显示已选商品 */}
{selectedDetails.length > 0 && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: 'var(--base)',
marginBottom: 'var(--base)',
}}
>
{selectedDetails.map((product) => (
<div
key={product.id}
style={{
border: '1px solid var(--theme-elevation-150)',
borderRadius: 'var(--border-radius-m)',
overflow: 'hidden',
background: 'var(--theme-elevation-50)',
position: 'relative',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--theme-elevation-400)'
e.currentTarget.style.background = 'var(--theme-elevation-100)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--theme-elevation-150)'
e.currentTarget.style.background = 'var(--theme-elevation-50)'
}}
>
<div
style={{
height: '120px',
overflow: 'hidden',
background: 'var(--theme-elevation-100)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{product.thumbnail ? (
<img
src={product.thumbnail}
alt={product.title}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<span style={{ fontSize: '12px', color: 'var(--theme-elevation-500)' }}>
</span>
)}
</div>
<div style={{ padding: 'calc(var(--base) / 2)' }}>
<div
style={{
fontSize: '13px',
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
color: 'var(--theme-text)',
}}
>
{product.title}
</div>
<div style={{ fontSize: '11px', color: 'var(--theme-elevation-500)' }}>
{product.status} {product._collection === 'preorder-products' ? '预售' : '常规'}
</div>
</div>
<button
type="button"
onClick={() => handleRemove(product.id)}
style={{
position: 'absolute',
top: '4px',
right: '4px',
background: 'var(--theme-elevation-0)',
border: '1px solid var(--theme-elevation-400)',
borderRadius: '50%',
width: '24px',
height: '24px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--theme-text)',
opacity: 0.8,
fontSize: '16px',
lineHeight: '1',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
e.currentTarget.style.background = 'var(--theme-error-100)'
e.currentTarget.style.color = 'var(--theme-error-600)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.8'
e.currentTarget.style.background = 'var(--theme-elevation-0)'
e.currentTarget.style.color = 'var(--theme-text)'
}}
>
×
</button>
</div>
))}
</div>
)}
{/* Search Input - 搜索输入框 */}
<div>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="搜索商品..."
style={{
width: '100%',
padding: 'calc(var(--base) / 2) var(--base)',
borderRadius: 'var(--border-radius-m)',
border: '1px solid var(--theme-elevation-400)',
background: 'var(--theme-input-bg)',
color: 'var(--theme-text)',
fontFamily: 'inherit',
fontSize: '1rem',
}}
onFocus={() => inputValue && searchProducts(inputValue)}
/>
{/* Horizontal Scroll Results - 横向滚动搜索结果(保留原始格子布局) */}
{inputValue && searchResults.length > 0 && (
<div
style={{
marginTop: 'calc(var(--base) / 2)',
display: 'flex',
overflowX: 'auto',
gap: 'var(--base)',
padding: 'calc(var(--base) / 2) 0',
}}
>
{searchResults.map((product) => (
<div
key={product.id}
onClick={() => handleAdd(product)}
style={{
flex: '0 0 160px',
cursor: 'pointer',
border: '1px solid var(--theme-elevation-150)',
borderRadius: 'var(--border-radius-m)',
background: 'var(--theme-elevation-50)',
overflow: 'hidden',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--theme-primary-500)'
e.currentTarget.style.background = 'var(--theme-elevation-100)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--theme-elevation-150)'
e.currentTarget.style.background = 'var(--theme-elevation-50)'
}}
>
<div
style={{
height: '120px',
overflow: 'hidden',
background: 'var(--theme-elevation-100)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{product.thumbnail ? (
<img
src={product.thumbnail}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<span style={{ fontSize: '10px', color: 'var(--theme-elevation-500)' }}>
</span>
)}
</div>
<div style={{ padding: 'calc(var(--base) / 2)' }}>
<div
style={{
fontSize: '13px',
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
color: 'var(--theme-text)',
}}
>
{product.title}
</div>
<div style={{ fontSize: '11px', color: 'var(--theme-elevation-500)' }}>
{product._collection === 'preorder-products' ? '预售' : '常规'}
</div>
</div>
</div>
))}
</div>
)}
{/* No results or loading - 加载状态和空结果提示 */}
{inputValue && !isLoading && searchResults.length === 0 && (
<div
style={{
marginTop: 'calc(var(--base) / 2)',
padding: 'var(--base)',
textAlign: 'center',
color: 'var(--theme-elevation-500)',
fontSize: '14px',
}}
>
</div>
)}
{isLoading && (
<div
style={{
marginTop: 'calc(var(--base) / 2)',
padding: 'var(--base)',
textAlign: 'center',
color: 'var(--theme-elevation-500)',
fontSize: '14px',
}}
>
...
</div>
)}
</div>
{/* Helper text */}
{field.admin?.description && (
<div
style={{
marginTop: 'calc(var(--base) / 4)',
fontSize: '0.875rem',
color: 'var(--theme-elevation-500)',
}}
>
{typeof field.admin.description === 'string'
? field.admin.description
: JSON.stringify(field.admin.description)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,85 @@
'use client'
import { useField, useFormFields } from '@payloadcms/ui'
import React, { useState } from 'react'
/**
*
* taobaoLinks url
* /api/taobao/parse / /
*
*
* 使TaobaoLinksField.ts UI
* { type: 'ui', name: 'fetchButton', admin: { components: { Field: '/components/fields/TaobaoFetchButton#TaobaoFetchButton' } } }
*/
export const TaobaoFetchButton: React.FC<{ path?: string }> = ({ path = '' }) => {
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState<string | null>(null)
// path 例如 "taobaoLinks.0.fetchButton",取前缀 "taobaoLinks.0."
const prefix = path.replace(/[^.]+$/, '')
const { value: url } = useField<string>({ path: `${prefix}url` })
const { setValue: setTitle } = useField<string>({ path: `${prefix}title` })
const { setValue: setThumbnail } = useField<string>({ path: `${prefix}thumbnail` })
const { setValue: setPrice } = useField<number>({ path: `${prefix}price` })
const handleFetch = async () => {
if (!url) {
setMessage('请先填写淘宝链接')
return
}
setLoading(true)
setMessage(null)
try {
const res = await fetch('/api/taobao/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
})
const data = await res.json()
if (!data.success) throw new Error(data.error || '解析失败')
if (data.title) setTitle(data.title)
if (data.thumbnail) setThumbnail(data.thumbnail)
if (data.price != null) setPrice(data.price)
const filled = [data.title && '标题', data.thumbnail && '封面', data.price != null && '价格']
.filter(Boolean)
.join('、')
setMessage(filled ? `✅ 已填入:${filled}` : '⚠️ 未能解析到内容')
} catch (err: any) {
setMessage(`${err?.message ?? '请求失败'}`)
} finally {
setLoading(false)
}
}
return (
<div style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<button
type="button"
onClick={handleFetch}
disabled={loading || !url}
style={{
padding: '0.4rem 0.9rem',
background: loading ? '#9ca3af' : '#f97316',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: loading || !url ? 'not-allowed' : 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{loading ? '解析中…' : '🔍 自动解析'}
</button>
{message && (
<span style={{ fontSize: '0.78rem', color: message.startsWith('✅') ? '#16a34a' : '#dc2626' }}>
{message}
</span>
)}
</div>
)
}

View File

@ -0,0 +1,79 @@
'use client'
import { useField, useFormFields } from '@payloadcms/ui'
import React from 'react'
/**
*
*
*/
export const TaobaoLinkPreview: React.FC = () => {
const { value: url } = useField<string>({ path: 'url' })
const { value: thumbnail } = useField<string>({ path: 'thumbnail' })
const openLink = () => {
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
if (!url && !thumbnail) {
return null
}
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.75rem',
background: '#f7f9fb',
borderRadius: '4px',
border: '1px solid #e5e7eb',
}}
>
{thumbnail && (
<div style={{ marginBottom: '0.75rem' }}>
<div style={{ fontSize: '0.75rem', color: '#666', marginBottom: '0.25rem' }}>
</div>
<img
src={thumbnail}
alt="淘宝商品预览"
style={{
maxWidth: '100%',
maxHeight: '200px',
borderRadius: '4px',
border: '1px solid #e0e0e0',
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
</div>
)}
{url && (
<button
type="button"
onClick={openLink}
style={{
padding: '0.5rem 1rem',
background: '#ff6700',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
width: 'fit-content',
}}
>
🔗
</button>
)}
</div>
)
}

View File

@ -0,0 +1,225 @@
'use client'
import React, { useState, useCallback } from 'react'
import { useField, Button } from '@payloadcms/ui'
import type { TextFieldClientComponent } from 'payload'
/**
* Thumbnail
* -
* - Media collection
* - URL
*/
export const ThumbnailField: TextFieldClientComponent = (props) => {
const { path, field } = props
const { value, setValue } = useField<string>({ path })
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState('')
const label = typeof field.label === 'string' ? field.label : '商品封面'
const required = field.required || false
// 处理文件上传
const handleFileUpload = useCallback(
async (file: File) => {
if (!file) return
setUploading(true)
setUploadError('')
try {
// 创建 FormData
const formData = new FormData()
formData.append('file', file)
formData.append('alt', file.name)
// 上传到自定义 API
const response = await fetch('/api/upload-media', {
method: 'POST',
body: formData,
credentials: 'include',
})
if (!response.ok) {
const errorText = await response.text()
let errorMsg = `上传失败 (${response.status})`
try {
const errorData = JSON.parse(errorText)
errorMsg = errorData.message || errorData.error || errorMsg
} catch {
errorMsg = errorText || errorMsg
}
throw new Error(errorMsg)
}
const data = await response.json()
// 获取上传后的图片 URL
if (data.doc?.url) {
setValue(data.doc.url)
setUploadError('') // 清除之前的错误
} else {
throw new Error('服务器返回数据中没有图片 URL')
}
} catch (error) {
console.error('Upload error:', error)
setUploadError(error instanceof Error ? error.message : '上传失败,请重试')
} finally {
setUploading(false)
}
},
[setValue],
)
// 处理 URL 输入
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
}
// 清除图片
const handleClear = () => {
setValue('')
setUploadError('')
}
return (
<div style={{ marginBottom: '1rem' }}>
<div style={{ marginBottom: '0.5rem' }}>
<label
style={{
display: 'block',
fontWeight: '600',
fontSize: '0.875rem',
marginBottom: '0.5rem',
}}
>
{label || '商品封面'}
{required && <span style={{ color: 'var(--theme-error-500)' }}> *</span>}
</label>
</div>
{/* 图片预览 */}
{value && (
<div
style={{
marginBottom: '1rem',
padding: '1rem',
backgroundColor: 'var(--theme-elevation-50)',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-100)',
}}
>
<img
src={value}
alt="商品封面"
style={{
maxWidth: '100%',
maxHeight: '300px',
objectFit: 'contain',
display: 'block',
margin: '0 auto',
}}
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
</div>
)}
{/* URL 输入框 */}
<div style={{ marginBottom: '0.75rem' }}>
<input
type="text"
value={value || ''}
onChange={handleUrlChange}
placeholder="输入图片 URL 或上传图片"
style={{
width: '100%',
padding: '0.5rem',
fontSize: '0.875rem',
border: '1px solid var(--theme-elevation-400)',
borderRadius: '4px',
backgroundColor: 'var(--theme-elevation-0)',
color: 'var(--theme-text)',
}}
/>
</div>
{/* 文件上传按钮 */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<label
style={{
display: 'inline-block',
padding: '0.5rem 1rem',
fontSize: '0.875rem',
fontWeight: '500',
color: 'var(--theme-elevation-0)',
backgroundColor: 'var(--theme-elevation-800)',
border: 'none',
borderRadius: '4px',
cursor: uploading ? 'not-allowed' : 'pointer',
opacity: uploading ? 0.6 : 1,
}}
>
{uploading ? '上传中...' : '上传图片'}
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
handleFileUpload(file)
}
}}
disabled={uploading}
style={{ display: 'none' }}
/>
</label>
{value && (
<button
type="button"
onClick={handleClear}
disabled={uploading}
style={{
padding: '0.5rem 1rem',
fontSize: '0.875rem',
fontWeight: '500',
color: 'var(--theme-error-500)',
backgroundColor: 'transparent',
border: '1px solid var(--theme-error-500)',
borderRadius: '4px',
cursor: uploading ? 'not-allowed' : 'pointer',
opacity: uploading ? 0.6 : 1,
}}
>
</button>
)}
</div>
{/* 错误提示 */}
{uploadError && (
<div
style={{
marginTop: '0.5rem',
padding: '0.5rem',
backgroundColor: 'var(--theme-error-50)',
color: 'var(--theme-error-700)',
fontSize: '0.75rem',
borderRadius: '4px',
border: '1px solid var(--theme-error-500)',
}}
>
{uploadError}
</div>
)}
{/* 说明文本 */}
<div
style={{ marginTop: '0.5rem', fontSize: '0.75rem', color: 'var(--theme-elevation-600)' }}
>
URL
</div>
</div>
)
}

View File

@ -0,0 +1,26 @@
'use client'
import { useEffect } from 'react'
import './preorder-product-grid-styler.scss'
/**
*
*
*/
export function PreorderProductGridStyler() {
useEffect(() => {
// 组件加载时添加样式类
const table = document.querySelector('.collection-list--preorder-products')
if (table) {
table.classList.add('preorder-grid-view')
}
return () => {
const table = document.querySelector('.collection-list--preorder-products')
if (table) {
table.classList.remove('preorder-grid-view')
}
}
}, [])
return null // 这是一个纯样式组件,不渲染任何内容
}

View File

@ -0,0 +1,9 @@
'use client'
import './product-grid-styler.scss'
// 这个组件本身不渲染任何内容,只负责在 Products 列表页注入 CSS
// 从而将 Payload 默认的表格变换为 Grid 布局
export default function ProductGridStyler() {
return <div className="product-grid-styler-injector" style={{ display: 'none' }} />
}

View File

@ -0,0 +1,240 @@
// 预购商品网格视图样式
// 将表格转换为卡片网格显示预购进度
.collection-list.collection-list--preorder-products.preorder-grid-view {
// 隐藏表头
thead {
display: none;
}
// 主体使用 Grid 布局
tbody {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
padding: 1rem 0;
}
// 每个 tr 变成卡片
tr {
display: flex;
flex-direction: column;
border: 1px solid var(--theme-elevation-150);
border-radius: 8px;
padding: 1rem;
background: var(--theme-elevation-0);
transition: all 0.2s ease;
cursor: pointer;
position: relative;
overflow: hidden;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: var(--theme-elevation-300);
transform: translateY(-2px);
}
// 选中状态
&.row-selected {
border-color: var(--theme-success-500);
background: var(--theme-success-50);
}
}
// 所有单元格
td {
border: none !important;
padding: 0.25rem 0 !important;
background: transparent !important;
width: 100% !important;
max-width: none !important;
// 隐藏不需要的列
&:not([class*='thumbnail']):not([class*='title']):not([class*='medusaId']):not([class*='status']):not([class*='updatedAt']) {
display: none;
}
}
// 缩略图
td[class*='thumbnail'] {
order: -1;
margin-bottom: 0.75rem;
img {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 6px;
background: var(--theme-elevation-100);
}
// 如果没有图片显示占位符
&:empty::before {
content: '📦';
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 200px;
font-size: 4rem;
background: var(--theme-elevation-100);
border-radius: 6px;
color: var(--theme-elevation-400);
}
}
// 标题
td[class*='title'] {
font-size: 1rem;
font-weight: 600;
color: var(--theme-elevation-1000);
margin-bottom: 0.5rem;
line-height: 1.4;
// 限制两行超出省略
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
// Medusa ID
td[class*='medusaId'] {
font-size: 0.75rem;
color: var(--theme-elevation-500);
font-family: monospace;
margin-bottom: 0.5rem;
&::before {
content: '🆔 ';
}
}
// 状态标签
td[class*='status'] {
margin: 0.5rem 0;
.pill {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
&.pill--published {
background: var(--theme-success-100);
color: var(--theme-success-900);
}
&.pill--draft {
background: var(--theme-warning-100);
color: var(--theme-warning-900);
}
}
}
// 更新时间
td[class*='updatedAt'] {
font-size: 0.75rem;
color: var(--theme-elevation-500);
margin-top: auto;
padding-top: 0.75rem !important;
border-top: 1px solid var(--theme-elevation-100);
&::before {
content: '🕐 ';
}
}
// 复选框单元格
td:first-child {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: auto !important;
z-index: 10;
input[type='checkbox'] {
width: 20px;
height: 20px;
cursor: pointer;
border: 2px solid var(--theme-elevation-400);
border-radius: 4px;
&:checked {
background: var(--theme-success-500);
border-color: var(--theme-success-500);
}
}
}
// 操作按钮单元格
td:last-child {
position: absolute;
bottom: 0.75rem;
right: 0.75rem;
width: auto !important;
}
}
// 预购进度信息需要通过自定义 Cell 组件添加
.preorder-progress-info {
margin: 0.75rem 0;
padding: 0.75rem;
background: var(--theme-elevation-50);
border-radius: 6px;
border: 1px solid var(--theme-elevation-150);
.progress-label {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--theme-elevation-600);
margin-bottom: 0.5rem;
.progress-count {
font-weight: 600;
color: var(--theme-elevation-900);
}
}
.progress-bar {
width: 100%;
height: 8px;
background: var(--theme-elevation-150);
border-radius: 4px;
overflow: hidden;
position: relative;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--theme-success-500), var(--theme-success-600));
transition: width 0.3s ease;
border-radius: 4px;
}
}
.progress-stats {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.75rem;
.stat-item {
display: flex;
flex-direction: column;
.stat-label {
color: var(--theme-elevation-500);
margin-bottom: 0.125rem;
}
.stat-value {
font-weight: 600;
color: var(--theme-elevation-900);
}
}
}
}

View File

@ -0,0 +1,124 @@
// 这是为了覆盖 Payload 默认表格样式的 SCSS
// 我们使用 CSS Grid 强制改变表格布局从而实现 Grid 视图同时保留 Payload 所有原生功能
.collection-list.collection-list--products,
.collection-list.collection-list--preorder-products {
table {
display: block !important;
thead {
display: none !important;
}
tbody {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)) !important;
gap: 1.5rem !important;
width: 100% !important;
}
tr {
display: flex !important;
flex-direction: column !important;
background: var(--theme-elevation-50) !important;
border: 1px solid var(--theme-elevation-150) !important;
border-radius: 8px !important;
overflow: hidden !important;
position: relative !important;
transition: all 0.2s ease-in-out !important;
height: 100% !important;
min-height: 320px;
cursor: pointer;
text-decoration: none !important;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.1);
border-color: var(--theme-elevation-300) !important;
background: var(--theme-elevation-100) !important;
}
td {
display: block !important;
border: none !important;
padding: 0.5rem 1rem !important;
width: 100% !important;
white-space: normal !important;
height: auto !important;
// 1. Selector/Checkbox (Always the first column usually)
&:first-child {
position: absolute !important;
top: 10px !important;
right: 10px !important;
width: auto !important;
padding: 0 !important;
z-index: 10 !important;
background: transparent !important;
.checkbox {
background: rgba(255, 255, 255, 0.8);
border-radius: 4px;
}
}
// 2. Thumbnail (First content column)
&:nth-child(2) {
padding: 0 !important;
height: 200px !important;
width: 100% !important;
overflow: hidden !important;
background: var(--theme-elevation-100) !important;
order: -1 !important; // Force to top
flex-grow: 0 !important;
position: relative;
img {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
display: block !important;
background: var(--theme-elevation-100);
}
.no-image {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: var(--theme-elevation-400);
font-size: 0.8rem;
background: transparent;
}
}
// 3. Title (Second content column)
&:nth-child(3) {
font-size: 1.1rem !important;
font-weight: 600 !important;
padding-top: 1rem !important;
margin-bottom: 0.5rem !important;
line-height: 1.4 !important;
flex-grow: 1 !important; // Push footer down
a {
text-decoration: none !important;
color: var(--theme-text) !important;
&:hover {
color: var(--theme-primary-500) !important;
}
}
}
// 4. Medusa ID or Status or Date
&:nth-child(n+4) {
font-size: 0.8rem !important;
color: var(--theme-elevation-500) !important;
padding-bottom: 0.25rem !important;
padding-top: 0 !important;
border-top: none !important;
}
}
}
}
}

View File

@ -0,0 +1,258 @@
'use client'
import { useState } from 'react'
import { Button } from '@payloadcms/ui'
import { AVAILABLE_SEEDS, type SeedKey } from './data/20260221-product-recommendations-seeds'
interface Props {
className?: string
}
/**
* Restore Recommendations Seed Button
* Quick restore predefined product recommendation configurations
*/
export function RestoreRecommendationsSeedButton({ className }: Props) {
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [showSeedSelector, setShowSeedSelector] = useState(false)
/**
* Find products by seed IDs
* Returns polymorphic relationship format: { relationTo, value }
*/
const findProductsBySeedIds = async (
seedIds: string[],
isPreorder: boolean = false,
): Promise<Array<{ relationTo: string; value: string }>> => {
const products: Array<{ relationTo: string; value: string }> = []
const primaryCollection = isPreorder ? 'preorder-products' : 'products'
const fallbackCollection = isPreorder ? 'products' : 'preorder-products'
for (const seedId of seedIds) {
try {
// Try primary collection first based on preorder flag
const primaryResponse = await fetch(
`/api/${primaryCollection}?where[seedId][equals]=${seedId}&limit=1`,
)
const primaryData = await primaryResponse.json()
if (primaryData.docs && primaryData.docs.length > 0) {
products.push({
relationTo: primaryCollection,
value: primaryData.docs[0].id,
})
continue
}
// Try fallback collection if not found
const fallbackResponse = await fetch(
`/api/${fallbackCollection}?where[seedId][equals]=${seedId}&limit=1`,
)
const fallbackData = await fallbackResponse.json()
if (fallbackData.docs && fallbackData.docs.length > 0) {
products.push({
relationTo: fallbackCollection,
value: fallbackData.docs[0].id,
})
} else {
console.warn(`Product not found for seedId: ${seedId}`)
}
} catch (error) {
console.error(`Error finding product ${seedId}:`, error)
}
}
return products
}
/**
* Restore recommendation list from seed
*/
const handleRestoreSeed = async (seedKey: SeedKey) => {
const seed = AVAILABLE_SEEDS[seedKey]
if (!confirm(
`Restore recommendation list configuration?\n\n` +
`Will create:\n${seed.lists.map(list => `${list.title} (${list.productSeedIds.length} products)`).join('\n')}\n\n` +
`Current configuration will be overwritten!`
)) {
return
}
setLoading(true)
setMessage('🔄 Finding products...')
try {
// Find all product IDs in polymorphic relationship format
const listsWithProductIds = await Promise.all(
seed.lists.map(async (list) => {
const products = await findProductsBySeedIds(list.productSeedIds, list.preorder || false)
return {
title: list.title,
subtitle: list.subtitle || '',
preorder: list.preorder || false,
products: products,
}
}),
)
// Filter out lists with no products found
const validLists = listsWithProductIds.filter((list) => list.products.length > 0)
if (validLists.length === 0) {
setMessage('❌ No matching products found. Please run seed script first.')
setLoading(false)
return
}
setMessage('💾 Updating configuration...')
// Update product-recommendations global via server-side API
const updateResponse = await fetch('/api/admin/restore-recommendations-seed', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: seed.enabled,
lists: validLists,
}),
})
const result = await updateResponse.json()
if (!result.success) {
throw new Error(result.error || 'Update failed')
}
setMessage(`✅ Successfully restored ${validLists.length} recommendation list(s)!`)
setShowSeedSelector(false)
// Refresh page after 2 seconds
setTimeout(() => {
window.location.reload()
}, 2000)
} catch (error) {
console.error('Failed to restore seed configuration:', error)
setMessage('❌ Restore failed: ' + (error instanceof Error ? error.message : 'Unknown error'))
} finally {
setLoading(false)
}
}
return (
<div className={className}>
<div style={{ marginBottom: '1rem' }}>
<Button
onClick={() => setShowSeedSelector(!showSeedSelector)}
buttonStyle="pill"
size="small"
disabled={loading}
>
🌱 {showSeedSelector ? 'Hide' : 'Restore from Seed'}
</Button>
</div>
{showSeedSelector && (
<div
style={{
padding: '1.5rem',
backgroundColor: 'var(--theme-elevation-50)',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-150)',
marginBottom: '1rem',
}}
>
<h4 style={{ marginTop: 0, marginBottom: '1rem', fontSize: '0.9rem', fontWeight: 600 }}>
📦 Available Seed Configurations
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{Object.entries(AVAILABLE_SEEDS).map(([key, seed]) => (
<div
key={key}
style={{
padding: '1rem',
backgroundColor: 'var(--theme-elevation-0)',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-100)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '0.5rem',
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, marginBottom: '0.25rem' }}>
{key === 'batch02' ? 'Batch 02 Recommendations' : key}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--theme-elevation-500)' }}>
{seed.lists.length} list(s), {' '}
{seed.lists.reduce((sum, list) => sum + list.productSeedIds.length, 0)} product(s) total
</div>
</div>
<Button
onClick={() => handleRestoreSeed(key as SeedKey)}
buttonStyle="primary"
size="small"
disabled={loading}
>
Restore
</Button>
</div>
<div style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid var(--theme-elevation-100)' }}>
{seed.lists.map((list, index) => (
<div
key={index}
style={{
fontSize: '0.85rem',
marginBottom: '0.5rem',
paddingLeft: '1rem',
}}
>
<div style={{ fontWeight: 500, color: 'var(--theme-elevation-700)' }}>
{list.title}
</div>
<div style={{ color: 'var(--theme-elevation-500)', fontSize: '0.8rem' }}>
{list.subtitle || `${list.productSeedIds.length} product(s)`}
</div>
</div>
))}
</div>
</div>
))}
</div>
{message && (
<div
style={{
marginTop: '1rem',
padding: '0.75rem',
backgroundColor: message.includes('❌')
? 'var(--theme-error-100)'
: message.includes('✅')
? 'var(--theme-success-100)'
: 'var(--theme-info-100)',
color: message.includes('❌')
? 'var(--theme-error-900)'
: message.includes('✅')
? 'var(--theme-success-900)'
: 'var(--theme-info-900)',
borderRadius: '4px',
fontSize: '0.9rem',
}}
>
{message}
</div>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,150 @@
'use client'
import React, { useState } from 'react'
import { Button, useConfig } from '@payloadcms/ui'
const DEFAULT_PAGES = [
{ name: 'Game Boy Color (GBC)', url: '/disassembly/gbc' },
{ name: 'Game Boy Advance (GBA)', url: '/disassembly/gba' },
{ name: 'Game Boy Advance SP (GBA SP)', url: '/disassembly/gba-sp' },
] as const
/** 每个拆解页默认创建的五层区域 */
const DEFAULT_AREA_NAMES = ['外壳', '按键', '屏幕', 'PCB与原件', '背面原件与电池扩展'] as const
/**
* DisassemblyPages
* GBC / GBA / GBA SP 5
* DisassemblyPages.ts admin.components.beforeListTable
*/
export function SeedDisassemblyButton() {
const { config } = useConfig()
const apiBase: string = (config as any)?.routes?.api ?? '/api'
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<string>('')
const handleSeed = async () => {
if (
!window.confirm(
`将创建以下 ${DEFAULT_PAGES.length} 条拆解页默认记录,每页含 ${DEFAULT_AREA_NAMES.length} 个区域:\n\n` +
DEFAULT_PAGES.map((p) => `${p.name}`).join('\n') +
`\n\n区域${DEFAULT_AREA_NAMES.join(' / ')}\n\n已存在的同名记录不会被重复创建。继续`,
)
)
return
setLoading(true)
setResult('')
let pagesCreated = 0
let pagesSkipped = 0
let areasCreated = 0
const errors: string[] = []
for (const page of DEFAULT_PAGES) {
try {
// ── 检查页面是否已存在 ──
const check = await fetch(
`${apiBase}/disassembly-pages?where[name][equals]=${encodeURIComponent(page.name)}&limit=1`,
{ credentials: 'include' },
)
const checkData = await check.json()
if (checkData?.totalDocs > 0) {
pagesSkipped++
continue
}
// ── 创建页面(先不带 areas──
const pageRes = await fetch(`${apiBase}/disassembly-pages`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: page.name, url: page.url }),
})
if (!pageRes.ok) {
const err = await pageRes.json()
errors.push(`${page.name}: ${err?.errors?.[0]?.message ?? pageRes.status}`)
continue
}
const pageData = await pageRes.json()
const pageId: string = pageData?.doc?.id ?? pageData?.id
if (!pageId) { errors.push(`${page.name}: 无法获取页面 ID`); continue }
pagesCreated++
// ── 为该页面创建 5 个默认区域 ──
const areaIds: string[] = []
for (const areaName of DEFAULT_AREA_NAMES) {
try {
const areaRes = await fetch(`${apiBase}/disassembly-areas`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: areaName, page: pageId }),
})
if (areaRes.ok) {
const areaData = await areaRes.json()
const areaId: string = areaData?.doc?.id ?? areaData?.id
if (areaId) { areaIds.push(areaId); areasCreated++ }
}
} catch (_) {
errors.push(`${page.name} > ${areaName}: 区域创建失败`)
}
}
// ── 将 areaIds 写回页面 ──
if (areaIds.length > 0) {
await fetch(`${apiBase}/disassembly-pages/${pageId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ areas: areaIds }),
})
}
} catch (_) {
errors.push(`${page.name}: 网络错误`)
}
}
setLoading(false)
const parts: string[] = []
if (pagesCreated) parts.push(`创建 ${pagesCreated} 页(共 ${areasCreated} 区域)`)
if (pagesSkipped) parts.push(`${pagesSkipped} 页已存在跳过`)
if (errors.length) parts.push(`${errors.length} 项失败`)
setResult(parts.join(' · ') + (errors.length ? `\n${errors.join('\n')}` : ''))
if (pagesCreated > 0) setTimeout(() => window.location.reload(), 800)
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--spacing-3)',
padding: 'var(--spacing-3) 0',
}}
>
<Button
buttonStyle="secondary"
size="small"
disabled={loading}
onClick={handleSeed}
>
{loading ? '创建中…' : '⊕ 初始化默认数据 (GBC / GBA / GBA SP各含 5 个区域)'}
</Button>
{result && (
<span
style={{
fontSize: 'var(--font-body-s)',
color: 'var(--theme-elevation-500)',
}}
>
{result}
</span>
)}
</div>
)
}

View File

@ -0,0 +1,132 @@
'use client'
import React, { useState } from 'react'
import { Button, useConfig } from '@payloadcms/ui'
// ─── seed data ────────────────────────────────────────────────────────────────
const SEED_PRECAUTIONS = [
{
title: 'No Returns',
summary:
'This is a customized product. Due to its personalized nature, we are unable to accept returns once an order has been placed.',
order: 1,
},
{
title: 'Refund Policy — Stripe Processing Fee',
summary:
'Refunds are fully supported. Please note that a Stripe payment processing fee of approximately 5% will be deducted from the refund amount, as this fee is non-recoverable once charged.',
order: 2,
},
{
title: 'Preorder Guarantee',
summary:
'Even if the preorder campaign does not reach its funding goal, you will still receive the corresponding product. Your order will not go unfulfilled.',
order: 3,
},
{
title: 'Exclusive Backer Rewards',
summary:
'Preorder backers will receive exclusive additional rewards as a thank-you for supporting the project early. These bonus items are only available to campaign backers.',
order: 4,
},
]
// ─── component ────────────────────────────────────────────────────────────────
/**
* Displayed before the Precautions list table.
* Creates the 4 standard English precaution records if they do not already exist.
*/
export function SeedPrecautionsButton() {
const { config } = useConfig()
const apiBase: string = (config as any)?.routes?.api ?? '/api'
const [loading, setLoading] = useState(false)
const [result, setResult] = useState('')
const handleSeed = async () => {
if (
!window.confirm(
`Create ${SEED_PRECAUTIONS.length} standard precaution records?\n\n` +
SEED_PRECAUTIONS.map((p) => `${p.title}`).join('\n') +
'\n\nExisting records with identical titles will be skipped.',
)
)
return
setLoading(true)
setResult('')
let created = 0
let skipped = 0
const errors: string[] = []
for (const precaution of SEED_PRECAUTIONS) {
try {
// Check for existing record with the same title
const check = await fetch(
`${apiBase}/precautions?where[title][equals]=${encodeURIComponent(precaution.title)}&limit=1`,
{ credentials: 'include' },
)
const checkData = await check.json()
if (checkData?.totalDocs > 0) {
skipped++
continue
}
const res = await fetch(`${apiBase}/precautions`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(precaution),
})
if (res.ok) {
created++
} else {
const err = await res.json().catch(() => ({}))
errors.push(`${precaution.title}: ${err?.errors?.[0]?.message ?? res.status}`)
}
} catch (e: any) {
errors.push(`${precaution.title}: ${e?.message ?? 'Network error'}`)
}
}
setLoading(false)
const parts: string[] = []
if (created) parts.push(`✓ Created ${created}`)
if (skipped) parts.push(`${skipped} already existed`)
if (errors.length) parts.push(`${errors.length} failed`)
setResult(parts.join(' · ') + (errors.length ? `\n${errors.join('\n')}` : ''))
if (created > 0) setTimeout(() => window.location.reload(), 800)
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--spacing-3)',
padding: 'var(--spacing-3) 0',
}}
>
<Button buttonStyle="secondary" size="small" disabled={loading} onClick={handleSeed}>
{loading ? 'Creating…' : '⊕ Seed Standard Precautions (EN)'}
</Button>
{result && (
<span
style={{
fontSize: 'var(--font-body-s)',
color: 'var(--theme-elevation-500)',
whiteSpace: 'pre-line',
}}
>
{result}
</span>
)}
</div>
)
}

View File

@ -0,0 +1,150 @@
'use client'
import React, { useState } from 'react'
import { Button, useConfig, useDocumentInfo } from '@payloadcms/ui'
// ─── helpers ──────────────────────────────────────────────────────────────────
/** Wrap plain text in minimal Lexical JSON so richText fields accept it */
function lexicalParagraph(text: string) {
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [{ type: 'text', format: 0, text, version: 1 }],
},
],
},
}
}
// ─── sample data ──────────────────────────────────────────────────────────────
const SAMPLE_STATUSES = [
{
title: 'In Development',
badge: 'Dev',
description: lexicalParagraph(
'Hardware prototyping and firmware development are actively in progress.',
),
order: 1,
},
{
title: 'Crowdfunding Live',
badge: 'Live',
description: lexicalParagraph(
'The crowdfunding campaign is now live and open to backers.',
),
order: 2,
},
{
title: 'Mass Production',
badge: 'Producing',
description: lexicalParagraph(
'Funding goal reached. Manufacturing has begun and is on schedule.',
),
order: 3,
},
{
title: 'Shipping',
badge: 'Shipping',
description: lexicalParagraph(
'Orders are being fulfilled and packages are on their way to backers.',
),
order: 4,
},
]
// ─── component ────────────────────────────────────────────────────────────────
/**
* Displayed as a UI field above the projectStatuses array in the product form.
* Appends sample statuses to whatever already exists, then reloads the page.
*/
export function SeedProjectStatusesButton() {
const { id: docId, collectionSlug } = useDocumentInfo()
const { config } = useConfig()
const apiBase: string = (config as any)?.routes?.api ?? '/api'
const [loading, setLoading] = useState(false)
const [result, setResult] = useState('')
const handleSeed = async () => {
if (!docId) {
setResult('Save the document first before adding sample data.')
return
}
if (
!window.confirm(
`Append ${SAMPLE_STATUSES.length} sample project statuses to this product?\n\n` +
SAMPLE_STATUSES.map((s) => `${s.title} [${s.badge}]`).join('\n'),
)
)
return
setLoading(true)
setResult('')
try {
// Fetch current doc to get existing statuses so we don't overwrite them
const getRes = await fetch(`${apiBase}/${collectionSlug}/${docId}?depth=0`, {
credentials: 'include',
})
const doc = await getRes.json()
const existing: any[] = doc.projectStatuses ?? []
const baseOrder = existing.length
const merged = [
...existing,
...SAMPLE_STATUSES.map((s, i) => ({ ...s, order: baseOrder + i + 1 })),
]
const patchRes = await fetch(`${apiBase}/${collectionSlug}/${docId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectStatuses: merged }),
})
if (patchRes.ok) {
setResult(`✓ Added ${SAMPLE_STATUSES.length} sample statuses`)
setTimeout(() => window.location.reload(), 700)
} else {
const err = await patchRes.json().catch(() => ({}))
setResult(`${err?.errors?.[0]?.message ?? patchRes.status}`)
}
} catch (e: any) {
setResult(`${e?.message ?? 'Network error'}`)
}
setLoading(false)
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--spacing-3)',
padding: 'var(--spacing-2) 0 var(--spacing-3)',
}}
>
<Button buttonStyle="secondary" size="small" disabled={loading} onClick={handleSeed}>
{loading ? 'Adding…' : '⊕ Insert Sample Statuses'}
</Button>
{result && (
<span style={{ fontSize: 'var(--font-body-s)', color: 'var(--theme-elevation-500)' }}>
{result}
</span>
)}
</div>
)
}

View File

@ -0,0 +1,58 @@
/**
* Product Recommendations Seed Data
* Predefined recommendation lists for quick restoration
* Date: 2026-02-21
*/
export interface RecommendationListSeed {
title: string
subtitle?: string
preorder?: boolean
productSeedIds: string[]
}
export interface RecommendationsSeed {
enabled: boolean
lists: RecommendationListSeed[]
}
/**
* Batch 02 Recommendations
* - PreGame: Preorder games collection (4 games)
* - PreMod: Preorder modification collection (2 metal shells + 1 custom console)
*/
export const BATCH_02_RECOMMENDATIONS: RecommendationsSeed = {
enabled: true,
lists: [
{
title: 'PreGame - Preorder Games',
subtitle: 'Selected indie games, now available for preorder! Support indie developers and get early access to new releases.',
preorder: true,
productSeedIds: [
'game-urcicus', // Urcicus - GBA Game
'game-mikoto-nikki', // Mikoto Nikki - GBA Game
'game-passaway', // Passaway - GB Game
'game-judys-adventure-dx', // Judys Adventure DX - GBA Game
],
},
{
title: 'PreMod - Preorder Modifications',
subtitle: 'Premium custom shells and consoles, full metal construction, artisan craftsmanship. Limited preorder available!',
preorder: true,
productSeedIds: [
'shell-gba-sp-metal-unhinged', // Metal Shell - GBA SP (Unhinged Mod)
'shell-gba-metal', // Metal Shell - GBA
'console-retro-tetra', // Retro Tetra Console
],
},
],
}
/**
* All available seed configurations
*/
export const AVAILABLE_SEEDS = {
batch02: BATCH_02_RECOMMENDATIONS,
}
export type SeedKey = keyof typeof AVAILABLE_SEEDS

View File

@ -0,0 +1,3 @@
export { RestoreRecommendationsSeedButton } from './RestoreRecommendationsSeedButton'
export { AVAILABLE_SEEDS, BATCH_02_RECOMMENDATIONS } from './data/20260221-product-recommendations-seeds'
export type { RecommendationListSeed, RecommendationsSeed, SeedKey } from './data/20260221-product-recommendations-seeds'

View File

@ -0,0 +1,481 @@
'use client'
import React, { useEffect, useState } from 'react'
import { Button, Modal, useSelection } from '@payloadcms/ui'
import { useRouter } from 'next/navigation'
// ── Types ─────────────────────────────────────────────────────────────────────
interface HealthCheckResult {
success: boolean
timestamp: string
summary: { total: number; healthy: number; warnings: number; errors: number }
products: Array<{
id: string
title: string
medusaId: string
seedId: string
status: string
severity: 'healthy' | 'warning' | 'error'
issues: string[]
stats: {
orderCount: number
fakeOrderCount: number
totalDisplayCount: number
fundingGoal: number
completionPercentage: number
}
dates: { preorderStartDate: string | null; preorderEndDate: string | null }
}>
issues: string[]
}
// ── Shared helpers ────────────────────────────────────────────────────────────
function Msg({ text }: { text: string }) {
if (!text) return null
const isErr = text.startsWith('❌')
const isWarn = text.startsWith('⚠️')
return (
<p
style={{
margin: '0.25rem 0 0',
padding: '0.2rem 0.5rem',
borderRadius: '4px',
fontSize: '0.78rem',
lineHeight: 1.4,
background: isErr
? 'var(--theme-error-50)'
: isWarn
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
color: isErr
? 'var(--theme-error-750)'
: isWarn
? 'var(--theme-warning-750)'
: 'var(--theme-success-750)',
}}
>
{text}
</p>
)
}
function Divider() {
return <div style={{ borderTop: '1px solid var(--theme-elevation-100)' }} />
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<p style={{
margin: '0 0 0.25rem',
fontSize: '0.72rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--theme-elevation-500)',
}}>
{children}
</p>
)
}
// ── Section: Medusa sync ──────────────────────────────────────────────────────
function MedusaSyncSection({ collection }: { collection: string }) {
const { getQueryParams, toggleAll } = useSelection()
const router = useRouter()
const [loadingNew, setLoadingNew] = useState(false)
const [loadingBatch, setLoadingBatch] = useState(false)
const [loadingForceBatch, setLoadingForceBatch] = useState(false)
const [showForceAll, setShowForceAll] = useState(false)
const [loadingForceAll, setLoadingForceAll] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [msg, setMsg] = useState('')
const busy = loadingNew || loadingBatch || loadingForceBatch || loadingForceAll
const syncNew = async () => {
setLoadingNew(true); setMsg('')
try {
const res = await fetch('/api/sync/medusa?forceUpdate=false')
const data = await res.json()
setMsg(data.success
? `${data.message || '同步成功'}`
: `${data.error || data.message || '同步失败'}`)
if (data.success) setTimeout(() => window.location.reload(), 1500)
} catch (e: any) { setMsg(`${e?.message ?? '未知错误'}`) }
finally { setLoadingNew(false) }
}
const batchSync = async (force: boolean) => {
const queryParams = getQueryParams()
let ids: string[] = []
if (queryParams && typeof queryParams === 'object') {
const where = (queryParams as any).where
if (where?.id?.in) ids = where.id.in
}
if (!ids.length) {
setMsg('⚠️ 请先勾选要同步的商品(列表左侧复选框)')
return
}
if (force && !confirm(`确定强制更新选中的 ${ids.length} 个商品?这将覆盖本地修改。`)) return
const setL = force ? setLoadingForceBatch : setLoadingBatch
setL(true); setMsg('')
try {
const res = await fetch('/api/admin/batch-sync-medusa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, collection, forceUpdate: force }),
})
const data = await res.json()
setMsg(data.success
? `${data.message || '批量同步成功'}`
: `${data.error || '失败'}`)
if (data.success) { toggleAll?.(); setTimeout(() => router.refresh(), 1500) }
} catch (e: any) { setMsg(`${e?.message ?? '未知错误'}`) }
finally { setL(false) }
}
const forceAll = async () => {
if (confirmText !== 'FORCE_UPDATE_ALL') {
setMsg('❌ 请输入: FORCE_UPDATE_ALL')
return
}
setLoadingForceAll(true); setMsg(''); setShowForceAll(false)
try {
const res = await fetch('/api/sync/medusa?forceUpdate=true')
const data = await res.json()
setMsg(data.success
? `${data.message || '强制更新成功'}`
: `${data.error || data.message || '失败'}`)
if (data.success) setTimeout(() => window.location.reload(), 1500)
} catch (e: any) { setMsg(`${e?.message ?? '未知错误'}`) }
finally { setLoadingForceAll(false); setConfirmText('') }
}
return (
<div>
<SectionLabel>🔄 Medusa </SectionLabel>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Button onClick={syncNew} disabled={busy} buttonStyle="primary" size="small">
{loadingNew ? '同步中…' : '📥 同步新商品'}
</Button>
<Button onClick={() => batchSync(false)} disabled={busy} buttonStyle="secondary" size="small">
{loadingBatch ? '同步中…' : '🔄 同步选中'}
</Button>
<Button onClick={() => batchSync(true)} disabled={busy} buttonStyle="secondary" size="small">
{loadingForceBatch ? '更新中…' : '⚡ 强制更新选中'}
</Button>
{!showForceAll ? (
<Button
onClick={() => { setShowForceAll(true); setMsg(''); setConfirmText('') }}
disabled={busy}
buttonStyle="secondary"
size="small"
>
🔥
</Button>
) : (
<span style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="输入 FORCE_UPDATE_ALL 确认"
disabled={loadingForceAll}
style={{
padding: '0.3rem 0.5rem',
border: '1px solid var(--theme-elevation-400)',
borderRadius: '4px',
fontSize: '0.78rem',
width: '200px',
}}
/>
<Button
onClick={forceAll}
disabled={loadingForceAll || confirmText !== 'FORCE_UPDATE_ALL'}
size="small"
>
{loadingForceAll ? '更新中…' : '确认'}
</Button>
<Button
onClick={() => { setShowForceAll(false); setConfirmText('') }}
disabled={loadingForceAll}
buttonStyle="secondary"
size="small"
>
</Button>
</span>
)}
</div>
<Msg text={msg} />
</div>
)
}
// ── Section: Taobao sync ──────────────────────────────────────────────────────
function TaobaoSyncSection() {
const [loadingNormal, setLoadingNormal] = useState(false)
const [loadingForce, setLoadingForce] = useState(false)
const [confirmForce, setConfirmForce] = useState(false)
const [msg, setMsg] = useState('')
const busy = loadingNormal || loadingForce
const run = async (force: boolean) => {
const setL = force ? setLoadingForce : setLoadingNormal
setL(true); setMsg(''); setConfirmForce(false)
try {
const res = await fetch('/api/admin/taobao/sync-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force }),
})
const data = await res.json()
if (!data.success) throw new Error(data.error || '请求失败')
setMsg(`${data.message}`)
} catch (e: any) { setMsg(`${e?.message ?? '未知错误'}`) }
finally { setL(false) }
}
return (
<div>
<SectionLabel>🛍 </SectionLabel>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Button onClick={() => run(false)} disabled={busy} buttonStyle="secondary" size="small">
{loadingNormal ? '更新中…' : '🔄 更新全部淘宝'}
</Button>
{!confirmForce ? (
<Button onClick={() => setConfirmForce(true)} disabled={busy} buttonStyle="secondary" size="small">
</Button>
) : (
<span style={{ display: 'inline-flex', gap: '0.4rem', alignItems: 'center' }}>
<span style={{ fontSize: '0.78rem', color: 'var(--theme-error-750)', fontWeight: 600 }}>
</span>
<Button onClick={() => run(true)} disabled={busy} size="small">
{loadingForce ? '更新中…' : '确认'}
</Button>
<Button onClick={() => setConfirmForce(false)} disabled={busy} buttonStyle="secondary" size="small">
</Button>
</span>
)}
</div>
<Msg text={msg} />
</div>
)
}
// ── Section: Preorder management ──────────────────────────────────────────────
function PreorderSection() {
const { getQueryParams, toggleAll } = useSelection()
const router = useRouter()
const [hcLoading, setHcLoading] = useState(false)
const [hcError, setHcError] = useState<string | null>(null)
const [hcResult, setHcResult] = useState<HealthCheckResult | null>(null)
const [hcOpen, setHcOpen] = useState(false)
const [rcLoading, setRcLoading] = useState(false)
const [rcMsg, setRcMsg] = useState('')
const runHealthCheck = async () => {
setHcLoading(true); setHcError(null)
try {
const res = await fetch('/api/preorders/health-check')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setHcResult(data); setHcOpen(true)
} catch (e: any) { setHcError(e.message || '健康检查失败') }
finally { setHcLoading(false) }
}
const refreshSelected = async () => {
const queryParams = getQueryParams()
let ids: string[] = []
if (queryParams && typeof queryParams === 'object') {
const where = (queryParams as any).where
if (where?.id?.in) ids = where.id.in
}
if (!ids.length) {
setRcMsg('⚠️ 请先勾选要刷新的商品(列表左侧复选框)')
return
}
setRcLoading(true); setRcMsg('')
try {
const res = await fetch('/api/preorders/refresh-order-counts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productIds: ids }),
})
const data = await res.json()
setRcMsg(data.success
? `${data.message || '刷新成功'}`
: `${data.error || '失败'}`)
if (data.success) { toggleAll?.(); setTimeout(() => router.refresh(), 1500) }
} catch (e: any) { setRcMsg(`${e?.message ?? '未知错误'}`) }
finally { setRcLoading(false) }
}
const refreshAll = async () => {
if (!confirm('确定要刷新所有预购商品的订单计数吗?')) return
setRcLoading(true); setRcMsg('')
try {
const res = await fetch('/api/preorders/refresh-order-counts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshAll: true }),
})
const data = await res.json()
setRcMsg(data.success
? `${data.message || '刷新成功'}`
: `${data.error || '失败'}`)
if (data.success) setTimeout(() => router.refresh(), 1500)
} catch (e: any) { setRcMsg(`${e?.message ?? '未知错误'}`) }
finally { setRcLoading(false) }
}
const severityIcon = (s: string) =>
({ error: '❌', warning: '⚠️', healthy: '✅' } as Record<string, string>)[s] ?? ''
const fmtDate = (d: string | null) => {
if (!d) return 'N/A'
try {
return new Date(d).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
} catch { return d }
}
return (
<div>
<SectionLabel>📦 </SectionLabel>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Button onClick={runHealthCheck} disabled={hcLoading} buttonStyle="secondary" size="small">
{hcLoading ? '检查中…' : '🏥 健康检查'}
</Button>
<Button onClick={refreshSelected} disabled={rcLoading} buttonStyle="secondary" size="small">
{rcLoading ? '刷新中…' : '📊 刷新选中计数'}
</Button>
<Button onClick={refreshAll} disabled={rcLoading} buttonStyle="secondary" size="small">
{rcLoading ? '刷新中…' : '📊 刷新全部计数'}
</Button>
</div>
{hcError && <Msg text={`${hcError}`} />}
<Msg text={rcMsg} />
{hcOpen && hcResult && (
<Modal slug="preorder-health-check-modal" onClose={() => setHcOpen(false)}>
<div style={{ padding: '2rem', maxWidth: '900px' }}>
<h2 style={{ marginBottom: '1.5rem', fontSize: '1.25rem', fontWeight: 700 }}>
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: '0.75rem', marginBottom: '1.25rem' }}>
{([
{ label: '总数', value: hcResult.summary.total, bg: '#EFF6FF', border: '#BFDBFE', color: '#1E40AF' },
{ label: '健康', value: hcResult.summary.healthy, bg: '#F0FDF4', border: '#BBF7D0', color: '#15803D' },
{ label: '警告', value: hcResult.summary.warnings, bg: '#FEFCE8', border: '#FDE047', color: '#A16207' },
{ label: '错误', value: hcResult.summary.errors, bg: '#FEF2F2', border: '#FECACA', color: '#B91C1C' },
] as const).map(({ label, value, bg, border, color }) => (
<div key={label} style={{ padding: '0.75rem', background: bg, border: `1px solid ${border}`, borderRadius: '6px' }}>
<p style={{ margin: '0 0 0.2rem', fontSize: '0.75rem', color, fontWeight: 500 }}>{label}</p>
<p style={{ margin: 0, fontSize: '1.75rem', fontWeight: 700, color }}>{value}</p>
</div>
))}
</div>
<p style={{ fontSize: '0.8rem', color: '#6B7280', marginBottom: '1rem' }}>
: {new Date(hcResult.timestamp).toLocaleString('zh-CN')}
</p>
<div style={{ maxHeight: '480px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{hcResult.products.map((p) => {
const borderColor = p.severity === 'error' ? '#FCA5A5' : p.severity === 'warning' ? '#FCD34D' : '#86EFAC'
const bgColor = p.severity === 'error' ? '#FEF2F2' : p.severity === 'warning' ? '#FEFCE8' : '#F0FDF4'
return (
<div key={p.id} style={{ border: `1px solid ${borderColor}`, background: bgColor, borderRadius: '6px', padding: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.25rem' }}>
<span>{severityIcon(p.severity)}</span>
<strong style={{ fontSize: '0.9rem' }}>{p.title}</strong>
<span style={{
padding: '0.1rem 0.45rem', fontSize: '0.7rem', borderRadius: '999px',
background: p.status === 'published' ? '#D1FAE5' : '#F3F4F6',
color: p.status === 'published' ? '#065F46' : '#374151',
}}>{p.status}</span>
</div>
<p style={{ margin: 0, fontSize: '0.78rem', color: '#4B5563' }}>Medusa ID: {p.medusaId}</p>
</div>
<div style={{ textAlign: 'right', fontSize: '0.78rem', color: '#4B5563', flexShrink: 0 }}>
<p style={{ margin: 0 }}> {p.stats.completionPercentage}%</p>
<p style={{ margin: 0 }}>{p.stats.totalDisplayCount} / {p.stats.fundingGoal}</p>
</div>
</div>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.78rem', color: '#4B5563', marginTop: '0.4rem' }}>
<span><strong>:</strong> {fmtDate(p.dates.preorderStartDate)}</span>
<span><strong>:</strong> {fmtDate(p.dates.preorderEndDate)}</span>
</div>
{p.issues.length > 0 && (
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem', fontSize: '0.78rem' }}>
{p.issues.map((issue, i) => <li key={i}>{issue}</li>)}
</ul>
)}
</div>
)
})}
{hcResult.products.length === 0 && (
<p style={{ textAlign: 'center', padding: '2rem', color: '#6B7280' }}></p>
)}
</div>
<div style={{ marginTop: '1.25rem', textAlign: 'right' }}>
<Button onClick={() => setHcOpen(false)} buttonStyle="primary"></Button>
</div>
</div>
</Modal>
)}
</div>
)
}
// ── Root export ───────────────────────────────────────────────────────────────
/**
* beforeListTable
* Medusa / / preorder-products
*/
export function UnifiedSyncButton() {
const [isPreorder, setIsPreorder] = useState(false)
const [collection, setCollection] = useState('products')
useEffect(() => {
if (typeof window !== 'undefined') {
const isP = window.location.pathname.includes('preorder-products')
setIsPreorder(isP)
setCollection(isP ? 'preorder-products' : 'products')
}
}, [])
return (
<div style={{
margin: '0.25rem 0 0.5rem',
padding: '0.5rem 0.75rem',
background: 'var(--theme-elevation-50)',
border: '1px solid var(--theme-elevation-150)',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}>
<MedusaSyncSection collection={collection} />
<Divider />
<TaobaoSyncSection />
{isPreorder && (
<>
<Divider />
<PreorderSection />
</>
)}
</div>
)
}

View File

@ -0,0 +1,116 @@
'use client'
import { useState } from 'react'
import { Button } from '@payloadcms/ui'
/**
* / Medusa
* API: POST /api/admin/reset-data
*/
export function ResetData() {
const [loading, setLoading] = useState<'full' | 'medusa-only' | null>(null)
const [message, setMessage] = useState('')
const [details, setDetails] = useState<any>(null)
const handle = async (mode: 'full' | 'medusa-only') => {
const confirmMsg =
mode === 'medusa-only'
? '⚠️ 重置 Medusa 数据\n\n此操作将\n1. 清理所有 Medusa 数据\n2. 重新导入 Medusa seed 数据\n\nPayload CMS 数据不受影响。\n\n⚠ 此操作不可撤销!确认继续吗?'
: '⚠️ 危险操作:重置所有数据\n\n此操作将\n1. 清理所有 Payload CMS 数据(保留用户)\n2. 清理所有 Medusa 数据\n3. 重新导入 Medusa seed 数据\n\n⚠ 此操作不可撤销!确认要继续吗?'
if (!confirm(confirmMsg)) return
setLoading(mode)
setMessage('🔄 开始数据重置流程...')
setDetails(null)
try {
const res = await fetch('/api/admin/reset-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
})
const result = await res.json()
if (!result.success) {
const stepError = result.steps?.find((s: any) => !s.success && !s.skipped && s.error)?.error
throw new Error(result.error || stepError || 'Reset failed')
}
setDetails(result)
setMessage(
mode === 'medusa-only'
? '✅ Medusa 数据重置完成!\n\n下一步\n1. 同步 Medusa 商品到 Payload CMS'
: '✅ 数据重置完成!\n\n下一步\n1. 同步 Medusa 商品到 Payload CMS\n2. 设置 ProductRecommendations\n3. 配置 PreorderProducts 的预购设置',
)
} catch (err) {
setMessage('❌ 重置失败: ' + (err instanceof Error ? err.message : 'Unknown error'))
} finally {
setLoading(null)
}
}
const busy = loading !== null
return (
<div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Button onClick={() => handle('full')} buttonStyle="error" disabled={busy} size="small">
{loading === 'full' ? '🔄 重置中...' : '🗑️ 重置所有数据'}
</Button>
<Button
onClick={() => handle('medusa-only')}
buttonStyle="secondary"
disabled={busy}
size="small"
>
{loading === 'medusa-only' ? '🔄 重置中...' : '🔄 仅重置 Medusa'}
</Button>
</div>
{message && (
<div
style={{
marginTop: '0.75rem',
padding: '0.75rem',
borderRadius: '4px',
whiteSpace: 'pre-wrap',
fontSize: '0.85rem',
backgroundColor: message.includes('✅')
? '#d4edda'
: message.includes('❌')
? '#f8d7da'
: '#d1ecf1',
border: `1px solid ${message.includes('✅') ? '#c3e6cb' : message.includes('❌') ? '#f5c6cb' : '#bee5eb'}`,
}}
>
{message}
</div>
)}
{details?.steps && (
<div
style={{
marginTop: '0.75rem',
padding: '0.75rem',
background: '#f8f9fa',
border: '1px solid #dee2e6',
borderRadius: '4px',
fontSize: '0.82rem',
}}
>
<h4 style={{ margin: '0 0 0.5rem' }}></h4>
{details.steps.map((step: any, i: number) => (
<div key={i} style={{ marginBottom: '0.4rem' }}>
<strong>
[{step.step}/3] {step.name}:{' '}
</strong>
<span style={{ color: step.skipped ? '#888' : step.success ? 'green' : 'red' }}>
{step.skipped ? '⏭️ 跳过' : step.success ? '✅ 成功' : '❌ 失败'}
</span>
{step.deleted !== undefined && (
<span style={{ marginLeft: '0.4rem' }}>( {step.deleted} )</span>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,119 @@
'use client'
import { useState, useEffect } from 'react'
import { Button, useSelection } from '@payloadcms/ui'
import { useRouter } from 'next/navigation'
/**
* /
* API: POST /api/admin/batch-sync-medusa
*/
export function BatchSyncProducts() {
const { getQueryParams, toggleAll } = useSelection()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [collectionSlug, setCollectionSlug] = useState('products')
const router = useRouter()
useEffect(() => {
if (typeof window !== 'undefined') {
setCollectionSlug(
window.location.pathname.includes('preorder-products')
? 'preorder-products'
: 'products',
)
}
}, [])
const handle = async (forceUpdate: boolean) => {
const queryParams = getQueryParams()
let selectedIds: string[] = []
if (queryParams && typeof queryParams === 'object') {
const where = (queryParams as any).where
if (where?.id?.in) selectedIds = where.id.in
}
if (!selectedIds.length) {
setMessage('⚠️ 请先勾选要同步的商品(使用列表左侧的复选框)')
return
}
if (
forceUpdate &&
!confirm(`确定要强制更新选中的 ${selectedIds.length} 个商品吗?这将覆盖本地修改。`)
)
return
setLoading(true)
setMessage('')
try {
const res = await fetch('/api/admin/batch-sync-medusa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: selectedIds, collection: collectionSlug, forceUpdate }),
})
const data = await res.json()
if (data.success) {
setMessage('✅ ' + (data.message || '批量同步成功!'))
toggleAll?.()
setTimeout(() => router.refresh(), 1500)
} else {
setMessage('❌ ' + (data.error || '批量同步失败'))
}
} catch (err) {
setMessage('❌ ' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setLoading(false)
}
}
return (
<div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Button
onClick={() => handle(false)}
disabled={loading}
buttonStyle="secondary"
size="small"
>
🔄 {loading ? '同步中...' : '同步选中商品'}
</Button>
<Button
onClick={() => handle(true)}
disabled={loading}
buttonStyle="secondary"
size="small"
>
{loading ? '更新中...' : '强制更新选中'}
</Button>
</div>
{message && <StatusMsg text={message} />}
</div>
)
}
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
const isWarn = text.startsWith('⚠️')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError
? 'var(--theme-error-50)'
: isWarn
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
color: isError
? 'var(--theme-error-750)'
: isWarn
? 'var(--theme-warning-750)'
: 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -0,0 +1,152 @@
'use client'
import { useState } from 'react'
import { Button } from '@payloadcms/ui'
/**
* Medusa
* API: GET /api/sync/medusa?forceUpdate=true
*/
export function ForceUpdateAll() {
const [showConfirm, setShowConfirm] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const handle = async () => {
if (confirmText !== 'FORCE_UPDATE_ALL') {
setMessage('❌ 确认字符不正确,请输入: FORCE_UPDATE_ALL')
return
}
setLoading(true)
setMessage('')
setShowConfirm(false)
try {
const res = await fetch('/api/sync/medusa?forceUpdate=true')
const data = await res.json()
if (data.success) {
setMessage('✅ ' + (data.message || '强制更新成功!'))
setTimeout(() => window.location.reload(), 1500)
} else {
setMessage('❌ ' + (data.error || data.message || '更新失败'))
}
} catch (err) {
setMessage('❌ ' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setLoading(false)
setConfirmText('')
}
}
const cancel = () => {
setShowConfirm(false)
setConfirmText('')
setMessage('')
}
if (showConfirm) {
return (
<div>
<div
style={{
marginBottom: '0.75rem',
padding: '0.75rem',
background: 'var(--theme-warning-50)',
borderRadius: '4px',
fontSize: '0.85rem',
}}
>
<p style={{ margin: '0 0 0.4rem', fontWeight: 700, color: 'var(--theme-warning-900)' }}>
</p>
<p style={{ margin: '0 0 0.4rem' }}></p>
<p style={{ margin: 0 }}>
{' '}
<code
style={{
padding: '0.1rem 0.3rem',
background: 'var(--theme-elevation-100)',
borderRadius: '2px',
}}
>
FORCE_UPDATE_ALL
</code>{' '}
</p>
</div>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="输入 FORCE_UPDATE_ALL"
disabled={loading}
style={{
width: '100%',
padding: '0.4rem 0.5rem',
marginBottom: '0.5rem',
border: '1px solid var(--theme-elevation-400)',
borderRadius: '4px',
fontSize: '0.875rem',
}}
/>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button
onClick={handle}
disabled={loading || confirmText !== 'FORCE_UPDATE_ALL'}
size="small"
>
{loading ? '更新中...' : '✅ 确认强制更新'}
</Button>
<Button onClick={cancel} disabled={loading} buttonStyle="secondary" size="small">
</Button>
</div>
{message && <StatusMsg text={message} />}
</div>
)
}
return (
<div>
<Button
onClick={() => {
setShowConfirm(true)
setMessage('')
setConfirmText('')
}}
disabled={loading}
buttonStyle="secondary"
size="small"
>
🔥
</Button>
{message && <StatusMsg text={message} />}
</div>
)
}
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
const isWarn = text.startsWith('⚠️')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError
? 'var(--theme-error-50)'
: isWarn
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
color: isError
? 'var(--theme-error-750)'
: isWarn
? 'var(--theme-warning-750)'
: 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -0,0 +1,67 @@
'use client'
import { useState } from 'react'
import { Button } from '@payloadcms/ui'
/**
* Medusa
* API: GET /api/sync/medusa?forceUpdate=false
*/
export function SyncNewProducts() {
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const handle = async () => {
setLoading(true)
setMessage('')
try {
const res = await fetch('/api/sync/medusa?forceUpdate=false')
const data = await res.json()
if (data.success) {
setMessage('✅ ' + (data.message || '同步成功!'))
setTimeout(() => window.location.reload(), 1500)
} else {
setMessage('❌ ' + (data.error || data.message || '同步失败'))
}
} catch (err) {
setMessage('❌ ' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setLoading(false)
}
}
return (
<div>
<Button onClick={handle} disabled={loading} buttonStyle="primary" size="small">
📥 {loading ? '同步中...' : '同步新商品'}
</Button>
{message && <StatusMsg text={message} />}
</div>
)
}
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
const isWarn = text.startsWith('⚠️')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError
? 'var(--theme-error-50)'
: isWarn
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
color: isError
? 'var(--theme-error-750)'
: isWarn
? 'var(--theme-warning-750)'
: 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -0,0 +1,224 @@
'use client'
import React, { useState } from 'react'
import { Button, Modal } from '@payloadcms/ui'
interface HealthCheckResult {
success: boolean
timestamp: string
summary: { total: number; healthy: number; warnings: number; errors: number }
products: Array<{
id: string
title: string
medusaId: string
seedId: string
status: string
severity: 'healthy' | 'warning' | 'error'
issues: string[]
stats: {
orderCount: number
fakeOrderCount: number
totalDisplayCount: number
fundingGoal: number
completionPercentage: number
}
dates: { preorderStartDate: string | null; preorderEndDate: string | null }
}>
issues: string[]
}
/**
*
* API: GET /api/preorders/health-check
*/
export function HealthCheck() {
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<HealthCheckResult | null>(null)
const [error, setError] = useState<string | null>(null)
const run = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/preorders/health-check')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setResult(data)
setIsOpen(true)
} catch (err: any) {
setError(err.message || '健康检查失败')
} finally {
setLoading(false)
}
}
const severityIcon = (s: string) =>
({ error: '❌', warning: '⚠️', healthy: '✅' })[s] ?? ''
const fmtDate = (d: string | null) => {
if (!d) return 'N/A'
try {
return new Date(d).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
} catch {
return d
}
}
return (
<>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '0.75rem' }}>
<Button onClick={run} disabled={loading} buttonStyle="secondary" size="small">
{loading ? '检查中...' : '🏥 健康检查'}
</Button>
{error && <span style={{ fontSize: '0.8rem', color: 'var(--theme-error-750)' }}> {error}</span>}
</div>
{isOpen && result && (
<Modal slug="preorder-health-check-modal" onClose={() => setIsOpen(false)}>
<div style={{ padding: '2rem', maxWidth: '900px' }}>
<h2 style={{ marginBottom: '1.5rem', fontSize: '1.5rem', fontWeight: 'bold' }}>
</h2>
{/* 概览 */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '1rem',
marginBottom: '1.5rem',
}}
>
{(
[
{ label: '总数', value: result.summary.total, bg: '#EFF6FF', border: '#BFDBFE', text: '#2563EB', bold: '#1E40AF' },
{ label: '健康', value: result.summary.healthy, bg: '#F0FDF4', border: '#BBF7D0', text: '#16A34A', bold: '#15803D' },
{ label: '警告', value: result.summary.warnings, bg: '#FEFCE8', border: '#FDE047', text: '#CA8A04', bold: '#A16207' },
{ label: '错误', value: result.summary.errors, bg: '#FEF2F2', border: '#FECACA', text: '#DC2626', bold: '#B91C1C' },
] as const
).map(({ label, value, bg, border, text, bold }) => (
<div
key={label}
style={{ padding: '1rem', backgroundColor: bg, borderRadius: '0.5rem', border: `1px solid ${border}` }}
>
<p style={{ fontSize: '0.875rem', color: text, fontWeight: '500', marginBottom: '0.25rem' }}>{label}</p>
<p style={{ fontSize: '2rem', fontWeight: 'bold', color: bold }}>{value}</p>
</div>
))}
</div>
<p style={{ fontSize: '0.875rem', color: '#6B7280', marginBottom: '1.5rem' }}>
: {new Date(result.timestamp).toLocaleString('zh-CN')}
</p>
{/* 产品列表 */}
<div style={{ maxHeight: '500px', overflowY: 'auto' }}>
{result.products.map((product) => {
const borderColor =
product.severity === 'error' ? '#FCA5A5' : product.severity === 'warning' ? '#FCD34D' : '#86EFAC'
const bgColor =
product.severity === 'error' ? '#FEF2F2' : product.severity === 'warning' ? '#FEFCE8' : '#F0FDF4'
return (
<div
key={product.id}
style={{
border: `1px solid ${borderColor}`,
backgroundColor: bgColor,
borderRadius: '0.5rem',
padding: '1rem',
marginBottom: '1rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '1.5rem' }}>{severityIcon(product.severity)}</span>
<h3 style={{ fontSize: '1.125rem', fontWeight: '600' }}>{product.title}</h3>
<span
style={{
padding: '0.25rem 0.5rem',
fontSize: '0.75rem',
borderRadius: '9999px',
backgroundColor: product.status === 'published' ? '#D1FAE5' : '#F3F4F6',
color: product.status === 'published' ? '#065F46' : '#374151',
}}
>
{product.status}
</span>
</div>
<div style={{ fontSize: '0.875rem', color: '#4B5563' }}>
<p>Medusa ID: {product.medusaId}</p>
{product.seedId && <p>Seed ID: {product.seedId}</p>}
</div>
</div>
<div style={{ textAlign: 'right', fontSize: '0.875rem', color: '#4B5563' }}>
<p>: {product.stats.completionPercentage}%</p>
<p>
{product.stats.totalDisplayCount} / {product.stats.fundingGoal}
</p>
</div>
</div>
<div
style={{
display: 'flex',
gap: '1rem',
fontSize: '0.875rem',
color: '#4B5563',
marginBottom: '0.75rem',
}}
>
<div>
<span style={{ fontWeight: '500' }}>:</span>{' '}
{fmtDate(product.dates.preorderStartDate)}
</div>
<div>
<span style={{ fontWeight: '500' }}>:</span>{' '}
{fmtDate(product.dates.preorderEndDate)}
</div>
</div>
{product.issues.length > 0 && (
<div
style={{
marginTop: '0.75rem',
paddingTop: '0.75rem',
borderTop: '1px solid #D1D5DB',
}}
>
<p style={{ fontSize: '0.875rem', fontWeight: '500', marginBottom: '0.5rem' }}>:</p>
<ul style={{ fontSize: '0.875rem', paddingLeft: '1rem' }}>
{product.issues.map((issue, i) => (
<li key={i} style={{ marginBottom: '0.25rem' }}>
{issue}
</li>
))}
</ul>
</div>
)}
</div>
)
})}
{result.products.length === 0 && (
<p style={{ textAlign: 'center', padding: '3rem', color: '#6B7280' }}>
</p>
)}
</div>
<div style={{ marginTop: '1.5rem', textAlign: 'right' }}>
<Button onClick={() => setIsOpen(false)} buttonStyle="primary">
</Button>
</div>
</div>
</Modal>
)}
</>
)
}

View File

@ -0,0 +1,136 @@
'use client'
import { useState } from 'react'
import { Button, useSelection } from '@payloadcms/ui'
import { useRouter } from 'next/navigation'
/**
* /
* API: POST /api/preorders/refresh-order-counts
*/
export function RefreshOrderCounts() {
const { getQueryParams, toggleAll } = useSelection()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const router = useRouter()
const handleAll = async () => {
if (!confirm('确定要刷新所有预购商品的订单计数吗?')) return
setLoading(true)
setMessage('')
try {
const res = await fetch('/api/preorders/refresh-order-counts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshAll: true }),
})
const data = await res.json()
if (data.success) {
setMessage(`${data.message || '订单计数刷新成功!'}`)
setTimeout(() => router.refresh(), 1500)
} else {
setMessage(`${data.error || '刷新失败'}`)
}
} catch (err) {
setMessage('❌ ' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setLoading(false)
}
}
const handleSelected = async () => {
const queryParams = getQueryParams()
let selectedIds: string[] = []
if (queryParams && typeof queryParams === 'object') {
const where = (queryParams as any).where
if (where?.id?.in) selectedIds = where.id.in
}
if (!selectedIds.length) {
setMessage('⚠️ 请先勾选要刷新的商品(使用列表左侧的复选框)')
return
}
setLoading(true)
setMessage('')
try {
const res = await fetch('/api/preorders/refresh-order-counts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productIds: selectedIds }),
})
const data = await res.json()
if (data.success) {
setMessage(`${data.message || '订单计数刷新成功!'}`)
toggleAll?.()
setTimeout(() => router.refresh(), 1500)
} else {
setMessage(`${data.error || '刷新失败'}`)
}
} catch (err) {
setMessage('❌ ' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setLoading(false)
}
}
return (
<div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Button onClick={handleSelected} disabled={loading} buttonStyle="secondary" size="small">
{loading ? '刷新中...' : '刷新选中订单计数'}
</Button>
<Button onClick={handleAll} disabled={loading} buttonStyle="secondary" size="small">
{loading ? '刷新中...' : '刷新全部订单计数'}
</Button>
</div>
{message && <StatusMsg text={message} />}
<div
style={{
marginTop: '0.75rem',
fontSize: '0.78rem',
color: 'var(--theme-elevation-500)',
lineHeight: 1.6,
}}
>
<p style={{ margin: '0.2rem 0' }}>
💡 <strong></strong> = + Fake计数
</p>
<p style={{ margin: '0.2rem 0' }}>
<strong></strong> Medusa
</p>
<p style={{ margin: '0.2rem 0' }}>
<strong>Fake计数</strong>
</p>
</div>
</div>
)
}
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
const isWarn = text.startsWith('⚠️')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError
? 'var(--theme-error-50)'
: isWarn
? 'var(--theme-warning-50)'
: 'var(--theme-success-50)',
color: isError
? 'var(--theme-error-750)'
: isWarn
? 'var(--theme-warning-750)'
: 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -0,0 +1,123 @@
'use client'
import { useState } from 'react'
/**
* /
* API: POST /api/admin/taobao/sync-all
*/
export function TaobaoAllSync() {
const [loadingNormal, setLoadingNormal] = useState(false)
const [loadingForce, setLoadingForce] = useState(false)
const [confirmForce, setConfirmForce] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const busy = loadingNormal || loadingForce
const run = async (force: boolean) => {
const setLoading = force ? setLoadingForce : setLoadingNormal
setLoading(true)
setMessage(null)
setConfirmForce(false)
try {
const res = await fetch('/api/admin/taobao/sync-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force }),
})
const data = await res.json()
if (!data.success) throw new Error(data.error || '请求失败')
setMessage(`${data.message}`)
} catch (err: any) {
setMessage(`${err?.message ?? '未知错误'}`)
} finally {
setLoading(false)
}
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
disabled={busy}
onClick={() => run(false)}
style={btnStyle(busy, '#10b981')}
>
{loadingNormal ? '更新中…' : '🔄 更新全部淘宝'}
</button>
{!confirmForce ? (
<button
type="button"
disabled={busy}
onClick={() => setConfirmForce(true)}
style={btnStyle(busy, '#ef4444')}
>
</button>
) : (
<>
<span style={{ fontSize: '0.78rem', color: '#dc2626', fontWeight: 600 }}>
</span>
<button
type="button"
disabled={busy}
onClick={() => run(true)}
style={btnStyle(busy, '#dc2626')}
>
{loadingForce ? '更新中…' : '确认'}
</button>
<button
type="button"
onClick={() => setConfirmForce(false)}
style={{
padding: '0.35rem 0.75rem',
background: 'transparent',
color: 'var(--theme-elevation-600)',
border: '1px solid var(--theme-elevation-200)',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.78rem',
}}
>
</button>
</>
)}
</div>
{message && <StatusMsg text={message} />}
</div>
)
}
const btnStyle = (busy: boolean, color: string): React.CSSProperties => ({
padding: '0.4rem 0.85rem',
background: busy ? '#9ca3af' : color,
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: busy ? 'not-allowed' : 'pointer',
fontSize: '0.78rem',
fontWeight: 500,
whiteSpace: 'nowrap',
})
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError ? 'var(--theme-error-50)' : 'var(--theme-success-50)',
color: isError ? 'var(--theme-error-750)' : 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -0,0 +1,121 @@
'use client'
import { useState } from 'react'
import { useDocumentInfo } from '@payloadcms/ui'
/**
* /
* API: POST /api/admin/taobao/sync-product
*
* 使 useDocumentInfo Product / PreorderProduct 使
*/
export function TaobaoProductSync() {
const { id, collectionSlug } = useDocumentInfo()
const [loadingNormal, setLoadingNormal] = useState(false)
const [loadingForce, setLoadingForce] = useState(false)
const [message, setMessage] = useState<string | null>(null)
if (!id) return null
const isValid = collectionSlug === 'products' || collectionSlug === 'preorder-products'
if (!isValid) return null
const busy = loadingNormal || loadingForce
const run = async (force: boolean) => {
const setLoading = force ? setLoadingForce : setLoadingNormal
setLoading(true)
setMessage(null)
try {
const res = await fetch('/api/admin/taobao/sync-product', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: id, collection: collectionSlug, force }),
})
const data = await res.json()
if (!data.success) throw new Error(data.error || '请求失败')
setMessage(`${data.message || '完成'}`)
setTimeout(() => window.location.reload(), 1200)
} catch (err: any) {
setMessage(`${err?.message ?? '未知错误'}`)
} finally {
setLoading(false)
}
}
return (
<div>
<div
style={{
fontSize: '0.8rem',
fontWeight: 600,
color: 'var(--theme-elevation-600)',
marginBottom: '0.5rem',
}}
>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
disabled={busy}
onClick={() => run(false)}
style={btnStyle(busy, '#3b82f6')}
>
{loadingNormal ? '解析中…' : '🔄 更新淘宝信息'}
</button>
<button
type="button"
disabled={busy}
onClick={() => run(true)}
style={btnStyle(busy, '#f97316')}
>
{loadingForce ? '解析中…' : '⚡ 强制更新淘宝信息'}
</button>
</div>
<div
style={{
marginTop: '0.5rem',
fontSize: '0.73rem',
color: 'var(--theme-elevation-450)',
lineHeight: 1.5,
}}
>
<strong>🔄 </strong>&emsp;
<strong> </strong>
</div>
{message && <StatusMsg text={message} />}
</div>
)
}
const btnStyle = (busy: boolean, color: string): React.CSSProperties => ({
padding: '0.4rem 0.9rem',
background: busy ? '#9ca3af' : color,
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: busy ? 'not-allowed' : 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
whiteSpace: 'nowrap',
})
function StatusMsg({ text }: { text: string }) {
const isError = text.startsWith('❌')
return (
<div
style={{
marginTop: '0.5rem',
padding: '0.4rem 0.75rem',
borderRadius: '4px',
fontSize: '0.8rem',
background: isError ? 'var(--theme-error-50)' : 'var(--theme-success-50)',
color: isError ? 'var(--theme-error-750)' : 'var(--theme-success-750)',
}}
>
{text}
</div>
)
}

View File

@ -0,0 +1,446 @@
'use client'
import React, { useState } from 'react'
import { Button } from '@payloadcms/ui'
import { ResetData } from '../sync/admin/ResetData'
/**
*
*
*/
export default function AdminPanel() {
const [clearLoading, setClearLoading] = useState(false)
const [clearMessage, setClearMessage] = useState('')
const [showClearConfirm, setShowClearConfirm] = useState(false)
// 缓存相关状态
const [cacheStats, setCacheStats] = useState<any>(null)
const [cacheLoading, setCacheLoading] = useState(false)
const [cacheMessage, setCacheMessage] = useState('')
const handleClearData = () => {
setShowClearConfirm(true)
setClearMessage('')
}
const handleConfirmClear = async () => {
setClearLoading(true)
setClearMessage('')
setShowClearConfirm(false)
try {
const response = await fetch('/api/clear-data?confirm=true', {
method: 'GET',
})
const data = await response.json()
if (data.success) {
setClearMessage(data.message || '数据清理成功!')
} else {
setClearMessage(`清理失败: ${data.error}`)
}
} catch (error) {
setClearMessage(`清理出错: ${error instanceof Error ? error.message : '未知错误'}`)
} finally {
setClearLoading(false)
}
}
const handleCancelClear = () => {
setShowClearConfirm(false)
setClearMessage('')
}
// 获取缓存状态
const fetchCacheStats = async () => {
setCacheLoading(true)
try {
const response = await fetch('/api/cache')
const data = await response.json()
if (data.success) {
setCacheStats(data.stats)
}
} catch (error) {
console.error('Failed to fetch cache stats:', error)
} finally {
setCacheLoading(false)
}
}
// 清除所有缓存
const handleClearAllCache = async () => {
if (!confirm('确定要清除所有 Redis 缓存吗?')) return
setCacheLoading(true)
setCacheMessage('')
try {
const response = await fetch('/api/cache', {
method: 'DELETE',
})
const data = await response.json()
if (data.success) {
setCacheMessage(data.message)
fetchCacheStats() // 刷新统计
} else {
setCacheMessage(`清除失败: ${data.error}`)
}
} catch (error) {
setCacheMessage(`清除出错: ${error instanceof Error ? error.message : '未知错误'}`)
} finally {
setCacheLoading(false)
}
}
// 清除产品缓存
const handleClearProductsCache = async () => {
setCacheLoading(true)
setCacheMessage('')
try {
const response = await fetch('/api/cache?pattern=products:*', {
method: 'DELETE',
})
const data = await response.json()
if (data.success) {
setCacheMessage(data.message)
fetchCacheStats() // 刷新统计
} else {
setCacheMessage(`清除失败: ${data.error}`)
}
} catch (error) {
setCacheMessage(`清除出错: ${error instanceof Error ? error.message : '未知错误'}`)
} finally {
setCacheLoading(false)
}
}
// 组件加载时获取缓存状态
React.useEffect(() => {
fetchCacheStats()
}, [])
return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '2rem', fontSize: '2rem', fontWeight: 'bold' }}>
🛠
</h1>
{/* 数据管理区域 */}
<div
style={{
backgroundColor: 'var(--theme-elevation-50)',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '1.5rem',
border: '1px solid var(--theme-elevation-100)',
}}
>
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
📦
</h2>
{/* 数据重置 */}
<div
style={{
backgroundColor: 'var(--theme-elevation-0)',
borderRadius: '6px',
padding: '1.5rem',
marginBottom: '1rem',
border: '1px solid var(--theme-elevation-150)',
}}
>
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem', fontWeight: '600' }}>
🔄 Payload + Medusa
</h3>
<p
style={{
marginBottom: '1rem',
fontSize: '0.875rem',
color: 'var(--theme-elevation-600)',
}}
>
Payload CMS Medusa Medusa seed
</p>
<ResetData />
</div>
{/* 清理数据库 */}
<div
style={{
backgroundColor: 'var(--theme-elevation-0)',
borderRadius: '6px',
padding: '1.5rem',
border: '1px solid var(--theme-elevation-150)',
}}
>
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem', fontWeight: '600' }}>
🗑
</h3>
<p
style={{
marginBottom: '1rem',
fontSize: '0.875rem',
color: 'var(--theme-elevation-600)',
}}
>
</p>
{showClearConfirm ? (
<div>
<div
style={{
marginBottom: '1rem',
padding: '1rem',
backgroundColor: 'var(--theme-error-50)',
borderRadius: '6px',
border: '1px solid var(--theme-error-500)',
}}
>
<p
style={{
margin: '0 0 0.5rem 0',
fontWeight: 'bold',
color: 'var(--theme-error-700)',
}}
>
</p>
<p style={{ margin: '0', fontSize: '0.875rem', color: 'var(--theme-error-600)' }}>
</p>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Button onClick={handleConfirmClear} disabled={clearLoading} buttonStyle="error">
{clearLoading ? '清理中...' : '确认清理'}
</Button>
<Button onClick={handleCancelClear} disabled={clearLoading} buttonStyle="secondary">
</Button>
</div>
</div>
) : (
<Button onClick={handleClearData} disabled={clearLoading} buttonStyle="error">
</Button>
)}
{clearMessage && (
<div
style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor:
clearMessage.includes('失败') || clearMessage.includes('出错')
? 'var(--theme-error-50)'
: 'var(--theme-success-50)',
borderRadius: '6px',
fontSize: '0.875rem',
border: `1px solid ${
clearMessage.includes('失败') || clearMessage.includes('出错')
? 'var(--theme-error-500)'
: 'var(--theme-success-500)'
}`,
}}
>
{clearMessage}
</div>
)}
</div>
</div>
{/* Redis 缓存管理区域 */}
<div
style={{
backgroundColor: 'var(--theme-elevation-50)',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '1.5rem',
border: '1px solid var(--theme-elevation-100)',
}}
>
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
🚀 Redis
</h2>
{/* 缓存统计 */}
<div
style={{
backgroundColor: 'var(--theme-elevation-0)',
borderRadius: '6px',
padding: '1.5rem',
marginBottom: '1rem',
border: '1px solid var(--theme-elevation-150)',
}}
>
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: '600' }}>📊 </h3>
{cacheStats ? (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '1rem',
}}
>
<div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--theme-elevation-600)',
marginBottom: '0.25rem',
}}
>
</div>
<div style={{ fontSize: '1.25rem', fontWeight: '600' }}>
{cacheStats.connected ? '✅ 已连接' : '❌ 未连接'}
</div>
</div>
<div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--theme-elevation-600)',
marginBottom: '0.25rem',
}}
>
</div>
<div style={{ fontSize: '1.25rem', fontWeight: '600' }}>{cacheStats.totalKeys}</div>
</div>
<div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--theme-elevation-600)',
marginBottom: '0.25rem',
}}
>
使
</div>
<div style={{ fontSize: '1.25rem', fontWeight: '600' }}>
{cacheStats.memoryUsage}
</div>
</div>
</div>
) : (
<div
style={{ textAlign: 'center', padding: '1rem', color: 'var(--theme-elevation-600)' }}
>
{cacheLoading ? '加载中...' : '无法获取缓存统计'}
</div>
)}
<div style={{ marginTop: '1rem' }}>
<Button onClick={fetchCacheStats} disabled={cacheLoading} buttonStyle="secondary">
{cacheLoading ? '刷新中...' : '刷新统计'}
</Button>
</div>
</div>
{/* 清除缓存操作 */}
<div
style={{
backgroundColor: 'var(--theme-elevation-0)',
borderRadius: '6px',
padding: '1.5rem',
border: '1px solid var(--theme-elevation-150)',
}}
>
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem', fontWeight: '600' }}>
🗑
</h3>
<p
style={{
marginBottom: '1rem',
fontSize: '0.875rem',
color: 'var(--theme-elevation-600)',
}}
>
Redis key payload:
</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Button
onClick={handleClearProductsCache}
disabled={cacheLoading}
buttonStyle="primary"
>
</Button>
<Button onClick={handleClearAllCache} disabled={cacheLoading} buttonStyle="error">
</Button>
</div>
{cacheMessage && (
<div
style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor:
cacheMessage.includes('失败') || cacheMessage.includes('出错')
? 'var(--theme-error-50)'
: 'var(--theme-success-50)',
borderRadius: '6px',
fontSize: '0.875rem',
border: `1px solid ${
cacheMessage.includes('失败') || cacheMessage.includes('出错')
? 'var(--theme-error-500)'
: 'var(--theme-success-500)'
}`,
}}
>
{cacheMessage}
</div>
)}
</div>
</div>
{/* 系统信息区域 */}
<div
style={{
backgroundColor: 'var(--theme-elevation-50)',
borderRadius: '8px',
padding: '1.5rem',
border: '1px solid var(--theme-elevation-100)',
}}
>
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
</h2>
<div
style={{
backgroundColor: 'var(--theme-elevation-0)',
borderRadius: '6px',
padding: '1.5rem',
border: '1px solid var(--theme-elevation-150)',
fontSize: '0.875rem',
}}
>
<div style={{ marginBottom: '0.5rem' }}>
<strong>Payload CMS:</strong> v3.75.0
</div>
<div style={{ marginBottom: '0.5rem' }}>
<strong>:</strong> PostgreSQL
</div>
<div style={{ marginBottom: '0.5rem' }}>
<strong>:</strong> Cloudflare R2 (S3 API)
</div>
<div style={{ marginBottom: '0.5rem' }}>
<strong>:</strong> Redis
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,34 @@
'use client'
import React from 'react'
import { SaveButton, Button, useDocumentInfo } from '@payloadcms/ui'
/**
* DisassemblyPages
*
*
* DisassemblyPages.ts admin.components.edit.SaveButton
*/
export default function DisassemblyPageSaveArea() {
const { id } = useDocumentInfo()
const handleOpenEditor = () => {
if (id) {
window.location.href = `/admin/disassembly-editor?id=${id}`
}
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SaveButton />
<Button
buttonStyle="secondary"
size="medium"
disabled={!id}
onClick={handleOpenEditor}
>
</Button>
</div>
)
}

View File

@ -0,0 +1,474 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import { useDocumentInfo, useConfig, useFormFields } from '@payloadcms/ui'
import type { UIFieldClientComponent } from 'payload'
/**
* DisassemblyVisualEditor DisassemblyPages
*
* DisassemblyPages.html 稿 v7.2.0
* + 线 + + SVG 线 +
*
*
* mainImage
* areas thumbnailImage
* name / url
*/
// ── 类型 ─────────────────────────────────────────────────────────────────────
interface Area {
id: string | number
name: string
thumbnailImage?: { url: string } | string | null
}
// ── 工具函数 ──────────────────────────────────────────────────────────────────
function isPopulated<T extends { id: unknown }>(v: T | string | number): v is T {
return typeof v === 'object' && v !== null && 'id' in v
}
function extractId(value: unknown): string | null {
if (!value) return null
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (typeof value === 'object') {
const v = value as Record<string, unknown>
if (typeof v.id === 'string' || typeof v.id === 'number') return String(v.id)
if (typeof v.value === 'string' || typeof v.value === 'number') return String(v.value)
}
return null
}
function extractUrl(value: unknown): string | null {
if (!value || typeof value !== 'object') return null
const v = value as Record<string, unknown>
if (typeof v.url === 'string') return v.url
return null
}
function toIdString(value: unknown): string {
if (!value || !Array.isArray(value)) return ''
return value.map(extractId).filter(Boolean).join(',')
}
function getThumbUrl(area: Area): string | null {
const v = area.thumbnailImage
if (!v) return null
if (typeof v === 'object' && 'url' in v) return v.url
return null
}
// ── CSS完全对应 DisassemblyPages.html styles────────────────────────────────
const CSS = `
@keyframes dve-scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
@keyframes dve-live-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.dve-blueprint-grid {
background-color: #ffffff;
background-image:
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
background-size: 30px 30px;
position: absolute;
inset: 0;
}
.dve-blueprint-grid::after {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0, 0, 0, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
background-size: 10px 10px;
pointer-events: none;
}
.dve-assembly-view {
filter: drop-shadow(0 15px 30px rgba(0,0,0,0.1)) contrast(1.05);
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.dve-assembly-view:hover {
filter: drop-shadow(0 20px 40px rgba(0,0,0,0.15)) contrast(1.1);
}
.dve-leader-line {
transition: all 0.4s ease;
}
.dve-label-container {
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
}
.dve-scan-effect {
position: absolute;
top: 0; left: 0; right: 0; height: 100%;
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.01), transparent);
animation: dve-scanline 15s linear infinite;
pointer-events: none;
z-index: 5;
}
.dve-node-wrap {
display: flex;
flex-direction: column;
align-items: center;
cursor: default;
position: relative;
flex: 1;
min-width: 0;
}
.dve-icon-node {
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.dve-node-wrap:hover .dve-icon-node {
transform: scale(1.1) translateY(-8px);
}
.dve-terminal-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid #ffffff;
margin-bottom: 12px;
transition: all 0.3s;
background: #e5e5e5;
flex-shrink: 0;
}
.dve-node-wrap:hover .dve-terminal-dot {
background: #171717;
transform: scale(1.25);
}
.dve-code-text {
font-size: 8px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #d4d4d4;
transition: color 0.3s;
}
.dve-node-wrap:hover .dve-code-text {
color: #171717;
}
.dve-part-name {
font-size: 20px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.05em;
color: #a3a3a3;
transition: all 0.3s;
white-space: nowrap;
}
.dve-node-wrap:hover .dve-part-name {
color: #171717;
transform: scale(1.05);
}
.dve-glow {
position: absolute;
inset: 0;
background: rgba(23, 23, 23, 0.05);
filter: blur(24px);
border-radius: 50%;
transform: scale(1.25);
display: none;
}
.dve-node-wrap:hover .dve-glow { display: block; }
.dve-live-dot { animation: dve-live-blink 1.6s ease-in-out infinite; }
`
// ── 物理常量(完全镜像 DisassemblyPages.html──────────────────────────────────
const ICON_SIZE = 80
const CENTER_AXIS_Y = 180
const OFFSET_Y = 40
// ── ChevronUp / ChevronDown (内联 SVG)─────────────────────────────────────────
const ChevronUp = () => (
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" style={{ display: 'block', flexShrink: 0 }}>
<polyline points="18 15 12 9 6 15" />
</svg>
)
const ChevronDown = () => (
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" style={{ display: 'block', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9" />
</svg>
)
// ── 单个区域节点(完全还原 DisassemblyPages.html PartNode────────────────────────
function AreaNode({
area,
index,
isHovered,
onHover,
}: {
area: Area
index: number
isHovered: boolean
onHover: (id: string | null) => void
}) {
const isUp = index % 2 === 0
const boxTop = isUp ? CENTER_AXIS_Y - OFFSET_Y : CENTER_AXIS_Y + OFFSET_Y
const SVG_CENTER_X = 50
const SVG_CENTER_Y = 40
const lineColor = isHovered ? '#000000' : '#f5f5f5'
const lineWidth = isHovered ? '2.5' : '1'
const thumbUrl = getThumbUrl(area)
return (
<div
className="dve-node-wrap"
onMouseEnter={() => onHover(String(area.id))}
onMouseLeave={() => onHover(null)}
>
{/* ── 垂直引导线 ── */}
<svg
style={{
position: 'absolute', top: 0, left: '50%', transform: 'translateX(-50%)',
width: '100px', height: '400px', overflow: 'visible', pointerEvents: 'none', zIndex: 0,
}}
viewBox="0 0 100 400"
>
<path
d={`M ${SVG_CENTER_X} ${SVG_CENTER_Y} L ${SVG_CENTER_X} ${boxTop}`}
fill="none"
stroke={lineColor}
strokeWidth={lineWidth}
strokeDasharray={isHovered ? 'none' : '3,3'}
className="dve-leader-line"
/>
<circle cx={SVG_CENTER_X} cy={SVG_CENTER_Y} r="2.5" fill={isHovered ? '#000000' : '#e0e0e0'} />
<circle cx={SVG_CENTER_X} cy={boxTop} r={isHovered ? '4' : '2'} fill={isHovered ? '#000000' : '#e0e0e0'} />
</svg>
{/* ── 图标节点80×80 容器64×64 图片)── */}
<div
className="dve-icon-node"
style={{
position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center',
width: `${ICON_SIZE}px`, height: `${ICON_SIZE}px`, zIndex: 10,
}}
>
{thumbUrl ? (
<img
src={thumbUrl}
style={{ width: 64, height: 64, objectFit: 'contain', position: 'relative', zIndex: 10 }}
alt={area.name}
/>
) : (
<div style={{
width: 64, height: 64,
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1px dashed #d4d4d4', background: '#fafafa',
}}>
<span style={{ fontSize: 7, color: '#a3a3a3', textTransform: 'uppercase', fontWeight: 700, letterSpacing: '.1em', textAlign: 'center', lineHeight: 1.4 }}>
NO<br />IMG
</span>
</div>
)}
<div className="dve-glow" />
</div>
{/* ── 标签容器(接线端子 + 方向箭头 + 编号 + 大字名称)── */}
<div
className="dve-label-container"
style={{
position: 'absolute',
display: 'flex', flexDirection: 'column', alignItems: 'center', whiteSpace: 'nowrap',
top: `${boxTop}px`, left: '50%',
transform: `translateX(-50%)${isHovered ? (isUp ? ' translateY(-4px)' : ' translateY(4px)') : ''}`,
zIndex: 30,
}}
>
<div className="dve-terminal-dot" />
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, opacity: 0.6 }}>
{isUp ? <ChevronUp /> : <div style={{ width: 8 }} />}
<div className="dve-code-text">CODE.{String(index + 1).padStart(2, '0')}</div>
{!isUp ? <ChevronDown /> : <div style={{ width: 8 }} />}
</div>
<div className="dve-part-name">{area.name}</div>
</div>
</div>
)
}
// ── 主组件 ───────────────────────────────────────────────────────────────────
const DisassemblyVisualEditor: UIFieldClientComponent = () => {
const { id: docId } = useDocumentInfo()
const { config } = useConfig()
const apiBase: string = (config as any)?.routes?.api ?? '/api'
// ── 实时监听表单字段 ──
const mainImageField = useFormFields(([fields]) => fields.mainImage)
const areasField = useFormFields(([fields]) => fields.areas)
const nameField = useFormFields(([fields]) => fields.name)
// ── 状态 ──
const [imgUrl, setImgUrl] = useState<string | null>(null)
const [imgLoading, setImgLoading] = useState(false)
const [areas, setAreas] = useState<Area[]>([])
const [areasLoading, setAreasLoading] = useState(false)
const [hoveredId, setHoveredId] = useState<string | null>(null)
// ── 响应 mainImage 变化 ──
const mainImgValue = mainImageField?.value
useEffect(() => {
const directUrl = extractUrl(mainImgValue)
if (directUrl) { setImgUrl(directUrl); return }
const imgId = extractId(mainImgValue)
if (!imgId) { setImgUrl(null); return }
setImgLoading(true)
fetch(`${apiBase}/media/${imgId}`, { credentials: 'include' })
.then(r => (r.ok ? r.json() : null))
.then((data: { url?: string } | null) => { if (data?.url) setImgUrl(data.url) })
.catch(() => {})
.finally(() => setImgLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mainImgValue, apiBase])
// ── 响应 areas IDs 变化 ──
const areasValue = areasField?.value
const areaIdStr = toIdString(areasValue)
useEffect(() => {
if (!areaIdStr) { setAreas([]); return }
const ids = areaIdStr.split(',').filter(Boolean)
setAreasLoading(true)
Promise.all(
ids.map(aid =>
fetch(`${apiBase}/disassembly-areas/${aid}?depth=1`, { credentials: 'include' })
.then(r => (r.ok ? (r.json() as Promise<Area>) : null))
.catch(() => null),
),
)
.then(results => { setAreas(results.filter((a): a is Area => a !== null)) })
.finally(() => setAreasLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [areaIdStr, apiBase])
// ── 初始加载(已保存文档)──
const fetchSaved = useCallback(async () => {
if (!docId || areaIdStr) return
try {
const res = await fetch(`${apiBase}/disassembly-pages/${docId}?depth=2`, { credentials: 'include' })
if (!res.ok) return
const data = await res.json()
if (data.mainImage?.url && !imgUrl) setImgUrl(data.mainImage.url)
if (Array.isArray(data.areas) && areas.length === 0) {
setAreas((data.areas as (Area | string | number)[]).filter(isPopulated<Area>))
}
} catch (_) {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, apiBase])
useEffect(() => { void fetchSaved() }, [fetchSaved])
// ── 衍生值 ──
const pageName = String(nameField?.value ?? '')
// ──────────────────────────────────────────────────────────────────────────
return (
<div style={{ fontFamily: 'monospace' }}>
<style>{CSS}</style>
{/* ── 主画布 ── */}
<div style={{
position: 'relative',
width: '100%',
height: 560,
overflow: 'hidden',
background: '#ffffff',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
padding: '48px 48px 0',
boxSizing: 'border-box',
userSelect: 'none',
}}>
<div className="dve-blueprint-grid" />
<div className="dve-scan-effect" />
{/* ── 中心工业总成图(相当于 max-w-4xl max-h-[35vh])── */}
<div style={{
position: 'relative', width: '100%', maxWidth: 700, height: 200,
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10, flexShrink: 0,
}}>
{/* 适配型角标 */}
<div style={{ position: 'absolute', inset: -16, pointerEvents: 'none', opacity: 0.4 }}>
<div style={{ position: 'absolute', top: 0, left: 0, width: 48, height: 48, borderTop: '1px solid #525252', borderLeft: '1px solid #525252' }} />
<div style={{ position: 'absolute', bottom: 0, right: 0, width: 48, height: 48, borderBottom: '1px solid #525252', borderRight: '1px solid #525252' }} />
</div>
{imgUrl ? (
<img src={imgUrl} className="dve-assembly-view" alt={pageName || 'Assembly'} />
) : (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '2px dashed #e5e5e5', background: '#fafafa',
}}>
<span style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.3em', color: '#d4d4d4' }}>
</span>
</div>
)}
</div>
{/* ── 底部区域节点导航pb-72 px-10 justify-between── */}
{areas.length > 0 ? (
<div style={{
display: 'flex', justifyContent: 'space-between',
width: '100%', maxWidth: 1000,
marginTop: 48,
padding: '0 40px 280px',
zIndex: 20,
boxSizing: 'border-box',
position: 'relative',
}}>
{areas.map((area, index) => (
<AreaNode
key={String(area.id)}
area={area}
index={index}
isHovered={hoveredId === String(area.id)}
onHover={setHoveredId}
/>
))}
</div>
) : (
!areasLoading && (
<div style={{
marginTop: 48, fontSize: 9, color: '#d4d4d4', fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '.2em', textAlign: 'center',
zIndex: 10, position: 'relative',
}}>
</div>
)
)}
{areasLoading && (
<div style={{
position: 'absolute', inset: 0, zIndex: 80,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(255,255,255,0.7)',
}}>
<span style={{ fontSize: 10, fontWeight: 900, color: '#f59e0b', textTransform: 'uppercase', letterSpacing: '.2em' }}>
</span>
</div>
)}
</div>
</div>
)
}
export default DisassemblyVisualEditor

View File

@ -0,0 +1,84 @@
'use client'
/**
* AreaDrawers DocumentDrawer
*
* ref openNew()
*/
import React, { forwardRef, useEffect, useImperativeHandle } from 'react'
import { useDocumentDrawer } from '@payloadcms/ui'
import { injectDrawerStyles } from './styles'
// ── Props & Handle ────────────────────────────────────────────────────────────
export interface DrawersHandle {
openNew: () => void
}
interface Props {
pageId: string | null
areaIds: string[]
selectedAreaId: string | undefined
onClearEdit: () => void
onRefresh: () => void
apiBase: string
}
// ── 组件 ─────────────────────────────────────────────────────────────────────
const AreaDrawers = forwardRef<DrawersHandle, Props>(
({ pageId, areaIds, selectedAreaId, onClearEdit, onRefresh, apiBase }, ref) => {
// 注入 Drawer 宽度覆盖样式(幂等)
useEffect(() => { injectDrawerStyles() }, [])
// 编辑已有区域(动态 id
const [AreaEditDrawer, , { openDrawer: openEditDrawer }] = useDocumentDrawer({
collectionSlug: 'disassembly-areas',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
id: selectedAreaId as any,
})
// 新建区域(无 id
const [AreaNewDrawer, , { openDrawer: openNewDrawer }] = useDocumentDrawer({
collectionSlug: 'disassembly-areas',
})
useImperativeHandle(ref, () => ({ openNew: openNewDrawer }), [openNewDrawer])
// selectedAreaId 变化时打开编辑 Drawer
useEffect(() => {
if (selectedAreaId) openEditDrawer()
}, [selectedAreaId, openEditDrawer])
return (
<>
<AreaEditDrawer
onSave={() => {
onClearEdit()
onRefresh()
}}
/>
<AreaNewDrawer
initialData={pageId ? { page: pageId } : undefined}
onSave={async ({ doc: saved }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const savedId = (saved as any)?.id as string | number | undefined
if (pageId && savedId) {
await fetch(`${apiBase}/disassembly-pages/${pageId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ areas: [...areaIds, String(savedId)] }),
})
}
onRefresh()
}}
/>
</>
)
},
)
AreaDrawers.displayName = 'AreaDrawers'
export default AreaDrawers

View File

@ -0,0 +1,125 @@
'use client'
import React from 'react'
import { type Area, imgUrl } from './types'
// ── 常量 ─────────────────────────────────────────────────────────────────────
export const ICON_SIZE = 80
export const CENTER_AXIS_Y = 180
export const OFFSET_Y = 40
// ── SVG 箭头 ──────────────────────────────────────────────────────────────────
const ChevronUp = () => (
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="18 15 12 9 6 15" />
</svg>
)
const ChevronDown = () => (
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="6 9 12 15 18 9" />
</svg>
)
// ── AreaNode ──────────────────────────────────────────────────────────────────
interface Props {
area: Area
index: number
isHovered: boolean
onHover: (id: string | null) => void
onEdit: (id: string) => void
}
export default function AreaNode({ area, index, isHovered, onHover, onEdit }: Props) {
const isUp = index % 2 === 0
const boxTop = isUp ? CENTER_AXIS_Y - OFFSET_Y : CENTER_AXIS_Y + OFFSET_Y
const thumb = imgUrl(area.thumbnailImage)
return (
// 固定高度容器,避免 hover 时触发画布 reflow
<div
style={{ flex: 1, minWidth: 0, height: 360, position: 'relative' }}
onMouseEnter={() => onHover(area.id)}
onMouseLeave={() => onHover(null)}
onClick={() => onEdit(area.id)}
>
{/* 整体节点包裹(绝对定位,不参与 flex 布局计算)*/}
<div
className="dep-node-wrap"
title={`点击编辑:${area.name}`}
style={{ position: 'absolute', inset: 0 }}
>
{/* 引线 */}
<svg
style={{
position: 'absolute', top: 0, left: '50%',
transform: 'translateX(-50%)', width: 100, height: 400,
overflow: 'visible', pointerEvents: 'none', zIndex: 0,
}}
viewBox="0 0 100 400"
>
<path
d={`M 50 40 L 50 ${boxTop}`}
fill="none"
stroke={isHovered ? '#000' : '#f5f5f5'}
strokeWidth={isHovered ? '2.5' : '1'}
strokeDasharray={isHovered ? 'none' : '3,3'}
className="dep-leader"
/>
<circle cx="50" cy="40" r="2.5" fill={isHovered ? '#000' : '#e0e0e0'} />
<circle cx="50" cy={boxTop} r={isHovered ? 4 : 2} fill={isHovered ? '#000' : '#e0e0e0'} />
</svg>
{/* 图标 */}
<div
className="dep-icon-node"
style={{
position: 'absolute',
top: 0, left: '50%', transform: 'translateX(-50%)',
display: 'flex', alignItems: 'center',
justifyContent: 'center', width: ICON_SIZE, height: ICON_SIZE, zIndex: 10,
}}
>
{thumb ? (
<img
src={thumb}
style={{ width: 64, height: 64, objectFit: 'contain', zIndex: 10, position: 'relative' }}
alt={area.name}
/>
) : (
<div style={{
width: 64, height: 64, display: 'flex', alignItems: 'center',
justifyContent: 'center', border: '1px dashed #d4d4d4', background: '#fafafa',
}}>
<span style={{
fontSize: 7, color: '#a3a3a3', textTransform: 'uppercase',
fontWeight: 700, letterSpacing: '.1em', textAlign: 'center', lineHeight: 1.4,
}}>NO<br />IMG</span>
</div>
)}
<div className="dep-glow" />
</div>
{/* 标签 */}
<div
className="dep-label"
style={{
position: 'absolute', display: 'flex', flexDirection: 'column',
alignItems: 'center', whiteSpace: 'nowrap',
top: `${boxTop}px`, left: '50%',
transform: `translateX(-50%)${isHovered ? (isUp ? ' translateY(-4px)' : ' translateY(4px)') : ''}`,
zIndex: 30,
}}
>
<div className="dep-terminal-dot" />
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, opacity: 0.6 }}>
{isUp ? <ChevronUp /> : <div style={{ width: 8 }} />}
<span className="dep-code">CODE.{String(index + 1).padStart(2, '0')}</span>
{!isUp ? <ChevronDown /> : <div style={{ width: 8 }} />}
</div>
<div className="dep-name">{area.name}</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,114 @@
'use client'
import React from 'react'
import { Button } from '@payloadcms/ui'
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
title: string
subtitle?: string
loading?: boolean
saving?: boolean
saveMsg?: string
canSave?: boolean
onRefresh: () => void
onBackToEdit: () => void
onSave: () => void
onAddArea: () => void
}
// ── Toolbar ───────────────────────────────────────────────────────────────────
export default function Toolbar({
title,
subtitle,
loading = false,
saving = false,
saveMsg = '',
canSave = true,
onRefresh,
onBackToEdit,
onSave,
onAddArea,
}: Props) {
const isError = saveMsg.includes('失败') || saveMsg.includes('error')
return (
<div
style={{
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: 52,
padding: '0 24px',
borderBottom: '1px solid var(--theme-elevation-150)',
background: 'var(--theme-bg)',
zIndex: 10,
}}
>
{/* 左侧:图标 + 标题 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div
style={{
width: 28, height: 28,
background: 'var(--theme-elevation-800)',
borderRadius: 4,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="var(--theme-bg)" strokeWidth="2" strokeLinecap="round">
<rect x="4" y="4" width="16" height="16" rx="2" />
<rect x="9" y="9" width="6" height="6" />
<line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" />
<line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" />
<line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" />
<line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" />
</svg>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontSize: 14, fontWeight: 600, lineHeight: 1, color: 'var(--theme-elevation-900)' }}>
{loading ? '加载中...' : title}
</span>
{subtitle && (
<span style={{ fontSize: 11, color: 'var(--theme-elevation-500)', lineHeight: 1 }}>
{subtitle}
</span>
)}
</div>
</div>
{/* 右侧:操作按钮组 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{saveMsg && (
<span style={{
fontSize: 12, fontWeight: 500, marginRight: 4,
color: isError ? 'var(--theme-error-500)' : 'var(--theme-success-500)',
}}>
{saveMsg}
</span>
)}
<Button buttonStyle="secondary" size="small" disabled={loading} onClick={onRefresh}>
</Button>
<Button buttonStyle="secondary" size="small" onClick={onBackToEdit}>
</Button>
<div style={{ width: 1, height: 20, background: 'var(--theme-elevation-200)', margin: '0 4px' }} />
<Button buttonStyle="secondary" size="small" disabled={!canSave} onClick={onAddArea}>
+
</Button>
<Button buttonStyle="primary" size="small" disabled={saving || !canSave} onClick={onSave}>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,205 @@
'use client'
/**
* DisassemblyEditorPage
* /admin/disassembly-editor?id=<pageId>
*/
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useConfig } from '@payloadcms/ui'
import Toolbar from './Toolbar'
import AreaNode from './AreaNode'
import AreaDrawers, { type DrawersHandle } from './AreaDrawers'
import { CANVAS_CSS } from './styles'
import { type Area, type PageDoc, imgUrl, isPopulatedArea } from './types'
// ── 主组件 ───────────────────────────────────────────────────────────────────
export default function DisassemblyEditorPage() {
const { config } = useConfig()
const apiBase: string = (config as any)?.routes?.api ?? '/api'
const adminBase: string = (config as any)?.routes?.admin ?? '/admin'
const [pageId, setPageId] = useState<string | null>(null)
useEffect(() => {
setPageId(new URLSearchParams(window.location.search).get('id'))
}, [])
const [doc, setDoc] = useState<PageDoc | null>(null)
const [areas, setAreas] = useState<Area[]>([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState('')
const [hoveredId, setHoveredId] = useState<string | null>(null)
const [selectedAreaId, setSelectedAreaId] = useState<string | undefined>(undefined)
const drawersRef = useRef<DrawersHandle>(null)
// ── 拉取文档 ──
const fetchDoc = useCallback(async (id: string) => {
setLoading(true)
try {
const res = await fetch(`${apiBase}/disassembly-pages/${id}?depth=2`, { credentials: 'include' })
if (!res.ok) return
const data: PageDoc = await res.json()
setDoc(data)
setAreas((data.areas ?? []).filter(isPopulatedArea))
} finally {
setLoading(false)
}
}, [apiBase])
useEffect(() => { if (pageId) void fetchDoc(pageId) }, [pageId, fetchDoc])
// ── 保存区域顺序 ──
const handleSave = useCallback(async () => {
if (!pageId) return
setSaving(true)
setSaveMsg('')
try {
const res = await fetch(`${apiBase}/disassembly-pages/${pageId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ areas: areas.map(a => a.id) }),
})
setSaveMsg(res.ok ? '已保存' : '保存失败')
if (res.ok) setTimeout(() => setSaveMsg(''), 2000)
} finally {
setSaving(false)
}
}, [pageId, apiBase, areas])
const handleBackToEdit = useCallback(() => {
window.location.href = pageId
? `${adminBase}/collections/disassembly-pages/${pageId}`
: `${adminBase}/collections/disassembly-pages`
}, [pageId, adminBase])
const mainImg = imgUrl(doc?.mainImage)
// ── 渲染 ──────────────────────────────────────────────────────────────────
return (
<>
<style>{CANVAS_CSS}</style>
{/*
- height:100vh + overflow:hidden
Payload admin
- flex:1 + overflow-y:auto
-
*/}
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
background: '#ffffff',
fontFamily: 'var(--font-body)',
}}>
{/* ── 工具栏(固定高度,不 sticky── */}
<Toolbar
title={doc?.name ?? '拆解可视化编辑器'}
subtitle={`${areas.length} 个区域 · 可视化编辑器`}
loading={loading}
saving={saving}
saveMsg={saveMsg}
canSave={!!pageId}
onRefresh={() => pageId && void fetchDoc(pageId)}
onBackToEdit={handleBackToEdit}
onSave={handleSave}
onAddArea={() => drawersRef.current?.openNew()}
/>
{/* ── 画布区固定填满视口剩余高度overflow:hidden 阻止绝对定位子元素撑开滚动高度)── */}
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
<div style={{
position: 'absolute',
inset: 0,
background: '#ffffff',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
padding: '64px 48px 0',
boxSizing: 'border-box',
userSelect: 'none',
overflowY: 'auto',
overflowX: 'hidden',
}}>
{/* 网格背景 */}
<div className="dep-blueprint-grid" />
<div className="dep-scan" />
{/* 中央装配主图 */}
<div style={{
position: 'relative', width: '100%', maxWidth: 700, height: 260,
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10, flexShrink: 0,
}}>
<div style={{ position: 'absolute', inset: -16, pointerEvents: 'none', opacity: 0.4 }}>
<div style={{ position: 'absolute', top: 0, left: 0, width: 48, height: 48, borderTop: '1px solid #525252', borderLeft: '1px solid #525252' }} />
<div style={{ position: 'absolute', bottom: 0, right: 0, width: 48, height: 48, borderBottom: '1px solid #525252', borderRight: '1px solid #525252' }} />
</div>
{loading ? (
<span style={{ fontSize: 10, color: '#a3a3a3', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.2em' }}>...</span>
) : mainImg ? (
<img src={mainImg} className="dep-assembly" alt={doc?.name ?? ''} style={{ maxHeight: 240 }} />
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '2px dashed #e5e5e5', background: '#fafafa' }}>
<span style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.3em', color: '#d4d4d4' }}></span>
</div>
)}
</div>
{/* 区域节点 */}
{areas.length > 0 && (
<div style={{
display: 'flex', justifyContent: 'space-between',
width: '100%', maxWidth: 1200,
height: 360,
marginTop: 48, padding: '0 40px',
zIndex: 20, boxSizing: 'border-box', position: 'relative',
overflow: 'visible',
}}>
{areas.map((area, index) => (
<AreaNode
key={area.id}
area={area}
index={index}
isHovered={hoveredId === area.id}
onHover={setHoveredId}
onEdit={setSelectedAreaId}
/>
))}
</div>
)}
{!loading && areas.length === 0 && (
<div style={{
marginTop: 64, fontSize: 9, color: '#d4d4d4',
fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.2em',
zIndex: 10, position: 'relative',
}}>
+
</div>
)}
</div>
</div>
</div>
{/* ── Drawers编辑 + 新建)── */}
<AreaDrawers
ref={drawersRef}
pageId={pageId}
areaIds={areas.map(a => a.id)}
selectedAreaId={selectedAreaId}
onClearEdit={() => setSelectedAreaId(undefined)}
onRefresh={() => pageId && void fetchDoc(pageId)}
apiBase={apiBase}
/>
</>
)
}

View File

@ -0,0 +1,100 @@
// ── 画布动画 & 节点 CSS ───────────────────────────────────────────────────────
export const CANVAS_CSS = `
@keyframes dep-scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
@keyframes dep-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.dep-blueprint-grid {
background-color: #ffffff;
background-image:
linear-gradient(rgba(0,0,0,0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.05) 1px, transparent 1px);
background-size: 30px 30px;
position: absolute; inset: 0;
}
.dep-blueprint-grid::after {
content: ""; position: absolute; inset: 0;
background-image:
linear-gradient(rgba(0,0,0,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.02) 1px, transparent 1px);
background-size: 10px 10px; pointer-events: none;
}
.dep-scan {
position: absolute; top: 0; left: 0; right: 0; height: 100%;
background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.01), transparent);
animation: dep-scanline 15s linear infinite; pointer-events: none; z-index: 5;
}
.dep-assembly {
filter: drop-shadow(0 15px 30px rgba(0,0,0,0.1)) contrast(1.05);
transition: filter 0.6s; max-width: 100%; max-height: 100%; object-fit: contain;
}
.dep-assembly:hover { filter: drop-shadow(0 20px 40px rgba(0,0,0,0.15)) contrast(1.1); }
.dep-node-wrap {
cursor: pointer; position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center;
}
.dep-icon-node { transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
.dep-node-wrap:hover .dep-icon-node { transform: translateX(-50%) scale(1.1) translateY(-8px); }
.dep-terminal-dot {
width: 10px; height: 10px; border-radius: 50%; border: 2px solid #fff;
margin-bottom: 12px; transition: background 0.3s, transform 0.3s; background: #e5e5e5; flex-shrink: 0;
}
.dep-node-wrap:hover .dep-terminal-dot { background: #171717; transform: scale(1.25); }
.dep-code {
font-size: 8px; font-weight: 900; text-transform: uppercase;
letter-spacing: 0.1em; color: #d4d4d4; transition: color 0.3s;
}
.dep-node-wrap:hover .dep-code { color: #171717; }
.dep-name {
font-size: 20px; font-weight: 900; text-transform: uppercase;
letter-spacing: -0.05em; color: #a3a3a3;
transition: color 0.3s, transform 0.3s;
white-space: nowrap; will-change: transform;
}
.dep-node-wrap:hover .dep-name { color: #171717; transform: scale(1.05); }
.dep-glow {
position: absolute; inset: 0; background: rgba(23,23,23,0.05);
filter: blur(24px); border-radius: 50%; transform: scale(1.25);
opacity: 0; transition: opacity 0.3s; pointer-events: none;
}
.dep-node-wrap:hover .dep-glow { opacity: 1; }
.dep-leader { transition: stroke 0.4s ease, stroke-width 0.4s ease; }
.dep-label { transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1); }
.dep-blink { animation: dep-blink 1.6s ease-in-out infinite; }
`
// ── Drawer 宽度覆盖(注入到 document.head────────────────────────────────────
const DRAWER_STYLE_ID = 'dep-drawer-overrides'
export function injectDrawerStyles(): void {
if (typeof document === 'undefined') return
if (document.getElementById(DRAWER_STYLE_ID)) return
const el = document.createElement('style')
el.id = DRAWER_STYLE_ID
el.textContent = `
/* 从左侧弹出,宽度 500px */
.drawer {
flex-direction: row-reverse !important;
}
.drawer__content {
width: 500px !important;
min-width: 0 !important;
max-width: 500px !important;
transform: translateX(calc(var(--base) * -4)) !important;
}
.drawer--is-open .drawer__content {
transform: translateX(0) !important;
}
.drawer__content-children {
overflow-x: hidden !important;
overflow-y: auto !important;
}
`
document.head.appendChild(el)
}

View File

@ -0,0 +1,33 @@
// ── Disassembly Editor 共享类型 ─────────────────────────────────────────────
export interface MediaDoc {
id: string
url: string
}
export interface Area {
id: string
name: string
thumbnailImage?: MediaDoc | string | null
mainImage?: MediaDoc | string | null
}
export interface PageDoc {
id: string
name: string
url?: string
mainImage?: MediaDoc | string | null
areas?: (Area | string)[]
}
// ── 工具函数 ─────────────────────────────────────────────────────────────────
export function imgUrl(v: MediaDoc | string | null | undefined): string | null {
if (!v) return null
if (typeof v === 'object' && v.url) return v.url
return null
}
export function isPopulatedArea(v: Area | string): v is Area {
return typeof v === 'object' && v !== null && 'id' in v
}

View File

@ -0,0 +1,500 @@
'use client'
import React, { useState, useEffect } from 'react'
import { Button, useAuth } from '@payloadcms/ui'
interface Log {
id: string
action: string
collection: string
documentId?: string
documentTitle?: string
user: {
id: string
email: string
}
createdAt: string
ip?: string
}
export default function LogsManagerView() {
const { user } = useAuth()
const [logs, setLogs] = useState<Log[]>([])
const [loading, setLoading] = useState(false)
const [totalDocs, setTotalDocs] = useState(0)
const [page, setPage] = useState(1)
const [limit] = useState(50)
// 筛选条件
const [days, setDays] = useState(7) // 默认显示最近7天
const [selectedCollection, setSelectedCollection] = useState('all')
const [selectedAction, setSelectedAction] = useState('all')
// 删除相关
const [deleteStartDate, setDeleteStartDate] = useState('')
const [deleteEndDate, setDeleteEndDate] = useState('')
const [deleteLoading, setDeleteLoading] = useState(false)
const [deleteMessage, setDeleteMessage] = useState('')
const isAdmin = user?.roles?.includes('admin')
// 加载日志
const loadLogs = async () => {
setLoading(true)
try {
// 计算日期范围
const endDate = new Date()
const startDate = new Date()
startDate.setDate(startDate.getDate() - days)
// 构建查询条件
const whereConditions: any[] = [
{
createdAt: {
greater_than_equal: startDate.toISOString(),
},
},
{
createdAt: {
less_than_equal: endDate.toISOString(),
},
},
]
if (selectedCollection !== 'all') {
whereConditions.push({
collection: {
equals: selectedCollection,
},
})
}
if (selectedAction !== 'all') {
whereConditions.push({
action: {
equals: selectedAction,
},
})
}
const query = new URLSearchParams({
depth: '1',
limit: limit.toString(),
page: page.toString(),
sort: '-createdAt',
where: JSON.stringify({
and: whereConditions,
}),
})
const response = await fetch(`/admin/api/logs?${query}`)
const data = await response.json()
setLogs(data.docs || [])
setTotalDocs(data.totalDocs || 0)
} catch (error) {
console.error('Failed to load logs:', error)
} finally {
setLoading(false)
}
}
// 删除指定日期范围的日志
const handleDeleteLogs = async () => {
if (!deleteStartDate || !deleteEndDate) {
setDeleteMessage('请选择开始和结束日期')
return
}
if (!confirm(`确定删除 ${deleteStartDate}${deleteEndDate} 的所有日志吗?此操作不可撤销!`)) {
return
}
setDeleteLoading(true)
setDeleteMessage('')
try {
const response = await fetch(
`/api/admin/log?startDate=${deleteStartDate}&endDate=${deleteEndDate}`,
{
method: 'DELETE',
},
)
const data = await response.json()
if (data.success) {
setDeleteMessage(data.message)
// 重新加载日志
loadLogs()
// 清空日期选择
setDeleteStartDate('')
setDeleteEndDate('')
} else {
setDeleteMessage(`删除失败: ${data.error}`)
}
} catch (error) {
setDeleteMessage(`删除出错: ${error instanceof Error ? error.message : '未知错误'}`)
} finally {
setDeleteLoading(false)
}
}
// 快速删除按钮
const handleQuickDelete = (daysAgo: number) => {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - daysAgo)
setDeleteStartDate(start.toISOString().split('T')[0])
setDeleteEndDate(end.toISOString().split('T')[0])
}
useEffect(() => {
loadLogs()
}, [days, selectedCollection, selectedAction, page])
const actionLabels: Record<string, string> = {
create: '创建',
update: '更新',
delete: '删除',
sync: '同步',
login: '登录',
logout: '登出',
}
const actionColors: Record<string, string> = {
create: '#10b981',
update: '#3b82f6',
delete: '#ef4444',
sync: '#8b5cf6',
login: '#14b8a6',
logout: '#6b7280',
}
return (
<div style={{ padding: '2rem', maxWidth: '1400px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '2rem', fontSize: '2rem', fontWeight: 'bold' }}>
📋
</h1>
{/* 筛选区域 */}
<div
style={{
backgroundColor: 'var(--theme-elevation-50)',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '1.5rem',
border: '1px solid var(--theme-elevation-100)',
}}
>
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
🔍
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
</label>
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
style={{
width: '100%',
padding: '0.5rem',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-400)',
backgroundColor: 'var(--theme-elevation-0)',
}}
>
<option value={1}> 1 </option>
<option value={3}> 3 </option>
<option value={7}> 7 </option>
<option value={15}> 15 </option>
<option value={30}> 30 </option>
<option value={90}> 90 </option>
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
</label>
<select
value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)}
style={{
width: '100%',
padding: '0.5rem',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-400)',
backgroundColor: 'var(--theme-elevation-0)',
}}
>
<option value="all"></option>
<option value="products"></option>
<option value="announcements"></option>
<option value="articles"></option>
<option value="media"></option>
<option value="users"></option>
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
</label>
<select
value={selectedAction}
onChange={(e) => setSelectedAction(e.target.value)}
style={{
width: '100%',
padding: '0.5rem',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-400)',
backgroundColor: 'var(--theme-elevation-0)',
}}
>
<option value="all"></option>
<option value="create"></option>
<option value="update"></option>
<option value="delete"></option>
<option value="sync"></option>
<option value="login"></option>
<option value="logout"></option>
</select>
</div>
</div>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
<Button onClick={loadLogs} disabled={loading}>
{loading ? '加载中...' : '刷新'}
</Button>
</div>
</div>
{/* 删除日志区域 (仅管理员) */}
{isAdmin && (
<div
style={{
backgroundColor: 'var(--theme-elevation-50)',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '1.5rem',
border: '1px solid var(--theme-error-500)',
}}
>
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
🗑
</h2>
<div style={{ marginBottom: '1rem' }}>
<p style={{ marginBottom: '0.5rem' }}></p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
onClick={() => handleQuickDelete(7)}
style={{
padding: '0.5rem 1rem',
fontSize: '0.875rem',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-400)',
backgroundColor: 'var(--theme-elevation-0)',
cursor: 'pointer',
}}
>
7
</button>
<button
onClick={() => handleQuickDelete(30)}
style={{
padding: '0.5rem 1rem',
fontSize: '0.875rem',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-400)',
backgroundColor: 'var(--theme-elevation-0)',
cursor: 'pointer',
}}
>
30
</button>
<button
onClick={() => handleQuickDelete(90)}
style={{
padding: '0.5rem 1rem',
fontSize: '0.875rem',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-400)',
backgroundColor: 'var(--theme-elevation-0)',
cursor: 'pointer',
}}
>
90
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '1rem', alignItems: 'end' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
</label>
<input
type="date"
value={deleteStartDate}
onChange={(e) => setDeleteStartDate(e.target.value)}
style={{
width: '100%',
padding: '0.5rem',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-400)',
backgroundColor: 'var(--theme-elevation-0)',
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
</label>
<input
type="date"
value={deleteEndDate}
onChange={(e) => setDeleteEndDate(e.target.value)}
style={{
width: '100%',
padding: '0.5rem',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-400)',
backgroundColor: 'var(--theme-elevation-0)',
}}
/>
</div>
<Button onClick={handleDeleteLogs} disabled={deleteLoading} buttonStyle="error">
{deleteLoading ? '删除中...' : '删除日志'}
</Button>
</div>
{deleteMessage && (
<div
style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor:
deleteMessage.includes('失败') || deleteMessage.includes('出错')
? 'var(--theme-error-50)'
: 'var(--theme-success-50)',
borderRadius: '6px',
fontSize: '0.875rem',
border: `1px solid ${
deleteMessage.includes('失败') || deleteMessage.includes('出错')
? 'var(--theme-error-500)'
: 'var(--theme-success-500)'
}`,
}}
>
{deleteMessage}
</div>
)}
</div>
)}
{/* 日志列表 */}
<div
style={{
backgroundColor: 'var(--theme-elevation-50)',
borderRadius: '8px',
padding: '1.5rem',
border: '1px solid var(--theme-elevation-100)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: '600' }}>
📊 {totalDocs}
</h2>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>...</div>
) : logs.length === 0 ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--theme-elevation-600)' }}>
</div>
) : (
<>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--theme-elevation-200)' }}>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}></th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}></th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}></th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}></th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}></th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>IP</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr
key={log.id}
style={{
borderBottom: '1px solid var(--theme-elevation-150)',
}}
>
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>
{new Date(log.createdAt).toLocaleString('zh-CN')}
</td>
<td style={{ padding: '0.75rem' }}>
<span
style={{
display: 'inline-block',
padding: '0.25rem 0.75rem',
borderRadius: '9999px',
fontSize: '0.75rem',
fontWeight: '600',
backgroundColor: actionColors[log.action] + '20',
color: actionColors[log.action],
}}
>
{actionLabels[log.action] || log.action}
</span>
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>{log.collection}</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>
{log.documentTitle || log.documentId || '-'}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>
{typeof log.user === 'object' ? log.user.email : log.user}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>{log.ip || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
{totalDocs > limit && (
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '0.5rem' }}>
<Button onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 1}>
</Button>
<span style={{ padding: '0.5rem 1rem', alignItems: 'center', display: 'flex' }}>
{page} / {Math.ceil(totalDocs / limit)}
</span>
<Button
onClick={() => setPage(page + 1)}
disabled={page >= Math.ceil(totalDocs / limit)}
>
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,248 @@
'use client'
import React, { useEffect, useState } from 'react'
import { Button } from '@payloadcms/ui'
interface HealthCheckResult {
success: boolean
timestamp: string
summary: {
total: number
healthy: number
warnings: number
errors: number
}
products: Array<{
id: string
title: string
medusaId: string
seedId: string
status: string
severity: 'healthy' | 'warning' | 'error'
issues: string[]
stats: {
orderCount: number
fakeOrderCount: number
totalDisplayCount: number
fundingGoal: number
completionPercentage: number
}
dates: {
preorderStartDate: string | null
preorderEndDate: string | null
}
}>
issues: string[]
}
export const PreorderHealthCheck: React.FC = () => {
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<HealthCheckResult | null>(null)
const [error, setError] = useState<string | null>(null)
const runHealthCheck = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch('/api/preorders/health-check')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
setResult(data)
} catch (err: any) {
setError(err.message || 'Failed to run health check')
console.error('Health check error:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
// 自动运行一次健康检查
runHealthCheck()
}, [])
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'error':
return 'text-red-600 bg-red-50'
case 'warning':
return 'text-yellow-600 bg-yellow-50'
case 'healthy':
return 'text-green-600 bg-green-50'
default:
return 'text-gray-600 bg-gray-50'
}
}
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'error':
return '❌'
case 'warning':
return '⚠️'
case 'healthy':
return '✅'
default:
return ''
}
}
const formatDate = (dateString: string | null) => {
if (!dateString) return 'N/A'
try {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
} catch {
return dateString
}
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-gray-600">
</p>
</div>
<Button
onClick={runHealthCheck}
disabled={loading}
buttonStyle="primary"
>
{loading ? '检查中...' : '刷新检查'}
</Button>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-800">
<strong>:</strong> {error}
</p>
</div>
)}
{result && (
<>
{/* 概览统计 */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
<p className="text-sm text-blue-600 font-medium mb-1"></p>
<p className="text-3xl font-bold text-blue-700">{result.summary.total}</p>
</div>
<div className="bg-green-50 p-4 rounded-lg border border-green-200">
<p className="text-sm text-green-600 font-medium mb-1"></p>
<p className="text-3xl font-bold text-green-700">{result.summary.healthy}</p>
</div>
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
<p className="text-sm text-yellow-600 font-medium mb-1"></p>
<p className="text-3xl font-bold text-yellow-700">{result.summary.warnings}</p>
</div>
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
<p className="text-sm text-red-600 font-medium mb-1"></p>
<p className="text-3xl font-bold text-red-700">{result.summary.errors}</p>
</div>
</div>
{/* 检查时间 */}
<p className="text-sm text-gray-500 mb-6">
: {new Date(result.timestamp).toLocaleString('zh-CN')}
</p>
{/* 产品列表 */}
<div className="space-y-4">
{result.products.map((product) => (
<div
key={product.id}
className={`border rounded-lg p-4 ${
product.severity === 'error'
? 'border-red-300 bg-red-50'
: product.severity === 'warning'
? 'border-yellow-300 bg-yellow-50'
: 'border-green-300 bg-green-50'
}`}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{getSeverityIcon(product.severity)}</span>
<h3 className="text-lg font-semibold">{product.title}</h3>
<span
className={`px-2 py-1 text-xs rounded-full ${
product.status === 'published'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{product.status}
</span>
</div>
<div className="text-sm text-gray-600 space-y-1">
<p>Medusa ID: {product.medusaId}</p>
{product.seedId && <p>Seed ID: {product.seedId}</p>}
</div>
</div>
<div className="text-right">
<div className="text-sm text-gray-600">
<p>: {product.stats.completionPercentage}%</p>
<p>
{product.stats.totalDisplayCount} / {product.stats.fundingGoal}
</p>
</div>
</div>
</div>
{/* 日期信息 */}
<div className="flex gap-4 text-sm text-gray-600 mb-3">
<div>
<span className="font-medium">:</span>{' '}
{formatDate(product.dates.preorderStartDate)}
</div>
<div>
<span className="font-medium">:</span>{' '}
{formatDate(product.dates.preorderEndDate)}
</div>
</div>
{/* 问题列表 */}
{product.issues.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-300">
<p className="text-sm font-medium mb-2">:</p>
<ul className="text-sm space-y-1">
{product.issues.map((issue, idx) => (
<li key={idx} className="flex items-start">
<span className="mr-2"></span>
<span>{issue}</span>
</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
{result.products.length === 0 && (
<div className="text-center py-12 text-gray-500">
<p className="text-lg"></p>
</div>
)}
</>
)}
{!result && !error && loading && (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,41 @@
import type { GlobalConfig } from 'payload'
export const AdminSettings: GlobalConfig = {
slug: 'admin-settings',
access: {
read: ({ req: { user } }) => {
// 只有 admin 可以访问
if (!user) return false
return user.roles?.includes('admin') || false
},
update: ({ req: { user } }) => {
// 只有 admin 可以更新
if (!user) return false
return user.roles?.includes('admin') || false
},
},
admin: {
group: '系统',
description: '管理员控制面板 - 数据管理和系统维护',
components: {
views: {
edit: {
default: {
Component: '/components/views/AdminPanel',
},
},
},
},
},
fields: [
{
name: 'title',
type: 'text',
defaultValue: '管理员设置',
admin: {
readOnly: true,
hidden: true,
},
},
],
}

239
src/globals/HeroSlider.ts Normal file
View File

@ -0,0 +1,239 @@
import type { GlobalConfig } from 'payload'
import { deleteCachePattern } from '@/lib/redis'
export const HeroSlider: GlobalConfig = {
slug: 'hero-slider',
label: {
en: 'Hero Slider',
zh: '首页幻灯片',
},
access: {
read: () => true, // 公开可读
update: ({ req: { user } }) => {
// 只有 admin 和 editor 可以更新
if (!user) return false
return user.roles?.includes('admin') || user.roles?.includes('editor') || false
},
},
admin: {
group: {
en: 'Content',
zh: '内容管理',
},
description: {
en: 'Manage homepage hero slider/banner',
zh: '管理首页轮播图/横幅',
},
},
fields: [
{
name: 'slides',
type: 'array',
label: {
en: 'Slides',
zh: '幻灯片',
},
labels: {
singular: {
en: 'Slide',
zh: '幻灯片',
},
plural: {
en: 'Slides',
zh: '幻灯片列表',
},
},
minRows: 1,
maxRows: 10,
admin: {
description: {
en: 'Add slides to the hero slider (drag to reorder)',
zh: '添加幻灯片(拖动排序)',
},
initCollapsed: true,
},
fields: [
{
name: 'title',
type: 'text',
label: {
en: 'Title',
zh: '标题',
},
required: true,
admin: {
description: {
en: 'Main heading text (e.g., "CHISFLASH GB")',
zh: '主标题文字(如:"CHISFLASH GB"',
},
},
},
{
name: 'subtitle',
type: 'text',
label: {
en: 'Subtitle',
zh: '副标题',
},
required: true,
admin: {
description: {
en: 'Small uppercase label (e.g., "8-Bit Architecture")',
zh: '小标签文字(如:"8-Bit Architecture"',
},
},
},
{
name: 'desc',
type: 'textarea',
label: {
en: 'Description',
zh: '描述',
},
required: true,
maxLength: 300,
admin: {
rows: 3,
description: {
en: 'Detailed product description',
zh: '产品详细描述',
},
},
},
{
name: 'image',
type: 'upload',
label: {
en: 'Product Image',
zh: '产品图片',
},
relationTo: 'media',
required: true,
admin: {
description: {
en: 'High-resolution product image (recommended: PNG with transparency)',
zh: '高清产品图片(推荐:带透明背景的 PNG',
},
},
},
{
name: 'layout',
type: 'select',
label: {
en: 'Layout',
zh: '布局',
},
required: true,
defaultValue: 'left',
options: [
{
label: {
en: 'Left Aligned',
zh: '左对齐',
},
value: 'left',
},
{
label: {
en: 'Right Aligned',
zh: '右对齐',
},
value: 'right',
},
{
label: {
en: 'Center Aligned',
zh: '居中对齐',
},
value: 'center',
},
],
admin: {
description: {
en: 'Text and button alignment position',
zh: '文字和按钮对齐位置',
},
},
},
{
name: 'showFocusCircle',
type: 'checkbox',
label: {
en: 'Show Focus Circle',
zh: '显示焦点圆圈',
},
defaultValue: false,
admin: {
description: {
en: 'Display subtle focus rings around the product image',
zh: '在产品图片周围显示焦点圆圈效果',
},
},
},
{
name: 'price',
type: 'text',
label: {
en: 'Price',
zh: '价格',
},
required: true,
admin: {
description: {
en: 'Product price (e.g., "$45.00")',
zh: '产品价格(如:"$45.00"',
},
},
},
{
name: 'product',
type: 'relationship',
label: {
en: 'Related Product',
zh: '关联商品',
},
relationTo: ['products', 'preorder-products'],
hasMany: false,
admin: {
description: {
en: 'Link this slide to a product (will auto-generate purchase link)',
zh: '关联到商品(自动生成购买链接)',
},
components: {
Field: '/components/fields/RelatedProductsField#RelatedProductsField',
},
},
},
{
name: 'link',
type: 'text',
label: {
en: 'Custom Link (Optional)',
zh: '自定义链接(可选)',
},
admin: {
description: {
en: 'Override with custom link if product is not set',
zh: '如未设置商品,可使用自定义链接',
},
condition: (data) => !data.product,
},
},
],
},
],
hooks: {
afterChange: [
async ({ doc }) => {
try {
// 清除 hero-slider 的所有缓存
const deletedCount = await deleteCachePattern('hero-slider:*')
console.log(`[Cache] Cleared ${deletedCount} cache keys for hero-slider after change`)
} catch (error) {
console.error('[Cache] Failed to clear hero-slider cache:', error)
}
return doc
},
],
},
}

View File

@ -0,0 +1,39 @@
import type { GlobalConfig } from 'payload'
export const LogsManager: GlobalConfig = {
slug: 'logs-manager',
access: {
read: ({ req: { user } }) => {
// admin 和 editor 可以访问
if (!user) return false
return user.roles?.includes('admin') || user.roles?.includes('editor') || false
},
update: ({ req: { user } }) => {
// 只有 admin 可以删除日志
if (!user) return false
return user.roles?.includes('admin') || false
},
},
admin: {
group: '系统',
description: '日志查看和管理工具',
components: {
views: {
edit: {
default: {
Component: '/components/views/LogsManagerView',
},
},
},
},
},
fields: [
{
name: 'placeholder',
type: 'text',
admin: {
hidden: true,
},
},
],
}

View File

@ -0,0 +1,160 @@
import type { GlobalConfig } from 'payload'
import { deleteCachePattern } from '@/lib/redis'
export const ProductRecommendations: GlobalConfig = {
slug: 'product-recommendations',
label: {
en: 'Product Recommendations',
zh: '商品推荐',
},
access: {
read: () => true, // 公开可读
update: ({ req: { user } }) => {
// 只有 admin 和 editor 可以更新
if (!user) return false
return user.roles?.includes('admin') || user.roles?.includes('editor') || false
},
},
admin: {
group: {
en: 'Content',
zh: '内容管理',
},
description: {
en: 'Manage product recommendation lists for homepage and other pages',
zh: '管理首页及其他页面的商品推荐列表',
},
},
fields: [
{
name: 'seedActions',
type: 'ui',
label: {
en: 'Quick Actions',
zh: '快捷操作',
},
admin: {
position: 'sidebar',
components: {
Field: '/components/seed/RestoreRecommendationsSeedButton#RestoreRecommendationsSeedButton',
},
},
},
{
name: 'enabled',
type: 'checkbox',
label: {
en: 'Enable Recommendations',
zh: '启用推荐',
},
defaultValue: true,
admin: {
description: {
en: 'Toggle to show/hide product recommendations',
zh: '切换显示/隐藏商品推荐',
},
},
},
{
name: 'lists',
type: 'array',
label: {
en: 'Recommendation Lists',
zh: '推荐列表',
},
labels: {
singular: {
en: 'List',
zh: '列表',
},
plural: {
en: 'Lists',
zh: '列表集合',
},
},
minRows: 0,
maxRows: 10,
admin: {
description: {
en: 'Create multiple product recommendation lists (e.g., Hot Items, New Arrivals, Limited Offers)',
zh: '创建多个商品推荐列表(如:热门商品、新品上架、限时优惠)',
},
initCollapsed: true,
},
fields: [
{
name: 'title',
type: 'text',
label: {
en: 'List Title',
zh: '列表标题',
},
required: true,
},
{
name: 'subtitle',
type: 'textarea',
label: {
en: 'Subtitle',
zh: '副标题',
},
maxLength: 200,
admin: {
rows: 2,
},
},
{
name: 'preorder',
type: 'checkbox',
label: {
en: 'Preorder Products',
zh: '预购商品',
},
defaultValue: false,
admin: {
description: {
en: 'Check if this list contains preorder products',
zh: '勾选表示此列表包含预购商品',
},
},
},
{
name: 'products',
type: 'relationship',
label: {
en: 'Products',
zh: '商品列表',
},
relationTo: ['products', 'preorder-products'],
hasMany: true,
admin: {
description: {
en: 'Select and drag to reorder products',
zh: '相关商品,支持搜索联想',
},
components: {
Field: '/components/fields/RelatedProductsField#RelatedProductsField',
},
},
},
],
},
],
hooks: {
afterChange: [
async ({ doc }) => {
try {
// 清除商品推荐和首页的所有缓存
const deletedCount = await deleteCachePattern('product-recommendations:*')
const deletedHomepage = await deleteCachePattern('homepage:*')
console.log(
`[Cache] Cleared ${deletedCount} product-recommendations cache keys and ${deletedHomepage} homepage cache keys`,
)
} catch (error) {
console.error('[Cache] Failed to clear product-recommendations cache:', error)
}
return doc
},
],
},
}

79
src/globals/SiteAccess.ts Normal file
View File

@ -0,0 +1,79 @@
import type { GlobalConfig } from 'payload'
export const SiteAccess: GlobalConfig = {
slug: 'site-access',
label: {
en: 'Site Access Control',
zh: '站点访问控制',
},
access: {
read: () => true, // 公开可读,前端需要检查访问状态
update: ({ req: { user } }) => {
// 只有 admin 可以更新
if (!user) return false
return user.roles?.includes('admin') || false
},
},
admin: {
group: {
en: 'System',
zh: '系统',
},
description: {
en: 'Control site accessibility and maintenance mode',
zh: '控制站点可访问性和维护模式',
},
},
fields: [
{
name: 'isAccessible',
type: 'checkbox',
label: {
en: 'Site Accessible',
zh: '站点可访问',
},
defaultValue: true,
required: true,
admin: {
description: {
en: 'Toggle to enable/disable public access to the site',
zh: '切换以启用/禁用站点的公开访问',
},
},
},
{
name: 'maintenanceMessage',
type: 'textarea',
label: {
en: 'Maintenance Message',
zh: '维护提示消息',
},
defaultValue: 'The site is currently under maintenance. Please check back later.',
required: true,
admin: {
description: {
en: 'Message to display when site is not accessible',
zh: '站点不可访问时显示的消息',
},
rows: 4,
},
},
{
name: 'estimatedRestoreTime',
type: 'date',
label: {
en: 'Estimated Restore Time',
zh: '预计恢复时间',
},
admin: {
description: {
en: 'Optional: When the site is expected to be back online',
zh: '可选:预计站点恢复在线的时间',
},
date: {
displayFormat: 'yyyy-MM-dd HH:mm',
},
},
},
],
}

View File

@ -0,0 +1,46 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { deleteCachePattern } from '../lib/redis'
/**
* afterChange hook -
*/
export const cacheAfterChange: CollectionAfterChangeHook = async ({ doc, req, collection }) => {
const collectionSlug = collection?.slug
if (!collectionSlug) return doc
try {
// 清除该 collection 的所有缓存
const deletedCount = await deleteCachePattern(`${collectionSlug}:*`)
console.log(
`[Cache] Cleared ${deletedCount} cache keys for collection: ${collectionSlug} after change`,
)
} catch (error) {
console.error(`[Cache] Failed to clear cache for ${collectionSlug}:`, error)
}
return doc
}
/**
* afterDelete hook -
*/
export const cacheAfterDelete: CollectionAfterDeleteHook = async ({ doc, req, id, collection }) => {
const collectionSlug = collection?.slug
if (!collectionSlug) return doc
try {
// 清除该 collection 的所有缓存
const deletedCount = await deleteCachePattern(`${collectionSlug}:*`)
console.log(
`[Cache] Cleared ${deletedCount} cache keys for collection: ${collectionSlug} after delete`,
)
} catch (error) {
console.error(`[Cache] Failed to clear cache for ${collectionSlug}:`, error)
}
return doc
}

107
src/hooks/logAction.ts Normal file
View File

@ -0,0 +1,107 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
/**
*
*/
export async function logAction({
req,
action,
collection,
documentId,
documentTitle,
changes,
}: {
req: any
action: 'create' | 'update' | 'delete' | 'sync' | 'login' | 'logout'
collection: string
documentId?: string
documentTitle?: string
changes?: any
}) {
try {
const { payload, user } = req
if (!user) return // 无用户信息则不记录
// 获取 IP 地址
const ip =
req.headers?.['x-forwarded-for'] ||
req.headers?.['x-real-ip'] ||
req.ip ||
req.connection?.remoteAddress ||
'unknown'
// 获取 User Agent
const userAgent = req.headers?.['user-agent'] || 'unknown'
// 创建日志记录
await payload.create({
collection: 'logs',
data: {
action,
collection,
documentId: documentId?.toString(),
documentTitle,
user: user.id,
changes,
ip,
userAgent,
},
// 不触发 hooks避免递归
context: { skipHooks: true },
})
} catch (error) {
// 静默失败,避免影响主要操作
console.error('[Log Hook Error]:', error)
}
}
/**
* afterChange
*/
export const logAfterChange: CollectionAfterChangeHook = async ({
doc,
req,
operation,
collection,
}) => {
// 跳过日志自身的操作,避免递归
if (req.context?.skipHooks) return doc
const collectionSlug = collection?.slug as string
// 不记录 logs 和 users-sessions 自身
if (collectionSlug === 'logs' || collectionSlug === 'users-sessions') return doc
await logAction({
req,
action: operation === 'create' ? 'create' : 'update',
collection: collectionSlug,
documentId: doc.id,
documentTitle: doc.title || doc.name || doc.email || doc.alt || `ID: ${doc.id}`,
changes: operation === 'update' ? { updatedFields: Object.keys(doc) } : undefined,
})
return doc
}
/**
* afterDelete
*/
export const logAfterDelete: CollectionAfterDeleteHook = async ({ doc, req, collection }) => {
if (req.context?.skipHooks) return doc
const collectionSlug = collection?.slug as string
if (collectionSlug === 'logs' || collectionSlug === 'users-sessions') return doc
await logAction({
req,
action: 'delete',
collection: collectionSlug,
documentId: doc.id,
documentTitle: doc.title || doc.name || doc.email || doc.alt || `ID: ${doc.id}`,
})
return doc
}

35
src/lib/cors.ts Normal file
View File

@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
/**
* CORS
* Medusa 访 Payload API
*/
const ALLOWED_ORIGINS = [
'http://localhost:9000', // Medusa 开发服务器
'http://localhost:8000', // Storefront 默认 端口
process.env.MEDUSA_URL,
process.env.ADMIN_URL,
].filter(Boolean) as string[]
/**
* CORS
*/
export function addCorsHeaders(response: NextResponse, origin?: string | null): NextResponse {
// 检查 origin 是否在允许列表中
const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]
response.headers.set('Access-Control-Allow-Origin', allowedOrigin)
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
response.headers.set('Access-Control-Allow-Credentials', 'true')
return response
}
/**
* OPTIONS
*/
export function handleCorsOptions(origin?: string | null): NextResponse {
const response = NextResponse.json({}, { status: 200 })
return addCorsHeaders(response, origin)
}

Some files were not shown because too many files have changed in this diff Show More