flnxi-Wu/app/admin/_components/TemplateManager.tsx
WuYongJie f646b71813
Some checks failed
CI Check / check (push) Has been cancelled
chore: 初始化本地仓库并同步最近改动
2025-11-18 17:50:16 +08:00

1330 lines
62 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 { useCallback, useEffect, useMemo, useState } from 'react'
import { format } from 'date-fns'
type ToastFn = (message: string, type?: 'success' | 'error') => void
interface UploadFieldDto {
id?: string
key: string
label: string
type: string
required: boolean
orderIndex: number
layoutWidth?: string | null
validations?: any
optionsStatic?: any
allowedExtensions?: any
visibleOnHomeFilter: boolean
isSearchable: boolean
isReadOnly: boolean
conditions?: any
}
interface UploadTemplateDto {
id: string
name: string
description: string | null
status: string
version: number
fieldGroups: any
displayConfig: any
createdAt: string
updatedAt: string
fields: UploadFieldDto[]
}
interface FieldForm {
id?: string
key: string
label: string
type: string
required: boolean
layoutWidth: string
visibleOnHomeFilter: boolean
isSearchable: boolean
isReadOnly: boolean
validations: string
optionsStatic: string
allowedExtensions: string
conditions: string
}
interface TemplateForm {
id?: string
version?: number
name: string
description: string
status: string
fieldGroups: string
displayConfig: string
fields: FieldForm[]
}
const STATUS_OPTIONS = [
{ value: 'draft', label: '草稿' },
{ value: 'active', label: '启用' },
{ value: 'archived', label: '归档' },
]
const FIELD_TYPES = ['text', 'textarea', 'number', 'select', 'checkbox', 'radio', 'image', 'file', 'date', 'uppercase']
const LAYOUT_OPTIONS = [
{ value: 'full', label: '整行' },
{ value: 'half', label: '半行' },
{ value: 'third', label: '三分之一' },
]
const defaultField = (): FieldForm => ({
key: '',
label: '',
type: 'text',
required: false,
layoutWidth: 'full',
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
})
// 默认模板字段
const getDefaultTemplateFields = (): FieldForm[] => [
{
key: 'materialType',
label: '素材类型',
type: 'select',
required: true,
layoutWidth: 'full',
visibleOnHomeFilter: true,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: JSON.stringify(['3DMAX', 'PSD', 'AI', 'CAD', 'C4D', '视频', '材质', '模特', '标准饰品', '其他']),
allowedExtensions: '',
conditions: '',
},
{
key: 'productModel',
label: '型号',
type: 'text',
required: true,
layoutWidth: 'full',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
},
{
key: 'sceneName',
label: '场景类别',
type: 'select',
required: false,
layoutWidth: 'full',
visibleOnHomeFilter: true,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
},
{
key: 'brandName',
label: '品牌名称',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
},
{
key: 'seriesName',
label: '系列 / 产品线',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
},
{
key: 'skuCode',
label: 'SKU 编码',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
},
{
key: 'materialTexture',
label: '材质 / 工艺',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: true,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
},
{
key: 'renderTime',
label: '渲染耗时',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
},
{
key: 'imageResolution',
label: '输出分辨率',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
},
{
key: 'colorMode',
label: '颜色模式',
type: 'text',
required: false,
layoutWidth: 'half',
visibleOnHomeFilter: false,
isSearchable: false,
isReadOnly: false,
validations: '',
optionsStatic: '',
allowedExtensions: '',
conditions: '',
},
]
interface BuiltinTemplateConfig {
key: string
name: string
description: string
getFields: () => FieldForm[]
}
// 内置常用模板(使用新的字段标准)
const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = [
{
key: 'general-ecommerce',
name: '通用电商素材模板',
description: '适用于大多数商品/场景素材管理,包含商品与渲染维度的关键字段。',
getFields: () => getDefaultTemplateFields(),
},
{
key: 'simple-basic',
name: '简洁基础模板',
description: '仅保留素材类型、型号与场景类别,适合快速接入和二次扩展。',
getFields: () => {
const base = getDefaultTemplateFields()
const keys = new Set(['materialType', 'productModel', 'sceneName'])
return base.filter((field) => keys.has(field.key))
},
},
{
key: 'render-focused',
name: '渲染/制作详情模板',
description: '突出品牌、系列、SKU 以及渲染输出信息,适合渲染团队内部管理。',
getFields: () => {
const base = getDefaultTemplateFields()
const keys = new Set([
'materialType',
'productModel',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'renderTime',
'imageResolution',
'colorMode',
])
return base.filter((field) => keys.has(field.key))
},
},
]
const safeJsonStringify = (value: any) => {
if (value === null || value === undefined || value === '') return ''
try {
return JSON.stringify(value, null, 2)
} catch {
return ''
}
}
const parseJson = (raw: string, message: string) => {
const trimmed = raw.trim()
if (!trimmed) return null
try {
return JSON.parse(trimmed)
} catch (error) {
throw new Error(message)
}
}
const parseExtensions = (raw: string) => {
const trimmed = raw.trim()
if (!trimmed) return null
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (!Array.isArray(parsed)) throw new Error()
return parsed
} catch {
throw new Error('文件扩展名需使用 JSON 数组或逗号分隔格式')
}
}
return trimmed
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
const requestJson = async <T,>(input: RequestInfo, init?: RequestInit): Promise<T> => {
const res = await fetch(input, init)
if (!res.ok) {
let message = '请求失败'
try {
const data = await res.json()
message = data?.error || message
} catch {
// ignore
}
throw new Error(message)
}
return res.json()
}
export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
const [templates, setTemplates] = useState<UploadTemplateDto[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editorOpen, setEditorOpen] = 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 [selectedPreset, setSelectedPreset] = useState<string | null>(null)
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 = () => {
const defaultPreset = BUILTIN_TEMPLATES[0]
const initialFields = defaultPreset ? defaultPreset.getFields() : getDefaultTemplateFields()
setForm({
name: '',
description: '',
status: 'draft',
fieldGroups: '',
displayConfig: '',
fields: initialFields,
})
setSelectedPreset(defaultPreset ? defaultPreset.key : null)
setEditorOpen(true)
setEditingFieldIndex(null)
setSaveMessage(null)
}
const openEdit = (tpl: UploadTemplateDto) => {
setForm({
id: tpl.id,
version: tpl.version,
name: tpl.name,
description: tpl.description || '',
status: tpl.status || 'draft',
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,
validations: safeJsonStringify(field.validations),
optionsStatic: safeJsonStringify(field.optionsStatic),
allowedExtensions: safeJsonStringify(field.allowedExtensions),
conditions: safeJsonStringify(field.conditions),
})),
})
setEditorOpen(true)
setSelectedPreset(null)
setEditingFieldIndex(null)
setSaveMessage(null)
}
const closeEditor = () => {
if (saving) return
setEditorOpen(false)
setForm(null)
setEditingFieldIndex(null)
setSaveMessage(null)
setSelectedPreset(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
return {
...prev,
name: prev.name || preset.name,
description: prev.description || preset.description,
fields: preset.getFields(),
}
})
setEditingFieldIndex(null)
setSaveMessage(null)
}
const updateField = (index: number, patch: Partial<FieldForm>) => {
setForm((prev) => {
if (!prev) return prev
const nextFields = prev.fields.map((field, i) => (i === index ? { ...field, ...patch } : field))
return { ...prev, fields: nextFields }
})
}
const addField = () => setForm((prev) => (prev ? { ...prev, fields: [...prev.fields, defaultField()] } : prev))
const removeField = (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) }
})
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 handleSave = async () => {
if (!form) return
setSaveMessage(null)
if (!form.name.trim()) {
setSaveMessage({ type: 'error', text: '模板名称不能为空' })
return
}
if (form.fields.some((field) => !field.key.trim())) {
setSaveMessage({ type: 'error', text: '字段键名不能为空' })
return
}
try {
const payload: any = {
name: form.name.trim(),
description: form.description.trim() || null,
status: form.status,
}
try {
payload.fieldGroups = parseJson(form.fieldGroups, '字段分组必须为合法 JSON')
} catch (error: any) {
setSaveMessage({ type: 'error', text: error.message })
return
}
try {
payload.displayConfig = parseJson(form.displayConfig, '展示配置必须为合法 JSON')
} catch (error: any) {
setSaveMessage({ type: 'error', text: error.message })
return
}
payload.fields = form.fields.map((field, index) => {
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格式,按行分割
const lines = trimmed.split('\n').map(line => line.trim()).filter(Boolean)
optionsStatic = lines.length > 0 ? lines : null
} else {
optionsStatic = parseJson(field.optionsStatic, '字段选项需为合法 JSON')
}
} 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}`)
}
return {
id: field.id,
key: field.key.trim(),
label: field.label.trim() || field.key.trim(),
type: field.type,
required: field.required,
orderIndex: index,
layoutWidth: field.layoutWidth,
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
validations,
optionsStatic,
allowedExtensions,
conditions,
}
})
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 {
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) {
setSaveMessage({ type: 'error', text: error.message || '保存失败' })
} 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,
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,
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)))
} catch (error: any) {
showToast(error.message || '启用失败', 'error')
}
}
const handleDelete = async (templateId: string) => {
if (deletingId || !confirm('确定删除该模板吗?')) return
setDeletingId(templateId)
try {
await requestJson(`/api/admin/upload-templates/${templateId}`, { method: 'DELETE' })
setTemplates((prev) => prev.filter((item) => item.id !== templateId))
showToast('模板已删除')
} catch (error: any) {
showToast(error.message || '删除失败', 'error')
} finally {
setDeletingId(null)
}
}
const displayTemplates = useMemo(
() =>
templates.map((tpl) => ({
...tpl,
updatedLabel: format(new Date(tpl.updatedAt), 'yyyy-MM-dd HH:mm'),
fieldCount: tpl.fields.length,
})),
[templates]
)
return (
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg overflow-hidden border border-gray-200/50 dark:border-gray-800/50">
<div className="flex items-center justify-between p-6 border-b border-gray-200/60 dark:border-gray-800/60">
<div>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white tracking-tight">素材上传模板</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1.5">配置各业务线所需的字段与校验逻辑</p>
</div>
<button
onClick={openCreate}
className="group px-5 py-2.5 bg-gray-900 dark:bg-gray-800 text-white rounded-xl hover:bg-gray-800 dark:hover:bg-gray-700 transition-all duration-200 ease-out shadow-sm hover:shadow-md active:scale-95 flex items-center gap-2 font-medium"
>
<svg className="w-5 h-5 transition-transform duration-200 group-hover:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
新建模板
</button>
</div>
{loading ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">加载中...</div>
) : error ? (
<div className="p-8 text-center text-red-500 dark:text-red-300">{error}</div>
) : displayTemplates.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">暂无模板,点击“新建模板”开始配置。</div>
) : (
<div className="divide-y divide-gray-200/60 dark:divide-gray-800/60">
{displayTemplates.map((tpl) => (
<div key={tpl.id} className="group p-6 space-y-3 hover:bg-gray-50/50 dark:hover:bg-gray-800/30 transition-all duration-200 ease-out">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{tpl.name}</h3>
<span
className={`px-2.5 py-1 text-xs font-medium rounded-full transition-all duration-200 ${
tpl.status === 'active'
? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
: tpl.status === 'draft'
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{STATUS_OPTIONS.find((option) => option.value === tpl.status)?.label || tpl.status}
</span>
</div>
{tpl.description && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1.5">{tpl.description}</p>}
</div>
<div className="flex items-center gap-2">
{tpl.status !== 'active' && (
<button
onClick={() => handleActivate(tpl.id)}
className="px-4 py-2 text-sm bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-xl hover:bg-green-100 dark:hover:bg-green-800/50 transition-all duration-200 ease-out hover:scale-105 active:scale-95 font-medium"
>
启用
</button>
)}
<button
onClick={() => openEdit(tpl)}
className="px-4 py-2 text-sm bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-xl hover:bg-blue-100 dark:hover:bg-blue-800/50 transition-all duration-200 ease-out hover:scale-105 active:scale-95 font-medium"
>
编辑
</button>
<button
onClick={() => handleDelete(tpl.id)}
disabled={deletingId === tpl.id}
className="px-4 py-2 text-sm bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-xl hover:bg-red-100 dark:hover:bg-red-800/50 transition-all duration-200 ease-out hover:scale-105 active:scale-95 disabled:opacity-60 disabled:cursor-not-allowed font-medium"
>
{deletingId === tpl.id ? '删除中...' : '删除'}
</button>
</div>
</div>
<div className="flex flex-wrap gap-4 text-sm text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
{tpl.fieldCount} 个字段
</span>
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{tpl.updatedLabel}
</span>
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
v{tpl.version}
</span>
</div>
{tpl.fields.length > 0 && (
<div className="bg-gray-50/80 dark:bg-gray-800/40 rounded-xl p-4 border border-gray-200/50 dark:border-gray-700/50">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2.5">字段预览</div>
<div className="flex flex-wrap gap-2">
{tpl.fields.slice(0, 6).map((field) => (
<span
key={field.id || field.key}
className="px-2.5 py-1 text-xs bg-white dark:bg-gray-900/60 border border-gray-200/60 dark:border-gray-700/60 rounded-lg text-gray-700 dark:text-gray-200 font-medium"
>
{field.label || field.key} · {field.type}
</span>
))}
{tpl.fields.length > 6 && (
<span className="text-xs text-gray-500 dark:text-gray-400 px-2.5 py-1">等 {tpl.fields.length} 个字段</span>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{editorOpen && form && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in duration-200">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-7xl max-h-[92vh] overflow-hidden flex flex-col animate-in zoom-in-95 duration-300">
<div className="px-6 py-5 border-b border-gray-200/60 dark:border-gray-800/60 flex items-center justify-between bg-gray-50/50 dark:bg-gray-800/30">
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white tracking-tight">{form.id ? '编辑模板' : '新建模板'}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">左侧配置字段,右侧实时预览上传面板效果</p>
</div>
<button
onClick={closeEditor}
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-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">
{/* 左侧:字段配置面板 */}
<div className="w-1/2 border-r border-gray-200/60 dark:border-gray-800/60 overflow-y-auto">
<div className="p-6 space-y-6">
{/* 模板基本信息 */}
<div className="space-y-5">
<div>
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">模板名称 <span className="text-red-500">*</span></label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((prev) => (prev ? { ...prev, name: e.target.value } : prev))}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
placeholder="例如:产品素材模板"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">给模板起一个容易识别的名称</p>
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">模板描述</label>
<textarea
rows={2}
value={form.description}
onChange={(e) => setForm((prev) => (prev ? { ...prev, description: e.target.value } : prev))}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 resize-none"
placeholder="简单描述这个模板的用途"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">模板状态</label>
<select
value={form.status}
onChange={(e) => setForm((prev) => (prev ? { ...prev, status: e.target.value } : prev))}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">草稿:仅保存,不生效;启用:用户可以使用</p>
</div>
{/* 预设模板选择(仅在新建时显示) */}
{!form.id && (
<div>
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">选择预设模板</label>
<div className="flex flex-wrap gap-2">
{BUILTIN_TEMPLATES.map((preset) => {
const active = selectedPreset === preset.key
return (
<button
key={preset.key}
type="button"
onClick={() => applyPreset(preset.key)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all duration-200 flex items-center gap-1.5 ${
active
? 'bg-blue-600 text-white border-blue-600 shadow-sm'
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:bg-white dark:hover:bg-gray-700'
}`}
>
{active && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-white" />
)}
<span>{preset.name}</span>
</button>
)
})}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
选择一个预设模板后,下方字段会自动填充,可在此基础上继续增删或调整。
</p>
</div>
)}
</div>
<div className="space-y-5 pt-4 border-t border-gray-200/60 dark:border-gray-800/60">
<div>
<h4 className="text-lg font-semibold text-gray-900 dark:text-white">字段配置</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">添加用户上传时需要填写的字段</p>
</div>
<div className="space-y-3">
{form.fields.map((field, index) => (
<div
key={index}
className={`group border rounded-xl p-4 transition-all duration-200 ease-out ${
editingFieldIndex === index
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 shadow-md'
: 'border-gray-200/60 dark:border-gray-700/60 bg-gray-50/50 dark:bg-gray-800/40 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-white dark:hover:bg-gray-800 hover:shadow-sm'
}`}
onMouseEnter={() => setEditingFieldIndex(index)}
onMouseLeave={() => setEditingFieldIndex(null)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 text-xs font-semibold bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded">字段 {index + 1}</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">{field.label || field.key || '未命名字段'}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation()
moveField(index, 'up')
}}
disabled={index === 0}
className="p-1.5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed"
title="上移"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation()
moveField(index, 'down')
}}
disabled={index === form.fields.length - 1}
className="p-1.5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed"
title="下移"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation()
removeField(index)
if (editingFieldIndex === index) {
setEditingFieldIndex(null)
} else if (editingFieldIndex !== null && editingFieldIndex > index) {
setEditingFieldIndex(editingFieldIndex - 1)
}
}}
className="p-1.5 text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-all duration-200"
title="删除"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
字段键名 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
placeholder="例如:productModel"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">显示名称</label>
<input
type="text"
value={field.label}
onChange={(e) => updateField(index, { label: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
placeholder="例如:商品型号"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">字段类型</label>
<select
value={field.type}
onChange={(e) => updateField(index, { type: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
>
{FIELD_TYPES.filter(t => t !== 'image' && t !== 'file').map((type) => (
<option key={type} value={type}>
{type === 'text' ? '文本' : type === 'textarea' ? '多行文本' : type === 'number' ? '数字' : type === 'select' ? '下拉选择' : type === 'checkbox' ? '复选框' : type === 'radio' ? '单选' : type === 'date' ? '日期' : type === 'uppercase' ? '仅大写+字母' : type}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">布局宽度</label>
<select
value={field.layoutWidth}
onChange={(e) => updateField(index, { layoutWidth: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
>
{LAYOUT_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
</div>
{field.type === 'select' && (
<div className="mt-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
下拉选项 <span className="text-gray-500 font-normal">(必填)</span>
</label>
<textarea
rows={6}
value={field.optionsStatic || ''}
onChange={(e) => updateField(index, { optionsStatic: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 resize-none font-mono"
placeholder='直接输入选项,每行一个,例如:&#10;3DMAX&#10;PSD&#10;AI&#10;CAD&#10;C4D'
/>
<div className="mt-2 p-2.5 bg-blue-50/50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-[10px] font-semibold text-blue-700 dark:text-blue-300 mb-1.5">填写说明:</p>
<div className="text-[10px] text-blue-600 dark:text-blue-400">
<p className="mb-1.5">每行输入一个选项,例如:</p>
<div className="ml-3 font-mono bg-white dark:bg-gray-800 px-2 py-1 rounded border border-blue-200 dark:border-blue-700">
3DMAX<br/>
PSD<br/>
AI<br/>
CAD
</div>
</div>
</div>
</div>
)}
<div className="flex flex-wrap gap-3 mt-3 pt-3 border-t border-gray-200/60 dark:border-gray-700/60">
<label className="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(index, { required: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
<span>必填</span>
</label>
<label className="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={field.visibleOnHomeFilter}
onChange={(e) => updateField(index, { visibleOnHomeFilter: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
<span>首页筛选</span>
</label>
<label className="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={field.isSearchable}
onChange={(e) => updateField(index, { isSearchable: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
<span>可搜索</span>
</label>
</div>
</div>
))}
{/* 添加元素按钮 */}
<button
onClick={addField}
className="w-full border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4 transition-all duration-200 hover:border-gray-400 dark:hover:border-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800/50 group"
>
<div className="flex items-center justify-center space-x-3">
<div className="w-16 h-16 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0 group-hover:bg-gray-200 dark:group-hover:bg-gray-600 transition-colors">
<svg className="w-8 h-8 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="text-left flex-1 min-w-0">
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-gray-200 transition-colors">添加元素</p>
<p className="text-[10px] text-gray-500 dark:text-gray-500 mt-0.5">点击添加新的字段配置</p>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
{/* 右侧:实时预览面板 */}
<div className="w-1/2 overflow-y-auto bg-gray-50/30 dark:bg-gray-900/50">
<div className="p-6">
<div className="mb-4 pb-4 border-b border-gray-200/60 dark:border-gray-800/60">
<h4 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">实时预览</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">预览上传面板的实际效果</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200/60 dark:border-gray-700/60 p-4 space-y-4">
{/* 固定的预览图片上传组件 */}
<div className="relative">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
预览图片 <span className="text-red-500">*</span>
</label>
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-3 transition-colors hover:border-gray-400 dark:hover:border-gray-500">
<div className="flex items-center justify-center space-x-3">
<div className="w-16 h-16 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div className="text-left flex-1 min-w-0">
<p className="text-xs text-gray-600 dark:text-gray-400">点击或拖拽上传预览图</p>
<p className="text-[10px] text-gray-500 dark:text-gray-500 mt-0.5">支持 JPG、PNG、WebP 等格式</p>
</div>
</div>
</div>
</div>
{/* 固定的文件上传组件 */}
<div className="relative">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
素材文件 <span className="text-red-500">*</span>
</label>
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-3 transition-colors hover:border-gray-400 dark:hover:border-gray-500">
<div className="flex items-center justify-center space-x-3">
<svg className="w-8 h-8 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div className="text-left flex-1 min-w-0">
<p className="text-xs text-gray-600 dark:text-gray-400">点击或拖拽上传素材文件</p>
<p className="text-[10px] text-gray-500 dark:text-gray-500 mt-0.5">支持 ZIP、RAR 等压缩格式</p>
</div>
</div>
</div>
</div>
{/* 动态渲染的自定义字段 */}
<div className="flex flex-wrap gap-3">
{form.fields
.map((field, originalIndex) => {
if (!field.key.trim() || field.type === 'image' || field.type === 'file') return null
const widthClass =
field.layoutWidth === 'half' ? 'w-[calc(50%-0.375rem)]' :
field.layoutWidth === 'third' ? 'w-[calc(33.333%-0.5rem)]' :
'w-full'
const isEditing = editingFieldIndex === originalIndex
return (
<div
key={originalIndex}
className={`group relative ${widthClass} transition-all duration-200 ease-out ${
isEditing
? 'transform scale-[1.02] shadow-lg border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 rounded-xl'
: 'hover:scale-[1.01] hover:shadow-md border border-transparent'
}`}
onMouseEnter={() => setEditingFieldIndex(originalIndex)}
onMouseLeave={() => setEditingFieldIndex(null)}
>
{/* 删除角标 */}
<button
onClick={(e) => {
e.stopPropagation()
removeField(originalIndex)
if (editingFieldIndex === originalIndex) {
setEditingFieldIndex(null)
} else if (editingFieldIndex !== null && editingFieldIndex > originalIndex) {
setEditingFieldIndex(editingFieldIndex - 1)
}
}}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-all duration-200 hover:scale-110 active:scale-95 shadow-md z-10"
title="删除此字段"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{field.label || field.key || '未命名字段'}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
{field.type === 'text' && (
<input
type="text"
placeholder={`请输入${field.label || field.key}`}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
disabled={field.isReadOnly}
/>
)}
{field.type === 'textarea' && (
<textarea
rows={2}
placeholder={`请输入${field.label || field.key}`}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 resize-none"
disabled={field.isReadOnly}
/>
)}
{field.type === 'number' && (
<input
type="number"
placeholder={`请输入${field.label || field.key}`}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
disabled={field.isReadOnly}
/>
)}
{field.type === 'select' && (
<select
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
disabled={field.isReadOnly}
>
<option value="">请选择{field.label || field.key}</option>
{field.optionsStatic && (() => {
try {
const options = JSON.parse(field.optionsStatic)
if (Array.isArray(options)) {
return options.map((opt: any, i: number) => (
<option key={i} value={typeof opt === 'string' ? opt : opt.value}>
{typeof opt === 'string' ? opt : opt.label}
</option>
))
}
} catch {}
return null
})()}
</select>
)}
{field.type === 'date' && (
<input
type="date"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
disabled={field.isReadOnly}
/>
)}
{field.type === 'uppercase' && (
<input
type="text"
placeholder={`请输入${field.label || field.key}(仅大写字母)`}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 uppercase"
disabled={field.isReadOnly}
style={{ textTransform: 'uppercase' }}
/>
)}
{field.type === 'checkbox' && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
disabled={field.isReadOnly}
/>
<span className="text-xs text-gray-700 dark:text-gray-300">选择{field.label || field.key}</span>
</label>
)}
{field.type === 'radio' && (
<div className="space-y-1.5">
{field.optionsStatic && (() => {
try {
const options = JSON.parse(field.optionsStatic)
if (Array.isArray(options)) {
return options.map((opt: any, i: number) => (
<label key={i} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`preview-${field.key}`}
value={typeof opt === 'string' ? opt : opt.value}
className="w-3.5 h-3.5 text-blue-600 focus:ring-2 focus:ring-blue-500"
disabled={field.isReadOnly}
/>
<span className="text-xs text-gray-700 dark:text-gray-300">
{typeof opt === 'string' ? opt : opt.label}
</span>
</label>
))
}
} catch {}
return null
})()}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
</div>
</div>
<div className="px-6 py-5 border-t border-gray-200/60 dark:border-gray-800/60 bg-gray-50/50 dark:bg-gray-800/30">
{/* 保存提示 */}
{saveMessage && (
<div className={`mb-4 px-4 py-3 rounded-xl flex items-center gap-2 ${
saveMessage.type === 'success'
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800'
}`}>
{saveMessage.type === 'success' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<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>
)}
<span className="text-sm font-medium">{saveMessage.text}</span>
</div>
)}
<div className="flex justify-end gap-3">
<button
onClick={closeEditor}
disabled={saving}
className="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ease-out hover:scale-105 active:scale-95 font-medium disabled:opacity-60"
>
取消
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2.5 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition-all duration-200 ease-out hover:scale-105 active:scale-95 shadow-sm hover:shadow-md font-medium disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<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"></path>
</svg>
保存中...
</span>
) : (
'保存模板'
)}
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}