2258 lines
80 KiB
TypeScript
2258 lines
80 KiB
TypeScript
'use client'
|
||
|
||
import { DragEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||
import { formatDateTimeShort } from '../_utils/formatters'
|
||
import { useThemeStyles, cn } from '@/lib/ui-styles'
|
||
import { 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">“{migrationSourceTemplate.name}”</span> 有{' '}
|
||
<span className="font-bold text-blue-600 dark:text-blue-400">{migrationSourceTemplate.materialCount}</span>{' '}
|
||
个素材正在使用。
|
||
</p>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2">
|
||
请选择一个目标模板,将这些素材迁移后再删除。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mb-6">
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||
迁移到模板
|
||
</label>
|
||
<select
|
||
value={migrationTargetTemplateId}
|
||
onChange={(e) => 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>
|
||
)
|
||
}
|