flnxi-Wu/app/admin/_components/TemplateManager.tsx
WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

1785 lines
61 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 { useDialog } from '@/components/ui/DialogProvider'
import Modal from '@/components/ui/Modal'
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 MATERIAL_TYPE_OPTIONS = ['3DMAX', 'PSD', 'AI 工作流', '视频', '模特', '其他']
// 默认需要进入分类管理的字段集合,可被后台实时配置覆盖
const DEFAULT_CATEGORY_FIELDS = [
'categoryName',
'sceneName',
'platformName',
'renderer',
'brandName',
'seriesName',
'materialTexture',
] as const
// 全局集合对象会在运行时被最新配置覆盖,保持与后台分类管理同步
const GLOBAL_CATEGORY_FIELDS = new Set<string>(DEFAULT_CATEGORY_FIELDS)
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,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: true,
})
// 标准字段配置:键名 + 推荐展示名称 + 基础FieldForm(属性不写死,由预制模板动态设置)
const STANDARD_FIELD_TEMPLATES: Record<string, FieldForm> = {
productModel: {
key: 'productModel',
label: 'SPU型号',
type: 'text',
required: false, // 默认不勾选,由预制模板动态设置
layoutWidth: 'full',
visibleOnHomeFilter: false,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
categoryName: {
key: 'categoryName',
label: '使用店铺',
type: 'select',
required: false, // 默认不勾选,由预制模板动态设置
layoutWidth: 'full',
visibleOnHomeFilter: false, // 默认不勾选,由预制模板动态设置
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: JSON.stringify(['默认店铺']),
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
sceneName: {
key: 'sceneName',
label: '场景类别',
type: 'select',
required: false, // 默认不勾选,由预制模板动态设置
layoutWidth: 'full',
visibleOnHomeFilter: false, // 默认不勾选,由预制模板动态设置
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: JSON.stringify(['默认场景']),
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
platformName: {
key: 'platformName',
label: '平台分类',
type: 'text', // 根据迁移文档,是text类型
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: '', // text类型不需要optionsStatic
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
renderer: {
key: 'renderer',
label: '渲染器',
type: 'select',
required: false, // 默认不勾选,由预制模板动态设置
layoutWidth: 'full',
visibleOnHomeFilter: false,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: JSON.stringify(['CR', 'VRay', 'Corona', 'Arnold', 'Octane', '其他']),
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
brandName: {
key: 'brandName',
label: '品牌名称',
type: 'text', // 根据迁移文档,是text类型
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: '', // text类型不需要optionsStatic
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
seriesName: {
key: 'seriesName',
label: '系列 / 产品线',
type: 'text', // 根据迁移文档,是text类型
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: '', // text类型不需要optionsStatic
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
skuCode: {
key: 'skuCode',
label: 'SKU 编码',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
materialTexture: {
key: 'materialTexture',
label: '材质 / 工艺',
type: 'text', // 根据迁移文档,是text类型
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: true,
validations: '',
optionsStatic: '', // text类型不需要optionsStatic
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
renderTime: {
key: 'renderTime',
label: '渲染耗时',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
imageResolution: {
key: 'imageResolution',
label: '输出分辨率',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
colorMode: {
key: 'colorMode',
label: '颜色模式',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
addToCategory: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
autoLabel: false,
},
authorName: {
key: 'authorName',
label: '作者',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false, // 默认不勾选,由预制模板动态设置
isReadOnly: false,
addToCategory: 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 = [
'productModel',
'sceneName',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'renderTime',
'imageResolution',
'colorMode',
]
return defaultKeys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
}
const applySimplifiedFieldFlags = (
fields: FieldForm[],
options: { requireRenderer?: boolean } = {}
) => {
const { requireRenderer = false } = options
fields.forEach((field) => {
if (field.key === 'productModel') {
field.required = true
field.isSearchable = true
} else if (field.key === 'categoryName' || field.key === 'sceneName') {
field.required = true
field.visibleOnHomeFilter = true
field.isSearchable = true
} else if (field.key === 'renderer') {
field.required = requireRenderer
field.isSearchable = true
}
})
return fields
}
// 内置常用模板(使用老版本简化字段)
// 全新版本仅保留 3DMAX、PSD、AI 工作流、视频、模特、其他 六种预制模板
const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = [
{
key: '3dmax-material',
name: '3DMAX 素材模板',
description: '适用于 3D 模型类素材,覆盖渲染器、SKU 与品牌等核心字段。',
materialType: '3DMAX',
materialKind: 'IMAGE',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName', 'renderer']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields, { requireRenderer: true })
},
},
{
key: 'psd-material',
name: 'PSD 素材模板',
description: '针对平面设计 PSD 素材,包含品牌、颜色模式和输出分辨率。',
materialType: 'PSD',
materialKind: 'IMAGE',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields)
},
},
{
key: 'ai-material',
name: 'AI 工作流',
description: '面向 AI 生成/矢量工作流,仅保留必要的基础字段。',
materialType: 'AI 工作流',
materialKind: 'IMAGE',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields)
},
},
{
key: 'video-material',
name: '视频素材模板',
description: '适用于视频文件素材。',
materialType: '视频',
materialKind: 'VIDEO',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields)
},
},
{
key: 'model-material',
name: '模特素材模板',
description: '用于模特/人像素材,关注拍摄场景与负责作者。',
materialType: '模特',
materialKind: 'IMAGE',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields)
},
},
{
key: 'other-material',
name: '其他素材模板',
description: '适用于其他类型的素材。',
materialType: '其他',
materialKind: 'OTHER',
getFields: () => {
const keys = ['productModel', 'categoryName', 'sceneName']
const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[]
return applySimplifiedFieldFlags(fields)
},
},
]
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 { confirm } = useDialog()
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 [enabledBuiltinTemplates, setEnabledBuiltinTemplates] = useState<Set<string>>(new Set())
// 分类字段集合同步自后台配置,默认使用常量兜底
const [categoryFieldKeys, setCategoryFieldKeys] = useState<string[]>(Array.from(GLOBAL_CATEGORY_FIELDS))
const ALL_BUILTIN_KEYS = useMemo(() => BUILTIN_TEMPLATES.map(t => t.key), []) // 所有预制模板key
const syncCategoryFieldSet = useCallback((keys: string[]) => {
GLOBAL_CATEGORY_FIELDS.clear()
keys.forEach((key) => GLOBAL_CATEGORY_FIELDS.add(key))
setCategoryFieldKeys(Array.from(GLOBAL_CATEGORY_FIELDS))
}, [])
useEffect(() => {
const loadCategoryFieldConfig = async () => {
try {
const response = await fetch('/api/category-fields', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
})
if (response.ok) {
const data = await response.json()
if (Array.isArray(data?.managedFields) && data.managedFields.length > 0) {
syncCategoryFieldSet(data.managedFields)
return
}
}
syncCategoryFieldSet([...DEFAULT_CATEGORY_FIELDS])
} catch (error) {
console.warn('Failed to load category field settings, fallback to defaults.', error)
syncCategoryFieldSet([...DEFAULT_CATEGORY_FIELDS])
}
}
loadCategoryFieldConfig()
}, [syncCategoryFieldSet])
// 加载启用的预制模板(从数据库)
useEffect(() => {
const loadEnabledTemplates = async () => {
try {
const response = await fetch('/api/system-settings', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
})
if (response.ok) {
const settings = await response.json()
if (settings.enabledBuiltinTemplates) {
const enabled = JSON.parse(settings.enabledBuiltinTemplates)
// 如果为空数组或null,表示全部启用
if (Array.isArray(enabled) && enabled.length > 0) {
setEnabledBuiltinTemplates(new Set(enabled))
} else {
// 全部启用,设置为所有key
setEnabledBuiltinTemplates(new Set(ALL_BUILTIN_KEYS))
}
} else {
// 字段不存在,表示全部启用
setEnabledBuiltinTemplates(new Set(ALL_BUILTIN_KEYS))
}
}
} catch (e) {
console.error('Failed to load enabled builtin templates:', e)
// 出错时默认全部启用
setEnabledBuiltinTemplates(new Set(ALL_BUILTIN_KEYS))
}
}
loadEnabledTemplates()
}, [ALL_BUILTIN_KEYS])
// 通知首页刷新模板列表的辅助函数
const notifyTemplatesUpdate = useCallback(() => {
try {
// 使用 BroadcastChannel 通知同源的其他标签页
const channel = new BroadcastChannel('templates-channel')
channel.postMessage({ type: 'templates-updated' })
channel.close()
// 使用 localStorage 触发 storage 事件(跨标签页)
localStorage.setItem('templates-updated', Date.now().toString())
setTimeout(() => {
localStorage.removeItem('templates-updated')
}, 100)
} catch (e) {
console.error('Failed to notify templates update:', e)
}
}, [])
// 保存启用的预制模板(到数据库)
const saveEnabledBuiltinTemplates = useCallback(async (enabled: Set<string>) => {
try {
const enabledArray = Array.from(enabled)
// 如果全部启用,保存空数组(表示全部启用)
const valueToSave = enabledArray.length === ALL_BUILTIN_KEYS.length ? [] : enabledArray
const response = await fetch('/api/system-settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
},
body: JSON.stringify({
enabledBuiltinTemplates: JSON.stringify(valueToSave),
}),
})
if (response.ok) {
// 先更新本地状态
setEnabledBuiltinTemplates(enabled)
showToast('启用状态已更新', 'success')
// 通知首页刷新模板列表
notifyTemplatesUpdate()
// 重新加载一次确保同步
const reloadResponse = await fetch('/api/system-settings', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
})
if (reloadResponse.ok) {
const settings = await reloadResponse.json()
if (settings.enabledBuiltinTemplates) {
const enabledReload = JSON.parse(settings.enabledBuiltinTemplates)
if (Array.isArray(enabledReload) && enabledReload.length > 0) {
setEnabledBuiltinTemplates(new Set(enabledReload))
} else {
setEnabledBuiltinTemplates(new Set(ALL_BUILTIN_KEYS))
}
}
}
} else {
throw new Error('保存失败')
}
} catch (e) {
console.error('Failed to save enabled builtin templates:', e)
showToast('保存启用状态失败', 'error')
throw e // 抛出错误以便调用者处理
}
}, [showToast, ALL_BUILTIN_KEYS, notifyTemplatesUpdate])
// 切换预制模板的启用/禁用
const toggleBuiltinTemplateEnabled = useCallback(async (presetKey: string) => {
const prev = enabledBuiltinTemplates
const newSet = new Set(prev)
if (newSet.has(presetKey)) {
newSet.delete(presetKey) // 禁用
} else {
newSet.add(presetKey) // 启用
}
setEnabledBuiltinTemplates(newSet)
try {
await saveEnabledBuiltinTemplates(newSet)
} catch (err) {
console.error('Failed to save enabled templates:', err)
setEnabledBuiltinTemplates(prev)
showToast('保存失败,已恢复', 'error')
}
}, [enabledBuiltinTemplates, saveEnabledBuiltinTemplates, showToast])
const enableAllBuiltinTemplates = useCallback(async () => {
if (enabledBuiltinTemplates.size === ALL_BUILTIN_KEYS.length) {
return
}
const prev = enabledBuiltinTemplates
const newSet = new Set(ALL_BUILTIN_KEYS)
setEnabledBuiltinTemplates(newSet)
try {
await saveEnabledBuiltinTemplates(newSet)
showToast('已启用所有预制模板', 'success')
} catch (err) {
console.error('Failed to enable all templates:', err)
setEnabledBuiltinTemplates(prev)
showToast('启用全部失败,已恢复', 'error')
}
}, [ALL_BUILTIN_KEYS, enabledBuiltinTemplates, saveEnabledBuiltinTemplates, showToast])
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',
materialType: preset?.materialType || MATERIAL_TYPE_OPTIONS[0],
materialKind: preset?.materialKind || 'IMAGE',
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',
materialType: tpl.materialType || MATERIAL_TYPE_OPTIONS[0],
materialKind: (tpl as any).materialKind || 'IMAGE',
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,
addToCategory: GLOBAL_CATEGORY_FIELDS.has(field.key) ? true : field.addToCategory ?? false,
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',
materialType: tpl.materialType || MATERIAL_TYPE_OPTIONS[0],
materialKind: (tpl as any).materialKind || 'IMAGE',
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,
addToCategory: GLOBAL_CATEGORY_FIELDS.has(field.key) ? true : field.addToCategory ?? false,
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',
materialType: preset.materialType,
materialKind: preset.materialKind,
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',
materialType: tpl.materialType || MATERIAL_TYPE_OPTIONS[0],
materialKind: (tpl as any).materialKind || 'IMAGE',
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,
addToCategory: field.addToCategory ?? false,
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, // 总是更新描述
materialType: preset.materialType,
materialKind: preset.materialKind,
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
// 如果字段类型改为 select,确保 addToCategory 存在
let finalPatch = { ...patch }
if (patch.type === 'select' && !('addToCategory' in patch)) {
if (!('addToCategory' in currentField) || currentField.addToCategory === undefined) {
finalPatch.addToCategory = currentField.addToCategory ?? false
}
}
// 检查是否有实际变化,避免不必要的更新
const hasChanges = Object.keys(finalPatch).some(key => {
const patchKey = key as keyof FieldForm
return currentField[patchKey] !== finalPatch[patchKey]
})
if (!hasChanges) return prev
const nextFields = prev.fields.map((field, i) => {
if (i === index) {
return { ...field, ...finalPatch }
}
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,
materialType: preset.materialType,
materialKind: preset.materialKind,
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,
addToCategory: field.addToCategory ?? false,
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,
materialType: form.materialType || MATERIAL_TYPE_OPTIONS[0],
// 如果没有显式设置 materialKind,则按照 materialType 粗略推断一个
materialKind:
form.materialKind ||
(form.materialType === '视频' ? 'VIDEO' : form.materialType === '其他' ? 'OTHER' : 'IMAGE'),
}
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}`)
}
// 确保 addToCategory 始终存在,即使是 undefined 也转换为 false
const addToCategoryValue = GLOBAL_CATEGORY_FIELDS.has(trimmedKey)
? true
: field.addToCategory !== undefined
? Boolean(field.addToCategory)
: false
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,
addToCategory: addToCategoryValue,
validations,
optionsSource: null,
optionsStatic,
allowedExtensions,
conditions,
}
})
// 确保所有 select 类型字段都有 addToCategory
payload.fields = payload.fields.map((field: any) => {
if (field.type === 'select') {
const addToCategoryValue = 'addToCategory' in field ? Boolean(field.addToCategory) : false
return { ...field, addToCategory: addToCategoryValue }
}
return field
})
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 {
// 确保所有 select 类型字段都有 addToCategory
payload.fields = payload.fields.map((field: any) => {
if (field.type === 'select') {
const addToCategoryValue = 'addToCategory' in field && field.addToCategory !== undefined && field.addToCategory !== null
? Boolean(field.addToCategory)
: false
return { ...field, addToCategory: addToCategoryValue }
}
return field
})
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,
materialType: template.materialType,
materialKind: (template as any).materialKind || 'IMAGE',
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,
addToCategory: field.addToCategory ?? false,
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)))
showToast('模板已启用', 'success')
// 通知首页刷新模板列表
notifyTemplatesUpdate()
} catch (error: any) {
showToast(error.message || '启用失败', 'error')
}
}
const handleDeactivate = 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: 'draft',
version: template.version,
materialType: template.materialType,
materialKind: (template as any).materialKind || 'IMAGE',
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,
addToCategory: field.addToCategory ?? false,
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)))
showToast('模板已取消启用', 'success')
// 通知首页刷新模板列表
notifyTemplatesUpdate()
} catch (error: any) {
showToast(error.message || '取消启用失败', 'error')
}
}
const handleDelete = async (templateId: string) => {
if (deletingId) return
const confirmed = await confirm({
title: '确认删除',
message: '确定删除该模板吗?',
variant: 'danger',
})
if (!confirmed) return
setDeletingId(templateId)
try {
await requestJson(`/api/admin/upload-templates/${templateId}`, { method: 'DELETE' })
setTemplates((prev) => prev.filter((item) => item.id !== templateId))
showToast('模板已删除')
// 通知首页刷新模板列表
notifyTemplatesUpdate()
} 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.filter(p => enabledBuiltinTemplates.has(p.key))}
onPreview={openPresetPreview}
onQuickCreate={(presetKey) => createTemplateFromPreset(presetKey, 'draft')}
onToggleEnabled={toggleBuiltinTemplateEnabled}
onEnableAll={enableAllBuiltinTemplates}
enabledTemplates={enabledBuiltinTemplates}
allPresets={BUILTIN_TEMPLATES}
/>
<CustomTemplateSection
loading={loading}
error={error}
templates={displayTemplates}
filteredTemplates={filteredTemplates}
deletingId={deletingId}
onCreate={openCreate}
onPreview={openCustomPreview}
onActivate={handleActivate}
onDeactivate={handleDeactivate}
onEdit={openEdit}
onDuplicate={openDuplicate}
onDelete={handleDelete}
/>
</div>
{editorOpen && form && (
<Modal
isOpen={editorOpen}
onClose={closeEditor}
size={isPreviewMode ? 'large' : 'xxlarge'}
showCloseButton={false}
closeOnOverlayClick={false}
closeOnEscape={false}
>
<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}
materialTypeOptions={MATERIAL_TYPE_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}
getStandardFieldTemplate={cloneFieldTemplate}
fieldTypes={FIELD_TYPES}
layoutOptions={LAYOUT_OPTIONS}
lockedCategoryFields={new Set(categoryFieldKeys)}
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>
</Modal>
)}
</div>
)
}