Some checks failed
CI Check / check (push) Has been cancelled
- 创建统一UI三层架构系统(基础层、颜色层、组合层) - 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等) - 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等) - 提取工具函数(validators、formatters、api等) - 创建统一弹窗组件(Modal)和useModal Hook - 添加错误边界和统一状态组件 - 完善类型定义和可访问性支持 - 更新文档(优化记录、优化计划、更新日志)
389 lines
20 KiB
TypeScript
389 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[]
|
||
fieldTypes: string[]
|
||
layoutOptions: LayoutOption[]
|
||
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,
|
||
fieldTypes,
|
||
layoutOptions,
|
||
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'
|
||
|
||
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-grab'
|
||
}`}
|
||
draggable
|
||
onMouseEnter={() => onEditingFieldChange(index)}
|
||
onMouseLeave={() => onEditingFieldChange(null)}
|
||
onDragStart={(event) => onFieldDragStart(event, index)}
|
||
onDragOver={(event) => onFieldDragOver(event, index, 'editor')}
|
||
onDrop={onFieldDrop}
|
||
onDragEnd={onFieldDragEnd}
|
||
>
|
||
<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
|
||
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>
|
||
|
||
{field.type === 'select' && (
|
||
<div className="mt-3">
|
||
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
下拉选项 <span className="text-slate-500 font-normal">(必填)</span>
|
||
</label>
|
||
<textarea
|
||
rows={6}
|
||
value={field.optionsStatic || ''}
|
||
onChange={(e) => onUpdateField(index, { optionsStatic: 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 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-slate-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-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}
|
||
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.visibleOnHomeFilter}
|
||
onChange={(e) => onUpdateField(index, { visibleOnHomeFilter: 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}
|
||
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>
|
||
</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>
|
||
)
|
||
}
|
||
|
||
|