WuYongJie e4dcb6260a
Some checks failed
CI Check / check (push) Has been cancelled
feat: Admin页面重构 & UI三层架构系统
- 创建统一UI三层架构系统(基础层、颜色层、组合层)
- 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等)
- 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等)
- 提取工具函数(validators、formatters、api等)
- 创建统一弹窗组件(Modal)和useModal Hook
- 添加错误边界和统一状态组件
- 完善类型定义和可访问性支持
- 更新文档(优化记录、优化计划、更新日志)
2025-11-22 15:17:10 +08:00

389 lines
20 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 } 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="直接输入选项,每行一个,例如:&#10;3DMAX&#10;PSD&#10;AI&#10;CAD&#10;C4D"
/>
<div className="mt-2 p-2.5 bg-blue-50/50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-[10px] font-semibold text-blue-700 dark:text-blue-300 mb-1.5">填写说明:</p>
<div className="text-[10px] text-blue-600 dark:text-blue-400">
<p className="mb-1.5">每行输入一个选项,例如:</p>
<div className="ml-3 font-mono bg-white dark:bg-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>
)
}