'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 UpgradeModal from '@/components/ui/UpgradeModal' import { animations } from '@/lib/ui-animations' import { useLicenseStatus } from '@/hooks/useLicenseStatus' 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 { BUILTIN_TEMPLATE_KEYS, createBuiltinTemplate, } from '@/lib/upload-template-presets' 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(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 = { 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, }, audioCategory: { key: 'audioCategory', label: '音频分类', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, isReadOnly: false, addToCategory: true, validations: '', optionsStatic: JSON.stringify(['默认分类']), allowedExtensions: '', conditions: '', autoLabel: false, }, audioScene: { key: 'audioScene', label: '音频使用场景', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, isReadOnly: false, addToCategory: true, validations: '', optionsStatic: JSON.stringify(['默认场景']), 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 safeJsonStringify = (value: any) => { if (value === null || value === undefined || value === '') return '' try { return JSON.stringify(value, null, 2) } catch { return '' } } const stringifyFieldConfig = (value: unknown) => { if (value === null || value === undefined || value === '') return '' return typeof value === 'string' ? value : safeJsonStringify(value) } const toFieldForm = (field: ReturnType['fields'][number]): FieldForm => ({ 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 ?? false, addToCategory: field.addToCategory ?? false, validations: stringifyFieldConfig(field.validations), optionsStatic: stringifyFieldConfig(field.optionsStatic), allowedExtensions: stringifyFieldConfig(field.allowedExtensions), conditions: stringifyFieldConfig(field.conditions), autoLabel: false, }) const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = BUILTIN_TEMPLATE_KEYS.map((key) => { const preset = createBuiltinTemplate(key) return { key, name: preset.name, description: preset.description, materialType: preset.materialType, materialKind: preset.materialKind, getFields: () => createBuiltinTemplate(key).fields.map((field) => toFieldForm(field)), } }) 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 (input: RequestInfo, init?: RequestInit): Promise => { 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 { isFree, isFeatureEnabled } = useLicenseStatus() const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [editorOpen, setEditorOpen] = useState(false) const [advancedConfigOpen, setAdvancedConfigOpen] = useState(false) const [saving, setSaving] = useState(false) const [deletingId, setDeletingId] = useState(null) const [form, setForm] = useState(null) const [editingFieldIndex, setEditingFieldIndex] = useState(null) const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null) const [fieldGroupsError, setFieldGroupsError] = useState(null) const [displayConfigError, setDisplayConfigError] = useState(null) const [selectedPreset, setSelectedPreset] = useState(null) const [dragFieldIndex, setDragFieldIndex] = useState(null) const [dragHover, setDragHover] = useState(null) const [isPreviewMode, setIsPreviewMode] = useState(false) const [previewTemplate, setPreviewTemplate] = useState(null) const [templateSearchTerm, setTemplateSearchTerm] = useState('') const [enabledBuiltinTemplates, setEnabledBuiltinTemplates] = useState>(new Set()) const [showUpgradeModal, setShowUpgradeModal] = useState(false) // 分类字段集合同步自后台配置,默认使用常量兜底 const [categoryFieldKeys, setCategoryFieldKeys] = useState(Array.from(GLOBAL_CATEGORY_FIELDS)) const ALL_BUILTIN_KEYS = useMemo(() => BUILTIN_TEMPLATES.map(t => t.key), []) // 所有预制模板key const canUseCustomTemplates = !isFree && isFeatureEnabled('customTemplates') // 素材迁移弹窗状态 const [migrationDialogOpen, setMigrationDialogOpen] = useState(false) const [migrationSourceTemplate, setMigrationSourceTemplate] = useState<{ id: string; name: string; materialCount: number } | null>(null) const [migrationTargetTemplateId, setMigrationTargetTemplateId] = useState('') 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) => { 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) => { 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('/api/admin/upload-templates') setTemplates(data) } catch (err: any) { setError(err.message || '获取模板失败') } finally { setLoading(false) } }, []) useEffect(() => { fetchTemplates() }, [fetchTemplates]) const openCreate = (presetKey?: string) => { if (!canUseCustomTemplates) { setShowUpgradeModal(true) return } 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) => { if (!canUseCustomTemplates) { setShowUpgradeModal(true) return } 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) => { if (!canUseCustomTemplates) { setShowUpgradeModal(true) return } 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) => { 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') => { if (!canUseCustomTemplates) { setShowUpgradeModal(true) return } 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('/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) => { 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) => { 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) => { 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, 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) => { event.preventDefault() event.stopPropagation() finalizeDragReorder() } const startFieldDrag = ( event: DragEvent, 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 (
松开即可放置
) } 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 === '音频' ? 'AUDIO' : 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(`/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('/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(`/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(`/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( () => 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 (
enabledBuiltinTemplates.has(p.key))} onPreview={openPresetPreview} onQuickCreate={(presetKey) => createTemplateFromPreset(presetKey, 'draft')} canQuickCreate={canUseCustomTemplates} onRequestUpgrade={() => setShowUpgradeModal(true)} onToggleEnabled={toggleBuiltinTemplateEnabled} onEnableAll={enableAllBuiltinTemplates} enabledTemplates={enabledBuiltinTemplates} allPresets={BUILTIN_TEMPLATES} /> setShowUpgradeModal(true)} onCreate={openCreate} onPreview={openCustomPreview} onActivate={handleActivate} onDeactivate={handleDeactivate} onEdit={openEdit} onDuplicate={openDuplicate} onDelete={handleDelete} />
{editorOpen && form && (

{isPreviewMode ? '模板预览' : form.id ? '编辑模板' : '新建模板'}

{isPreviewMode ? previewTemplate ? '查看自定义模板的上传效果' : '查看预制模板的上传效果' : '左侧配置字段,右侧查看预览'}

{/* 左侧:字段配置面板 */} {!isPreviewMode && (
)}
)} setShowUpgradeModal(false)} featureKey="customTemplates" featureName="自定义模板" featureDescription="创建、复制、编辑和迁移自定义模板需要升级后才能使用。" /> {/* 素材迁移弹窗 */} {migrationDialogOpen && migrationSourceTemplate && (

无法直接删除模板

该模板正在被使用

模板 “{migrationSourceTemplate.name}” 有{' '} {migrationSourceTemplate.materialCount}{' '} 个素材正在使用。

请选择一个目标模板,将这些素材迁移后再删除。

{availableTargetTemplates.length === 0 && (

没有可用的目标模板,请先创建或启用其他模板。

)}
)}
) }