2026-05-28 20:09:09 +08:00

626 lines
30 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 { Fragment, DragEvent, useState, useMemo } from 'react'
import { cn } from '@/lib/ui-styles'
import type { FieldForm, DragHoverState } from '../../template-manager/types'
import FieldValidationConfig from './FieldValidationConfig'
import FieldConditionsConfig from './FieldConditionsConfig'
interface StandardFieldOption {
key: string
label: string
}
interface LayoutOption {
value: string
label: string
}
interface TemplateFieldsEditorProps {
fields: FieldForm[]
dragFieldIndex: number | null
dragHover: DragHoverState | null
editingFieldIndex: number | null
standardFields: StandardFieldOption[]
getStandardFieldTemplate?: (key: string) => FieldForm | null
fieldTypes: string[]
layoutOptions: LayoutOption[]
lockedCategoryFields?: Set<string>
onEditingFieldChange: (index: number | null) => void
onFieldDragStart: (event: DragEvent<HTMLElement>, index: number) => void
onFieldDragOver: (event: DragEvent<HTMLDivElement>, index: number, surface: 'editor') => void
onFieldDrop: (event: DragEvent<HTMLDivElement>) => void
onFieldDragEnd: () => void
onContainerDragOver: (event: DragEvent<HTMLDivElement>) => void
onContainerDrop: (event: DragEvent<HTMLDivElement>) => void
onContainerDragLeave: (event: DragEvent<HTMLDivElement>) => void
onMoveField: (index: number, direction: 'up' | 'down') => void
onRemoveField: (index: number) => void
onDuplicateField?: (index: number) => void
onAddField: () => void
renderDropPlaceholder: (key: string) => React.ReactNode
onUpdateField: (index: number, patch: Partial<FieldForm>) => void
}
const editorInputClass =
'w-full px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-white placeholder:text-slate-400 outline-none transition-colors duration-150 focus:border-slate-500 dark:focus:border-slate-500 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700'
const editorCheckboxClass =
'w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-slate-700 focus:ring-2 focus:ring-slate-300 dark:focus:ring-slate-600'
// 字段key验证函数
function validateFieldKey(key: string, currentIndex: number, allFields: FieldForm[]): {
valid: boolean
error?: string
warning?: string
} {
const trimmedKey = key.trim()
// 空值检查
if (!trimmedKey) {
return { valid: false, error: '字段键名不能为空' }
}
// 格式检查:只允许字母、数字、下划线,且必须以字母开头
const keyPattern = /^[a-zA-Z][a-zA-Z0-9_]*$/
if (!keyPattern.test(trimmedKey)) {
return {
valid: false,
error: '键名必须以字母开头,只能包含字母、数字和下划线'
}
}
// 长度检查
if (trimmedKey.length > 50) {
return { valid: false, error: '键名长度不能超过50个字符' }
}
// 唯一性检查
const duplicateIndex = allFields.findIndex(
(f, idx) => idx !== currentIndex && f.key.trim() === trimmedKey
)
if (duplicateIndex !== -1) {
return {
valid: false,
error: `键名重复:字段 ${duplicateIndex + 1} 已使用此键名`
}
}
// 警告:建议使用驼峰命名
if (trimmedKey.includes('_') && trimmedKey.toLowerCase() === trimmedKey) {
return {
valid: true,
warning: '建议使用驼峰命名(如 productModel)而非下划线命名'
}
}
return { valid: true }
}
export default function TemplateFieldsEditor({
fields,
dragFieldIndex,
dragHover,
editingFieldIndex,
standardFields,
getStandardFieldTemplate,
fieldTypes,
layoutOptions,
lockedCategoryFields,
onEditingFieldChange,
onFieldDragStart,
onFieldDragOver,
onFieldDrop,
onFieldDragEnd,
onContainerDragOver,
onContainerDrop,
onContainerDragLeave,
onMoveField,
onRemoveField,
onDuplicateField,
onAddField,
renderDropPlaceholder,
onUpdateField,
}: TemplateFieldsEditorProps) {
// 删除确认对话框状态
const [deleteConfirmIndex, setDeleteConfirmIndex] = useState<number | null>(null)
// 实时验证所有字段的key
const fieldValidations = useMemo(() => {
return fields.map((field, index) => validateFieldKey(field.key, index, fields))
}, [fields])
// 复制字段功能
const handleDuplicateField = (index: number) => {
if (onDuplicateField) {
onDuplicateField(index)
}
}
// 删除字段(带确认)
const handleDeleteField = (index: number) => {
if (fields.length === 1) {
// 不显示确认对话框,直接提示
return
}
setDeleteConfirmIndex(index)
}
const confirmDelete = () => {
if (deleteConfirmIndex !== null) {
onRemoveField(deleteConfirmIndex)
setDeleteConfirmIndex(null)
}
}
const cancelDelete = () => {
setDeleteConfirmIndex(null)
}
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 transition-colors dark:border-slate-700 dark:bg-slate-800 space-y-5">
<div>
<h4 className="text-lg font-semibold text-slate-900 dark:text-white">字段配置</h4>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">添加用户上传时需要填写的字段</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">
先选预设再按需调整,使用上下箭头按钮调整字段顺序。
</p>
</div>
<div
className="space-y-3"
onDragOver={onContainerDragOver}
onDrop={onContainerDrop}
onDragLeave={onContainerDragLeave}
>
{fields.map((field, index) => {
const isDragging = dragFieldIndex === index
const placeholderBefore =
dragFieldIndex !== null &&
dragHover &&
dragHover.index === index &&
dragHover.position === 'before'
const placeholderAfter =
dragFieldIndex !== null &&
dragHover &&
dragHover.index === index &&
dragHover.position === 'after'
const standardOption = standardFields.find((opt) => opt.key === field.key)
const selectValue = standardOption ? standardOption.key : '__custom'
// 判断是否为下拉选择类型 - 使用多种方式确保准确性
const fieldTypeStr = String(field.type || '').trim()
const isSelectType = fieldTypeStr === 'select'
const isLockedCategoryField = Boolean(lockedCategoryFields?.has(field.key))
// 获取当前字段的验证结果
const validation = fieldValidations[index]
const hasError = !validation.valid
const hasWarning = validation.valid && validation.warning
return (
<Fragment key={`editor-field-${index}`}>
{placeholderBefore && renderDropPlaceholder(`editor-before-${index}`)}
<div
id={`editor-field-${index}`}
className={`group border rounded-lg p-4 transition-colors duration-150 ${
hasError
? 'border-red-300 dark:border-red-600 bg-red-50/50 dark:bg-red-900/10'
: editingFieldIndex === index
? 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
: 'border-slate-200/60 dark:border-slate-700/60 bg-slate-50/50 dark:bg-slate-800/40 hover:border-slate-300 dark:hover:border-slate-600 hover:bg-white dark:hover:bg-slate-800'
} ${
isDragging
? 'opacity-60 cursor-grabbing border-slate-400 dark:border-slate-500'
: 'cursor-default'
}`}
draggable={true}
onDragStart={(e) => onFieldDragStart(e, index)}
onDragOver={(e) => onFieldDragOver(e, index, 'editor')}
onDrop={onFieldDrop}
onDragEnd={onFieldDragEnd}
onMouseEnter={() => onEditingFieldChange(index)}
onMouseLeave={() => onEditingFieldChange(null)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{/* 拖拽手柄 */}
<div
className="cursor-grab active:cursor-grabbing p-1 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors"
title="拖拽排序"
>
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
</svg>
</div>
<span className="px-2 py-0.5 text-xs font-semibold bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded">
字段 {index + 1}
</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{field.label || field.key || '未命名字段'}
</span>
{/* 验证状态图标 */}
{hasError && (
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
{hasWarning && (
<svg className="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)}
</div>
<div className="flex items-center gap-1.5">
{/* 复制按钮 */}
<button
onClick={(e) => {
e.stopPropagation()
handleDuplicateField(index)
}}
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
title="复制字段"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation()
onMoveField(index, 'up')
}}
disabled={index === 0}
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors 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()
onMoveField(index, 'down')
}}
disabled={index === fields.length - 1}
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors 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()
handleDeleteField(index)
}}
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-colors"
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>
{/* 验证错误/警告提示 */}
{(hasError || hasWarning) && (
<div className={`mb-3 p-2 rounded-lg text-xs ${
hasError
? 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800'
: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800'
}`}>
<div className="flex items-start gap-2">
<svg className="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{hasError ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
)}
</svg>
<span>{hasError ? validation.error : validation.warning}</span>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
字段键名 <span className="text-red-500">*</span>
</label>
<div className="space-y-1.5">
<select
value={selectValue}
onChange={(e) => {
e.stopPropagation()
const value = e.target.value
if (value === '__custom') {
onUpdateField(index, { key: '', label: '', autoLabel: field.autoLabel })
return
}
const option = standardFields.find((item) => item.key === value)
if (!option) return
// 如果提供了获取标准字段模板的函数,应用完整的模板
if (getStandardFieldTemplate) {
const template = getStandardFieldTemplate(option.key)
if (template) {
// 应用完整模板,但保留用户可能已修改的 autoLabel
const shouldAutoLabel = field.autoLabel !== false
const patch: Partial<FieldForm> = {
...template,
autoLabel: shouldAutoLabel ? true : field.autoLabel,
}
if (!shouldAutoLabel) {
patch.label = field.label || template.label
}
onUpdateField(index, patch)
return
}
}
// 如果没有模板函数,只更新 key 和 label(向后兼容)
const shouldAutoLabel = field.autoLabel !== false
const patch: Partial<FieldForm> = {
key: option.key,
autoLabel: shouldAutoLabel ? true : field.autoLabel,
}
if (shouldAutoLabel) {
patch.label = option.label
}
onUpdateField(index, patch)
}}
onClick={(e) => e.stopPropagation()}
className={editorInputClass}
>
<option value="__custom">自定义字段(手动输入键名)</option>
<optgroup label="标准字段">
{standardFields.map((option) => (
<option key={option.key} value={option.key}>
{option.label}({option.key})
</option>
))}
</optgroup>
</select>
{selectValue === '__custom' ? (
<input
key={`field-key-input-${index}`}
type="text"
value={field.key || ''}
onChange={(e) => {
e.stopPropagation()
onUpdateField(index, { key: e.target.value })
}}
onClick={(e) => e.stopPropagation()}
className={editorInputClass}
placeholder="例如:productModel"
autoComplete="off"
/>
) : (
<p className="text-[11px] text-slate-500 dark:text-slate-400">
键名:<span className="font-mono">{field.key}</span>
</p>
)}
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">显示名称</label>
<input
key={`field-label-input-${index}`}
type="text"
value={field.label || ''}
onChange={(e) => {
e.stopPropagation()
onUpdateField(index, { label: e.target.value, autoLabel: false })
}}
onClick={(e) => e.stopPropagation()}
className={editorInputClass}
placeholder="例如:商品型号"
autoComplete="off"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">字段类型</label>
<select
value={field.type}
onChange={(e) => {
onUpdateField(index, { type: e.target.value })
}}
onClick={(e) => e.stopPropagation()}
className={editorInputClass}
>
{fieldTypes
.filter((type) => type !== 'image' && type !== '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-slate-700 dark:text-slate-300 mb-1.5">布局宽度</label>
<select
value={field.layoutWidth}
onChange={(e) => onUpdateField(index, { layoutWidth: e.target.value })}
onClick={(e) => e.stopPropagation()}
className={editorInputClass}
>
{layoutOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{/* 选项管理提示 - 仅在select或radio类型时显示 */}
{(isSelectType || field.type === 'radio') && (
<div className="mt-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
<div className="flex items-start gap-2">
<svg className="w-4 h-4 text-slate-400 dark:text-slate-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>勾选“添加到分类”后,选项可在分类管理中统一维护。</p>
</div>
</div>
)}
<div className="flex flex-wrap gap-3 mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-700/60">
<label className="flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300 cursor-pointer">
<input
type="checkbox"
checked={field.required ?? false}
onChange={(e) => onUpdateField(index, { required: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className={editorCheckboxClass}
/>
<span>必填</span>
</label>
<label className="flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300 cursor-pointer">
<input
type="checkbox"
checked={field.isSearchable ?? false}
onChange={(e) => onUpdateField(index, { isSearchable: e.target.checked })}
onClick={(e) => e.stopPropagation()}
className={editorCheckboxClass}
/>
<span>可搜索</span>
</label>
{/* 添加到分类勾选框 - 仅在下拉选择类型时显示 */}
{isSelectType ? (
<label
className={cn(
'flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300',
isLockedCategoryField ? 'cursor-not-allowed opacity-70' : 'cursor-pointer',
)}
title={isLockedCategoryField ? '已全局启用,统一由分类管理控制' : undefined}
>
<input
type="checkbox"
checked={isLockedCategoryField ? true : Boolean(field.addToCategory)}
disabled={isLockedCategoryField}
onChange={(e) => {
onUpdateField(index, { addToCategory: e.target.checked })
}}
onClick={(e) => e.stopPropagation()}
className={editorCheckboxClass}
/>
<span>
添加到分类
{isLockedCategoryField && (
<span className="ml-1 text-[10px] text-slate-400">(全局同步)</span>
)}
</span>
</label>
) : null}
</div>
{/* 验证规则配置 */}
<FieldValidationConfig
fieldType={field.type}
validations={field.validations || ''}
onChange={(validations) => onUpdateField(index, { validations })}
/>
{/* 条件显示配置 */}
<FieldConditionsConfig
currentFieldKey={field.key}
conditions={field.conditions || ''}
availableFields={fields.map(f => ({ key: f.key, label: f.label || f.key }))}
onChange={(conditions) => onUpdateField(index, { conditions })}
/>
</div>
{placeholderAfter && renderDropPlaceholder(`editor-after-${index}`)}
</Fragment>
)
})}
<button
onClick={onAddField}
className="w-full rounded-lg border border-dashed border-slate-300 px-3 py-3 text-left transition-colors hover:border-slate-400 hover:bg-slate-50 dark:border-slate-600 dark:hover:border-slate-500 dark:hover:bg-slate-800/50 group"
>
<div className="flex items-center justify-center gap-2">
<div className="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center flex-shrink-0 group-hover:bg-slate-200 dark:group-hover:bg-slate-600 transition-colors">
<svg className="w-4 h-4 text-slate-500 group-hover:text-slate-700 dark:group-hover:text-slate-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>
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 group-hover:text-slate-900 dark:group-hover:text-slate-200 transition-colors">
添加字段
</span>
</div>
</button>
</div>
{/* 删除确认对话框 */}
{deleteConfirmIndex !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 max-w-md w-full mx-4">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-900/40 flex items-center justify-center">
<svg className="w-5 h-5 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
确认删除字段
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">
确定要删除字段「{fields[deleteConfirmIndex]?.label || fields[deleteConfirmIndex]?.key || '未命名字段'}」吗?
</p>
<p className="text-xs text-slate-500 dark:text-slate-500">
此操作不可撤销,已使用此模板的素材将无法显示该字段的数据。
</p>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 bg-slate-50 dark:bg-slate-900/50 rounded-b-lg border-t border-slate-200 dark:border-slate-700">
<button
onClick={cancelDelete}
className="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
取消
</button>
<button
onClick={confirmDelete}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 rounded-lg transition-colors"
>
确认删除
</button>
</div>
</div>
</div>
)}
</div>
)
}