diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index ad479b2..8677edd 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -35,10 +35,14 @@ import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997e 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 { 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 { default as default_3e6848ddbbb7b926ae9afb108e1f6856 } from '../../../components/views/Disassembly/editor' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' export const importMap = { @@ -79,9 +83,13 @@ export const importMap = { "@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/views/AdminPanel#default": default_767734c8b7b095ea28d54c32abcf46e4, "/components/views/LogsManagerView#default": default_a766ef013722c08f9bb937940272cb5f, "/components/seed/RestoreRecommendationsSeedButton#RestoreRecommendationsSeedButton": RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da, "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, + "/components/views/Disassembly/editor#default": default_3e6848ddbbb7b926ae9afb108e1f6856, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/src/collections/disassembly/DisassemblyAreas.ts b/src/collections/disassembly/DisassemblyAreas.ts new file mode 100644 index 0000000..d9dfcfc --- /dev/null +++ b/src/collections/disassembly/DisassemblyAreas.ts @@ -0,0 +1,96 @@ +import type { CollectionConfig } from 'payload' +import { logAfterChange, logAfterDelete } from '../../hooks/logAction' +import { cacheAfterChange, cacheAfterDelete } from '../../hooks/cacheInvalidation' + +/** + * 第二层 - 拆解区域 + * + * 位于拆解页(DisassemblyPages)与拆解组件(DisassemblyComponents)之间。 + * 每个区域拥有独立的装配主图(区域总览图)和所属组件列表。 + * + * 完整数据层级: + * DisassemblyPages (第一层 — 拆解页) + * └─ DisassemblyAreas (第二层 — 拆解区域) ← 本集合 + * └─ DisassemblyComponents (第三层 — 拆解组件) + * └─ DisassemblyLinkedProducts (第四层 — 关联商品) + * + * 区域预览:编辑页底部内嵌 DisassemblyAreaViewer(ui 字段), + * 以 DisassemblyPages.html 的工业草稿风格展示装配主图 + 组件节点。 + */ +export const DisassemblyAreas: CollectionConfig = { + slug: 'disassembly-areas', + admin: { + useAsTitle: 'name', + hidden: true, + description: '拆解页的区域层(仅通过可视化编辑器管理)', + defaultColumns: ['name', 'page', 'updatedAt'], + pagination: { + defaultLimit: 50, + }, + }, + access: { + read: () => true, + create: ({ req: { user } }) => !!user, + update: ({ req: { user } }) => !!user, + delete: ({ req: { user } }) => !!user, + }, + hooks: { + afterChange: [logAfterChange, cacheAfterChange], + afterDelete: [logAfterDelete, cacheAfterDelete], + }, + fields: [ + // 区域名称 + { + name: 'name', + label: '区域名称', + type: 'text', + required: true, + admin: { + description: '例如:主板区域、上盖区域、按键组', + }, + }, + + // 所属拆解页 + { + name: 'page', + label: '所属拆解页', + type: 'relationship', + relationTo: 'disassembly-pages', + required: true, + admin: { + description: '该区域归属的顶层拆解页(如 GBA、GBC)', + }, + }, + + // 装配大图(在预览中显示于中央) + { + name: 'mainImage', + label: '装配大图', + type: 'upload', + relationTo: 'media', + admin: { + description: '该区域的整体装配图,在区域预览中显示于中央(大图)', + }, + }, + + // 缩略小图(在列表预览卡片中使用) + { + name: 'thumbnailImage', + label: '缩略小图', + type: 'upload', + relationTo: 'media', + admin: { + description: '该区域的缩略图,用于列表预览卡片(小图)', + }, + }, + + // 拆解组件列表(第三层) + { + name: 'components', + label: '拆解组件', + type: 'relationship', + relationTo: 'disassembly-components', + hasMany: true, + }, + ], +} diff --git a/src/collections/disassembly/DisassemblyComponents.ts b/src/collections/disassembly/DisassemblyComponents.ts index 03e405e..4ccc2c0 100644 --- a/src/collections/disassembly/DisassemblyComponents.ts +++ b/src/collections/disassembly/DisassemblyComponents.ts @@ -38,6 +38,27 @@ export const DisassemblyComponents: CollectionConfig = { }, }, + // 零件编号(如 GBC-001、BOARD-A) + { + name: 'productCode', + label: '零件编号', + type: 'text', + admin: { + description: '该组件的型号/零件编号,显示在区域预览节点中', + }, + }, + + // 组件图片(在区域预览中作为节点图标) + { + name: 'componentImage', + label: '组件图片', + type: 'upload', + relationTo: 'media', + admin: { + description: '该组件的外观图片,显示在区域预览的节点处', + }, + }, + // 起点坐标 { name: 'startCoordinate', diff --git a/src/collections/disassembly/DisassemblyPages.ts b/src/collections/disassembly/DisassemblyPages.ts index 1654055..8abcdf5 100644 --- a/src/collections/disassembly/DisassemblyPages.ts +++ b/src/collections/disassembly/DisassemblyPages.ts @@ -9,19 +9,30 @@ import { cacheAfterChange, cacheAfterDelete } from '../../hooks/cacheInvalidatio * 包含主图、名称、URL 及拆解组件列表(关联 DisassemblyComponents)。 * * 数据层级: - * DisassemblyPages - * └─ components[] → DisassemblyComponents (第二层) - * └─ linkedProducts[] → DisassemblyLinkedProducts (第三层) + * DisassemblyPages (第一层 — 拆解页) ← 本集合 + * └─ DisassemblyAreas (第二层 — 拆解区域) + * └─ DisassemblyComponents (第三层 — 拆解组件) + * └─ DisassemblyLinkedProducts (第四层 — 关联商品) + * + * 可视化编辑:编辑页底部内嵌 DisassemblyVisualEditor(ui 字段)。 */ export const DisassemblyPages: CollectionConfig = { slug: 'disassembly-pages', admin: { useAsTitle: 'name', - defaultColumns: ['mainImage', 'name', 'url', 'updatedAt'], + 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, @@ -40,7 +51,6 @@ export const DisassemblyPages: CollectionConfig = { label: '主图', type: 'upload', relationTo: 'media', - required: true, }, // 名称 { @@ -58,16 +68,24 @@ export const DisassemblyPages: CollectionConfig = { description: '该拆解页对应的页面路径或外部链接', }, }, - // 第二层:拆解组件列表 + // 列表视图:可视化编辑器跳转按钮(仅 Cell,不在编辑表单中显示) { - name: 'components', - label: '拆解组件', - type: 'relationship', - relationTo: 'disassembly-components', - hasMany: true, + name: 'editorLink', + label: '可视化编辑', + type: 'ui', admin: { - description: '该拆解页包含的拆解组件列表', + components: { + Cell: '/components/cells/DisassemblyEditorCell#DisassemblyEditorCell', + }, }, }, + // 第二层:拆解区域列表 + { + name: 'areas', + label: '拆解区域', + type: 'relationship', + relationTo: 'disassembly-areas', + hasMany: true, + } ], -} +} \ No newline at end of file diff --git a/src/components/cells/DisassemblyEditorCell.tsx b/src/components/cells/DisassemblyEditorCell.tsx new file mode 100644 index 0000000..ffbae89 --- /dev/null +++ b/src/components/cells/DisassemblyEditorCell.tsx @@ -0,0 +1,27 @@ +'use client' + +import React from 'react' +import { Button } from '@payloadcms/ui' + +/** + * 拆解编辑器跳转单元格 + * 在 DisassemblyPages 列表每行显示「拆解编辑器」按钮 + * 注册:DisassemblyPages.ts → fields[].admin.components.Cell + */ +export function DisassemblyEditorCell({ rowData }: any) { + const id = rowData?.id + if (!id) return null + + return ( + + ) +} diff --git a/src/components/seed/SeedDisassemblyButton.tsx b/src/components/seed/SeedDisassemblyButton.tsx new file mode 100644 index 0000000..1958d9a --- /dev/null +++ b/src/components/seed/SeedDisassemblyButton.tsx @@ -0,0 +1,150 @@ +'use client' + +import React, { useState } from 'react' +import { Button, useConfig } from '@payloadcms/ui' + +const DEFAULT_PAGES = [ + { name: 'Game Boy Color (GBC)', url: '/disassembly/gbc' }, + { name: 'Game Boy Advance (GBA)', url: '/disassembly/gba' }, + { name: 'Game Boy Advance SP (GBA SP)', url: '/disassembly/gba-sp' }, +] as const + +/** 每个拆解页默认创建的五层区域 */ +const DEFAULT_AREA_NAMES = ['外壳', '按键', '屏幕', 'PCB与原件', '背面原件与电池扩展'] as const + +/** + * 在 DisassemblyPages 列表顶部显示「初始化默认数据」按钮 + * 建立 GBC / GBA / GBA SP 三条默认拆解页记录,并为每条记录创建 5 个默认区域 + * 注册:DisassemblyPages.ts → admin.components.beforeListTable + */ +export function SeedDisassemblyButton() { + const { config } = useConfig() + const apiBase: string = (config as any)?.routes?.api ?? '/api' + + const [loading, setLoading] = useState(false) + const [result, setResult] = useState('') + + 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 ( +
+ + {result && ( + + {result} + + )} +
+ ) +} diff --git a/src/components/views/Disassembly/DisassemblyPageSaveArea.tsx b/src/components/views/Disassembly/DisassemblyPageSaveArea.tsx new file mode 100644 index 0000000..2163737 --- /dev/null +++ b/src/components/views/Disassembly/DisassemblyPageSaveArea.tsx @@ -0,0 +1,34 @@ +'use client' + +import React from 'react' +import { SaveButton, Button, useDocumentInfo } from '@payloadcms/ui' + +/** + * 替换 DisassemblyPages 编辑页的保存按钮区域 + * 保留原有「保存」,在其旁边增加「可视化编辑」跳转按钮 + * + * 注册:DisassemblyPages.ts → admin.components.edit.SaveButton + */ +export default function DisassemblyPageSaveArea() { + const { id } = useDocumentInfo() + + const handleOpenEditor = () => { + if (id) { + window.location.href = `/admin/disassembly-editor?id=${id}` + } + } + + return ( +
+ + +
+ ) +} diff --git a/src/components/views/Disassembly/DisassemblyVisualEditor.tsx b/src/components/views/Disassembly/DisassemblyVisualEditor.tsx new file mode 100644 index 0000000..28907c1 --- /dev/null +++ b/src/components/views/Disassembly/DisassemblyVisualEditor.tsx @@ -0,0 +1,474 @@ +'use client' + +import React, { useState, useEffect, useCallback } from 'react' +import { useDocumentInfo, useConfig, useFormFields } from '@payloadcms/ui' +import type { UIFieldClientComponent } from 'payload' + +/** + * DisassemblyVisualEditor — DisassemblyPages 编辑页内嵌预览 + * + * 完全还原 DisassemblyPages.html 视觉风格(工业草稿 v7.2.0): + * 蓝图网格 + 扫描线 + 中央装配主图 + 底部区域节点(SVG 引线 + 缩略图) + * + * 实时监听表单字段(无需保存即可预览): + * mainImage → 中央装配主图 + * areas → 底部区域节点(每个区域用 thumbnailImage 作为图标) + * name / url → 顶栏信息 + */ + +// ── 类型 ───────────────────────────────────────────────────────────────────── + +interface Area { + id: string | number + name: string + thumbnailImage?: { url: string } | string | null +} + +// ── 工具函数 ────────────────────────────────────────────────────────────────── + +function isPopulated(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 + 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 + 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 = () => ( + + + +) +const ChevronDown = () => ( + + + +) + +// ── 单个区域节点(完全还原 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 ( +
onHover(String(area.id))} + onMouseLeave={() => onHover(null)} + > + {/* ── 垂直引导线 ── */} + + + + + + + {/* ── 图标节点(80×80 容器,64×64 图片)── */} +
+ {thumbUrl ? ( + {area.name} + ) : ( +
+ + NO
IMG +
+
+ )} +
+
+ + {/* ── 标签容器(接线端子 + 方向箭头 + 编号 + 大字名称)── */} +
+
+
+ {isUp ? :
} +
CODE.{String(index + 1).padStart(2, '0')}
+ {!isUp ? :
} +
+
{area.name}
+
+
+ ) +} + +// ── 主组件 ─────────────────────────────────────────────────────────────────── + +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(null) + const [imgLoading, setImgLoading] = useState(false) + const [areas, setAreas] = useState([]) + const [areasLoading, setAreasLoading] = useState(false) + const [hoveredId, setHoveredId] = useState(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) : 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)) + } + } catch (_) {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [docId, apiBase]) + + useEffect(() => { void fetchSaved() }, [fetchSaved]) + + // ── 衍生值 ── + const pageName = String(nameField?.value ?? '') + + // ────────────────────────────────────────────────────────────────────────── + return ( +
+ + + {/* ── 主画布 ── */} +
+
+
+ + {/* ── 中心工业总成图(相当于 max-w-4xl max-h-[35vh])── */} +
+ {/* 适配型角标 */} +
+
+
+
+ + {imgUrl ? ( + {pageName + ) : ( +
+ + 暂无主图 + +
+ )} +
+ + {/* ── 底部区域节点导航(pb-72 px-10 justify-between)── */} + {areas.length > 0 ? ( +
+ {areas.map((area, index) => ( + + ))} +
+ ) : ( + !areasLoading && ( +
+ 暂无区域 — 在上方「拆解区域」字段中添加后自动更新 +
+ ) + )} + + {areasLoading && ( +
+ + 同步区域数据中… + +
+ )} +
+
+ ) +} + +export default DisassemblyVisualEditor diff --git a/src/components/views/Disassembly/editor/AreaDrawers.tsx b/src/components/views/Disassembly/editor/AreaDrawers.tsx new file mode 100644 index 0000000..57e3e77 --- /dev/null +++ b/src/components/views/Disassembly/editor/AreaDrawers.tsx @@ -0,0 +1,84 @@ +'use client' + +/** + * AreaDrawers — 封装区域的编辑与新建 DocumentDrawer + * + * 通过 ref 向父组件暴露 openNew() 方法。 + */ + +import React, { forwardRef, useEffect, useImperativeHandle } from 'react' +import { useDocumentDrawer } from '@payloadcms/ui' +import { injectDrawerStyles } from './styles' + +// ── Props & Handle ──────────────────────────────────────────────────────────── + +export interface DrawersHandle { + openNew: () => void +} + +interface Props { + pageId: string | null + areaIds: string[] + selectedAreaId: string | undefined + onClearEdit: () => void + onRefresh: () => void + apiBase: string +} + +// ── 组件 ───────────────────────────────────────────────────────────────────── + +const AreaDrawers = forwardRef( + ({ 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 ( + <> + { + onClearEdit() + onRefresh() + }} + /> + { + // 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 diff --git a/src/components/views/Disassembly/editor/AreaNode.tsx b/src/components/views/Disassembly/editor/AreaNode.tsx new file mode 100644 index 0000000..028ce71 --- /dev/null +++ b/src/components/views/Disassembly/editor/AreaNode.tsx @@ -0,0 +1,125 @@ +'use client' + +import React from 'react' +import { type Area, imgUrl } from './types' + +// ── 常量 ───────────────────────────────────────────────────────────────────── +export const ICON_SIZE = 80 +export const CENTER_AXIS_Y = 180 +export const OFFSET_Y = 40 + +// ── SVG 箭头 ────────────────────────────────────────────────────────────────── +const ChevronUp = () => ( + + + +) +const ChevronDown = () => ( + + + +) + +// ── 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 +
onHover(area.id)} + onMouseLeave={() => onHover(null)} + onClick={() => onEdit(area.id)} + > + {/* 整体节点包裹(绝对定位,不参与 flex 布局计算)*/} +
+ {/* 引线 */} + + + + + + + {/* 图标 */} +
+ {thumb ? ( + {area.name} + ) : ( +
+ NO
IMG
+
+ )} +
+
+ + {/* 标签 */} +
+
+
+ {isUp ? :
} + CODE.{String(index + 1).padStart(2, '0')} + {!isUp ? :
} +
+
{area.name}
+
+
+
+ ) +} diff --git a/src/components/views/Disassembly/editor/Toolbar.tsx b/src/components/views/Disassembly/editor/Toolbar.tsx new file mode 100644 index 0000000..9e23413 --- /dev/null +++ b/src/components/views/Disassembly/editor/Toolbar.tsx @@ -0,0 +1,114 @@ +'use client' + +import React from 'react' +import { Button } from '@payloadcms/ui' + +// ── Props ───────────────────────────────────────────────────────────────────── + +interface Props { + title: string + subtitle?: string + loading?: boolean + saving?: boolean + saveMsg?: string + canSave?: boolean + onRefresh: () => void + onBackToEdit: () => void + onSave: () => void + onAddArea: () => void +} + +// ── Toolbar ─────────────────────────────────────────────────────────────────── + +export default function Toolbar({ + title, + subtitle, + loading = false, + saving = false, + saveMsg = '', + canSave = true, + onRefresh, + onBackToEdit, + onSave, + onAddArea, +}: Props) { + const isError = saveMsg.includes('失败') || saveMsg.includes('error') + + return ( +
+ {/* 左侧:图标 + 标题 */} +
+
+ + + + + + + + +
+ +
+ + {loading ? '加载中...' : title} + + {subtitle && ( + + {subtitle} + + )} +
+
+ + {/* 右侧:操作按钮组 */} +
+ {saveMsg && ( + + {saveMsg} + + )} + + + + +
+ + + +
+
+ ) +} diff --git a/src/components/views/Disassembly/editor/index.tsx b/src/components/views/Disassembly/editor/index.tsx new file mode 100644 index 0000000..b799590 --- /dev/null +++ b/src/components/views/Disassembly/editor/index.tsx @@ -0,0 +1,205 @@ +'use client' + +/** + * DisassemblyEditorPage — 拆解可视化编辑器 + * 路由:/admin/disassembly-editor?id= + */ + +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(null) + useEffect(() => { + setPageId(new URLSearchParams(window.location.search).get('id')) + }, []) + + const [doc, setDoc] = useState(null) + const [areas, setAreas] = useState([]) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [saveMsg, setSaveMsg] = useState('') + const [hoveredId, setHoveredId] = useState(null) + const [selectedAreaId, setSelectedAreaId] = useState(undefined) + const drawersRef = useRef(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 ( + <> + + + {/* + 布局: + - 外层用 height:100vh + overflow:hidden 完全接管视口, + 避免 Payload admin 外层滚动容器。 + - 画布区 flex:1 + overflow-y:auto 自行处理滚动。 + - 这样工具栏不会遮挡内容,向下滚动不出现黑边。 + */} +
+ + {/* ── 工具栏(固定高度,不 sticky)── */} + pageId && void fetchDoc(pageId)} + onBackToEdit={handleBackToEdit} + onSave={handleSave} + onAddArea={() => drawersRef.current?.openNew()} + /> + + {/* ── 画布区(固定填满视口剩余高度,overflow:hidden 阻止绝对定位子元素撑开滚动高度)── */} +
+
+ {/* 网格背景 */} +
+
+ + {/* 中央装配主图 */} +
+
+
+
+
+ {loading ? ( + 加载中... + ) : mainImg ? ( + {doc?.name + ) : ( +
+ 暂无主图 +
+ )} +
+ + {/* 区域节点 */} + {areas.length > 0 && ( +
+ {areas.map((area, index) => ( + + ))} +
+ )} + + {!loading && areas.length === 0 && ( +
+ 暂无区域 — 点击工具栏「+ 新建区域」添加 +
+ )} +
+
+
+ + {/* ── Drawers(编辑 + 新建)── */} + a.id)} + selectedAreaId={selectedAreaId} + onClearEdit={() => setSelectedAreaId(undefined)} + onRefresh={() => pageId && void fetchDoc(pageId)} + apiBase={apiBase} + /> + + ) +} diff --git a/src/components/views/Disassembly/editor/styles.ts b/src/components/views/Disassembly/editor/styles.ts new file mode 100644 index 0000000..bec76a4 --- /dev/null +++ b/src/components/views/Disassembly/editor/styles.ts @@ -0,0 +1,100 @@ +// ── 画布动画 & 节点 CSS ─────────────────────────────────────────────────────── + +export const CANVAS_CSS = ` + @keyframes dep-scanline { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(100%); } + } + @keyframes dep-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } + } + .dep-blueprint-grid { + background-color: #ffffff; + background-image: + linear-gradient(rgba(0,0,0,0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(0,0,0,0.05) 1px, transparent 1px); + background-size: 30px 30px; + position: absolute; inset: 0; + } + .dep-blueprint-grid::after { + content: ""; position: absolute; inset: 0; + background-image: + linear-gradient(rgba(0,0,0,0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(0,0,0,0.02) 1px, transparent 1px); + background-size: 10px 10px; pointer-events: none; + } + .dep-scan { + position: absolute; top: 0; left: 0; right: 0; height: 100%; + background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.01), transparent); + animation: dep-scanline 15s linear infinite; pointer-events: none; z-index: 5; + } + .dep-assembly { + filter: drop-shadow(0 15px 30px rgba(0,0,0,0.1)) contrast(1.05); + transition: filter 0.6s; max-width: 100%; max-height: 100%; object-fit: contain; + } + .dep-assembly:hover { filter: drop-shadow(0 20px 40px rgba(0,0,0,0.15)) contrast(1.1); } + .dep-node-wrap { + cursor: pointer; position: absolute; inset: 0; + display: flex; flex-direction: column; align-items: center; + } + .dep-icon-node { transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); } + .dep-node-wrap:hover .dep-icon-node { transform: translateX(-50%) scale(1.1) translateY(-8px); } + .dep-terminal-dot { + width: 10px; height: 10px; border-radius: 50%; border: 2px solid #fff; + margin-bottom: 12px; transition: background 0.3s, transform 0.3s; background: #e5e5e5; flex-shrink: 0; + } + .dep-node-wrap:hover .dep-terminal-dot { background: #171717; transform: scale(1.25); } + .dep-code { + font-size: 8px; font-weight: 900; text-transform: uppercase; + letter-spacing: 0.1em; color: #d4d4d4; transition: color 0.3s; + } + .dep-node-wrap:hover .dep-code { color: #171717; } + .dep-name { + font-size: 20px; font-weight: 900; text-transform: uppercase; + letter-spacing: -0.05em; color: #a3a3a3; + transition: color 0.3s, transform 0.3s; + white-space: nowrap; will-change: transform; + } + .dep-node-wrap:hover .dep-name { color: #171717; transform: scale(1.05); } + .dep-glow { + position: absolute; inset: 0; background: rgba(23,23,23,0.05); + filter: blur(24px); border-radius: 50%; transform: scale(1.25); + opacity: 0; transition: opacity 0.3s; pointer-events: none; + } + .dep-node-wrap:hover .dep-glow { opacity: 1; } + .dep-leader { transition: stroke 0.4s ease, stroke-width 0.4s ease; } + .dep-label { transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1); } + .dep-blink { animation: dep-blink 1.6s ease-in-out infinite; } +` + +// ── Drawer 宽度覆盖(注入到 document.head)──────────────────────────────────── + +const DRAWER_STYLE_ID = 'dep-drawer-overrides' + +export function injectDrawerStyles(): void { + if (typeof document === 'undefined') return + if (document.getElementById(DRAWER_STYLE_ID)) return + const el = document.createElement('style') + el.id = DRAWER_STYLE_ID + el.textContent = ` + /* 从左侧弹出,宽度 500px */ + .drawer { + flex-direction: row-reverse !important; + } + .drawer__content { + width: 500px !important; + min-width: 0 !important; + max-width: 500px !important; + transform: translateX(calc(var(--base) * -4)) !important; + } + .drawer--is-open .drawer__content { + transform: translateX(0) !important; + } + .drawer__content-children { + overflow-x: hidden !important; + overflow-y: auto !important; + } + ` + document.head.appendChild(el) +} diff --git a/src/components/views/Disassembly/editor/types.ts b/src/components/views/Disassembly/editor/types.ts new file mode 100644 index 0000000..954194b --- /dev/null +++ b/src/components/views/Disassembly/editor/types.ts @@ -0,0 +1,33 @@ +// ── Disassembly Editor 共享类型 ───────────────────────────────────────────── + +export interface MediaDoc { + id: string + url: string +} + +export interface Area { + id: string + name: string + thumbnailImage?: MediaDoc | string | null + mainImage?: MediaDoc | string | null +} + +export interface PageDoc { + id: string + name: string + url?: string + mainImage?: MediaDoc | string | null + areas?: (Area | string)[] +} + +// ── 工具函数 ───────────────────────────────────────────────────────────────── + +export function imgUrl(v: MediaDoc | string | null | undefined): string | null { + if (!v) return null + if (typeof v === 'object' && v.url) return v.url + return null +} + +export function isPopulatedArea(v: Area | string): v is Area { + return typeof v === 'object' && v !== null && 'id' in v +} diff --git a/src/payload-types.ts b/src/payload-types.ts index b1dd213..55b500c 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -75,6 +75,7 @@ export interface Config { articles: Article; logs: Log; 'disassembly-pages': DisassemblyPage; + 'disassembly-areas': DisassemblyArea; 'disassembly-components': DisassemblyComponent; 'disassembly-linked-products': DisassemblyLinkedProduct; 'payload-kv': PayloadKv; @@ -93,6 +94,7 @@ export interface Config { articles: ArticlesSelect | ArticlesSelect; logs: LogsSelect | LogsSelect; 'disassembly-pages': DisassemblyPagesSelect | DisassemblyPagesSelect; + 'disassembly-areas': DisassemblyAreasSelect | DisassemblyAreasSelect; 'disassembly-components': DisassemblyComponentsSelect | DisassemblyComponentsSelect; 'disassembly-linked-products': DisassemblyLinkedProductsSelect | DisassemblyLinkedProductsSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; @@ -725,15 +727,40 @@ export interface Log { */ export interface DisassemblyPage { id: number; - mainImage: number | Media; + mainImage?: (number | null) | Media; name: string; /** * 该拆解页对应的页面路径或外部链接 */ url?: string | null; + areas?: (number | DisassemblyArea)[] | null; + updatedAt: string; + createdAt: string; +} +/** + * 拆解页的区域层(仅通过可视化编辑器管理) + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "disassembly-areas". + */ +export interface DisassemblyArea { + id: number; /** - * 该拆解页包含的拆解组件列表 + * 例如:主板区域、上盖区域、按键组 */ + name: string; + /** + * 该区域归属的顶层拆解页(如 GBA、GBC) + */ + page: number | DisassemblyPage; + /** + * 该区域的整体装配图,在区域预览中显示于中央(大图) + */ + mainImage?: (number | null) | Media; + /** + * 该区域的缩略图,用于列表预览卡片(小图) + */ + thumbnailImage?: (number | null) | Media; components?: (number | DisassemblyComponent)[] | null; updatedAt: string; createdAt: string; @@ -750,6 +777,14 @@ export interface DisassemblyComponent { * 用于 Admin 面板中标识该组件 */ label: string; + /** + * 该组件的型号/零件编号,显示在区域预览节点中 + */ + productCode?: string | null; + /** + * 该组件的外观图片,显示在区域预览的节点处 + */ + componentImage?: (number | null) | Media; /** * 组件锚点在页面/图片上的坐标 */ @@ -958,6 +993,10 @@ export interface PayloadLockedDocument { relationTo: 'disassembly-pages'; value: number | DisassemblyPage; } | null) + | ({ + relationTo: 'disassembly-areas'; + value: number | DisassemblyArea; + } | null) | ({ relationTo: 'disassembly-components'; value: number | DisassemblyComponent; @@ -1202,6 +1241,19 @@ export interface DisassemblyPagesSelect { mainImage?: T; name?: T; url?: T; + areas?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "disassembly-areas_select". + */ +export interface DisassemblyAreasSelect { + name?: T; + page?: T; + mainImage?: T; + thumbnailImage?: T; components?: T; updatedAt?: T; createdAt?: T; @@ -1212,6 +1264,8 @@ export interface DisassemblyPagesSelect { */ export interface DisassemblyComponentsSelect { label?: T; + productCode?: T; + componentImage?: T; startCoordinate?: | T | { diff --git a/src/payload.config.ts b/src/payload.config.ts index 4cb2d2a..d5c6e2c 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -13,6 +13,7 @@ import { Announcements } from './collections/Announcements' import { Articles } from './collections/Articles' import { Logs } from './collections/Logs' import { DisassemblyPages } from './collections/disassembly/DisassemblyPages' +import { DisassemblyAreas } from './collections/disassembly/DisassemblyAreas' import { DisassemblyComponents } from './collections/disassembly/DisassemblyComponents' import { DisassemblyLinkedProducts } from './collections/disassembly/DisassemblyLinkedProducts' import { AdminSettings } from './globals/AdminSettings' @@ -37,6 +38,15 @@ export default buildConfig({ importMap: { baseDir: path.resolve(dirname), }, + components: { + views: { + // 全页拆解可视化编辑器:/admin/disassembly-editor?id= + disassemblyEditor: { + Component: '/components/views/Disassembly/editor#default', + path: '/disassembly-editor', + }, + }, + }, }, i18n: { supportedLanguages: { @@ -51,7 +61,7 @@ export default buildConfig({ }, fallbackLanguage: 'zh', }, - collections: [Users, Media, Products, PreorderProducts, Announcements, Articles, Logs, DisassemblyPages, DisassemblyComponents, DisassemblyLinkedProducts], + collections: [Users, Media, Products, PreorderProducts, Announcements, Articles, Logs, DisassemblyPages, DisassemblyAreas, DisassemblyComponents, DisassemblyLinkedProducts], globals: [AdminSettings, LogsManager, HeroSlider, ProductRecommendations, SiteAccess], editor: lexicalEditor(), secret: process.env.PAYLOAD_SECRET || '',