411 lines
20 KiB
TypeScript
411 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import { Fragment, DragEvent } from 'react'
|
||
import { cardStyles, inputStyles, inputModifiers, cn } from '@/lib/ui-styles'
|
||
import type { FieldForm, DragHoverState } from '../../template-manager/types'
|
||
|
||
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
|
||
onAddField: () => void
|
||
renderDropPlaceholder: (key: string) => React.ReactNode
|
||
onUpdateField: (index: number, patch: Partial<FieldForm>) => void
|
||
}
|
||
|
||
export default function TemplateFieldsEditor({
|
||
fields,
|
||
dragFieldIndex,
|
||
dragHover,
|
||
editingFieldIndex,
|
||
standardFields,
|
||
getStandardFieldTemplate,
|
||
fieldTypes,
|
||
layoutOptions,
|
||
lockedCategoryFields,
|
||
onEditingFieldChange,
|
||
onFieldDragStart,
|
||
onFieldDragOver,
|
||
onFieldDrop,
|
||
onFieldDragEnd,
|
||
onContainerDragOver,
|
||
onContainerDrop,
|
||
onContainerDragLeave,
|
||
onMoveField,
|
||
onRemoveField,
|
||
onAddField,
|
||
renderDropPlaceholder,
|
||
onUpdateField,
|
||
}: TemplateFieldsEditorProps) {
|
||
return (
|
||
<div className={cn(cardStyles.base, 'p-4 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))
|
||
|
||
return (
|
||
<Fragment key={`editor-field-${index}`}>
|
||
{placeholderBefore && renderDropPlaceholder(`editor-before-${index}`)}
|
||
<div
|
||
id={`editor-field-${index}`}
|
||
className={`group border rounded-xl p-4 transition-all duration-200 ease-out ${
|
||
editingFieldIndex === index
|
||
? 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 shadow-md'
|
||
: '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 hover:shadow-sm'
|
||
} ${
|
||
isDragging
|
||
? 'opacity-40 scale-[0.96] cursor-grabbing border-blue-500 dark:border-blue-400 shadow-lg shadow-blue-200/50 dark:shadow-blue-900/50 transition-all duration-200'
|
||
: 'cursor-default'
|
||
}`}
|
||
draggable={false}
|
||
onMouseEnter={() => onEditingFieldChange(index)}
|
||
onMouseLeave={() => onEditingFieldChange(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-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>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<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-200 dark:hover:bg-slate-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()
|
||
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-200 dark:hover:bg-slate-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()
|
||
onRemoveField(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-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-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={cn(inputStyles.base, inputModifiers.compact)}
|
||
>
|
||
<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="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-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"
|
||
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="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
|
||
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="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
|
||
>
|
||
{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="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
|
||
>
|
||
{layoutOptions.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</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="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-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-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="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<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="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<span>
|
||
添加到分类
|
||
{isLockedCategoryField && (
|
||
<span className="ml-1 text-[10px] text-slate-400">(全局同步)</span>
|
||
)}
|
||
</span>
|
||
</label>
|
||
) : (
|
||
// 调试信息:显示为什么不显示复选框
|
||
editingFieldIndex === index && field.type === 'select' && (
|
||
<div className="text-[10px] text-red-500 dark:text-red-400 font-mono">
|
||
调试: type="{field.type}", isSelectType={String(isSelectType)}
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
{placeholderAfter && renderDropPlaceholder(`editor-after-${index}`)}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
|
||
<button
|
||
onClick={onAddField}
|
||
className="w-full border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 transition-all duration-200 hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800/50 group"
|
||
>
|
||
<div className="flex items-center justify-center gap-3">
|
||
<div className="w-14 h-14 rounded-full 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-7 h-7 text-slate-400 group-hover:text-slate-600 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>
|
||
<div className="text-left">
|
||
<p 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">
|
||
添加元素
|
||
</p>
|
||
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">点击添加新的字段配置</p>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|