flnxi-Wu/app/admin/_components/TemplateManager.tsx
2026-05-28 17:37:27 +08:00

2258 lines
80 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 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', '音频', '视频', '模特', '其他']
type MigrationPreviewField = {
key: string
label: string
type?: string
source: 'template' | 'standard'
affectedCount?: number
}
type MigrationPreviewResponse = {
success: boolean
previewOnly: true
migratableCount: number
message: string
sourceTemplate: { id: string; name: string }
targetTemplate: { id: string; name: string }
migrationBackup: {
materialsWithDroppedFields: number
droppedTemplateFieldCount: number
droppedStandardFieldCount: number
}
migrationPreview: {
totalMaterials: number
targetCurrentFields: Array<{
key: string
label: string
type: string
required: boolean
}>
backupFields: {
templateFields: MigrationPreviewField[]
standardFields: MigrationPreviewField[]
}
samples: Array<{
materialId: string
productModel: string | null
droppedTemplateFields: MigrationPreviewField[]
droppedStandardFields: MigrationPreviewField[]
}>
}
}
// 默认需要进入分类管理的字段集合,可被后台实时配置覆盖
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,
},
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<typeof createBuiltinTemplate>['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 <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 { isFree, isFeatureEnabled } = useLicenseStatus()
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 [showUpgradeModal, setShowUpgradeModal] = useState(false)
// 分类字段集合同步自后台配置,默认使用常量兜底
const [categoryFieldKeys, setCategoryFieldKeys] = useState<string[]>(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<string>('')
const [migrating, setMigrating] = useState(false)
const [migrationPreview, setMigrationPreview] = useState<MigrationPreviewResponse | null>(null)
const [migrationPreviewLoading, setMigrationPreviewLoading] = useState(false)
const [migrationPreviewError, setMigrationPreviewError] = useState<string | null>(null)
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) => {
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<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') => {
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<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 border-dashed border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800/40 rounded-lg py-8 flex items-center justify-center text-sm font-semibold text-slate-600 dark:text-slate-300"
>
<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 === '音频'
? '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<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('')
setMigrationPreview(null)
setMigrationPreviewError(null)
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 handleMigrationTargetChange = (targetTemplateId: string) => {
setMigrationTargetTemplateId(targetTemplateId)
setMigrationPreview(null)
setMigrationPreviewError(null)
}
const loadMigrationPreview = async () => {
if (!migrationSourceTemplate || !migrationTargetTemplateId || migrationPreviewLoading) return
setMigrationPreviewLoading(true)
setMigrationPreviewError(null)
try {
const preview = await requestJson<MigrationPreviewResponse>(
`/api/admin/upload-templates/${migrationSourceTemplate.id}/migrate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetTemplateId: migrationTargetTemplateId,
previewOnly: true,
}),
}
)
setMigrationPreview(preview)
} catch (error: any) {
setMigrationPreview(null)
setMigrationPreviewError(error.message || '生成迁移预览失败')
} finally {
setMigrationPreviewLoading(false)
}
}
// 执行素材迁移并删除模板
const handleMigrateAndDelete = async () => {
if (!migrationSourceTemplate || !migrationTargetTemplateId) return
if (!migrationPreview || migrationPreview.targetTemplate.id !== migrationTargetTemplateId) {
setMigrationPreviewError('请先生成迁移预览,确认字段影响后再执行迁移。')
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('')
setMigrationPreview(null)
setMigrationPreviewError(null)
// 通知首页刷新模板列表
notifyTemplatesUpdate()
} catch (error: any) {
showToast(error.message || '操作失败', 'error')
} finally {
setMigrating(false)
}
}
// 关闭迁移弹窗
const closeMigrationDialog = () => {
if (migrating || migrationPreviewLoading) return
setMigrationDialogOpen(false)
setMigrationSourceTemplate(null)
setMigrationTargetTemplateId('')
setMigrationPreview(null)
setMigrationPreviewError(null)
}
// 获取可选的目标模板列表(排除源模板)
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')}
canQuickCreate={canUseCustomTemplates}
onRequestUpgrade={() => setShowUpgradeModal(true)}
onToggleEnabled={toggleBuiltinTemplateEnabled}
onEnableAll={enableAllBuiltinTemplates}
enabledTemplates={enabledBuiltinTemplates}
allPresets={BUILTIN_TEMPLATES}
/>
<CustomTemplateSection
loading={loading}
error={error}
templates={displayTemplates}
filteredTemplates={filteredTemplates}
deletingId={deletingId}
canManageCustomTemplates={canUseCustomTemplates}
onRequestUpgrade={() => setShowUpgradeModal(true)}
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-xl shadow-lg max-h-[92vh] overflow-hidden flex flex-col ${
isPreviewMode
? 'w-full max-w-4xl'
: 'w-full max-w-6xl'
}`}
>
<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">
{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 ${isPreviewMode ? 'justify-center' : 'flex-col lg:flex-row'}`}>
{/* 左侧:字段配置面板 */}
{!isPreviewMode && (
<div className="w-full lg:w-1/2 border-b lg:border-b-0 lg:border-r border-slate-200/60 dark:border-slate-800/60 overflow-y-auto">
<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>
)}
<UpgradeModal
isOpen={showUpgradeModal}
onClose={() => setShowUpgradeModal(false)}
featureKey="customTemplates"
featureName="自定义模板"
featureDescription="当前许可证未包含创建、复制、编辑和迁移自定义模板的授权。"
/>
{/* 素材迁移弹窗 */}
{migrationDialogOpen && migrationSourceTemplate && (
<Modal
isOpen={migrationDialogOpen}
onClose={closeMigrationDialog}
size="large"
showCloseButton={true}
closeOnOverlayClick={!migrating && !migrationPreviewLoading}
closeOnEscape={!migrating && !migrationPreviewLoading}
>
<div className="bg-white dark:bg-slate-900 rounded-lg shadow-lg p-6 max-h-[84vh] overflow-y-auto">
<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">&ldquo;{migrationSourceTemplate.name}&rdquo;</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) => handleMigrationTargetChange(e.target.value)}
disabled={migrating || migrationPreviewLoading}
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="mb-6">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
迁移预览
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
预览不会修改数据,确认字段影响后再执行迁移。
</p>
</div>
<button
type="button"
onClick={loadMigrationPreview}
disabled={migrating || migrationPreviewLoading || !migrationTargetTemplateId}
className={cn(
'px-3 py-2 rounded-lg text-sm font-medium transition-colors',
'border border-slate-300 dark:border-slate-600',
'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200',
'hover:bg-slate-50 dark:hover:bg-slate-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{migrationPreviewLoading ? '生成中...' : migrationPreview ? '刷新预览' : '生成预览'}
</button>
</div>
{migrationPreviewError && (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
{migrationPreviewError}
</p>
)}
{migrationPreview && (
<div className="mt-3 rounded-lg border border-slate-200 dark:border-slate-700 p-4 space-y-4">
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">可迁移素材</p>
<p className="mt-1 font-semibold text-slate-900 dark:text-white">
{migrationPreview.migratableCount}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">需备份素材</p>
<p className="mt-1 font-semibold text-slate-900 dark:text-white">
{migrationPreview.migrationBackup.materialsWithDroppedFields}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">备份字段</p>
<p className="mt-1 font-semibold text-slate-900 dark:text-white">
{migrationPreview.migrationBackup.droppedTemplateFieldCount + migrationPreview.migrationBackup.droppedStandardFieldCount}
</p>
</div>
</div>
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
迁移后当前字段
</p>
<div className="flex flex-wrap gap-2">
{migrationPreview.migrationPreview.targetCurrentFields.slice(0, 12).map((field) => (
<span
key={field.key}
className="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-300"
>
{field.label}{field.required ? ' *' : ''}
</span>
))}
{migrationPreview.migrationPreview.targetCurrentFields.length > 12 && (
<span className="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
另有 {migrationPreview.migrationPreview.targetCurrentFields.length - 12} 个
</span>
)}
</div>
</div>
{(migrationPreview.migrationPreview.backupFields.templateFields.length > 0 ||
migrationPreview.migrationPreview.backupFields.standardFields.length > 0) ? (
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
会进入备份区的旧字段
</p>
<div className="flex flex-wrap gap-2">
{[
...migrationPreview.migrationPreview.backupFields.templateFields,
...migrationPreview.migrationPreview.backupFields.standardFields,
].slice(0, 12).map((field) => (
<span
key={`${field.source}-${field.key}`}
className="rounded-md bg-amber-50 px-2 py-1 text-xs text-amber-800 dark:bg-amber-900/20 dark:text-amber-200"
>
{field.label}({field.affectedCount || 0})
</span>
))}
</div>
{migrationPreview.migrationPreview.samples.length > 0 && (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
示例素材:{migrationPreview.migrationPreview.samples
.map((sample) => sample.productModel || sample.materialId)
.join('、')}
</p>
)}
</div>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400">
目标模板已接收当前字段,本次迁移不会产生旧字段备份。
</p>
)}
</div>
)}
</div>
<div className="flex gap-3 justify-end">
<button
onClick={closeMigrationDialog}
disabled={migrating || migrationPreviewLoading}
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 || migrationPreviewLoading || !migrationTargetTemplateId || !migrationPreview || migrationPreview.targetTemplate.id !== 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>
)
}