基础布局

This commit is contained in:
龟男日记\www 2026-02-24 03:27:01 +08:00
parent 482bcda16d
commit e9947bdbdd
16 changed files with 1569 additions and 16 deletions

View File

@ -35,10 +35,14 @@ import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997e
import { StrikethroughFeatureClient as StrikethroughFeatureClient_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 { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_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_767734c8b7b095ea28d54c32abcf46e4 } from '../../../components/views/AdminPanel'
import { default as default_a766ef013722c08f9bb937940272cb5f } from '../../../components/views/LogsManagerView' import { default as default_a766ef013722c08f9bb937940272cb5f } from '../../../components/views/LogsManagerView'
import { RestoreRecommendationsSeedButton as RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da } from '../../../components/seed/RestoreRecommendationsSeedButton' 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 = {
@ -79,9 +83,13 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_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/AdminPanel#default": default_767734c8b7b095ea28d54c32abcf46e4,
"/components/views/LogsManagerView#default": default_a766ef013722c08f9bb937940272cb5f, "/components/views/LogsManagerView#default": default_a766ef013722c08f9bb937940272cb5f,
"/components/seed/RestoreRecommendationsSeedButton#RestoreRecommendationsSeedButton": RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da, "/components/seed/RestoreRecommendationsSeedButton#RestoreRecommendationsSeedButton": RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da,
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
"/components/views/Disassembly/editor#default": default_3e6848ddbbb7b926ae9afb108e1f6856,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
} }

View File

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

View File

@ -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', name: 'startCoordinate',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -75,6 +75,7 @@ export interface Config {
articles: Article; articles: Article;
logs: Log; logs: Log;
'disassembly-pages': DisassemblyPage; 'disassembly-pages': DisassemblyPage;
'disassembly-areas': DisassemblyArea;
'disassembly-components': DisassemblyComponent; 'disassembly-components': DisassemblyComponent;
'disassembly-linked-products': DisassemblyLinkedProduct; 'disassembly-linked-products': DisassemblyLinkedProduct;
'payload-kv': PayloadKv; 'payload-kv': PayloadKv;
@ -93,6 +94,7 @@ export interface Config {
articles: ArticlesSelect<false> | ArticlesSelect<true>; articles: ArticlesSelect<false> | ArticlesSelect<true>;
logs: LogsSelect<false> | LogsSelect<true>; logs: LogsSelect<false> | LogsSelect<true>;
'disassembly-pages': DisassemblyPagesSelect<false> | DisassemblyPagesSelect<true>; 'disassembly-pages': DisassemblyPagesSelect<false> | DisassemblyPagesSelect<true>;
'disassembly-areas': DisassemblyAreasSelect<false> | DisassemblyAreasSelect<true>;
'disassembly-components': DisassemblyComponentsSelect<false> | DisassemblyComponentsSelect<true>; 'disassembly-components': DisassemblyComponentsSelect<false> | DisassemblyComponentsSelect<true>;
'disassembly-linked-products': DisassemblyLinkedProductsSelect<false> | DisassemblyLinkedProductsSelect<true>; 'disassembly-linked-products': DisassemblyLinkedProductsSelect<false> | DisassemblyLinkedProductsSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>; 'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
@ -725,15 +727,40 @@ export interface Log {
*/ */
export interface DisassemblyPage { export interface DisassemblyPage {
id: number; id: number;
mainImage: number | Media; mainImage?: (number | null) | Media;
name: string; name: string;
/** /**
* *
*/ */
url?: string | null; 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;
/**
* GBAGBC
*/
page: number | DisassemblyPage;
/**
*
*/
mainImage?: (number | null) | Media;
/**
*
*/
thumbnailImage?: (number | null) | Media;
components?: (number | DisassemblyComponent)[] | null; components?: (number | DisassemblyComponent)[] | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@ -750,6 +777,14 @@ export interface DisassemblyComponent {
* Admin * Admin
*/ */
label: string; label: string;
/**
* /
*/
productCode?: string | null;
/**
*
*/
componentImage?: (number | null) | Media;
/** /**
* / * /
*/ */
@ -958,6 +993,10 @@ export interface PayloadLockedDocument {
relationTo: 'disassembly-pages'; relationTo: 'disassembly-pages';
value: number | DisassemblyPage; value: number | DisassemblyPage;
} | null) } | null)
| ({
relationTo: 'disassembly-areas';
value: number | DisassemblyArea;
} | null)
| ({ | ({
relationTo: 'disassembly-components'; relationTo: 'disassembly-components';
value: number | DisassemblyComponent; value: number | DisassemblyComponent;
@ -1202,6 +1241,19 @@ export interface DisassemblyPagesSelect<T extends boolean = true> {
mainImage?: T; mainImage?: T;
name?: T; name?: T;
url?: 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<T extends boolean = true> {
name?: T;
page?: T;
mainImage?: T;
thumbnailImage?: T;
components?: T; components?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
@ -1212,6 +1264,8 @@ export interface DisassemblyPagesSelect<T extends boolean = true> {
*/ */
export interface DisassemblyComponentsSelect<T extends boolean = true> { export interface DisassemblyComponentsSelect<T extends boolean = true> {
label?: T; label?: T;
productCode?: T;
componentImage?: T;
startCoordinate?: startCoordinate?:
| T | T
| { | {

View File

@ -13,6 +13,7 @@ import { Announcements } from './collections/Announcements'
import { Articles } from './collections/Articles' import { Articles } from './collections/Articles'
import { Logs } from './collections/Logs' import { Logs } from './collections/Logs'
import { DisassemblyPages } from './collections/disassembly/DisassemblyPages' import { DisassemblyPages } from './collections/disassembly/DisassemblyPages'
import { DisassemblyAreas } from './collections/disassembly/DisassemblyAreas'
import { DisassemblyComponents } from './collections/disassembly/DisassemblyComponents' import { DisassemblyComponents } from './collections/disassembly/DisassemblyComponents'
import { DisassemblyLinkedProducts } from './collections/disassembly/DisassemblyLinkedProducts' import { DisassemblyLinkedProducts } from './collections/disassembly/DisassemblyLinkedProducts'
import { AdminSettings } from './globals/AdminSettings' import { AdminSettings } from './globals/AdminSettings'
@ -37,6 +38,15 @@ export default buildConfig({
importMap: { importMap: {
baseDir: path.resolve(dirname), baseDir: path.resolve(dirname),
}, },
components: {
views: {
// 全页拆解可视化编辑器:/admin/disassembly-editor?id=<pageId>
disassemblyEditor: {
Component: '/components/views/Disassembly/editor#default',
path: '/disassembly-editor',
},
},
},
}, },
i18n: { i18n: {
supportedLanguages: { supportedLanguages: {
@ -51,7 +61,7 @@ export default buildConfig({
}, },
fallbackLanguage: 'zh', 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], globals: [AdminSettings, LogsManager, HeroSlider, ProductRecommendations, SiteAccess],
editor: lexicalEditor(), editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '', secret: process.env.PAYLOAD_SECRET || '',