/** * ListFieldEditor - 动态数组字段编辑器 * * 支持功能: * - 字符串数组 (string[]) * - 数字数组 (number[]) * - 对象数组 (object[]) - 根据 item_fields 定义渲染 * - 拖拽排序 * - 动态增删项 */ import { useState, useCallback, useMemo } from 'react' import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core' import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Card } from '@/components/ui/card' import { Switch } from '@/components/ui/switch' import { Slider } from '@/components/ui/slider' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { GripVertical, Plus, Trash2, AlertCircle } from 'lucide-react' import { cn } from '@/lib/utils' // ============ 类型定义 ============ export interface ItemFieldDefinition { /** 字段类型: "string" | "number" | "boolean" | "select" */ type: string label?: string placeholder?: string default?: unknown /** select 类型的选项 */ choices?: unknown[] /** slider 类型的最小值 */ min?: number /** slider 类型的最大值 */ max?: number /** slider 类型的步进 */ step?: number } export interface ListFieldEditorProps { /** 当前值 */ value: unknown[] | unknown /** 值变化回调 */ onChange: (value: unknown[]) => void /** 数组元素类型: "string" | "number" | "object" */ itemType?: string /** 当 itemType="object" 时的字段定义 */ itemFields?: Record /** 最小元素数量 */ minItems?: number /** 最大元素数量 */ maxItems?: number /** 是否禁用 */ disabled?: boolean /** 新项的占位符文字 */ placeholder?: string } // ============ 可排序项组件 ============ interface SortableItemProps { id: string index: number itemType: string itemFields?: Record value: unknown onChange: (value: unknown) => void onRemove: () => void disabled?: boolean canRemove: boolean placeholder?: string } function SortableItem({ id, index, itemType, itemFields, value, onChange, onRemove, disabled, canRemove, placeholder, }: SortableItemProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id, disabled }) const style = { transform: CSS.Transform.toString(transform), transition, } return (
{/* 拖拽手柄 */} {/* 内容区域 */}
{itemType === 'object' && itemFields ? ( } onChange={onChange} fields={itemFields} disabled={disabled} /> ) : itemType === 'number' ? ( onChange(parseFloat(e.target.value) || 0)} placeholder={placeholder ?? `第 ${index + 1} 项`} disabled={disabled} className="font-mono" /> ) : ( onChange(e.target.value)} placeholder={placeholder ?? `第 ${index + 1} 项`} disabled={disabled} /> )}
{/* 删除按钮 */}
) } // ============ 对象项编辑器 ============ interface ObjectItemEditorProps { value: Record onChange: (value: Record) => void fields: Record disabled?: boolean } function ObjectItemEditor({ value, onChange, fields, disabled, }: ObjectItemEditorProps) { const handleFieldChange = useCallback( (fieldName: string, fieldValue: unknown) => { onChange({ ...value, [fieldName]: fieldValue, }) }, [value, onChange] ) const renderField = (fieldName: string, fieldDef: ItemFieldDefinition) => { const fieldValue = value?.[fieldName] // boolean / switch if (fieldDef.type === 'boolean' || fieldDef.type === 'switch') { return (
handleFieldChange(fieldName, checked)} disabled={disabled} />
) } // slider (number with min/max) if (fieldDef.type === 'slider' || (fieldDef.type === 'number' && fieldDef.min != null && fieldDef.max != null)) { const numValue = (fieldValue as number) ?? (fieldDef.default as number) ?? fieldDef.min ?? 0 return (
{numValue}
handleFieldChange(fieldName, v[0])} min={fieldDef.min ?? 0} max={fieldDef.max ?? 100} step={fieldDef.step ?? 1} disabled={disabled} className="py-1" />
) } // select if (fieldDef.type === 'select' && fieldDef.choices) { return (
) } // number if (fieldDef.type === 'number') { return (
handleFieldChange(fieldName, parseFloat(e.target.value) || 0) } placeholder={fieldDef.placeholder} disabled={disabled} className="h-8 text-sm" />
) } // string (default) return (
handleFieldChange(fieldName, e.target.value)} placeholder={fieldDef.placeholder} disabled={disabled} className="h-8 text-sm" />
) } return ( {Object.entries(fields).map(([fieldName, fieldDef]) => (
{renderField(fieldName, fieldDef)}
))}
) } // ============ 主组件 ============ export function ListFieldEditor({ value, onChange, itemType = 'string', itemFields, minItems, maxItems, disabled, placeholder, }: ListFieldEditorProps) { // 确保 value 是数组 const items: unknown[] = useMemo(() => { if (Array.isArray(value)) return value if (typeof value === 'string' && value.trim()) { // 尝试解析逗号分隔的字符串 return value.split(',').map((s: string) => s.trim()) } return [] }, [value]) // 为每个项生成稳定的 ID const [itemIds] = useState(() => new Map()) const getItemId = useCallback( (index: number) => { if (!itemIds.has(index)) { itemIds.set(index, `item-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`) } return itemIds.get(index)! }, [itemIds] ) // 同步 itemIds const sortableIds = useMemo(() => { // 清理多余的 ID const newIds: string[] = [] for (let i = 0; i < items.length; i++) { newIds.push(getItemId(i)) } return newIds }, [items.length, getItemId]) // DnD 传感器配置 const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ) // 拖拽结束处理 const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event if (over && active.id !== over.id) { const oldIndex = sortableIds.indexOf(active.id as string) const newIndex = sortableIds.indexOf(over.id as string) const newItems = arrayMove(items, oldIndex, newIndex) onChange(newItems) } }, [items, sortableIds, onChange] ) // 添加新项 const handleAddItem = useCallback(() => { if (maxItems != null && items.length >= maxItems) return let newItem: unknown if (itemType === 'object' && itemFields) { // 创建包含默认值的对象 newItem = Object.fromEntries( Object.entries(itemFields).map(([k, v]) => [k, v.default ?? '']) ) } else if (itemType === 'number') { newItem = 0 } else { newItem = '' } onChange([...items, newItem]) }, [items, maxItems, itemType, itemFields, onChange]) // 修改项 const handleItemChange = useCallback( (index: number, newValue: unknown) => { const newItems = [...items] newItems[index] = newValue onChange(newItems) }, [items, onChange] ) // 删除项 const handleRemoveItem = useCallback( (index: number) => { if (minItems != null && items.length <= minItems) return const newItems = items.filter((_: unknown, i: number) => i !== index) // 清理 itemIds 映射 itemIds.delete(index) onChange(newItems) }, [items, minItems, itemIds, onChange] ) const canAdd = maxItems == null || items.length < maxItems const canRemove = minItems == null || items.length > minItems return (
{/* 列表项 */} {items.length === 0 ? (
暂无数据,点击下方按钮添加
) : (
{items.map((item: unknown, index: number) => ( handleItemChange(index, newValue)} onRemove={() => handleRemoveItem(index)} disabled={disabled} canRemove={canRemove} placeholder={placeholder} /> ))}
)} {/* 添加按钮 */} {/* 限制提示 */} {(minItems != null || maxItems != null) && (minItems !== null || maxItems !== null) && (

{minItems != null && maxItems != null ? `允许 ${minItems} - ${maxItems} 项` : minItems != null ? `至少 ${minItems} 项` : `最多 ${maxItems} 项`}

)}
) } export default ListFieldEditor