Some checks failed
CI Check / check (push) Has been cancelled
- 移除高亮卡片效果,避免与虚位卡片视觉重复 - 松开鼠标后立即更新布局,不再等待动画完成 - 优化拖拽动画效果,使用弹性缓动函数 - 更新 CHANGELOG.md 记录本次优化
2435 lines
104 KiB
TypeScript
2435 lines
104 KiB
TypeScript
'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='直接输入选项,每行一个,例如: 3DMAX PSD AI CAD 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>
|
||
)
|
||
}
|
||
|
||
|