Files
mai-bot/dashboard/src/components/ListFieldEditor.tsx
2026-01-13 06:24:35 +08:00

526 lines
14 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.
/**
* 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<string, ItemFieldDefinition>
/** 最小元素数量 */
minItems?: number
/** 最大元素数量 */
maxItems?: number
/** 是否禁用 */
disabled?: boolean
/** 新项的占位符文字 */
placeholder?: string
}
// ============ 可排序项组件 ============
interface SortableItemProps {
id: string
index: number
itemType: string
itemFields?: Record<string, ItemFieldDefinition>
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 (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-start gap-2 group',
isDragging && 'opacity-50 z-50'
)}
>
{/* 拖拽手柄 */}
<button
type="button"
className={cn(
'flex-shrink-0 p-2 cursor-grab active:cursor-grabbing',
'text-muted-foreground hover:text-foreground transition-colors',
'opacity-0 group-hover:opacity-100 focus:opacity-100',
disabled && 'cursor-not-allowed opacity-30'
)}
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
{/* 内容区域 */}
<div className="flex-1 min-w-0">
{itemType === 'object' && itemFields ? (
<ObjectItemEditor
value={value as Record<string, unknown>}
onChange={onChange}
fields={itemFields}
disabled={disabled}
/>
) : itemType === 'number' ? (
<Input
type="number"
value={value as number ?? ''}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
placeholder={placeholder ?? `${index + 1}`}
disabled={disabled}
className="font-mono"
/>
) : (
<Input
type="text"
value={value as string ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? `${index + 1}`}
disabled={disabled}
/>
)}
</div>
{/* 删除按钮 */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
disabled={disabled || !canRemove}
className={cn(
'flex-shrink-0 text-muted-foreground hover:text-destructive',
'opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity'
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
}
// ============ 对象项编辑器 ============
interface ObjectItemEditorProps {
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
fields: Record<string, ItemFieldDefinition>
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 (
<div className="flex items-center justify-between py-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Switch
checked={Boolean(fieldValue ?? fieldDef.default)}
onCheckedChange={(checked) => handleFieldChange(fieldName, checked)}
disabled={disabled}
/>
</div>
)
}
// 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 (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<span className="text-xs text-muted-foreground">{numValue}</span>
</div>
<Slider
value={[numValue]}
onValueChange={(v) => handleFieldChange(fieldName, v[0])}
min={fieldDef.min ?? 0}
max={fieldDef.max ?? 100}
step={fieldDef.step ?? 1}
disabled={disabled}
className="py-1"
/>
</div>
)
}
// select
if (fieldDef.type === 'select' && fieldDef.choices) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Select
value={String(fieldValue ?? fieldDef.default ?? '')}
onValueChange={(v) => handleFieldChange(fieldName, v)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder={fieldDef.placeholder ?? '请选择'} />
</SelectTrigger>
<SelectContent>
{fieldDef.choices.map((choice) => (
<SelectItem key={String(choice)} value={String(choice)}>
{String(choice)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
// number
if (fieldDef.type === 'number') {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Input
type="number"
value={(fieldValue as number) ?? fieldDef.default ?? ''}
onChange={(e) =>
handleFieldChange(fieldName, parseFloat(e.target.value) || 0)
}
placeholder={fieldDef.placeholder}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
)
}
// string (default)
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Input
type="text"
value={(fieldValue as string) ?? fieldDef.default ?? ''}
onChange={(e) => handleFieldChange(fieldName, e.target.value)}
placeholder={fieldDef.placeholder}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
)
}
return (
<Card className="p-3 space-y-2 bg-muted/30">
{Object.entries(fields).map(([fieldName, fieldDef]) => (
<div key={fieldName}>
{renderField(fieldName, fieldDef)}
</div>
))}
</Card>
)
}
// ============ 主组件 ============
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<number, string>())
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 (
<div className="space-y-2">
{/* 列表项 */}
{items.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center border border-dashed rounded-md">
<AlertCircle className="h-4 w-4" />
<span></span>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortableIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{items.map((item: unknown, index: number) => (
<SortableItem
key={sortableIds[index]}
id={sortableIds[index]}
index={index}
itemType={itemType}
itemFields={itemFields}
value={item}
onChange={(newValue) => handleItemChange(index, newValue)}
onRemove={() => handleRemoveItem(index)}
disabled={disabled}
canRemove={canRemove}
placeholder={placeholder}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* 添加按钮 */}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddItem}
disabled={disabled || !canAdd}
className="w-full"
>
<Plus className="h-4 w-4 mr-1" />
{maxItems !== undefined && (
<span className="ml-2 text-xs text-muted-foreground">
({items.length}/{maxItems})
</span>
)}
</Button>
{/* 限制提示 */}
{(minItems != null || maxItems != null) && (minItems !== null || maxItems !== null) && (
<p className="text-xs text-muted-foreground text-center">
{minItems != null && maxItems != null
? `允许 ${minItems} - ${maxItems}`
: minItems != null
? `至少 ${minItems}`
: `最多 ${maxItems}`}
</p>
)}
</div>
)
}
export default ListFieldEditor