flnxi-Wu/app/admin/_components/TemplateManager.tsx
WuYongJie e4dcb6260a
Some checks failed
CI Check / check (push) Has been cancelled
feat: Admin页面重构 & UI三层架构系统
- 创建统一UI三层架构系统(基础层、颜色层、组合层)
- 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等)
- 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等)
- 提取工具函数(validators、formatters、api等)
- 创建统一弹窗组件(Modal)和useModal Hook
- 添加错误边界和统一状态组件
- 完善类型定义和可访问性支持
- 更新文档(优化记录、优化计划、更新日志)
2025-11-22 15:17:10 +08:00

1416 lines
46 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, useCallback, useEffect, useMemo, useState } from 'react'
import { formatDateTimeShort } from '../_utils/formatters'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import { animations } from '@/lib/ui-animations'
import TemplateManagerHeader from './template-manager/TemplateManagerHeader'
import BuiltInTemplateGrid from './template-manager/BuiltInTemplateGrid'
import CustomTemplateSection from './template-manager/CustomTemplateSection'
import TemplateBasicsForm from './template-manager/editor/TemplateBasicsForm'
import TemplateAdvancedConfigPanel from './template-manager/editor/TemplateAdvancedConfigPanel'
import TemplateFieldsEditor from './template-manager/editor/TemplateFieldsEditor'
import TemplateEditorFooter from './template-manager/editor/TemplateEditorFooter'
import TemplatePreviewPanel from './template-manager/editor/TemplatePreviewPanel'
import type { ToastFn } from './types'
import type {
BuiltinTemplateConfig,
DisplayTemplate,
FieldForm,
TemplateForm,
UploadTemplateDto,
DragHoverState,
} from './template-manager/types'
import { validateTemplateName, validateFieldKey, validateUniqueKeys } from '../_utils/validators'
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: '半行' },
]
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[]
}
// 内置常用模板(使用新的字段标准)
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 = '请求失败'
let errorData: any = null
try {
errorData = await res.json()
message = errorData?.error || message
} catch {
// 如果响应不是 JSON,尝试获取文本
try {
const text = await res.text()
message = text || message
} catch {
// ignore
}
}
// 记录详细的错误信息
console.error('API 请求失败:', {
url: typeof input === 'string' ? input : input.toString(),
status: res.status,
statusText: res.statusText,
error: errorData,
message: message,
})
throw new Error(message)
}
return res.json()
}
export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
const styles = useThemeStyles()
const [templates, setTemplates] = useState<UploadTemplateDto[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const [advancedConfigOpen, setAdvancedConfigOpen] = 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 [fieldGroupsError, setFieldGroupsError] = useState<string | null>(null)
const [displayConfigError, setDisplayConfigError] = useState<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 [isPreviewMode, setIsPreviewMode] = useState(false)
const [previewTemplate, setPreviewTemplate] = useState<UploadTemplateDto | null>(null)
const [templateSearchTerm, setTemplateSearchTerm] = useState('')
const updateForm = useCallback((patch: Partial<TemplateForm>) => {
setForm((prev) => (prev ? { ...prev, ...patch } : prev))
}, [])
const toggleAdvancedConfig = useCallback(() => {
setAdvancedConfigOpen((prev) => !prev)
}, [])
const handleFieldGroupsChange = useCallback(
(value: string) => {
setFieldGroupsError(null)
updateForm({ fieldGroups: value })
},
[updateForm]
)
const handleDisplayConfigChange = useCallback(
(value: string) => {
setDisplayConfigError(null)
updateForm({ displayConfig: value })
},
[updateForm]
)
const handlePreviewReorder = useCallback(
(nextFields: FieldForm[], movedFieldLabel?: string) => {
setForm((prev) => (prev ? { ...prev, fields: nextFields } : prev))
if (movedFieldLabel) {
setTimeout(() => {
showToast(`已将"${movedFieldLabel}"移动到新位置`, 'success')
}, 150)
}
},
[showToast],
)
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()
// 生成默认名称:模板名称-自定义+数字
const baseName = preset ? preset.name : '自定义模板'
const existingCustomNames = templates
.filter(t => t.name.startsWith(baseName + '-自定义'))
.map(t => {
const match = t.name.match(/-自定义(\d+)$/)
return match ? parseInt(match[1], 10) : 0
})
const nextNumber = existingCustomNames.length > 0 ? Math.max(...existingCustomNames) + 1 : 1
const defaultName = `${baseName}-自定义${nextNumber}`
setForm({
name: defaultName,
description: preset ? preset.description : '',
status: 'draft',
fieldGroups: '',
displayConfig: '',
fields: initialFields,
})
setSelectedPreset(preset ? preset.key : null)
setEditorOpen(true)
setIsPreviewMode(false)
setPreviewTemplate(null)
setEditingFieldIndex(null)
setSaveMessage(null)
setAdvancedConfigOpen(false)
setFieldGroupsError(null)
setDisplayConfigError(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)
setIsPreviewMode(false)
setPreviewTemplate(null)
setAdvancedConfigOpen(false)
setFieldGroupsError(null)
setDisplayConfigError(null)
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)
setIsPreviewMode(false)
setPreviewTemplate(null)
setAdvancedConfigOpen(false)
setFieldGroupsError(null)
setDisplayConfigError(null)
setSelectedPreset(null)
setEditingFieldIndex(null)
setSaveMessage(null)
}
const closeEditor = () => {
if (saving) return
setEditorOpen(false)
setForm(null)
setEditingFieldIndex(null)
setSaveMessage(null)
setAdvancedConfigOpen(false)
setFieldGroupsError(null)
setDisplayConfigError(null)
setSelectedPreset(null)
setPreviewTemplate(null)
setIsPreviewMode(false)
}
const openPresetPreview = (presetKey: string) => {
const preset = BUILTIN_TEMPLATES.find((item) => item.key === presetKey)
if (!preset) return
const initialFields = preset.getFields()
setForm({
name: preset.name,
description: preset.description,
status: 'draft',
fieldGroups: '',
displayConfig: '',
fields: initialFields,
})
setSelectedPreset(preset.key)
setPreviewTemplate(null)
setIsPreviewMode(true)
setEditorOpen(true)
setAdvancedConfigOpen(false)
setFieldGroupsError(null)
setDisplayConfigError(null)
setEditingFieldIndex(null)
setSaveMessage(null)
}
const openCustomPreview = (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,
})),
})
setPreviewTemplate(tpl)
setSelectedPreset(null)
setIsPreviewMode(true)
setEditorOpen(true)
setAdvancedConfigOpen(false)
setFieldGroupsError(null)
setDisplayConfigError(null)
setEditingFieldIndex(null)
setSaveMessage(null)
}
const createFromPreview = () => {
if (!selectedPreset) return
setIsPreviewMode(false)
setPreviewTemplate(null)
openCreate(selectedPreset)
}
const exitPreviewToEditor = useCallback(() => {
setIsPreviewMode(false)
setPreviewTemplate(null)
setEditingFieldIndex(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
// 生成新的默认名称
const baseName = preset.name
const existingCustomNames = templates
.filter(t => t.name.startsWith(baseName + '-自定义'))
.map(t => {
const match = t.name.match(/-自定义(\d+)$/)
return match ? parseInt(match[1], 10) : 0
})
const nextNumber = existingCustomNames.length > 0 ? Math.max(...existingCustomNames) + 1 : 1
const defaultName = `${baseName}-自定义${nextNumber}`
return {
...prev,
name: defaultName,
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 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 = useCallback(() => {
setDragFieldIndex(null)
setDragHover(null)
}, [])
const finalizeDragReorder = useCallback(
(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()
},
[dragFieldIndex, dragHover, form, reorderFieldByIndex, resetDragState]
)
const handleFieldsContainerDragOver = useCallback(
(event: DragEvent<HTMLDivElement>) => {
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',
})
},
[dragFieldIndex, form]
)
const handleFieldsContainerDrop = useCallback(
(event: DragEvent<HTMLDivElement>) => {
if (dragFieldIndex === null) return
event.preventDefault()
event.stopPropagation()
const fallback: DragHoverState = {
index: form && form.fields.length > 0 ? form.fields.length - 1 : 0,
position: form && form.fields.length > 0 ? 'after' : 'before',
surface: 'editor',
}
finalizeDragReorder(dragHover ?? fallback)
},
[dragFieldIndex, dragHover, finalizeDragReorder, form]
)
const handleFieldsContainerDragLeave = useCallback(
(event: DragEvent<HTMLDivElement>) => {
if (dragFieldIndex === null) return
const nextTarget = event.relatedTarget as Node | null
if (nextTarget && event.currentTarget.contains(nextTarget)) return
setDragHover(null)
},
[dragFieldIndex]
)
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)
setFieldGroupsError(null)
setDisplayConfigError(null)
// 验证模板名称
const nameValidation = validateTemplateName(form.name)
if (!nameValidation.valid) {
setSaveMessage({ type: 'error', text: nameValidation.error || '模板名称不能为空' })
return
}
// 验证所有字段键名
const fieldKeys = form.fields.map((field) => field.key)
for (let i = 0; i < form.fields.length; i++) {
const field = form.fields[i]
const keyValidation = validateFieldKey(field.key, i)
if (!keyValidation.valid) {
setEditingFieldIndex(i)
setSaveMessage({ type: 'error', text: keyValidation.error || '字段键名无效' })
return
}
}
// 验证字段键名唯一性
const uniquenessValidation = validateUniqueKeys(fieldKeys)
if (!uniquenessValidation.valid) {
// 找到重复的字段索引
const duplicateKey = uniquenessValidation.error?.match(/"([^"]+)"/)?.[1]
if (duplicateKey) {
const duplicateIndex = fieldKeys.findIndex((key) => key.trim() === duplicateKey)
if (duplicateIndex !== -1) {
setEditingFieldIndex(duplicateIndex)
}
}
setSaveMessage({ type: 'error', text: uniquenessValidation.error || '字段键名重复' })
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) {
const message = error.message || '字段分组必须为合法 JSON'
setFieldGroupsError(message)
setSaveMessage({ type: 'error', text: message })
return
}
try {
payload.displayConfig = parseJson(form.displayConfig, '展示配置必须为合法 JSON')
} catch (error: any) {
const message = error.message || '展示配置必须为合法 JSON'
setDisplayConfigError(message)
setSaveMessage({ type: 'error', text: message })
return
}
payload.fields = form.fields.map((field, index) => {
const trimmedKey = field.key.trim()
const trimmedLabel = (field.label || '').trim() || trimmedKey
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格式,按行分割,然后转换为JSON字符串
const lines = trimmed.split('\n').map(line => line.trim()).filter(Boolean)
optionsStatic = lines.length > 0 ? JSON.stringify(lines) : null
} else {
const parsed = parseJson(field.optionsStatic, '字段选项需为合法 JSON')
// 如果解析结果是数组或对象,转换为JSON字符串;如果是null,保持null
optionsStatic = parsed !== null ? JSON.stringify(parsed) : null
}
} 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: trimmedKey,
label: trimmedLabel,
type: field.type,
required: field.required,
orderIndex: index,
layoutWidth: field.layoutWidth,
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
validations,
optionsSource: null, // 添加 optionsSource 字段
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 {
// 在发送请求前记录 payload,便于调试
console.log('创建模板 - 发送的 payload:', JSON.stringify(payload, null, 2))
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) {
let errorMessage = '保存失败'
// 尝试从错误对象中提取更详细的信息
if (error?.message) {
errorMessage = error.message
} else if (typeof error === 'string') {
errorMessage = error
}
// 记录完整的错误信息到控制台以便调试
console.error('保存模板失败 - 详细错误信息:', {
message: errorMessage,
error: error,
errorType: typeof error,
errorString: String(error),
operation: form.id ? '更新模板' : '创建模板',
formName: form?.name,
formFieldsCount: form?.fields?.length,
payload: form.id ? '更新' : '创建',
})
// 如果错误信息已经包含"创建上传模板失败",直接使用;否则添加前缀
const finalErrorMessage = errorMessage.includes('创建上传模板失败') || errorMessage.includes('保存失败')
? errorMessage
: `创建上传模板失败: ${errorMessage}`
setSaveMessage({
type: 'error',
text: finalErrorMessage
})
} 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<DisplayTemplate[]>(
() =>
templates.map((tpl) => ({
...tpl,
updatedLabel: formatDateTimeShort(tpl.updatedAt),
fieldCount: tpl.fields.length,
statusLabel: STATUS_OPTIONS.find((option) => option.value === tpl.status)?.label || tpl.status,
})),
[templates]
)
const filteredTemplates = useMemo(() => {
const keyword = templateSearchTerm.trim().toLowerCase()
if (!keyword) return displayTemplates
return displayTemplates.filter((tpl) => tpl.name.toLowerCase().includes(keyword))
}, [displayTemplates, templateSearchTerm])
return (
<div className="space-y-6">
<TemplateManagerHeader searchTerm={templateSearchTerm} onSearchChange={setTemplateSearchTerm} />
<div className={cn(styles.cardStyles.base, 'overflow-hidden')}>
<BuiltInTemplateGrid
presets={BUILTIN_TEMPLATES}
onPreview={openPresetPreview}
onQuickCreate={(presetKey) => createTemplateFromPreset(presetKey, 'active')}
/>
<CustomTemplateSection
loading={loading}
error={error}
templates={displayTemplates}
filteredTemplates={filteredTemplates}
deletingId={deletingId}
onCreate={openCreate}
onPreview={openCustomPreview}
onActivate={handleActivate}
onEdit={openEdit}
onDuplicate={openDuplicate}
onDelete={handleDelete}
/>
</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-slate-900 rounded-2xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col transition-all duration-500 ease-in-out ${
isPreviewMode
? 'w-full max-w-2xl animate-in zoom-in-95'
: 'w-full max-w-7xl animate-in zoom-in-95'
}`}>
<div className="px-6 py-5 border-b border-slate-200/60 dark:border-slate-800/60 flex items-center justify-between bg-slate-50/50 dark:bg-slate-800/30">
<div>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white tracking-tight">
{isPreviewMode ? '预览模板' : form.id ? '编辑模板' : '新建模板'}
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
{isPreviewMode
? previewTemplate
? '查看自定义模板的真实上传界面(预览模式不可编辑)'
: '预览预制模板的实际效果(预览模式不可编辑)'
: '左侧配置字段,右侧实时预览上传面板效果'}
</p>
</div>
<button
onClick={closeEditor}
className="p-2 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-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 transition-all duration-500 ease-in-out ${
isPreviewMode ? 'justify-center' : ''
}`}>
{/* 左侧:字段配置面板 */}
{!isPreviewMode && (
<div className="w-1/2 border-r border-slate-200/60 dark:border-slate-800/60 overflow-y-auto transition-all duration-500 ease-in-out animate-in slide-in-from-left">
<div className="p-6 space-y-6">
<TemplateBasicsForm
form={form}
statusOptions={STATUS_OPTIONS}
showPresetSelector={!form.id}
selectedPreset={selectedPreset}
presets={BUILTIN_TEMPLATES}
onChange={updateForm}
onSelectPreset={applyPreset}
/>
<TemplateAdvancedConfigPanel
form={form}
advancedConfigOpen={advancedConfigOpen}
onToggle={toggleAdvancedConfig}
fieldGroupsError={fieldGroupsError}
displayConfigError={displayConfigError}
onFieldGroupsChange={handleFieldGroupsChange}
onDisplayConfigChange={handleDisplayConfigChange}
onFieldGroupsErrorChange={setFieldGroupsError}
onDisplayConfigErrorChange={setDisplayConfigError}
/>
<TemplateFieldsEditor
fields={form.fields}
dragFieldIndex={dragFieldIndex}
dragHover={dragHover}
editingFieldIndex={editingFieldIndex}
standardFields={STANDARD_FIELDS}
fieldTypes={FIELD_TYPES}
layoutOptions={LAYOUT_OPTIONS}
onEditingFieldChange={setEditingFieldIndex}
onFieldDragStart={startFieldDrag}
onFieldDragOver={handleFieldDragOver}
onFieldDrop={handleFieldDrop}
onFieldDragEnd={resetDragState}
onContainerDragOver={handleFieldsContainerDragOver}
onContainerDrop={handleFieldsContainerDrop}
onContainerDragLeave={handleFieldsContainerDragLeave}
onMoveField={moveField}
onRemoveField={removeFieldAndSyncEditing}
onAddField={addField}
renderDropPlaceholder={renderEditorDropPlaceholder}
onUpdateField={updateField}
/>
</div>
</div>
)}
<TemplatePreviewPanel
fields={form.fields}
isPreviewMode={isPreviewMode}
editingFieldIndex={editingFieldIndex}
onEditingFieldChange={setEditingFieldIndex}
onScrollToField={scrollToEditorField}
onRemoveField={removeFieldAndSyncEditing}
onFieldsReordered={handlePreviewReorder}
/>
</div>
<TemplateEditorFooter
isPreviewMode={isPreviewMode}
saving={saving}
saveMessage={saveMessage}
previewTemplate={previewTemplate}
onClose={closeEditor}
onSave={handleSave}
onExitPreview={exitPreviewToEditor}
onCreateFromPreview={createFromPreview}
/>
</div>
</div>
)}
</div>
)
}