Compare commits
No commits in common. "dev" and "master" have entirely different histories.
28
.env.example
28
.env.example
|
|
@ -1,28 +1,2 @@
|
||||||
# Database
|
DATABASE_URL=mongodb://127.0.0.1/your-database-name
|
||||||
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 1145
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT 1145
|
ENV PORT 3000
|
||||||
|
|
||||||
# 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:1145` to open the app in your browser
|
4. open `http://localhost:3000` 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:
|
||||||
- '1145:1145'
|
- '3000:3000'
|
||||||
volumes:
|
volumes:
|
||||||
- .:/home/node/app
|
- .:/home/node/app
|
||||||
- node_modules:/home/node/app/node_modules
|
- node_modules:/home/node/app/node_modules
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,24 @@
|
||||||
"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 PORT=1145 next dev",
|
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||||
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation PORT=1145 next dev",
|
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation 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 PORT=1145 next start",
|
"start": "cross-env NODE_OPTIONS=--no-deprecation 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",
|
||||||
|
|
@ -34,7 +32,6 @@
|
||||||
"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:1145',
|
// baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
/* 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:1145',
|
url: 'http://localhost:3000',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
108
pnpm-lock.yaml
108
pnpm-lock.yaml
|
|
@ -11,9 +11,6 @@ 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)
|
||||||
|
|
@ -29,9 +26,6 @@ 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)
|
||||||
|
|
@ -56,9 +50,6 @@ 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
|
||||||
|
|
@ -1577,15 +1568,6 @@ 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}
|
||||||
|
|
@ -1643,34 +1625,6 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -2581,10 +2535,6 @@ 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'}
|
||||||
|
|
@ -4168,10 +4118,6 @@ 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'}
|
||||||
|
|
@ -6618,14 +6564,6 @@ 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)
|
||||||
|
|
@ -6789,26 +6727,6 @@ 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':
|
||||||
|
|
@ -7818,8 +7736,6 @@ 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
|
||||||
|
|
@ -8223,8 +8139,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)(eslint@9.39.2)
|
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-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-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-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)
|
||||||
|
|
@ -8243,7 +8159,7 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2):
|
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):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nolyfill/is-core-module': 1.0.39
|
'@nolyfill/is-core-module': 1.0.39
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
|
|
@ -8254,22 +8170,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@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)
|
||||||
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@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-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):
|
||||||
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)(eslint@9.39.2)
|
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)
|
||||||
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@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):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
|
|
@ -8280,7 +8196,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@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-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)
|
||||||
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
|
||||||
|
|
@ -9695,14 +9611,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,515 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,568 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
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,99 +1,7 @@
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
# 数据重置功能说明
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
通过 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 完成,无需命令行。
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,304 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
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],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
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],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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,5 +1,4 @@
|
||||||
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',
|
||||||
|
|
@ -13,9 +12,5 @@ export const Media: CollectionConfig = {
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hooks: {
|
|
||||||
afterChange: [logAfterChange],
|
|
||||||
afterDelete: [logAfterDelete],
|
|
||||||
},
|
|
||||||
upload: true,
|
upload: true,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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,104 +8,6 @@ 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
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,432 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
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: '该组件下的关联商品信息条目',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
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: '关联的预售商品',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,360 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,425 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
'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 // 这是一个纯样式组件,不渲染任何内容
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
'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' }} />
|
|
||||||
}
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
// 预购商品网格视图样式
|
|
||||||
// 将表格转换为卡片网格,显示预购进度
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
// 这是为了覆盖 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,258 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
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'
|
|
||||||
|
|
@ -1,481 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
'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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,446 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,474 +0,0 @@
|
||||||
'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
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
'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
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
'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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
// ── 画布动画 & 节点 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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// ── 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,500 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
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
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
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
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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