flnxi-Wu/app/admin/_components/TemplateManager.tsx
WuYongJie 63ec6b7dfb fix: 修复排行榜按钮消失、tag排序和输入框浅色模式问题
1. 排行榜悬浮窗关闭后小按钮消失问题
   - 修复面板居中模式关闭时按钮位置错误
   - 保存并恢复按钮的居中状态

2. 首页tag按钮按素材数量排序
   - 添加按素材数量降序排序逻辑

3. 上传弹窗输入框浅色模式文字不可见
   - 添加 text-slate-900 确保浅色模式下文字可见
   - 添加 placeholder 样式确保占位符正确显示
2025-12-14 21:41:29 +08:00

2054 lines
72 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', '音频', '视频', '模特', '其他']
// 默认需要进入分类管理的字段集合,可被后台实时配置覆盖
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、音频、视频、模特、其他 六种预制模板
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: 'audio-material',
name: '音频素材模板',
description: '适用于音频文件素材,支持 MP3、WAV、FLAC、AAC 等格式。',
materialType: '音频',
materialKind: 'AUDIO' as const,
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 [migrationDialogOpen, setMigrationDialogOpen] = useState(false)
const [migrationSourceTemplate, setMigrationSourceTemplate] = useState<{ id: string; name: string; materialCount: number } | null>(null)
const [migrationTargetTemplateId, setMigrationTargetTemplateId] = useState<string>('')
const [migrating, setMigrating] = useState(false)
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 duplicateField = useCallback((index: number) => {
setForm((prev) => {
if (!prev) return prev
const fieldToCopy = prev.fields[index]
// 生成新的键名(添加_copy后缀或递增数字)
let newKey = `${fieldToCopy.key}_copy`
let counter = 1
while (prev.fields.some(f => f.key === newKey)) {
newKey = `${fieldToCopy.key}_copy${counter}`
counter++
}
// 创建新字段(不包含id,因为是新字段)
const newField: FieldForm = {
...fieldToCopy,
id: undefined,
key: newKey,
label: `${fieldToCopy.label} - 副本`,
}
// 在当前字段后面插入新字段
const newFields = [...prev.fields]
newFields.splice(index + 1, 0, newField)
return { ...prev, fields: newFields }
})
// 滚动到新字段并设置为编辑状态
setTimeout(() => {
const newIndex = index + 1
const editorElement = document.getElementById(`editor-field-${newIndex}`)
if (editorElement) {
editorElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
setEditingFieldIndex(newIndex)
}
}, 100)
showToast('字段已复制', 'success')
}, [showToast])
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
// 先获取模板详情,检查是否有素材使用
try {
const templateDetail = await requestJson<{
id: string
name: string
materialCount: number
}>(`/api/admin/upload-templates/${templateId}`)
if (templateDetail.materialCount > 0) {
// 有素材使用该模板,显示迁移弹窗
setMigrationSourceTemplate({
id: templateDetail.id,
name: templateDetail.name,
materialCount: templateDetail.materialCount,
})
setMigrationTargetTemplateId('')
setMigrationDialogOpen(true)
return
}
// 没有素材使用,直接确认删除
const confirmed = await confirm({
title: '确认删除',
message: `确定删除模板"${templateDetail.name}"吗?`,
variant: 'danger',
})
if (!confirmed) return
setDeletingId(templateId)
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 handleMigrateAndDelete = async () => {
if (!migrationSourceTemplate || !migrationTargetTemplateId) return
setMigrating(true)
try {
// 1. 迁移素材
const migrateResult = await requestJson<{ success: boolean; migratedCount: number; message: string }>(
`/api/admin/upload-templates/${migrationSourceTemplate.id}/migrate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetTemplateId: migrationTargetTemplateId }),
}
)
showToast(migrateResult.message, 'success')
// 2. 删除源模板
await requestJson(`/api/admin/upload-templates/${migrationSourceTemplate.id}`, { method: 'DELETE' })
setTemplates((prev) => prev.filter((item) => item.id !== migrationSourceTemplate.id))
showToast('模板已删除')
// 关闭弹窗
setMigrationDialogOpen(false)
setMigrationSourceTemplate(null)
setMigrationTargetTemplateId('')
// 通知首页刷新模板列表
notifyTemplatesUpdate()
} catch (error: any) {
showToast(error.message || '操作失败', 'error')
} finally {
setMigrating(false)
}
}
// 关闭迁移弹窗
const closeMigrationDialog = () => {
if (migrating) return
setMigrationDialogOpen(false)
setMigrationSourceTemplate(null)
setMigrationTargetTemplateId('')
}
// 获取可选的目标模板列表(排除源模板)
const availableTargetTemplates = useMemo(() => {
if (!migrationSourceTemplate) return []
// 包含所有启用的预制模板和自定义模板
const builtinOptions = BUILTIN_TEMPLATES
.filter(t => enabledBuiltinTemplates.has(t.key))
.map(t => ({
id: `builtin-${t.key}`,
name: t.name,
isBuiltin: true,
}))
const customOptions = templates
.filter(t => !t.id.startsWith('builtin-') && t.id !== migrationSourceTemplate.id)
.map(t => ({
id: t.id,
name: t.name,
isBuiltin: false,
}))
return [...builtinOptions, ...customOptions]
}, [migrationSourceTemplate, templates, enabledBuiltinTemplates])
const displayTemplates = useMemo<DisplayTemplate[]>(
() =>
templates
// 过滤掉内置模板(builtin-开头的模板由系统自动创建,不应显示在自定义模板区域)
.filter((tpl) => !tpl.id.startsWith('builtin-'))
.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}
onDuplicateField={duplicateField}
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>
)}
{/* 素材迁移弹窗 */}
{migrationDialogOpen && migrationSourceTemplate && (
<Modal
isOpen={migrationDialogOpen}
onClose={closeMigrationDialog}
size="medium"
showCloseButton={true}
closeOnOverlayClick={!migrating}
closeOnEscape={!migrating}
>
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-2xl p-6 max-w-lg w-full">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
无法直接删除模板
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
该模板正在被使用
</p>
</div>
</div>
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4 mb-4">
<p className="text-sm text-slate-700 dark:text-slate-300">
模板 <span className="font-medium text-slate-900 dark:text-white">"{migrationSourceTemplate.name}"</span> 有{' '}
<span className="font-bold text-blue-600 dark:text-blue-400">{migrationSourceTemplate.materialCount}</span>{' '}
个素材正在使用。
</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2">
请选择一个目标模板,将这些素材迁移后再删除。
</p>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
迁移到模板
</label>
<select
value={migrationTargetTemplateId}
onChange={(e) => setMigrationTargetTemplateId(e.target.value)}
disabled={migrating}
className={cn(
'w-full px-3 py-2 rounded-lg border transition-colors',
'bg-white dark:bg-slate-800',
'border-slate-300 dark:border-slate-600',
'text-slate-900 dark:text-white',
'focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<option value="">请选择目标模板...</option>
{availableTargetTemplates.length > 0 && (
<>
<optgroup label="预制模板">
{availableTargetTemplates
.filter(t => t.isBuiltin)
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))
}
</optgroup>
{availableTargetTemplates.filter(t => !t.isBuiltin).length > 0 && (
<optgroup label="自定义模板">
{availableTargetTemplates
.filter(t => !t.isBuiltin)
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))
}
</optgroup>
)}
</>
)}
</select>
{availableTargetTemplates.length === 0 && (
<p className="text-sm text-amber-600 dark:text-amber-400 mt-2">
没有可用的目标模板,请先创建或启用其他模板。
</p>
)}
</div>
<div className="flex gap-3 justify-end">
<button
onClick={closeMigrationDialog}
disabled={migrating}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300',
'hover:bg-slate-200 dark:hover:bg-slate-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
取消
</button>
<button
onClick={handleMigrateAndDelete}
disabled={migrating || !migrationTargetTemplateId}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
'bg-red-600 text-white',
'hover:bg-red-700',
'disabled:opacity-50 disabled:cursor-not-allowed',
'flex items-center gap-2'
)}
>
{migrating ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
迁移中...
</>
) : (
<>迁移并删除</>
)}
</button>
</div>
</div>
</Modal>
)}
</div>
)
}