Compare commits
40 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
0fc2899f25 | |
|
|
1f03387619 | |
|
|
4fb29d9cb7 | |
|
|
e9947bdbdd | |
|
|
482bcda16d | |
|
|
6e75c34faf | |
|
|
c84eef485b | |
|
|
b90005038f | |
|
|
c6771a7098 | |
|
|
63620571a2 | |
|
|
c8de57af22 | |
|
|
a424a5c1a9 | |
|
|
dc8c88e463 | |
|
|
14a2aaced0 | |
|
|
4def0e2c0b | |
|
|
1860affd69 | |
|
|
b9cb60e3d0 | |
|
|
41f3eb5adf | |
|
|
c29ee4d0c3 | |
|
|
dae1b2704f | |
|
|
35928a6144 | |
|
|
efb3f2c727 | |
|
|
9a47af76ce | |
|
|
1f78d88d10 | |
|
|
f5621a3aa1 | |
|
|
ee3ec61548 | |
|
|
029c85f1a3 | |
|
|
397dcb93ae | |
|
|
af1023c3d7 | |
|
|
fa30986946 | |
|
|
b26fe1e117 | |
|
|
07d1c2274b | |
|
|
249423d73d | |
|
|
bdee359c4c | |
|
|
84f94b53d9 | |
|
|
b4991fcefd | |
|
|
4f14ac59f3 | |
|
|
cccbe20aa0 | |
|
|
3ad86524d4 | |
|
|
93f8261622 |
28
.env.example
28
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
108
pnpm-lock.yaml
108
pnpm-lock.yaml
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 完成,无需命令行。
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
|
||||||
|
import { cacheAfterChange, cacheAfterDelete } from '../../hooks/cacheInvalidation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第二层 - 拆解区域
|
||||||
|
*
|
||||||
|
* 位于拆解页(DisassemblyPages)与拆解组件(DisassemblyComponents)之间。
|
||||||
|
* 每个区域拥有独立的装配主图(区域总览图)和所属组件列表。
|
||||||
|
*
|
||||||
|
* 完整数据层级:
|
||||||
|
* DisassemblyPages (第一层 — 拆解页)
|
||||||
|
* └─ DisassemblyAreas (第二层 — 拆解区域) ← 本集合
|
||||||
|
* └─ DisassemblyComponents (第三层 — 拆解组件)
|
||||||
|
* └─ DisassemblyLinkedProducts (第四层 — 关联商品)
|
||||||
|
*
|
||||||
|
* 区域预览:编辑页底部内嵌 DisassemblyAreaViewer(ui 字段),
|
||||||
|
* 以 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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: '该组件下的关联商品信息条目',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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: '关联的预售商品',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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 (第四层 — 关联商品)
|
||||||
|
*
|
||||||
|
* 可视化编辑:编辑页底部内嵌 DisassemblyVisualEditor(ui 字段)。
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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}>
|
||||||
|
🔄 刷新订单
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 // 这是一个纯样式组件,不渲染任何内容
|
||||||
|
}
|
||||||
|
|
@ -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' }} />
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>:仅填充空白字段(标题、封面、价格) 
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in New Issue