flnxi-Wu/app/admin/_components/TemplateManager.tsx
WuYongJie 5f2ba48532
Some checks failed
CI Check / check (push) Has been cancelled
优化模板管理器拖拽交互体验
- 移除高亮卡片效果,避免与虚位卡片视觉重复
- 松开鼠标后立即更新布局,不再等待动画完成
- 优化拖拽动画效果,使用弹性缓动函数
- 更新 CHANGELOG.md 记录本次优化
2025-11-20 11:24:25 +08:00

2435 lines
104 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { DragEvent, Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
defaultDropAnimationSideEffects,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
rectSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { format } from 'date-fns'
type ToastFn = (message: string, type?: 'success' | 'error') => void
interface UploadFieldDto {
id?: string
key: string
label: string
type: string
required: boolean
orderIndex: number
layoutWidth?: string | null
validations?: any
optionsStatic?: any
allowedExtensions?: any
visibleOnHomeFilter: boolean
isSearchable: boolean
isReadOnly: boolean
conditions?: any
}
interface UploadTemplateDto {
id: string
name: string
description: string | null
status: string
version: number
fieldGroups: any
displayConfig: any
createdAt: string
updatedAt: string
fields: UploadFieldDto[]
}
interface FieldForm {
id?: string
key: string
label: string
type: string
required: boolean
layoutWidth: string
visibleOnHomeFilter: boolean
isSearchable: boolean
isReadOnly: boolean
validations: string
optionsStatic: string
allowedExtensions: string
conditions: string
autoLabel?: boolean
}
interface TemplateForm {
id?: string
version?: number
name: string
description: string
status: string
fieldGroups: string
displayConfig: string
fields: FieldForm[]
}
const STATUS_OPTIONS = [
{ value: 'draft', label: '草稿' },
{ value: 'active', label: '启用' },
{ value: 'archived', label: '归档' },
]
const FIELD_TYPES = ['text', 'textarea', 'number', 'select', 'checkbox', 'radio', 'image', 'file', 'date', 'uppercase']
const LAYOUT_OPTIONS = [
{ value: 'full', label: '整行' },
{ value: 'half', label: '半行' },
]
type DragHoverState = {
index: number
position: 'before' | 'after'
surface: 'editor'
}
type PreviewSortableItem = {
field: FieldForm
originalIndex: number
sortId: string
}
type PreviewRenderableItem =
| (PreviewSortableItem & { placeholder?: false })
| {
placeholder: true
sortId: string
layoutWidth: string
}
const getPreviewWidthStyle = (_layoutWidth?: string | null): React.CSSProperties => ({
width: '100%',
minWidth: 0,
})
const getPreviewWidthStyleValue = (layoutWidth?: string) => {
if (layoutWidth === 'full') {
return '520px'
}
return '320px'
}
const defaultField = (): FieldForm => ({
key: '',
label: '',
type: 'text',
required: false,
layoutWidth: 'full',
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: true,
})
// 标准字段配置:键名 + 推荐展示名称 + 默认FieldForm
const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
materialType: {
key: 'materialType',
label: '素材类型',
type: 'select',
required: true,
layoutWidth: 'full',
visibleOnHomeFilter: true,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: JSON.stringify(['3DMAX', 'PSD', 'AI', 'CAD', 'C4D', '视频', '材质', '模特', '标准饰品', '其他']),
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
productModel: {
key: 'productModel',
label: '型号',
type: 'text',
required: true,
layoutWidth: 'full',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
categoryName: {
key: 'categoryName',
label: '使用店铺',
type: 'text',
required: false,
layoutWidth: 'full',
visibleOnHomeFilter: true,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
sceneName: {
key: 'sceneName',
label: '场景类别',
type: 'select',
required: false,
layoutWidth: 'full',
visibleOnHomeFilter: true,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
platformName: {
key: 'platformName',
label: '平台分类',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
renderer: {
key: 'renderer',
label: '渲染器',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
brandName: {
key: 'brandName',
label: '品牌名称',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
seriesName: {
key: 'seriesName',
label: '系列 / 产品线',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
skuCode: {
key: 'skuCode',
label: 'SKU 编码',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
materialTexture: {
key: 'materialTexture',
label: '材质 / 工艺',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
renderTime: {
key: 'renderTime',
label: '渲染耗时',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
imageResolution: {
key: 'imageResolution',
label: '输出分辨率',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
colorMode: {
key: 'colorMode',
label: '颜色模式',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
authorName: {
key: 'authorName',
label: '作者',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
}
const STANDARD_FIELDS: Array<{ key: string; label: string }> = Object.keys(STANDARD_FIELD_TEMPLATES).map((key) => ({
key,
label: STANDARD_FIELD_TEMPLATES[key].label,
}))
const cloneFieldTemplate = (key: string): FieldForm | null => {
const template = STANDARD_FIELD_TEMPLATES[key]
if (!template) return null
return { ...template }
}
// 默认模板字段
const getDefaultTemplateFields = (): FieldForm[] => {
const defaultKeys = [
'materialType',
'productModel',
'sceneName',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'renderTime',
'imageResolution',
'colorMode',
]
return defaultKeys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
}
interface BuiltinTemplateConfig {
key: string
name: string
description: string
getFields: () => FieldForm[]
}
// 内置常用模板(使用新的字段标准)
const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = [
{
key: '3dmax-material',
name: '3DMAX 素材模板',
description: '适用于 3D 模型类素材,覆盖渲染器、SKU 与品牌等核心字段。',
getFields: () => {
const keys = ['materialType', 'productModel', 'renderer', 'sceneName', 'brandName', 'seriesName', 'skuCode']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
},
},
{
key: 'psd-material',
name: 'PS 素材模板',
description: '针对平面设计 PSD 素材,包含品牌、颜色模式和输出分辨率。',
getFields: () => {
const keys = ['materialType', 'productModel', 'categoryName', 'sceneName', 'brandName', 'colorMode', 'imageResolution']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
},
},
{
key: 'model-material',
name: '模特素材模板',
description: '用于模特/人像素材,关注拍摄场景与负责作者。',
getFields: () => {
const keys = ['materialType', 'productModel', 'sceneName', 'categoryName', 'authorName', 'brandName']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
},
},
{
key: 'texture-material',
name: '材质素材模板',
description: '聚焦材质/工艺与输出参数,便于材质库管理。',
getFields: () => {
const keys = ['materialType', 'materialTexture', 'sceneName', 'imageResolution', 'colorMode']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
},
},
{
key: 'photo-material',
name: '实拍素材模板',
description: '适用于实拍图/场景图,包含店铺、场景和作者信息。',
getFields: () => {
const keys = ['materialType', 'categoryName', 'sceneName', 'productModel', 'authorName']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
},
},
{
key: 'video-material',
name: '视频素材模板',
description: '面向短视频/实景动画,配置渲染时间与分辨率。',
getFields: () => {
const keys = ['materialType', 'productModel', 'sceneName', 'renderTime', 'imageResolution', 'colorMode']
return keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
},
},
]
const safeJsonStringify = (value: any) => {
if (value === null || value === undefined || value === '') return ''
try {
return JSON.stringify(value, null, 2)
} catch {
return ''
}
}
const parseJson = (raw: string, message: string) => {
const trimmed = raw.trim()
if (!trimmed) return null
try {
return JSON.parse(trimmed)
} catch (error) {
throw new Error(message)
}
}
const parseExtensions = (raw: string) => {
const trimmed = raw.trim()
if (!trimmed) return null
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (!Array.isArray(parsed)) throw new Error()
return parsed
} catch {
throw new Error('文件扩展名需使用 JSON 数组或逗号分隔格式')
}
}
return trimmed
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
const requestJson = async <T,>(input: RequestInfo, init?: RequestInit): Promise<T> => {
const res = await fetch(input, init)
if (!res.ok) {
let message = '请求失败'
try {
const data = await res.json()
message = data?.error || message
} catch {
// ignore
}
throw new Error(message)
}
return res.json()
}
export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
const [templates, setTemplates] = useState<UploadTemplateDto[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [form, setForm] = useState<TemplateForm | null>(null)
const [editingFieldIndex, setEditingFieldIndex] = useState<number | null>(null)
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
const [selectedPreset, setSelectedPreset] = useState<string | null>(null)
const [dragFieldIndex, setDragFieldIndex] = useState<number | null>(null)
const [dragHover, setDragHover] = useState<DragHoverState | null>(null)
const [previewActiveId, setPreviewActiveId] = useState<string | null>(null)
const fetchTemplates = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await requestJson<UploadTemplateDto[]>('/api/admin/upload-templates')
setTemplates(data)
} catch (err: any) {
setError(err.message || '获取模板失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchTemplates()
}, [fetchTemplates])
const openCreate = (presetKey?: string) => {
const preset =
(presetKey && BUILTIN_TEMPLATES.find((item) => item.key === presetKey)) ||
BUILTIN_TEMPLATES[0]
const initialFields = preset ? preset.getFields() : getDefaultTemplateFields()
setForm({
name: preset ? preset.name : '',
description: preset ? preset.description : '',
status: 'draft',
fieldGroups: '',
displayConfig: '',
fields: initialFields,
})
setSelectedPreset(preset ? preset.key : null)
setEditorOpen(true)
setEditingFieldIndex(null)
setSaveMessage(null)
}
const openEdit = (tpl: UploadTemplateDto) => {
setForm({
id: tpl.id,
version: tpl.version,
name: tpl.name,
description: tpl.description || '',
status: tpl.status || 'draft',
fieldGroups: safeJsonStringify(tpl.fieldGroups),
displayConfig: safeJsonStringify(tpl.displayConfig),
fields: tpl.fields.map((field) => ({
id: field.id,
key: field.key,
label: field.label,
type: field.type,
required: field.required,
layoutWidth: field.layoutWidth || 'full',
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
validations: safeJsonStringify(field.validations),
optionsStatic: safeJsonStringify(field.optionsStatic),
allowedExtensions: safeJsonStringify(field.allowedExtensions),
conditions: safeJsonStringify(field.conditions),
autoLabel: false,
})),
})
setEditorOpen(true)
setSelectedPreset(null)
setEditingFieldIndex(null)
setSaveMessage(null)
}
const openDuplicate = (tpl: UploadTemplateDto) => {
setForm({
name: `${tpl.name} - 副本`,
description: tpl.description || '',
status: 'draft',
fieldGroups: safeJsonStringify(tpl.fieldGroups),
displayConfig: safeJsonStringify(tpl.displayConfig),
fields: tpl.fields.map((field) => ({
key: field.key,
label: field.label,
type: field.type,
required: field.required,
layoutWidth: field.layoutWidth || 'full',
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
validations: safeJsonStringify(field.validations),
optionsStatic: safeJsonStringify(field.optionsStatic),
allowedExtensions: safeJsonStringify(field.allowedExtensions),
conditions: safeJsonStringify(field.conditions),
autoLabel: false,
})),
})
setEditorOpen(true)
setSelectedPreset(null)
setEditingFieldIndex(null)
setSaveMessage(null)
}
const closeEditor = () => {
if (saving) return
setEditorOpen(false)
setForm(null)
setEditingFieldIndex(null)
setSaveMessage(null)
setSelectedPreset(null)
}
const applyPreset = (presetKey: string) => {
const preset = BUILTIN_TEMPLATES.find((item) => item.key === presetKey)
if (!preset) return
setSelectedPreset(presetKey)
setForm((prev) => {
if (!prev) return prev
return {
...prev,
name: prev.name || preset.name,
description: prev.description || preset.description,
fields: preset.getFields(),
}
})
setEditingFieldIndex(null)
setSaveMessage(null)
}
const updateField = useCallback((index: number, patch: Partial<FieldForm>) => {
setForm((prev) => {
if (!prev) return prev
const currentField = prev.fields[index]
if (!currentField) return prev
// 检查是否有实际变化,避免不必要的更新
const hasChanges = Object.keys(patch).some(key => {
const patchKey = key as keyof FieldForm
return currentField[patchKey] !== patch[patchKey]
})
if (!hasChanges) return prev
const nextFields = prev.fields.map((field, i) => {
if (i === index) {
return { ...field, ...patch }
}
return field
})
return { ...prev, fields: nextFields }
})
}, [])
const addField = useCallback(() => {
setForm((prev) => {
if (!prev) return prev
const newField = defaultField()
return { ...prev, fields: [...prev.fields, newField] }
})
// 添加字段后,自动滚动到新字段
setTimeout(() => {
const newIndex = form?.fields.length ?? 0
const editorElement = document.getElementById(`editor-field-${newIndex}`)
if (editorElement) {
editorElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
setEditingFieldIndex(newIndex)
}
}, 100)
}, [form?.fields.length])
const removeField = useCallback(
(index: number) =>
setForm((prev) => {
if (!prev) return prev
if (prev.fields.length === 1) {
showToast('至少保留一个字段', 'error')
return prev
}
return { ...prev, fields: prev.fields.filter((_, i) => i !== index) }
}),
[showToast]
)
const removeFieldAndSyncEditing = useCallback(
(index: number) => {
removeField(index)
setEditingFieldIndex((prev) => {
if (prev === null) return prev
if (prev === index) return null
if (prev > index) return prev - 1
return prev
})
},
[removeField]
)
const moveField = (index: number, direction: 'up' | 'down') =>
setForm((prev) => {
if (!prev) return prev
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= prev.fields.length) return prev
const nextFields = [...prev.fields]
const [item] = nextFields.splice(index, 1)
nextFields.splice(targetIndex, 0, item)
return { ...prev, fields: nextFields }
})
const reorderFieldByIndex = useCallback(
(fromIndex: number, toIndex: number) => {
setForm((prev) => {
if (!prev) return prev
if (fromIndex === toIndex) return prev
if (fromIndex < 0 || fromIndex >= prev.fields.length) return prev
if (toIndex < 0 || toIndex > prev.fields.length) return prev
const nextFields = [...prev.fields]
const [item] = nextFields.splice(fromIndex, 1)
const boundedIndex = Math.min(Math.max(toIndex, 0), nextFields.length)
nextFields.splice(boundedIndex, 0, item)
return { ...prev, fields: nextFields }
})
},
[]
)
const previewSensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
})
)
const previewSortableFields = useMemo<PreviewSortableItem[]>(() => {
if (!form) return []
// 使用 index 作为 sortId 的一部分,确保唯一性
return form.fields
.map((field, index) => ({
field,
originalIndex: index,
sortId: `preview-${index}-${field.id ?? field.key ?? `field-${index}`}`,
}))
.filter(
(item) =>
item.field.type !== 'image' &&
item.field.type !== 'file'
)
}, [form])
// 使用字段在 form.fields 中的实际索引
const previewIndexLookup = useMemo(() => {
const map = new Map<string, number>()
previewSortableFields.forEach((item) => {
map.set(item.sortId, item.originalIndex)
})
return map
}, [previewSortableFields])
const previewSortableMap = useMemo(() => {
const map = new Map<string, PreviewSortableItem>()
previewSortableFields.forEach((item) => {
map.set(item.sortId, item)
})
return map
}, [previewSortableFields])
const previewDefaultOrder = useMemo(
() => previewSortableFields.map((item) => item.sortId),
[previewSortableFields]
)
const [previewOrder, setPreviewOrder] = useState<string[]>(previewDefaultOrder)
useEffect(() => {
if (!previewActiveId) {
// 没有拖拽时,直接同步到最新的order
setPreviewOrder(previewDefaultOrder)
setPreviewPlaceholderIndex(null)
} else {
// 正在拖拽时,如果字段列表变化了,需要更新order(但保持拖拽状态)
// 只有当order长度不匹配时才更新
setPreviewOrder((currentOrder) => {
if (currentOrder.length !== previewDefaultOrder.length) {
// 重新计算order,保持当前拖拽的卡片在正确位置
const activeId = previewActiveId
const newOrder = previewDefaultOrder.filter(id => id !== activeId)
const currentIndex = currentOrder.indexOf(activeId)
if (currentIndex >= 0 && currentIndex < newOrder.length) {
newOrder.splice(currentIndex, 0, activeId)
} else {
newOrder.push(activeId)
}
return newOrder
}
return currentOrder
})
}
}, [previewDefaultOrder, previewActiveId])
const previewActiveItem = previewActiveId ? previewSortableMap.get(previewActiveId) : null
const [previewOverId, setPreviewOverId] = useState<string | null>(null)
const [previewActiveRect, setPreviewActiveRect] = useState<{ width: number; height: number } | null>(null)
const [previewPlaceholderVisible, setPreviewPlaceholderVisible] = useState(false)
const [previewPlaceholderIndex, setPreviewPlaceholderIndex] = useState<number | null>(null)
const pendingPlaceholderIndexRef = useRef<number | null>(null)
// 当 previewOrder 变化时,如果有待更新的 placeholder index,则更新它
useEffect(() => {
if (pendingPlaceholderIndexRef.current !== null) {
const index = pendingPlaceholderIndexRef.current
pendingPlaceholderIndexRef.current = null
// 使用 requestAnimationFrame 确保在下一个渲染周期更新
requestAnimationFrame(() => {
setPreviewPlaceholderIndex(index)
})
}
}, [previewOrder])
const orderedPreviewItems = useMemo(() => {
return previewOrder
.map((id) => previewSortableMap.get(id))
.filter((item): item is PreviewSortableItem => !!item)
}, [previewOrder, previewSortableMap])
const previewRenderableItems = useMemo<PreviewRenderableItem[]>(() => {
let items: PreviewRenderableItem[] = orderedPreviewItems
if (previewActiveId) {
items = items.filter((item) => item.sortId !== previewActiveId)
}
if (previewPlaceholderVisible && previewPlaceholderIndex !== null && previewActiveItem) {
const placeholder: PreviewRenderableItem = {
placeholder: true,
sortId: `${previewActiveItem.sortId}-placeholder`,
layoutWidth: previewActiveItem.field.layoutWidth,
}
const insertIndex = Math.max(0, Math.min(previewPlaceholderIndex, items.length))
const cloned = [...items]
cloned.splice(insertIndex, 0, placeholder)
return cloned
}
return items
}, [orderedPreviewItems, previewActiveId, previewActiveItem, previewPlaceholderIndex, previewPlaceholderVisible])
const resetPreviewDragState = useCallback(() => {
setPreviewActiveId(null)
setPreviewOverId(null)
setPreviewActiveRect(null)
setPreviewPlaceholderVisible(false)
setPreviewPlaceholderIndex(null)
pendingPlaceholderIndexRef.current = null
}, [])
const handlePreviewDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
if (!form) {
resetPreviewDragState()
return
}
const activeId = String(active.id)
const activeIndex = previewIndexLookup.get(activeId)
const targetOrderIndex = previewOrder.indexOf(activeId)
if (activeIndex === undefined || targetOrderIndex === -1 || activeIndex === targetOrderIndex) {
resetPreviewDragState()
return
}
// previewOrder 已经反映了拖拽后的顺序,直接根据它来更新 form.fields
setForm((prev) => {
if (!prev) return prev
const newFields = [...prev.fields]
// 根据 previewOrder 的顺序重新排列字段
const reorderedFields = previewOrder
.map((sortId) => {
const item = previewSortableMap.get(sortId)
return item ? newFields[item.originalIndex] : null
})
.filter((field): field is FieldForm => field !== null)
return { ...prev, fields: reorderedFields }
})
// 立即重置拖拽状态,布局会立即更新(因为 previewOrder 已经正确)
setDragFieldIndex(null)
setDragHover(null)
resetPreviewDragState()
// 延迟显示成功提示,确保Toast在弹窗之上(通过延迟确保DOM更新完成)
setTimeout(() => {
showToast(`已将"${form.fields[activeIndex]?.label || '字段'}"移动到新位置`, 'success')
}, 150)
},
[form, previewIndexLookup, previewOrder, previewSortableMap, resetPreviewDragState, showToast]
)
const handlePreviewDragStart = useCallback(
(event: DragStartEvent) => {
const activeId = String(event.active.id)
const initialRect = event.active.rect.current?.initial
setPreviewActiveId(activeId)
setPreviewOverId(activeId)
setPreviewPlaceholderVisible(true)
setPreviewPlaceholderIndex(previewOrder.indexOf(activeId))
setPreviewOrder((order) => {
if (order.length === previewDefaultOrder.length) return order
return previewDefaultOrder
})
if (initialRect) {
setPreviewActiveRect({
width: initialRect.width,
height: initialRect.height,
})
} else {
setPreviewActiveRect(null)
}
},
[previewDefaultOrder, previewOrder]
)
const handlePreviewDragCancel = useCallback(() => {
setPreviewOrder(previewDefaultOrder)
resetPreviewDragState()
}, [previewDefaultOrder, resetPreviewDragState])
const handlePreviewDragOver = useCallback((event: DragOverEvent) => {
const { over, active } = event
if (!over) {
setPreviewOverId(null)
setPreviewPlaceholderVisible(false)
return
}
const overId = String(over.id)
const activeId = String(active.id)
// 如果悬停在占位符上,忽略
if (overId.includes('-placeholder')) {
return
}
if (!previewSortableMap.has(overId) || !previewSortableMap.has(activeId)) {
setPreviewOverId(null)
setPreviewPlaceholderVisible(false)
return
}
// 只有当值真正改变时才更新状态,避免闪烁
setPreviewPlaceholderVisible((prev) => {
if (!prev) return true
return prev
})
setPreviewOverId((prev) => {
if (prev !== overId) return overId
return prev
})
setPreviewOrder((items) => {
if (!items.includes(activeId)) {
return items
}
const currentIndex = items.indexOf(activeId)
const overIndex = items.indexOf(overId)
if (currentIndex === -1 || overIndex === -1) {
return items
}
const overRect = over.rect
const overItem = previewSortableMap.get(overId)
// 使用鼠标的实际位置(通过 delta 计算)而不是卡片中心点
// event.delta 表示鼠标相对于初始位置的偏移
const delta = event.delta
let mouseX = 0
let mouseY = 0
// 尝试从 active.rect.current.translated 获取鼠标位置
const activeRect = event.active.rect.current.translated ?? event.active.rect.current.initial
if (activeRect && delta) {
// 鼠标位置 = 卡片初始中心点 + delta
mouseX = activeRect.left + activeRect.width / 2 + delta.x
mouseY = activeRect.top + activeRect.height / 2 + delta.y
} else if (overRect) {
// 如果没有 delta,使用 overRect 的中心点作为参考
mouseX = overRect.left + overRect.width / 2
mouseY = overRect.top + overRect.height / 2
}
// 默认情况下,占位符应该显示在目标卡片的位置(替换目标卡片)
// 只有当鼠标移动到目标卡片的边缘时,才插入到目标卡片之前或之后
let insertionIndex = overIndex
if (overRect && mouseX > 0 && mouseY > 0) {
// 判断鼠标相对于目标卡片的位置
const overCenterX = overRect.left + overRect.width / 2
const overCenterY = overRect.top + overRect.height / 2
// 计算鼠标相对于目标卡片中心的位置
const relativeX = mouseX - overCenterX
const relativeY = mouseY - overCenterY
// 判断是否在同一行(Y 轴距离小于卡片高度的 65%)
const sameRow = Math.abs(relativeY) < overRect.height * 0.65
// 边缘阈值:卡片宽度/高度的 30%
const edgeThresholdX = overRect.width * 0.3
const edgeThresholdY = overRect.height * 0.3
if (overItem?.field.layoutWidth === 'full' || !sameRow) {
// 全宽卡片或不同行:根据 Y 坐标判断
if (Math.abs(relativeY) > edgeThresholdY) {
// 鼠标在目标卡片的上边缘或下边缘,插入到目标卡片之前或之后
insertionIndex = mouseY > overCenterY ? overIndex + 1 : overIndex
} else {
// 鼠标在目标卡片中间,占位符替换目标卡片
insertionIndex = overIndex
}
} else {
// 半宽卡片且同一行:根据 X 坐标判断
if (Math.abs(relativeX) > edgeThresholdX) {
// 鼠标在目标卡片的左边缘或右边缘,插入到目标卡片之前或之后
insertionIndex = mouseX > overCenterX ? overIndex + 1 : overIndex
} else {
// 鼠标在目标卡片中间,占位符替换目标卡片
insertionIndex = overIndex
}
}
}
const withoutActive = items.filter((id) => id !== activeId)
// 如果插入位置大于当前索引,需要减1(因为 activeId 已经被移除)
if (insertionIndex > currentIndex) {
insertionIndex = Math.max(0, insertionIndex - 1)
}
insertionIndex = Math.max(0, Math.min(insertionIndex, withoutActive.length))
// 计算新的顺序
const nextOrder = [...withoutActive]
nextOrder.splice(insertionIndex, 0, activeId)
// 如果位置没有变化,不更新状态,避免无限循环和闪烁
// 比较新旧顺序是否相同
if (nextOrder.length === items.length &&
nextOrder.every((id, idx) => id === items[idx])) {
return items
}
// 使用 ref 存储 insertionIndex,在 useEffect 中更新 state
pendingPlaceholderIndexRef.current = insertionIndex
return nextOrder
})
}, [previewSortableMap])
// 点击预览元素时滚动到左侧编辑面板对应元素
const scrollToEditorField = useCallback((fieldIndex: number) => {
const editorElement = document.getElementById(`editor-field-${fieldIndex}`)
if (editorElement) {
editorElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
setEditingFieldIndex(fieldIndex)
}
}, [])
const createTemplateFromPreset = async (presetKey: string, status: 'draft' | 'active' = 'draft') => {
const preset = BUILTIN_TEMPLATES.find((item) => item.key === presetKey)
if (!preset) return
try {
const fields = preset.getFields()
const payload: any = {
name: preset.name,
description: preset.description,
status,
fieldGroups: null,
displayConfig: null,
fields: fields.map((field, index) => {
let validations: any = null
let optionsStatic: any = null
let allowedExtensions: any = null
let conditions: any = null
try {
validations = parseJson(field.validations, '字段校验需为合法 JSON')
} catch {
validations = null
}
try {
optionsStatic = parseJson(field.optionsStatic, '下拉选项需为合法 JSON 或留空')
} catch {
optionsStatic = null
}
try {
allowedExtensions = parseExtensions(field.allowedExtensions)
} catch {
allowedExtensions = null
}
try {
conditions = parseJson(field.conditions, '展示条件需为合法 JSON')
} catch {
conditions = null
}
return {
key: String(field.key || '').trim(),
label: String(field.label || '').trim() || `字段${index + 1}`,
type: field.type || 'text',
required: !!field.required,
orderIndex: index,
layoutWidth: field.layoutWidth || 'full',
validations,
optionsSource: null,
optionsStatic,
allowedExtensions,
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
isSearchable: !!field.isSearchable,
isReadOnly: !!field.isReadOnly,
conditions,
}
}),
}
const created = await requestJson<UploadTemplateDto>('/api/admin/upload-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
setTemplates((prev) => [created, ...prev])
showToast(status === 'active' ? '模板已创建并启用' : '模板已创建为草稿', 'success')
} catch (error: any) {
showToast(error.message || '创建预制模板失败', 'error')
}
}
const resetDragState = () => {
setDragFieldIndex(null)
setDragHover(null)
}
const finalizeDragReorder = (customHover?: DragHoverState | null) => {
if (!form || dragFieldIndex === null) {
resetDragState()
return
}
const target = customHover ?? dragHover
if (!target) {
resetDragState()
return
}
let insertionIndex = target.position === 'before' ? target.index : target.index + 1
insertionIndex = Math.min(Math.max(insertionIndex, 0), form.fields.length)
const adjustedIndex = dragFieldIndex < insertionIndex ? insertionIndex - 1 : insertionIndex
reorderFieldByIndex(dragFieldIndex, adjustedIndex)
resetDragState()
}
const handleFieldDragOver = (
event: DragEvent<HTMLDivElement>,
targetIndex: number,
surface: 'editor'
) => {
if (dragFieldIndex === null) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
const rect = event.currentTarget.getBoundingClientRect()
const insertBefore = event.clientY - rect.top < rect.height / 2
setDragHover({ index: targetIndex, position: insertBefore ? 'before' : 'after', surface })
}
const handleFieldDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
finalizeDragReorder()
}
const startFieldDrag = (
event: DragEvent<HTMLElement>,
index: number,
surface: 'editor' = 'editor'
) => {
event.stopPropagation()
event.dataTransfer.effectAllowed = 'move'
setDragFieldIndex(index)
setDragHover({ index, position: 'before', surface })
try {
event.dataTransfer.setData('text/plain', String(index))
} catch {
// ignore
}
}
const renderEditorDropPlaceholder = (key: string) => {
if (!form || dragFieldIndex === null || !dragHover) return null
return (
<div
key={key}
className="w-full border-2 border-dashed border-blue-500 dark:border-blue-400 bg-blue-100/60 dark:bg-blue-900/30 rounded-lg py-8 flex items-center justify-center text-sm font-semibold text-blue-700 dark:text-blue-200 shadow-lg shadow-blue-200/50 dark:shadow-blue-900/30 animate-pulse"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
松开即可放置
</div>
)
}
const handleSave = async () => {
if (!form) return
setSaveMessage(null)
if (!form.name.trim()) {
setSaveMessage({ type: 'error', text: '模板名称不能为空' })
return
}
if (form.fields.some((field) => !field.key.trim())) {
setSaveMessage({ type: 'error', text: '字段键名不能为空' })
return
}
try {
const payload: any = {
name: form.name.trim(),
description: form.description.trim() || null,
status: form.status,
}
try {
payload.fieldGroups = parseJson(form.fieldGroups, '字段分组必须为合法 JSON')
} catch (error: any) {
setSaveMessage({ type: 'error', text: error.message })
return
}
try {
payload.displayConfig = parseJson(form.displayConfig, '展示配置必须为合法 JSON')
} catch (error: any) {
setSaveMessage({ type: 'error', text: error.message })
return
}
payload.fields = form.fields.map((field, index) => {
let validations = null
let optionsStatic = null
let allowedExtensions = null
let conditions = null
try {
validations = parseJson(field.validations, '字段校验需为合法 JSON')
} catch (error: any) {
throw new Error(`字段「${field.label || field.key || `字段${index + 1}`}」校验配置错误:${error.message}`)
}
try {
// 支持每行一个选项的格式
const trimmed = field.optionsStatic.trim()
if (trimmed && !trimmed.startsWith('[') && !trimmed.startsWith('{')) {
// 如果不是JSON格式,按行分割
const lines = trimmed.split('\n').map(line => line.trim()).filter(Boolean)
optionsStatic = lines.length > 0 ? lines : null
} else {
optionsStatic = parseJson(field.optionsStatic, '字段选项需为合法 JSON')
}
} catch (error: any) {
throw new Error(`字段「${field.label || field.key || `字段${index + 1}`}」选项配置错误:${error.message}`)
}
try {
allowedExtensions = parseExtensions(field.allowedExtensions)
} catch (error: any) {
throw new Error(`字段「${field.label || field.key || `字段${index + 1}`}」扩展名配置错误:${error.message}`)
}
try {
conditions = parseJson(field.conditions, '字段联动需为合法 JSON')
} catch (error: any) {
throw new Error(`字段「${field.label || field.key || `字段${index + 1}`}」联动配置错误:${error.message}`)
}
return {
id: field.id,
key: field.key.trim(),
label: field.label.trim() || field.key.trim(),
type: field.type,
required: field.required,
orderIndex: index,
layoutWidth: field.layoutWidth,
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
validations,
optionsStatic,
allowedExtensions,
conditions,
}
})
setSaving(true)
if (form.id) {
payload.version = form.version
const updated = await requestJson<UploadTemplateDto>(`/api/admin/upload-templates/${form.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
setTemplates((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))
setSaveMessage({ type: 'success', text: '模板已更新' })
setTimeout(() => {
closeEditor()
}, 1500)
} else {
const created = await requestJson<UploadTemplateDto>('/api/admin/upload-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
setTemplates((prev) => [created, ...prev])
setSaveMessage({ type: 'success', text: '模板创建成功' })
setTimeout(() => {
closeEditor()
}, 1500)
}
} catch (error: any) {
setSaveMessage({ type: 'error', text: error.message || '保存失败' })
} finally {
setSaving(false)
}
}
const handleActivate = async (templateId: string) => {
try {
const template = templates.find(t => t.id === templateId)
if (!template) return
const payload: any = {
name: template.name,
description: template.description,
status: 'active',
version: template.version,
fieldGroups: template.fieldGroups,
displayConfig: template.displayConfig,
fields: template.fields.map((field, index) => ({
id: field.id,
key: field.key,
label: field.label,
type: field.type,
required: field.required,
orderIndex: index,
layoutWidth: field.layoutWidth,
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
validations: field.validations,
optionsStatic: field.optionsStatic,
allowedExtensions: field.allowedExtensions,
conditions: field.conditions,
})),
}
const updated = await requestJson<UploadTemplateDto>(`/api/admin/upload-templates/${templateId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
setTemplates((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))
} catch (error: any) {
showToast(error.message || '启用失败', 'error')
}
}
const handleDelete = async (templateId: string) => {
if (deletingId || !confirm('确定删除该模板吗?')) return
setDeletingId(templateId)
try {
await requestJson(`/api/admin/upload-templates/${templateId}`, { method: 'DELETE' })
setTemplates((prev) => prev.filter((item) => item.id !== templateId))
showToast('模板已删除')
} catch (error: any) {
showToast(error.message || '删除失败', 'error')
} finally {
setDeletingId(null)
}
}
const displayTemplates = useMemo(
() =>
templates.map((tpl) => ({
...tpl,
updatedLabel: format(new Date(tpl.updatedAt), 'yyyy-MM-dd HH:mm'),
fieldCount: tpl.fields.length,
})),
[templates]
)
return (
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg overflow-hidden border border-gray-200/50 dark:border-gray-800/50">
{/* 预制模板区域 */}
<div className="px-6 pt-5 pb-4 border-b border-gray-200/60 dark:border-gray-800/60 bg-gray-50/40 dark:bg-gray-900/40">
<div className="flex items-center justify-between gap-4 mb-3">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">预制模板(推荐)</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">常用场景一键启用,也可作为自定义模板的起点</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{BUILTIN_TEMPLATES.map((preset) => (
<div
key={preset.key}
className="rounded-xl border border-gray-200/70 dark:border-gray-800/70 bg-white/80 dark:bg-gray-900/80 p-4 shadow-sm hover:shadow-md hover:border-gray-300 dark:hover:border-gray-700 transition-all duration-200"
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">{preset.name}</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 leading-snug">{preset.description}</p>
<div className="flex flex-wrap gap-2 mt-1.5">
<button
type="button"
onClick={() => openCreate(preset.key)}
className="px-3 py-1.5 text-xs rounded-lg bg-gray-900 text-white hover:bg-gray-800 dark:bg-gray-800 dark:hover:bg-gray-700 transition-all duration-200 flex items-center gap-1.5"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
使用此预设新建
</button>
<button
type="button"
onClick={() => createTemplateFromPreset(preset.key, 'active')}
className="px-3 py-1.5 text-xs rounded-lg bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-800/50 transition-all duration-200"
>
一键创建并启用
</button>
</div>
</div>
</div>
))}
<button
type="button"
onClick={() => openCreate()}
className="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900/80 p-4 shadow-sm hover:border-gray-400 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-all duration-200 flex items-center justify-center gap-3"
>
<div className="w-14 h-14 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center flex-shrink-0 group-hover:bg-gray-200 dark:group-hover:bg-gray-700 transition-colors">
<svg className="w-8 h-8 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-200 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="text-left">
<p className="text-sm font-semibold text-gray-800 dark:text-gray-200">新建自定义模板</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400 mt-1 leading-snug">从空白模板起步,完全按照业务需求自由配置字段</p>
</div>
</button>
</div>
</div>
{loading ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">加载中...</div>
) : error ? (
<div className="p-8 text-center text-red-500 dark:text-red-300">{error}</div>
) : displayTemplates.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">暂无模板,可从上方预制模板开始,或新建自定义模板。</div>
) : (
<div className="divide-y divide-gray-200/60 dark:divide-gray-800/60">
{displayTemplates.map((tpl) => (
<div key={tpl.id} className="group p-6 space-y-3 hover:bg-gray-50/50 dark:hover:bg-gray-800/30 transition-all duration-200 ease-out">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{tpl.name}</h3>
<span
className={`px-2.5 py-1 text-xs font-medium rounded-full transition-all duration-200 ${
tpl.status === 'active'
? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
: tpl.status === 'draft'
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{STATUS_OPTIONS.find((option) => option.value === tpl.status)?.label || tpl.status}
</span>
</div>
{tpl.description && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1.5">{tpl.description}</p>}
</div>
<div className="flex items-center gap-2">
{tpl.status !== 'active' && (
<button
onClick={() => handleActivate(tpl.id)}
className="px-4 py-2 text-sm bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-xl hover:bg-green-100 dark:hover:bg-green-800/50 transition-all duration-200 ease-out hover:scale-105 active:scale-95 font-medium"
>
启用
</button>
)}
<button
onClick={() => openEdit(tpl)}
className="px-4 py-2 text-sm bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-xl hover:bg-blue-100 dark:hover:bg-blue-800/50 transition-all duration-200 ease-out hover:scale-105 active:scale-95 font-medium"
>
编辑
</button>
<button
onClick={() => openDuplicate(tpl)}
className="px-4 py-2 text-sm bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ease-out hover:scale-105 active:scale-95 font-medium"
>
复制
</button>
<button
onClick={() => handleDelete(tpl.id)}
disabled={deletingId === tpl.id}
className="px-4 py-2 text-sm bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-xl hover:bg-red-100 dark:hover:bg-red-800/50 transition-all duration-200 ease-out hover:scale-105 active:scale-95 disabled:opacity-60 disabled:cursor-not-allowed font-medium"
>
{deletingId === tpl.id ? '删除中...' : '删除'}
</button>
</div>
</div>
<div className="flex flex-wrap gap-4 text-sm text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
{tpl.fieldCount} 个字段
</span>
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{tpl.updatedLabel}
</span>
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
v{tpl.version}
</span>
</div>
{tpl.fields.length > 0 && (
<div className="bg-gray-50/80 dark:bg-gray-800/40 rounded-xl p-4 border border-gray-200/50 dark:border-gray-700/50">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2.5">字段预览</div>
<div className="flex flex-wrap gap-2">
{tpl.fields.slice(0, 6).map((field) => (
<span
key={field.id || field.key}
className="px-2.5 py-1 text-xs bg-white dark:bg-gray-900/60 border border-gray-200/60 dark:border-gray-700/60 rounded-lg text-gray-700 dark:text-gray-200 font-medium"
>
{field.label || field.key} · {field.type}
</span>
))}
{tpl.fields.length > 6 && (
<span className="text-xs text-gray-500 dark:text-gray-400 px-2.5 py-1">等 {tpl.fields.length} 个字段</span>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{editorOpen && form && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in duration-200">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-7xl max-h-[92vh] overflow-hidden flex flex-col animate-in zoom-in-95 duration-300">
<div className="px-6 py-5 border-b border-gray-200/60 dark:border-gray-800/60 flex items-center justify-between bg-gray-50/50 dark:bg-gray-800/30">
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white tracking-tight">{form.id ? '编辑模板' : '新建模板'}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">左侧配置字段,右侧实时预览上传面板效果</p>
</div>
<button
onClick={closeEditor}
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-all duration-200"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex flex-1 overflow-hidden">
{/* 左侧:字段配置面板 */}
<div className="w-1/2 border-r border-gray-200/60 dark:border-gray-800/60 overflow-y-auto">
<div className="p-6 space-y-6">
{/* 模板基本信息 */}
<div className="space-y-5">
<div>
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">模板名称 <span className="text-red-500">*</span></label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((prev) => (prev ? { ...prev, name: e.target.value } : prev))}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
placeholder="例如:产品素材模板"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">给模板起一个容易识别的名称</p>
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">模板描述</label>
<textarea
rows={2}
value={form.description}
onChange={(e) => setForm((prev) => (prev ? { ...prev, description: e.target.value } : prev))}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 resize-none"
placeholder="简单描述这个模板的用途"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">模板状态</label>
<select
value={form.status}
onChange={(e) => setForm((prev) => (prev ? { ...prev, status: e.target.value } : prev))}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">草稿:仅保存,不生效;启用:用户可以使用</p>
</div>
{/* 预设模板选择(仅在新建时显示) */}
{!form.id && (
<div>
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">选择预设模板</label>
<div className="flex flex-wrap gap-2">
{BUILTIN_TEMPLATES.map((preset) => {
const active = selectedPreset === preset.key
return (
<button
key={preset.key}
type="button"
onClick={() => applyPreset(preset.key)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all duration-200 flex items-center gap-1.5 ${
active
? 'bg-blue-600 text-white border-blue-600 shadow-sm'
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:bg-white dark:hover:bg-gray-700'
}`}
>
{active && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-white" />
)}
<span>{preset.name}</span>
</button>
)
})}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
选择一个预设模板后,下方字段会自动填充,可在此基础上继续增删或调整。
</p>
</div>
)}
</div>
<div className="space-y-5 pt-4 border-t border-gray-200/60 dark:border-gray-800/60">
<div>
<h4 className="text-lg font-semibold text-gray-900 dark:text-white">字段配置</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">添加用户上传时需要填写的字段</p>
</div>
<div
className="space-y-3"
onDragOver={(event) => {
if (!form || dragFieldIndex === null) return
if (event.target !== event.currentTarget) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
const hasAny = form.fields.length > 0
setDragHover({
index: hasAny ? form.fields.length - 1 : 0,
position: hasAny ? 'after' : 'before',
surface: 'editor',
})
}}
onDrop={(event) => {
if (dragFieldIndex === null) return
event.preventDefault()
event.stopPropagation()
finalizeDragReorder(
dragHover ?? {
index: form.fields.length > 0 ? form.fields.length - 1 : 0,
position: form.fields.length > 0 ? 'after' : 'before',
surface: 'editor',
}
)
}}
onDragLeave={(event) => {
if (dragFieldIndex === null) return
const nextTarget = event.relatedTarget as Node | null
if (nextTarget && event.currentTarget.contains(nextTarget)) return
setDragHover(null)
}}
>
{form.fields.map((field, index) => {
const isDraggingEditorCard = dragFieldIndex === index
const editorPlaceholderBefore =
dragFieldIndex !== null &&
dragHover &&
dragHover.index === index &&
dragHover.position === 'before'
const editorPlaceholderAfter =
dragFieldIndex !== null &&
dragHover &&
dragHover.index === index &&
dragHover.position === 'after'
return (
<Fragment key={`editor-${index}`}>
{editorPlaceholderBefore && renderEditorDropPlaceholder(`editor-before-${index}`)}
<div
id={`editor-field-${index}`}
key={`editor-field-${index}`}
className={`group border rounded-xl p-4 transition-all duration-200 ease-out ${
editingFieldIndex === index
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 shadow-md'
: 'border-gray-200/60 dark:border-gray-700/60 bg-gray-50/50 dark:bg-gray-800/40 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-white dark:hover:bg-gray-800 hover:shadow-sm'
} ${isDraggingEditorCard ? 'opacity-40 scale-[0.96] cursor-grabbing border-blue-500 dark:border-blue-400 shadow-lg shadow-blue-200/50 dark:shadow-blue-900/50 transition-all duration-200' : 'cursor-grab'}`}
draggable
onMouseEnter={() => setEditingFieldIndex(index)}
onMouseLeave={() => setEditingFieldIndex(null)}
onDragStart={(event) => startFieldDrag(event, index, 'editor')}
onDragOver={(event) => handleFieldDragOver(event, index, 'editor')}
onDrop={handleFieldDrop}
onDragEnd={resetDragState}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 text-xs font-semibold bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded">字段 {index + 1}</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">{field.label || field.key || '未命名字段'}</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={(e) => {
e.stopPropagation()
moveField(index, 'up')
}}
disabled={index === 0}
className="p-1.5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed"
title="上移"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation()
moveField(index, 'down')
}}
disabled={index === form.fields.length - 1}
className="p-1.5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed"
title="下移"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation()
removeField(index)
if (editingFieldIndex === index) {
setEditingFieldIndex(null)
} else if (editingFieldIndex !== null && editingFieldIndex > index) {
setEditingFieldIndex(editingFieldIndex - 1)
}
}}
className="p-1.5 text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-all duration-200"
title="删除"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
字段键名 <span className="text-red-500">*</span>
</label>
{/* 标准字段下拉 + 自定义选项 */}
{(() => {
const standardOption = STANDARD_FIELDS.find((opt) => opt.key === field.key)
const selectValue = standardOption ? standardOption.key : '__custom'
return (
<div className="space-y-1.5">
<select
value={selectValue}
onChange={(e) => {
e.stopPropagation()
const value = e.target.value
if (value === '__custom') {
// 切换到自定义字段时,清空 key 和 label,保留其他设置
updateField(index, {
key: '',
label: '',
autoLabel: field.autoLabel
})
return
}
const opt = STANDARD_FIELDS.find((item) => item.key === value)
if (!opt) return
const shouldAutoLabel = field.autoLabel !== false
const patch: Partial<FieldForm> = {
key: opt.key,
autoLabel: shouldAutoLabel ? true : field.autoLabel,
}
if (shouldAutoLabel) {
patch.label = opt.label
}
updateField(index, patch)
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
>
<option value="__custom">自定义字段(手动输入键名)</option>
<optgroup label="标准字段">
{STANDARD_FIELDS.map((opt) => (
<option key={opt.key} value={opt.key}>
{opt.label}({opt.key})
</option>
))}
</optgroup>
</select>
{selectValue === '__custom' ? (
<input
key={`field-key-input-${index}`}
type="text"
value={field.key || ''}
onChange={(e) => {
e.stopPropagation()
const newValue = e.target.value
updateField(index, { key: newValue })
}}
onFocus={(e) => {
e.stopPropagation()
// 确保输入框保持焦点
e.target.focus()
}}
onClick={(e) => e.stopPropagation()}
onBlur={(e) => {
// 不要阻止 blur,但确保值已更新
e.stopPropagation()
}}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
placeholder="例如:productModel"
autoComplete="off"
/>
) : (
<p className="text-[11px] text-gray-500 dark:text-gray-400">
键名:<span className="font-mono">{field.key}</span>
</p>
)}
</div>
)
})()}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">显示名称</label>
<input
key={`field-label-input-${index}`}
type="text"
value={field.label || ''}
onChange={(e) => {
e.stopPropagation()
const newValue = e.target.value
updateField(index, { label: newValue, autoLabel: false })
}}
onFocus={(e) => {
e.stopPropagation()
e.target.focus()
}}
onClick={(e) => e.stopPropagation()}
onBlur={(e) => {
e.stopPropagation()
}}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
placeholder="例如:商品型号"
autoComplete="off"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">字段类型</label>
<select
value={field.type}
onChange={(e) => updateField(index, { type: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
>
{FIELD_TYPES.filter(t => t !== 'image' && t !== 'file').map((type) => (
<option key={type} value={type}>
{type === 'text' ? '文本' : type === 'textarea' ? '多行文本' : type === 'number' ? '数字' : type === 'select' ? '下拉选择' : type === 'checkbox' ? '复选框' : type === 'radio' ? '单选' : type === 'date' ? '日期' : type === 'uppercase' ? '仅大写+字母' : type}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">布局宽度</label>
<select
value={field.layoutWidth}
onChange={(e) => updateField(index, { layoutWidth: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
>
{LAYOUT_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
</div>
{field.type === 'select' && (
<div className="mt-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
下拉选项 <span className="text-gray-500 font-normal">(必填)</span>
</label>
<textarea
rows={6}
value={field.optionsStatic || ''}
onChange={(e) => updateField(index, { optionsStatic: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 resize-none font-mono"
placeholder='直接输入选项,每行一个,例如:&#10;3DMAX&#10;PSD&#10;AI&#10;CAD&#10;C4D'
/>
<div className="mt-2 p-2.5 bg-blue-50/50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-[10px] font-semibold text-blue-700 dark:text-blue-300 mb-1.5">填写说明:</p>
<div className="text-[10px] text-blue-600 dark:text-blue-400">
<p className="mb-1.5">每行输入一个选项,例如:</p>
<div className="ml-3 font-mono bg-white dark:bg-gray-800 px-2 py-1 rounded border border-blue-200 dark:border-blue-700">
3DMAX<br/>
PSD<br/>
AI<br/>
CAD
</div>
</div>
</div>
</div>
)}
<div className="flex flex-wrap gap-3 mt-3 pt-3 border-t border-gray-200/60 dark:border-gray-700/60">
<label className="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(index, { required: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
<span>必填</span>
</label>
<label className="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={field.visibleOnHomeFilter}
onChange={(e) => updateField(index, { visibleOnHomeFilter: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
<span>首页筛选</span>
</label>
<label className="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={field.isSearchable}
onChange={(e) => updateField(index, { isSearchable: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
<span>可搜索</span>
</label>
</div>
</div>
{editorPlaceholderAfter && renderEditorDropPlaceholder(`editor-after-${index}`)}
</Fragment>
)
})}
{/* 添加元素按钮 */}
<button
onClick={addField}
className="w-full border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4 transition-all duration-200 hover:border-gray-400 dark:hover:border-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800/50 group"
>
<div className="flex items-center justify-center gap-3">
<div className="w-14 h-14 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0 group-hover:bg-gray-200 dark:group-hover:bg-gray-600 transition-colors">
<svg className="w-7 h-7 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="text-left">
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-gray-200 transition-colors">添加元素</p>
<p className="text-[10px] text-gray-500 dark:text-gray-500 mt-0.5">点击添加新的字段配置</p>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
{/* 右侧:实时预览面板 */}
<div className="w-1/2 overflow-y-auto bg-gray-50/30 dark:bg-gray-900/50">
<div className="p-6">
<div className="mb-5 pb-5 border-b border-gray-200/40 dark:border-gray-800/40">
<h4 className="text-xl font-semibold text-gray-900 dark:text-white mb-1.5 tracking-tight">实时预览</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 font-light">预览上传面板的实际效果</p>
</div>
<div className="bg-white/95 dark:bg-gray-800/95 backdrop-blur-xl rounded-2xl shadow-lg shadow-gray-200/30 dark:shadow-gray-900/30 border border-gray-200/40 dark:border-gray-700/40 p-5 space-y-5">
{/* 固定的预览图片上传组件 */}
<div className="relative">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
预览图片 <span className="text-red-500">*</span>
</label>
<div className="border-2 border-dashed border-gray-300/60 dark:border-gray-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-gray-400/80 dark:hover:border-gray-500/80 hover:bg-gray-50/50 dark:hover:bg-gray-700/30 hover:shadow-md">
<div className="flex items-center justify-center space-x-3">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-gray-100 to-gray-50 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center flex-shrink-0 shadow-inner">
<svg className="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div className="text-left flex-1 min-w-0">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300">点击或拖拽上传预览图</p>
<p className="text-[10px] text-gray-500 dark:text-gray-500 mt-0.5">支持 JPG、PNG、WebP 等格式</p>
</div>
</div>
</div>
</div>
{/* 固定的文件上传组件 */}
<div className="relative">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
素材文件 <span className="text-red-500">*</span>
</label>
<div className="border-2 border-dashed border-gray-300/60 dark:border-gray-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-gray-400/80 dark:hover:border-gray-500/80 hover:bg-gray-50/50 dark:hover:bg-gray-700/30 hover:shadow-md">
<div className="flex items-center justify-center space-x-3">
<svg className="w-8 h-8 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div className="text-left flex-1 min-w-0">
<p className="text-xs text-gray-600 dark:text-gray-400">点击或拖拽上传素材文件</p>
<p className="text-[10px] text-gray-500 dark:text-gray-500 mt-0.5">支持 ZIP、RAR 等压缩格式</p>
</div>
</div>
</div>
</div>
{/* 动态渲染的自定义字段(支持拖拽排序) */}
<DndContext
sensors={previewSensors}
collisionDetection={closestCenter}
onDragStart={handlePreviewDragStart}
onDragOver={handlePreviewDragOver}
onDragCancel={handlePreviewDragCancel}
onDragEnd={handlePreviewDragEnd}
>
<SortableContext items={previewOrder} strategy={rectSortingStrategy}>
{orderedPreviewItems.length === 0 ? (
<div className="w-full rounded-2xl border-2 border-dashed border-gray-200/60 dark:border-gray-700/60 bg-gradient-to-br from-gray-50/50 to-transparent dark:from-gray-800/30 dark:to-transparent backdrop-blur-sm py-8 text-center">
<div className="flex flex-col items-center gap-2">
<svg className="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
</svg>
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">暂无自定义字段</p>
<p className="text-xs text-gray-400 dark:text-gray-500">点击左侧「添加元素」开始配置</p>
</div>
</div>
) : (
<div className="grid grid-cols-12 gap-4">
{previewRenderableItems.map((item) => {
const layoutWidth = item.placeholder
? item.layoutWidth
: (item.field.layoutWidth || 'full')
const widthClass = layoutWidth === 'full'
? 'col-span-12'
: 'col-span-12 min-[480px]:col-span-6'
if (item.placeholder) {
return <PreviewPlaceholderCard key={item.sortId} gridClassName={widthClass} />
}
return (
<PreviewSortableCard
key={item.sortId}
item={item}
isEditing={editingFieldIndex === item.originalIndex}
isDragging={previewActiveId === item.sortId}
onHover={() => setEditingFieldIndex(item.originalIndex)}
onHoverEnd={() => setEditingFieldIndex(null)}
onRemove={() => removeFieldAndSyncEditing(item.originalIndex)}
onClick={() => scrollToEditorField(item.originalIndex)}
gridClassName={widthClass}
/>
)
})}
</div>
)}
</SortableContext>
{typeof window !== 'undefined' &&
createPortal(
<DragOverlay
dropAnimation={{
duration: 500,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: '0',
transform: 'scale(0.95)',
},
},
}),
}}
style={{ cursor: 'grabbing', zIndex: 1000 }}
>
{previewActiveItem ? (
<PreviewDragOverlayCard item={previewActiveItem} rect={previewActiveRect} />
) : null}
</DragOverlay>,
document.body
)}
</DndContext>
</div>
</div>
</div>
</div>
<div className="px-6 py-5 border-t border-gray-200/60 dark:border-gray-800/60 bg-gray-50/50 dark:bg-gray-800/30">
{/* 保存提示 */}
{saveMessage && (
<div className={`mb-4 px-4 py-3 rounded-xl flex items-center gap-2 ${
saveMessage.type === 'success'
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800'
}`}>
{saveMessage.type === 'success' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span className="text-sm font-medium">{saveMessage.text}</span>
</div>
)}
<div className="flex justify-end gap-3">
<button
onClick={closeEditor}
disabled={saving}
className="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ease-out hover:scale-105 active:scale-95 font-medium disabled:opacity-60"
>
取消
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2.5 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition-all duration-200 ease-out hover:scale-105 active:scale-95 shadow-sm hover:shadow-md font-medium disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
保存中...
</span>
) : (
'保存模板'
)}
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
type PreviewSortableCardProps = {
item: PreviewSortableItem
isEditing: boolean
isDragging: boolean
gridClassName?: string
onHover: () => void
onHoverEnd: () => void
onRemove: () => void
onClick: () => void
}
function PreviewSortableCard({
item,
isEditing,
isDragging,
gridClassName,
onHover,
onHoverEnd,
onRemove,
onClick,
}: PreviewSortableCardProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: item.sortId,
})
// 优化的流畅动画 - 使用更自然的缓动函数
const style = {
transform: isDragging ? undefined : CSS.Transform.toString(transform),
transition: isDragging
? undefined
: transition ?? 'transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
zIndex: isDragging ? 30 : undefined,
opacity: isDragging ? 0.4 : 1,
}
const field = item.field
const widthStyle = getPreviewWidthStyle(item.field.layoutWidth)
const containerPointerEvents = isDragging ? 'none' : 'auto'
const cardStateClass = isEditing
? 'transform scale-[1.02] shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50 border-2 border-gray-200/80 dark:border-gray-700/80 bg-white dark:bg-gray-800 rounded-2xl backdrop-blur-sm'
: 'hover:scale-[1.01] hover:shadow-lg hover:shadow-gray-200/50 dark:hover:shadow-gray-900/50 border border-gray-200/40 dark:border-gray-700/40 bg-white/95 dark:bg-gray-800/95 rounded-2xl backdrop-blur-sm'
return (
<div
ref={setNodeRef}
style={{
...style,
...widthStyle,
position: 'relative',
pointerEvents: containerPointerEvents,
}}
className={`group relative transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)] ${gridClassName ?? 'col-span-12'}`}
onMouseEnter={onHover}
onMouseLeave={onHoverEnd}
onClick={onClick}
>
<div
className={`relative transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] ${cardStateClass}`}
>
<div className="absolute top-2 right-2 z-20 flex flex-col items-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<button
type="button"
className="w-8 h-8 rounded-full bg-white/95 dark:bg-gray-900/95 backdrop-blur-md border border-gray-200/70 dark:border-gray-700/70 shadow-lg shadow-gray-200/50 dark:shadow-gray-900/50 flex items-center justify-center cursor-grab active:cursor-grabbing hover:border-blue-400/80 dark:hover:border-blue-300/80 hover:bg-white dark:hover:bg-gray-900 hover:shadow-xl hover:scale-110 active:scale-95 transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]"
title="拖拽调整顺序"
aria-label="拖拽调整顺序"
{...attributes}
{...listeners}
>
<svg className="w-3.5 h-3.5 text-gray-600 dark:text-gray-300" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 5a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zM7 10a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zM7 15a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className="w-7 h-7 bg-gradient-to-br from-red-500 to-red-600 text-white rounded-full flex items-center justify-center hover:from-red-600 hover:to-red-700 transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:scale-110 active:scale-95 shadow-md shadow-red-500/30 hover:shadow-lg hover:shadow-red-500/40"
title="删除此字段"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<PreviewFieldContent field={field} />
</div>
</div>
)
}
function PreviewFieldContent({ field }: { field: FieldForm }) {
return (
<div className="pointer-events-none space-y-2.5">
<label className="block text-xs font-semibold text-gray-800 dark:text-gray-200 tracking-tight">
{field.label || field.key || '未命名字段'}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
{field.type === 'text' && (
<input
type="text"
placeholder={`请输入${field.label || field.key}`}
className="w-full px-3.5 py-2.5 text-sm border border-gray-200/60 dark:border-gray-600/60 dark:bg-gray-700/50 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-400/60 outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] bg-white/80 backdrop-blur-sm"
disabled
/>
)}
{field.type === 'textarea' && (
<textarea
rows={2}
placeholder={`请输入${field.label || field.key}`}
className="w-full px-3.5 py-2.5 text-sm border border-gray-200/60 dark:border-gray-600/60 dark:bg-gray-700/50 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-400/60 outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] resize-none bg-white/80 backdrop-blur-sm"
disabled
/>
)}
{field.type === 'number' && (
<input
type="number"
placeholder={`请输入${field.label || field.key}`}
className="w-full px-3.5 py-2.5 text-sm border border-gray-200/60 dark:border-gray-600/60 dark:bg-gray-700/50 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-400/60 outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] bg-white/80 backdrop-blur-sm"
disabled
/>
)}
{field.type === 'select' && (
<div className="space-y-1.5">
<select
className="w-full px-3.5 py-2.5 text-sm border border-gray-200/60 dark:border-gray-600/60 dark:bg-gray-700/50 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-400/60 outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] bg-white/80 backdrop-blur-sm"
disabled
>
<option value="">请选择{field.label || field.key}</option>
{field.optionsStatic &&
field.optionsStatic
.split('\n')
.map((opt) => opt.trim())
.filter(Boolean)
.map((opt, idx) => (
<option key={`${field.key}-opt-${idx}`} value={opt}>
{opt}
</option>
))}
</select>
{!field.optionsStatic && (
<p className="text-[10px] text-gray-400 dark:text-gray-500">暂无下拉选项</p>
)}
</div>
)}
{field.type === 'date' && (
<input
type="date"
className="w-full px-3.5 py-2.5 text-sm border border-gray-200/60 dark:border-gray-600/60 dark:bg-gray-700/50 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-400/60 outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] bg-white/80 backdrop-blur-sm"
disabled
/>
)}
{field.type === 'uppercase' && (
<input
type="text"
placeholder={`请输入${field.label || field.key}(仅大写字母)`}
className="w-full px-3.5 py-2.5 text-sm border border-gray-200/60 dark:border-gray-600/60 dark:bg-gray-700/50 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-400/60 outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] bg-white/80 backdrop-blur-sm uppercase"
disabled
/>
)}
{field.type === 'checkbox' && (
<label className="flex items-center gap-2 cursor-default select-none">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
disabled
/>
<span className="text-xs text-gray-700 dark:text-gray-300">选择{field.label || field.key}</span>
</label>
)}
{field.type === 'radio' && (
<div className="space-y-1.5">
{field.optionsStatic &&
field.optionsStatic
.split('\n')
.map((opt) => opt.trim())
.filter(Boolean)
.map((opt, idx) => (
<label key={`${field.key}-radio-${idx}`} className="flex items-center gap-2 cursor-default select-none">
<input
type="radio"
name={`preview-${field.key}`}
value={opt}
className="w-3.5 h-3.5 text-blue-600 focus:ring-2 focus:ring-blue-500"
disabled
/>
<span className="text-xs text-gray-700 dark:text-gray-300">{opt}</span>
</label>
))}
{!field.optionsStatic && (
<p className="text-[10px] text-gray-400 dark:text-gray-500">暂无单选项</p>
)}
</div>
)}
</div>
)
}
function PreviewDragOverlayCard({
item,
rect,
}: {
item: PreviewSortableItem
rect: { width: number; height: number } | null
}) {
const widthValue = getPreviewWidthStyleValue(item.field.layoutWidth)
return (
<div
className="pointer-events-none"
style={{
width: rect?.width ?? widthValue,
height: rect?.height,
maxWidth: '90vw',
transform: 'none',
}}
>
<div className="rounded-2xl border-2 border-blue-500/60 dark:border-blue-400/60 bg-white/98 dark:bg-gray-800/98 backdrop-blur-xl shadow-2xl shadow-blue-500/30 dark:shadow-blue-900/50 px-4 py-3 transform rotate-[1.5deg] scale-[1.08] transition-all duration-200 ease-out">
<PreviewFieldContent field={item.field} />
</div>
</div>
)
}
function PreviewPlaceholderCard({ gridClassName }: { gridClassName: string }) {
return (
<div className={gridClassName}>
<div className="h-full min-h-[88px] rounded-2xl border-2 border-dashed border-blue-500 dark:border-blue-400 bg-gradient-to-br from-blue-50/90 to-blue-100/70 dark:from-blue-900/40 dark:to-blue-800/30 flex items-center justify-center text-blue-600 dark:text-blue-300 text-xs font-semibold tracking-wide pointer-events-none shadow-xl shadow-blue-200/60 dark:shadow-blue-900/60 animate-pulse">
<div className="flex flex-col items-center gap-2 animate-bounce">
<svg className="w-6 h-6 text-blue-500 dark:text-blue-400 animate-pulse" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5l7 7-7 7" />
</svg>
<span className="opacity-80 animate-pulse">松开即可放置</span>
</div>
</div>
</div>
)
}