1330 lines
62 KiB
TypeScript
1330 lines
62 KiB
TypeScript
'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='直接输入选项,每行一个,例如: 3DMAX PSD AI CAD 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>
|
||
)
|
||
}
|
||
|
||
|