基础布局
This commit is contained in:
parent
482bcda16d
commit
e9947bdbdd
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Button } from '@payloadcms/ui'
|
||||
|
||||
/**
|
||||
* 拆解编辑器跳转单元格
|
||||
* 在 DisassemblyPages 列表每行显示「拆解编辑器」按钮
|
||||
* 注册:DisassemblyPages.ts → fields[].admin.components.Cell
|
||||
*/
|
||||
export function DisassemblyEditorCell({ rowData }: any) {
|
||||
const id = rowData?.id
|
||||
if (!id) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e?.stopPropagation?.()
|
||||
window.location.href = `/admin/disassembly-editor?id=${id}`
|
||||
}}
|
||||
>
|
||||
可视化编辑
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Button, useConfig } from '@payloadcms/ui'
|
||||
|
||||
const DEFAULT_PAGES = [
|
||||
{ name: 'Game Boy Color (GBC)', url: '/disassembly/gbc' },
|
||||
{ name: 'Game Boy Advance (GBA)', url: '/disassembly/gba' },
|
||||
{ name: 'Game Boy Advance SP (GBA SP)', url: '/disassembly/gba-sp' },
|
||||
] as const
|
||||
|
||||
/** 每个拆解页默认创建的五层区域 */
|
||||
const DEFAULT_AREA_NAMES = ['外壳', '按键', '屏幕', 'PCB与原件', '背面原件与电池扩展'] as const
|
||||
|
||||
/**
|
||||
* 在 DisassemblyPages 列表顶部显示「初始化默认数据」按钮
|
||||
* 建立 GBC / GBA / GBA SP 三条默认拆解页记录,并为每条记录创建 5 个默认区域
|
||||
* 注册:DisassemblyPages.ts → admin.components.beforeListTable
|
||||
*/
|
||||
export function SeedDisassemblyButton() {
|
||||
const { config } = useConfig()
|
||||
const apiBase: string = (config as any)?.routes?.api ?? '/api'
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<string>('')
|
||||
|
||||
const handleSeed = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`将创建以下 ${DEFAULT_PAGES.length} 条拆解页默认记录,每页含 ${DEFAULT_AREA_NAMES.length} 个区域:\n\n` +
|
||||
DEFAULT_PAGES.map((p) => `• ${p.name}`).join('\n') +
|
||||
`\n\n区域:${DEFAULT_AREA_NAMES.join(' / ')}\n\n已存在的同名记录不会被重复创建。继续?`,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
setLoading(true)
|
||||
setResult('')
|
||||
|
||||
let pagesCreated = 0
|
||||
let pagesSkipped = 0
|
||||
let areasCreated = 0
|
||||
const errors: string[] = []
|
||||
|
||||
for (const page of DEFAULT_PAGES) {
|
||||
try {
|
||||
// ── 检查页面是否已存在 ──
|
||||
const check = await fetch(
|
||||
`${apiBase}/disassembly-pages?where[name][equals]=${encodeURIComponent(page.name)}&limit=1`,
|
||||
{ credentials: 'include' },
|
||||
)
|
||||
const checkData = await check.json()
|
||||
if (checkData?.totalDocs > 0) {
|
||||
pagesSkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// ── 创建页面(先不带 areas)──
|
||||
const pageRes = await fetch(`${apiBase}/disassembly-pages`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: page.name, url: page.url }),
|
||||
})
|
||||
if (!pageRes.ok) {
|
||||
const err = await pageRes.json()
|
||||
errors.push(`${page.name}: ${err?.errors?.[0]?.message ?? pageRes.status}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const pageData = await pageRes.json()
|
||||
const pageId: string = pageData?.doc?.id ?? pageData?.id
|
||||
if (!pageId) { errors.push(`${page.name}: 无法获取页面 ID`); continue }
|
||||
pagesCreated++
|
||||
|
||||
// ── 为该页面创建 5 个默认区域 ──
|
||||
const areaIds: string[] = []
|
||||
for (const areaName of DEFAULT_AREA_NAMES) {
|
||||
try {
|
||||
const areaRes = await fetch(`${apiBase}/disassembly-areas`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: areaName, page: pageId }),
|
||||
})
|
||||
if (areaRes.ok) {
|
||||
const areaData = await areaRes.json()
|
||||
const areaId: string = areaData?.doc?.id ?? areaData?.id
|
||||
if (areaId) { areaIds.push(areaId); areasCreated++ }
|
||||
}
|
||||
} catch (_) {
|
||||
errors.push(`${page.name} > ${areaName}: 区域创建失败`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 将 areaIds 写回页面 ──
|
||||
if (areaIds.length > 0) {
|
||||
await fetch(`${apiBase}/disassembly-pages/${pageId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ areas: areaIds }),
|
||||
})
|
||||
}
|
||||
} catch (_) {
|
||||
errors.push(`${page.name}: 网络错误`)
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
|
||||
const parts: string[] = []
|
||||
if (pagesCreated) parts.push(`创建 ${pagesCreated} 页(共 ${areasCreated} 区域)`)
|
||||
if (pagesSkipped) parts.push(`${pagesSkipped} 页已存在跳过`)
|
||||
if (errors.length) parts.push(`${errors.length} 项失败`)
|
||||
setResult(parts.join(' · ') + (errors.length ? `\n${errors.join('\n')}` : ''))
|
||||
|
||||
if (pagesCreated > 0) setTimeout(() => window.location.reload(), 800)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--spacing-3)',
|
||||
padding: 'var(--spacing-3) 0',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
size="small"
|
||||
disabled={loading}
|
||||
onClick={handleSeed}
|
||||
>
|
||||
{loading ? '创建中…' : '⊕ 初始化默认数据 (GBC / GBA / GBA SP,各含 5 个区域)'}
|
||||
</Button>
|
||||
{result && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--font-body-s)',
|
||||
color: 'var(--theme-elevation-500)',
|
||||
}}
|
||||
>
|
||||
{result}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { SaveButton, Button, useDocumentInfo } from '@payloadcms/ui'
|
||||
|
||||
/**
|
||||
* 替换 DisassemblyPages 编辑页的保存按钮区域
|
||||
* 保留原有「保存」,在其旁边增加「可视化编辑」跳转按钮
|
||||
*
|
||||
* 注册:DisassemblyPages.ts → admin.components.edit.SaveButton
|
||||
*/
|
||||
export default function DisassemblyPageSaveArea() {
|
||||
const { id } = useDocumentInfo()
|
||||
|
||||
const handleOpenEditor = () => {
|
||||
if (id) {
|
||||
window.location.href = `/admin/disassembly-editor?id=${id}`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SaveButton />
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
size="medium"
|
||||
disabled={!id}
|
||||
onClick={handleOpenEditor}
|
||||
>
|
||||
可视化编辑
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,474 @@
|
|||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useDocumentInfo, useConfig, useFormFields } from '@payloadcms/ui'
|
||||
import type { UIFieldClientComponent } from 'payload'
|
||||
|
||||
/**
|
||||
* DisassemblyVisualEditor — DisassemblyPages 编辑页内嵌预览
|
||||
*
|
||||
* 完全还原 DisassemblyPages.html 视觉风格(工业草稿 v7.2.0):
|
||||
* 蓝图网格 + 扫描线 + 中央装配主图 + 底部区域节点(SVG 引线 + 缩略图)
|
||||
*
|
||||
* 实时监听表单字段(无需保存即可预览):
|
||||
* mainImage → 中央装配主图
|
||||
* areas → 底部区域节点(每个区域用 thumbnailImage 作为图标)
|
||||
* name / url → 顶栏信息
|
||||
*/
|
||||
|
||||
// ── 类型 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Area {
|
||||
id: string | number
|
||||
name: string
|
||||
thumbnailImage?: { url: string } | string | null
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function isPopulated<T extends { id: unknown }>(v: T | string | number): v is T {
|
||||
return typeof v === 'object' && v !== null && 'id' in v
|
||||
}
|
||||
|
||||
function extractId(value: unknown): string | null {
|
||||
if (!value) return null
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'object') {
|
||||
const v = value as Record<string, unknown>
|
||||
if (typeof v.id === 'string' || typeof v.id === 'number') return String(v.id)
|
||||
if (typeof v.value === 'string' || typeof v.value === 'number') return String(v.value)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractUrl(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const v = value as Record<string, unknown>
|
||||
if (typeof v.url === 'string') return v.url
|
||||
return null
|
||||
}
|
||||
|
||||
function toIdString(value: unknown): string {
|
||||
if (!value || !Array.isArray(value)) return ''
|
||||
return value.map(extractId).filter(Boolean).join(',')
|
||||
}
|
||||
|
||||
function getThumbUrl(area: Area): string | null {
|
||||
const v = area.thumbnailImage
|
||||
if (!v) return null
|
||||
if (typeof v === 'object' && 'url' in v) return v.url
|
||||
return null
|
||||
}
|
||||
|
||||
// ── CSS(完全对应 DisassemblyPages.html styles)────────────────────────────────
|
||||
|
||||
const CSS = `
|
||||
@keyframes dve-scanline {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
@keyframes dve-live-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.dve-blueprint-grid {
|
||||
background-color: #ffffff;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.dve-blueprint-grid::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 0, 0, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
|
||||
background-size: 10px 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dve-assembly-view {
|
||||
filter: drop-shadow(0 15px 30px rgba(0,0,0,0.1)) contrast(1.05);
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.dve-assembly-view:hover {
|
||||
filter: drop-shadow(0 20px 40px rgba(0,0,0,0.15)) contrast(1.1);
|
||||
}
|
||||
.dve-leader-line {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
.dve-label-container {
|
||||
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.dve-scan-effect {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 100%;
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.01), transparent);
|
||||
animation: dve-scanline 15s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
.dve-node-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.dve-icon-node {
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.dve-node-wrap:hover .dve-icon-node {
|
||||
transform: scale(1.1) translateY(-8px);
|
||||
}
|
||||
.dve-terminal-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.3s;
|
||||
background: #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dve-node-wrap:hover .dve-terminal-dot {
|
||||
background: #171717;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
.dve-code-text {
|
||||
font-size: 8px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #d4d4d4;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
.dve-node-wrap:hover .dve-code-text {
|
||||
color: #171717;
|
||||
}
|
||||
.dve-part-name {
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.05em;
|
||||
color: #a3a3a3;
|
||||
transition: all 0.3s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dve-node-wrap:hover .dve-part-name {
|
||||
color: #171717;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.dve-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(23, 23, 23, 0.05);
|
||||
filter: blur(24px);
|
||||
border-radius: 50%;
|
||||
transform: scale(1.25);
|
||||
display: none;
|
||||
}
|
||||
.dve-node-wrap:hover .dve-glow { display: block; }
|
||||
.dve-live-dot { animation: dve-live-blink 1.6s ease-in-out infinite; }
|
||||
`
|
||||
|
||||
// ── 物理常量(完全镜像 DisassemblyPages.html)──────────────────────────────────
|
||||
const ICON_SIZE = 80
|
||||
const CENTER_AXIS_Y = 180
|
||||
const OFFSET_Y = 40
|
||||
|
||||
// ── ChevronUp / ChevronDown (内联 SVG)─────────────────────────────────────────
|
||||
const ChevronUp = () => (
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" style={{ display: 'block', flexShrink: 0 }}>
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
)
|
||||
const ChevronDown = () => (
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" style={{ display: 'block', flexShrink: 0 }}>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── 单个区域节点(完全还原 DisassemblyPages.html PartNode)────────────────────────
|
||||
function AreaNode({
|
||||
area,
|
||||
index,
|
||||
isHovered,
|
||||
onHover,
|
||||
}: {
|
||||
area: Area
|
||||
index: number
|
||||
isHovered: boolean
|
||||
onHover: (id: string | null) => void
|
||||
}) {
|
||||
const isUp = index % 2 === 0
|
||||
const boxTop = isUp ? CENTER_AXIS_Y - OFFSET_Y : CENTER_AXIS_Y + OFFSET_Y
|
||||
const SVG_CENTER_X = 50
|
||||
const SVG_CENTER_Y = 40
|
||||
|
||||
const lineColor = isHovered ? '#000000' : '#f5f5f5'
|
||||
const lineWidth = isHovered ? '2.5' : '1'
|
||||
const thumbUrl = getThumbUrl(area)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dve-node-wrap"
|
||||
onMouseEnter={() => onHover(String(area.id))}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
{/* ── 垂直引导线 ── */}
|
||||
<svg
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: '50%', transform: 'translateX(-50%)',
|
||||
width: '100px', height: '400px', overflow: 'visible', pointerEvents: 'none', zIndex: 0,
|
||||
}}
|
||||
viewBox="0 0 100 400"
|
||||
>
|
||||
<path
|
||||
d={`M ${SVG_CENTER_X} ${SVG_CENTER_Y} L ${SVG_CENTER_X} ${boxTop}`}
|
||||
fill="none"
|
||||
stroke={lineColor}
|
||||
strokeWidth={lineWidth}
|
||||
strokeDasharray={isHovered ? 'none' : '3,3'}
|
||||
className="dve-leader-line"
|
||||
/>
|
||||
<circle cx={SVG_CENTER_X} cy={SVG_CENTER_Y} r="2.5" fill={isHovered ? '#000000' : '#e0e0e0'} />
|
||||
<circle cx={SVG_CENTER_X} cy={boxTop} r={isHovered ? '4' : '2'} fill={isHovered ? '#000000' : '#e0e0e0'} />
|
||||
</svg>
|
||||
|
||||
{/* ── 图标节点(80×80 容器,64×64 图片)── */}
|
||||
<div
|
||||
className="dve-icon-node"
|
||||
style={{
|
||||
position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: `${ICON_SIZE}px`, height: `${ICON_SIZE}px`, zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
style={{ width: 64, height: 64, objectFit: 'contain', position: 'relative', zIndex: 10 }}
|
||||
alt={area.name}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: 64, height: 64,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: '1px dashed #d4d4d4', background: '#fafafa',
|
||||
}}>
|
||||
<span style={{ fontSize: 7, color: '#a3a3a3', textTransform: 'uppercase', fontWeight: 700, letterSpacing: '.1em', textAlign: 'center', lineHeight: 1.4 }}>
|
||||
NO<br />IMG
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="dve-glow" />
|
||||
</div>
|
||||
|
||||
{/* ── 标签容器(接线端子 + 方向箭头 + 编号 + 大字名称)── */}
|
||||
<div
|
||||
className="dve-label-container"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', whiteSpace: 'nowrap',
|
||||
top: `${boxTop}px`, left: '50%',
|
||||
transform: `translateX(-50%)${isHovered ? (isUp ? ' translateY(-4px)' : ' translateY(4px)') : ''}`,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<div className="dve-terminal-dot" />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, opacity: 0.6 }}>
|
||||
{isUp ? <ChevronUp /> : <div style={{ width: 8 }} />}
|
||||
<div className="dve-code-text">CODE.{String(index + 1).padStart(2, '0')}</div>
|
||||
{!isUp ? <ChevronDown /> : <div style={{ width: 8 }} />}
|
||||
</div>
|
||||
<div className="dve-part-name">{area.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 主组件 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const DisassemblyVisualEditor: UIFieldClientComponent = () => {
|
||||
const { id: docId } = useDocumentInfo()
|
||||
const { config } = useConfig()
|
||||
const apiBase: string = (config as any)?.routes?.api ?? '/api'
|
||||
|
||||
// ── 实时监听表单字段 ──
|
||||
const mainImageField = useFormFields(([fields]) => fields.mainImage)
|
||||
const areasField = useFormFields(([fields]) => fields.areas)
|
||||
const nameField = useFormFields(([fields]) => fields.name)
|
||||
|
||||
// ── 状态 ──
|
||||
const [imgUrl, setImgUrl] = useState<string | null>(null)
|
||||
const [imgLoading, setImgLoading] = useState(false)
|
||||
const [areas, setAreas] = useState<Area[]>([])
|
||||
const [areasLoading, setAreasLoading] = useState(false)
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
|
||||
// ── 响应 mainImage 变化 ──
|
||||
const mainImgValue = mainImageField?.value
|
||||
|
||||
useEffect(() => {
|
||||
const directUrl = extractUrl(mainImgValue)
|
||||
if (directUrl) { setImgUrl(directUrl); return }
|
||||
const imgId = extractId(mainImgValue)
|
||||
if (!imgId) { setImgUrl(null); return }
|
||||
setImgLoading(true)
|
||||
fetch(`${apiBase}/media/${imgId}`, { credentials: 'include' })
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then((data: { url?: string } | null) => { if (data?.url) setImgUrl(data.url) })
|
||||
.catch(() => {})
|
||||
.finally(() => setImgLoading(false))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mainImgValue, apiBase])
|
||||
|
||||
// ── 响应 areas IDs 变化 ──
|
||||
const areasValue = areasField?.value
|
||||
const areaIdStr = toIdString(areasValue)
|
||||
|
||||
useEffect(() => {
|
||||
if (!areaIdStr) { setAreas([]); return }
|
||||
const ids = areaIdStr.split(',').filter(Boolean)
|
||||
setAreasLoading(true)
|
||||
Promise.all(
|
||||
ids.map(aid =>
|
||||
fetch(`${apiBase}/disassembly-areas/${aid}?depth=1`, { credentials: 'include' })
|
||||
.then(r => (r.ok ? (r.json() as Promise<Area>) : null))
|
||||
.catch(() => null),
|
||||
),
|
||||
)
|
||||
.then(results => { setAreas(results.filter((a): a is Area => a !== null)) })
|
||||
.finally(() => setAreasLoading(false))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [areaIdStr, apiBase])
|
||||
|
||||
// ── 初始加载(已保存文档)──
|
||||
const fetchSaved = useCallback(async () => {
|
||||
if (!docId || areaIdStr) return
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/disassembly-pages/${docId}?depth=2`, { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
if (data.mainImage?.url && !imgUrl) setImgUrl(data.mainImage.url)
|
||||
if (Array.isArray(data.areas) && areas.length === 0) {
|
||||
setAreas((data.areas as (Area | string | number)[]).filter(isPopulated<Area>))
|
||||
}
|
||||
} catch (_) {}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [docId, apiBase])
|
||||
|
||||
useEffect(() => { void fetchSaved() }, [fetchSaved])
|
||||
|
||||
// ── 衍生值 ──
|
||||
const pageName = String(nameField?.value ?? '')
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{ fontFamily: 'monospace' }}>
|
||||
<style>{CSS}</style>
|
||||
|
||||
{/* ── 主画布 ── */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 560,
|
||||
overflow: 'hidden',
|
||||
background: '#ffffff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
padding: '48px 48px 0',
|
||||
boxSizing: 'border-box',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
<div className="dve-blueprint-grid" />
|
||||
<div className="dve-scan-effect" />
|
||||
|
||||
{/* ── 中心工业总成图(相当于 max-w-4xl max-h-[35vh])── */}
|
||||
<div style={{
|
||||
position: 'relative', width: '100%', maxWidth: 700, height: 200,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 10, flexShrink: 0,
|
||||
}}>
|
||||
{/* 适配型角标 */}
|
||||
<div style={{ position: 'absolute', inset: -16, pointerEvents: 'none', opacity: 0.4 }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: 48, height: 48, borderTop: '1px solid #525252', borderLeft: '1px solid #525252' }} />
|
||||
<div style={{ position: 'absolute', bottom: 0, right: 0, width: 48, height: 48, borderBottom: '1px solid #525252', borderRight: '1px solid #525252' }} />
|
||||
</div>
|
||||
|
||||
{imgUrl ? (
|
||||
<img src={imgUrl} className="dve-assembly-view" alt={pageName || 'Assembly'} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: '2px dashed #e5e5e5', background: '#fafafa',
|
||||
}}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.3em', color: '#d4d4d4' }}>
|
||||
暂无主图
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 底部区域节点导航(pb-72 px-10 justify-between)── */}
|
||||
{areas.length > 0 ? (
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
width: '100%', maxWidth: 1000,
|
||||
marginTop: 48,
|
||||
padding: '0 40px 280px',
|
||||
zIndex: 20,
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{areas.map((area, index) => (
|
||||
<AreaNode
|
||||
key={String(area.id)}
|
||||
area={area}
|
||||
index={index}
|
||||
isHovered={hoveredId === String(area.id)}
|
||||
onHover={setHoveredId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!areasLoading && (
|
||||
<div style={{
|
||||
marginTop: 48, fontSize: 9, color: '#d4d4d4', fontWeight: 700,
|
||||
textTransform: 'uppercase', letterSpacing: '.2em', textAlign: 'center',
|
||||
zIndex: 10, position: 'relative',
|
||||
}}>
|
||||
暂无区域 — 在上方「拆解区域」字段中添加后自动更新
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{areasLoading && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 80,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
}}>
|
||||
<span style={{ fontSize: 10, fontWeight: 900, color: '#f59e0b', textTransform: 'uppercase', letterSpacing: '.2em' }}>
|
||||
同步区域数据中…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DisassemblyVisualEditor
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
'use client'
|
||||
|
||||
/**
|
||||
* AreaDrawers — 封装区域的编辑与新建 DocumentDrawer
|
||||
*
|
||||
* 通过 ref 向父组件暴露 openNew() 方法。
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useEffect, useImperativeHandle } from 'react'
|
||||
import { useDocumentDrawer } from '@payloadcms/ui'
|
||||
import { injectDrawerStyles } from './styles'
|
||||
|
||||
// ── Props & Handle ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DrawersHandle {
|
||||
openNew: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pageId: string | null
|
||||
areaIds: string[]
|
||||
selectedAreaId: string | undefined
|
||||
onClearEdit: () => void
|
||||
onRefresh: () => void
|
||||
apiBase: string
|
||||
}
|
||||
|
||||
// ── 组件 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const AreaDrawers = forwardRef<DrawersHandle, Props>(
|
||||
({ pageId, areaIds, selectedAreaId, onClearEdit, onRefresh, apiBase }, ref) => {
|
||||
// 注入 Drawer 宽度覆盖样式(幂等)
|
||||
useEffect(() => { injectDrawerStyles() }, [])
|
||||
|
||||
// 编辑已有区域(动态 id)
|
||||
const [AreaEditDrawer, , { openDrawer: openEditDrawer }] = useDocumentDrawer({
|
||||
collectionSlug: 'disassembly-areas',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
id: selectedAreaId as any,
|
||||
})
|
||||
|
||||
// 新建区域(无 id)
|
||||
const [AreaNewDrawer, , { openDrawer: openNewDrawer }] = useDocumentDrawer({
|
||||
collectionSlug: 'disassembly-areas',
|
||||
})
|
||||
|
||||
useImperativeHandle(ref, () => ({ openNew: openNewDrawer }), [openNewDrawer])
|
||||
|
||||
// selectedAreaId 变化时打开编辑 Drawer
|
||||
useEffect(() => {
|
||||
if (selectedAreaId) openEditDrawer()
|
||||
}, [selectedAreaId, openEditDrawer])
|
||||
|
||||
return (
|
||||
<>
|
||||
<AreaEditDrawer
|
||||
onSave={() => {
|
||||
onClearEdit()
|
||||
onRefresh()
|
||||
}}
|
||||
/>
|
||||
<AreaNewDrawer
|
||||
initialData={pageId ? { page: pageId } : undefined}
|
||||
onSave={async ({ doc: saved }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const savedId = (saved as any)?.id as string | number | undefined
|
||||
if (pageId && savedId) {
|
||||
await fetch(`${apiBase}/disassembly-pages/${pageId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ areas: [...areaIds, String(savedId)] }),
|
||||
})
|
||||
}
|
||||
onRefresh()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
AreaDrawers.displayName = 'AreaDrawers'
|
||||
export default AreaDrawers
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { type Area, imgUrl } from './types'
|
||||
|
||||
// ── 常量 ─────────────────────────────────────────────────────────────────────
|
||||
export const ICON_SIZE = 80
|
||||
export const CENTER_AXIS_Y = 180
|
||||
export const OFFSET_Y = 40
|
||||
|
||||
// ── SVG 箭头 ──────────────────────────────────────────────────────────────────
|
||||
const ChevronUp = () => (
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
)
|
||||
const ChevronDown = () => (
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── AreaNode ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
area: Area
|
||||
index: number
|
||||
isHovered: boolean
|
||||
onHover: (id: string | null) => void
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
export default function AreaNode({ area, index, isHovered, onHover, onEdit }: Props) {
|
||||
const isUp = index % 2 === 0
|
||||
const boxTop = isUp ? CENTER_AXIS_Y - OFFSET_Y : CENTER_AXIS_Y + OFFSET_Y
|
||||
const thumb = imgUrl(area.thumbnailImage)
|
||||
|
||||
return (
|
||||
// 固定高度容器,避免 hover 时触发画布 reflow
|
||||
<div
|
||||
style={{ flex: 1, minWidth: 0, height: 360, position: 'relative' }}
|
||||
onMouseEnter={() => onHover(area.id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onClick={() => onEdit(area.id)}
|
||||
>
|
||||
{/* 整体节点包裹(绝对定位,不参与 flex 布局计算)*/}
|
||||
<div
|
||||
className="dep-node-wrap"
|
||||
title={`点击编辑:${area.name}`}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
>
|
||||
{/* 引线 */}
|
||||
<svg
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: '50%',
|
||||
transform: 'translateX(-50%)', width: 100, height: 400,
|
||||
overflow: 'visible', pointerEvents: 'none', zIndex: 0,
|
||||
}}
|
||||
viewBox="0 0 100 400"
|
||||
>
|
||||
<path
|
||||
d={`M 50 40 L 50 ${boxTop}`}
|
||||
fill="none"
|
||||
stroke={isHovered ? '#000' : '#f5f5f5'}
|
||||
strokeWidth={isHovered ? '2.5' : '1'}
|
||||
strokeDasharray={isHovered ? 'none' : '3,3'}
|
||||
className="dep-leader"
|
||||
/>
|
||||
<circle cx="50" cy="40" r="2.5" fill={isHovered ? '#000' : '#e0e0e0'} />
|
||||
<circle cx="50" cy={boxTop} r={isHovered ? 4 : 2} fill={isHovered ? '#000' : '#e0e0e0'} />
|
||||
</svg>
|
||||
|
||||
{/* 图标 */}
|
||||
<div
|
||||
className="dep-icon-node"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: '50%', transform: 'translateX(-50%)',
|
||||
display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', width: ICON_SIZE, height: ICON_SIZE, zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{thumb ? (
|
||||
<img
|
||||
src={thumb}
|
||||
style={{ width: 64, height: 64, objectFit: 'contain', zIndex: 10, position: 'relative' }}
|
||||
alt={area.name}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: 64, height: 64, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', border: '1px dashed #d4d4d4', background: '#fafafa',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 7, color: '#a3a3a3', textTransform: 'uppercase',
|
||||
fontWeight: 700, letterSpacing: '.1em', textAlign: 'center', lineHeight: 1.4,
|
||||
}}>NO<br />IMG</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="dep-glow" />
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div
|
||||
className="dep-label"
|
||||
style={{
|
||||
position: 'absolute', display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', whiteSpace: 'nowrap',
|
||||
top: `${boxTop}px`, left: '50%',
|
||||
transform: `translateX(-50%)${isHovered ? (isUp ? ' translateY(-4px)' : ' translateY(4px)') : ''}`,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<div className="dep-terminal-dot" />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, opacity: 0.6 }}>
|
||||
{isUp ? <ChevronUp /> : <div style={{ width: 8 }} />}
|
||||
<span className="dep-code">CODE.{String(index + 1).padStart(2, '0')}</span>
|
||||
{!isUp ? <ChevronDown /> : <div style={{ width: 8 }} />}
|
||||
</div>
|
||||
<div className="dep-name">{area.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Button } from '@payloadcms/ui'
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
loading?: boolean
|
||||
saving?: boolean
|
||||
saveMsg?: string
|
||||
canSave?: boolean
|
||||
onRefresh: () => void
|
||||
onBackToEdit: () => void
|
||||
onSave: () => void
|
||||
onAddArea: () => void
|
||||
}
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Toolbar({
|
||||
title,
|
||||
subtitle,
|
||||
loading = false,
|
||||
saving = false,
|
||||
saveMsg = '',
|
||||
canSave = true,
|
||||
onRefresh,
|
||||
onBackToEdit,
|
||||
onSave,
|
||||
onAddArea,
|
||||
}: Props) {
|
||||
const isError = saveMsg.includes('失败') || saveMsg.includes('error')
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
height: 52,
|
||||
padding: '0 24px',
|
||||
borderBottom: '1px solid var(--theme-elevation-150)',
|
||||
background: 'var(--theme-bg)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* 左侧:图标 + 标题 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28, height: 28,
|
||||
background: 'var(--theme-elevation-800)',
|
||||
borderRadius: 4,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="var(--theme-bg)" strokeWidth="2" strokeLinecap="round">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<rect x="9" y="9" width="6" height="6" />
|
||||
<line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" />
|
||||
<line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" />
|
||||
<line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" />
|
||||
<line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, lineHeight: 1, color: 'var(--theme-elevation-900)' }}>
|
||||
{loading ? '加载中...' : title}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span style={{ fontSize: 11, color: 'var(--theme-elevation-500)', lineHeight: 1 }}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮组 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{saveMsg && (
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 500, marginRight: 4,
|
||||
color: isError ? 'var(--theme-error-500)' : 'var(--theme-success-500)',
|
||||
}}>
|
||||
{saveMsg}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Button buttonStyle="secondary" size="small" disabled={loading} onClick={onRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button buttonStyle="secondary" size="small" onClick={onBackToEdit}>
|
||||
返回编辑
|
||||
</Button>
|
||||
|
||||
<div style={{ width: 1, height: 20, background: 'var(--theme-elevation-200)', margin: '0 4px' }} />
|
||||
|
||||
<Button buttonStyle="secondary" size="small" disabled={!canSave} onClick={onAddArea}>
|
||||
+ 新建区域
|
||||
</Button>
|
||||
<Button buttonStyle="primary" size="small" disabled={saving || !canSave} onClick={onSave}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
'use client'
|
||||
|
||||
/**
|
||||
* DisassemblyEditorPage — 拆解可视化编辑器
|
||||
* 路由:/admin/disassembly-editor?id=<pageId>
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
|
||||
import Toolbar from './Toolbar'
|
||||
import AreaNode from './AreaNode'
|
||||
import AreaDrawers, { type DrawersHandle } from './AreaDrawers'
|
||||
import { CANVAS_CSS } from './styles'
|
||||
import { type Area, type PageDoc, imgUrl, isPopulatedArea } from './types'
|
||||
|
||||
// ── 主组件 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DisassemblyEditorPage() {
|
||||
const { config } = useConfig()
|
||||
const apiBase: string = (config as any)?.routes?.api ?? '/api'
|
||||
const adminBase: string = (config as any)?.routes?.admin ?? '/admin'
|
||||
|
||||
const [pageId, setPageId] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
setPageId(new URLSearchParams(window.location.search).get('id'))
|
||||
}, [])
|
||||
|
||||
const [doc, setDoc] = useState<PageDoc | null>(null)
|
||||
const [areas, setAreas] = useState<Area[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMsg, setSaveMsg] = useState('')
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
const [selectedAreaId, setSelectedAreaId] = useState<string | undefined>(undefined)
|
||||
const drawersRef = useRef<DrawersHandle>(null)
|
||||
|
||||
// ── 拉取文档 ──
|
||||
const fetchDoc = useCallback(async (id: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/disassembly-pages/${id}?depth=2`, { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const data: PageDoc = await res.json()
|
||||
setDoc(data)
|
||||
setAreas((data.areas ?? []).filter(isPopulatedArea))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [apiBase])
|
||||
|
||||
useEffect(() => { if (pageId) void fetchDoc(pageId) }, [pageId, fetchDoc])
|
||||
|
||||
// ── 保存区域顺序 ──
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!pageId) return
|
||||
setSaving(true)
|
||||
setSaveMsg('')
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/disassembly-pages/${pageId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ areas: areas.map(a => a.id) }),
|
||||
})
|
||||
setSaveMsg(res.ok ? '已保存' : '保存失败')
|
||||
if (res.ok) setTimeout(() => setSaveMsg(''), 2000)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [pageId, apiBase, areas])
|
||||
|
||||
const handleBackToEdit = useCallback(() => {
|
||||
window.location.href = pageId
|
||||
? `${adminBase}/collections/disassembly-pages/${pageId}`
|
||||
: `${adminBase}/collections/disassembly-pages`
|
||||
}, [pageId, adminBase])
|
||||
|
||||
const mainImg = imgUrl(doc?.mainImage)
|
||||
|
||||
// ── 渲染 ──────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<>
|
||||
<style>{CANVAS_CSS}</style>
|
||||
|
||||
{/*
|
||||
布局:
|
||||
- 外层用 height:100vh + overflow:hidden 完全接管视口,
|
||||
避免 Payload admin 外层滚动容器。
|
||||
- 画布区 flex:1 + overflow-y:auto 自行处理滚动。
|
||||
- 这样工具栏不会遮挡内容,向下滚动不出现黑边。
|
||||
*/}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
background: '#ffffff',
|
||||
fontFamily: 'var(--font-body)',
|
||||
}}>
|
||||
|
||||
{/* ── 工具栏(固定高度,不 sticky)── */}
|
||||
<Toolbar
|
||||
title={doc?.name ?? '拆解可视化编辑器'}
|
||||
subtitle={`${areas.length} 个区域 · 可视化编辑器`}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
saveMsg={saveMsg}
|
||||
canSave={!!pageId}
|
||||
onRefresh={() => pageId && void fetchDoc(pageId)}
|
||||
onBackToEdit={handleBackToEdit}
|
||||
onSave={handleSave}
|
||||
onAddArea={() => drawersRef.current?.openNew()}
|
||||
/>
|
||||
|
||||
{/* ── 画布区(固定填满视口剩余高度,overflow:hidden 阻止绝对定位子元素撑开滚动高度)── */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: '#ffffff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
padding: '64px 48px 0',
|
||||
boxSizing: 'border-box',
|
||||
userSelect: 'none',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
}}>
|
||||
{/* 网格背景 */}
|
||||
<div className="dep-blueprint-grid" />
|
||||
<div className="dep-scan" />
|
||||
|
||||
{/* 中央装配主图 */}
|
||||
<div style={{
|
||||
position: 'relative', width: '100%', maxWidth: 700, height: 260,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 10, flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ position: 'absolute', inset: -16, pointerEvents: 'none', opacity: 0.4 }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: 48, height: 48, borderTop: '1px solid #525252', borderLeft: '1px solid #525252' }} />
|
||||
<div style={{ position: 'absolute', bottom: 0, right: 0, width: 48, height: 48, borderBottom: '1px solid #525252', borderRight: '1px solid #525252' }} />
|
||||
</div>
|
||||
{loading ? (
|
||||
<span style={{ fontSize: 10, color: '#a3a3a3', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.2em' }}>加载中...</span>
|
||||
) : mainImg ? (
|
||||
<img src={mainImg} className="dep-assembly" alt={doc?.name ?? ''} style={{ maxHeight: 240 }} />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '2px dashed #e5e5e5', background: '#fafafa' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.3em', color: '#d4d4d4' }}>暂无主图</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 区域节点 */}
|
||||
{areas.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
width: '100%', maxWidth: 1200,
|
||||
height: 360,
|
||||
marginTop: 48, padding: '0 40px',
|
||||
zIndex: 20, boxSizing: 'border-box', position: 'relative',
|
||||
overflow: 'visible',
|
||||
}}>
|
||||
{areas.map((area, index) => (
|
||||
<AreaNode
|
||||
key={area.id}
|
||||
area={area}
|
||||
index={index}
|
||||
isHovered={hoveredId === area.id}
|
||||
onHover={setHoveredId}
|
||||
onEdit={setSelectedAreaId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && areas.length === 0 && (
|
||||
<div style={{
|
||||
marginTop: 64, fontSize: 9, color: '#d4d4d4',
|
||||
fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.2em',
|
||||
zIndex: 10, position: 'relative',
|
||||
}}>
|
||||
暂无区域 — 点击工具栏「+ 新建区域」添加
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Drawers(编辑 + 新建)── */}
|
||||
<AreaDrawers
|
||||
ref={drawersRef}
|
||||
pageId={pageId}
|
||||
areaIds={areas.map(a => a.id)}
|
||||
selectedAreaId={selectedAreaId}
|
||||
onClearEdit={() => setSelectedAreaId(undefined)}
|
||||
onRefresh={() => pageId && void fetchDoc(pageId)}
|
||||
apiBase={apiBase}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
// ── 画布动画 & 节点 CSS ───────────────────────────────────────────────────────
|
||||
|
||||
export const CANVAS_CSS = `
|
||||
@keyframes dep-scanline {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
@keyframes dep-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.dep-blueprint-grid {
|
||||
background-color: #ffffff;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,0,0,0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,0,0,0.05) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
position: absolute; inset: 0;
|
||||
}
|
||||
.dep-blueprint-grid::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,0,0,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,0,0,0.02) 1px, transparent 1px);
|
||||
background-size: 10px 10px; pointer-events: none;
|
||||
}
|
||||
.dep-scan {
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 100%;
|
||||
background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.01), transparent);
|
||||
animation: dep-scanline 15s linear infinite; pointer-events: none; z-index: 5;
|
||||
}
|
||||
.dep-assembly {
|
||||
filter: drop-shadow(0 15px 30px rgba(0,0,0,0.1)) contrast(1.05);
|
||||
transition: filter 0.6s; max-width: 100%; max-height: 100%; object-fit: contain;
|
||||
}
|
||||
.dep-assembly:hover { filter: drop-shadow(0 20px 40px rgba(0,0,0,0.15)) contrast(1.1); }
|
||||
.dep-node-wrap {
|
||||
cursor: pointer; position: absolute; inset: 0;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.dep-icon-node { transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
.dep-node-wrap:hover .dep-icon-node { transform: translateX(-50%) scale(1.1) translateY(-8px); }
|
||||
.dep-terminal-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; border: 2px solid #fff;
|
||||
margin-bottom: 12px; transition: background 0.3s, transform 0.3s; background: #e5e5e5; flex-shrink: 0;
|
||||
}
|
||||
.dep-node-wrap:hover .dep-terminal-dot { background: #171717; transform: scale(1.25); }
|
||||
.dep-code {
|
||||
font-size: 8px; font-weight: 900; text-transform: uppercase;
|
||||
letter-spacing: 0.1em; color: #d4d4d4; transition: color 0.3s;
|
||||
}
|
||||
.dep-node-wrap:hover .dep-code { color: #171717; }
|
||||
.dep-name {
|
||||
font-size: 20px; font-weight: 900; text-transform: uppercase;
|
||||
letter-spacing: -0.05em; color: #a3a3a3;
|
||||
transition: color 0.3s, transform 0.3s;
|
||||
white-space: nowrap; will-change: transform;
|
||||
}
|
||||
.dep-node-wrap:hover .dep-name { color: #171717; transform: scale(1.05); }
|
||||
.dep-glow {
|
||||
position: absolute; inset: 0; background: rgba(23,23,23,0.05);
|
||||
filter: blur(24px); border-radius: 50%; transform: scale(1.25);
|
||||
opacity: 0; transition: opacity 0.3s; pointer-events: none;
|
||||
}
|
||||
.dep-node-wrap:hover .dep-glow { opacity: 1; }
|
||||
.dep-leader { transition: stroke 0.4s ease, stroke-width 0.4s ease; }
|
||||
.dep-label { transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1); }
|
||||
.dep-blink { animation: dep-blink 1.6s ease-in-out infinite; }
|
||||
`
|
||||
|
||||
// ── Drawer 宽度覆盖(注入到 document.head)────────────────────────────────────
|
||||
|
||||
const DRAWER_STYLE_ID = 'dep-drawer-overrides'
|
||||
|
||||
export function injectDrawerStyles(): void {
|
||||
if (typeof document === 'undefined') return
|
||||
if (document.getElementById(DRAWER_STYLE_ID)) return
|
||||
const el = document.createElement('style')
|
||||
el.id = DRAWER_STYLE_ID
|
||||
el.textContent = `
|
||||
/* 从左侧弹出,宽度 500px */
|
||||
.drawer {
|
||||
flex-direction: row-reverse !important;
|
||||
}
|
||||
.drawer__content {
|
||||
width: 500px !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 500px !important;
|
||||
transform: translateX(calc(var(--base) * -4)) !important;
|
||||
}
|
||||
.drawer--is-open .drawer__content {
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
.drawer__content-children {
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// ── Disassembly Editor 共享类型 ─────────────────────────────────────────────
|
||||
|
||||
export interface MediaDoc {
|
||||
id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface Area {
|
||||
id: string
|
||||
name: string
|
||||
thumbnailImage?: MediaDoc | string | null
|
||||
mainImage?: MediaDoc | string | null
|
||||
}
|
||||
|
||||
export interface PageDoc {
|
||||
id: string
|
||||
name: string
|
||||
url?: string
|
||||
mainImage?: MediaDoc | string | null
|
||||
areas?: (Area | string)[]
|
||||
}
|
||||
|
||||
// ── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function imgUrl(v: MediaDoc | string | null | undefined): string | null {
|
||||
if (!v) return null
|
||||
if (typeof v === 'object' && v.url) return v.url
|
||||
return null
|
||||
}
|
||||
|
||||
export function isPopulatedArea(v: Area | string): v is Area {
|
||||
return typeof v === 'object' && v !== null && 'id' in v
|
||||
}
|
||||
|
|
@ -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<false> | ArticlesSelect<true>;
|
||||
logs: LogsSelect<false> | LogsSelect<true>;
|
||||
'disassembly-pages': DisassemblyPagesSelect<false> | DisassemblyPagesSelect<true>;
|
||||
'disassembly-areas': DisassemblyAreasSelect<false> | DisassemblyAreasSelect<true>;
|
||||
'disassembly-components': DisassemblyComponentsSelect<false> | DisassemblyComponentsSelect<true>;
|
||||
'disassembly-linked-products': DisassemblyLinkedProductsSelect<false> | DisassemblyLinkedProductsSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
|
|
@ -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<T extends boolean = true> {
|
|||
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<T extends boolean = true> {
|
||||
name?: T;
|
||||
page?: T;
|
||||
mainImage?: T;
|
||||
thumbnailImage?: T;
|
||||
components?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
@ -1212,6 +1264,8 @@ export interface DisassemblyPagesSelect<T extends boolean = true> {
|
|||
*/
|
||||
export interface DisassemblyComponentsSelect<T extends boolean = true> {
|
||||
label?: T;
|
||||
productCode?: T;
|
||||
componentImage?: T;
|
||||
startCoordinate?:
|
||||
| T
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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=<pageId>
|
||||
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 || '',
|
||||
|
|
|
|||
Loading…
Reference in New Issue