Some checks failed
CI Check / check (push) Has been cancelled
- 创建统一UI三层架构系统(基础层、颜色层、组合层) - 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等) - 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等) - 提取工具函数(validators、formatters、api等) - 创建统一弹窗组件(Modal)和useModal Hook - 添加错误边界和统一状态组件 - 完善类型定义和可访问性支持 - 更新文档(优化记录、优化计划、更新日志)
1416 lines
46 KiB
TypeScript
1416 lines
46 KiB
TypeScript
'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>
|
||
)
|
||
}
|