/** * 通用列表列自定义组件。 * * 布局: * - 上半区「已启用」:已启用的列,@dnd-kit 拖拽排序 * - 下半区「内置列」:按业务分组折叠 * - 底部「扩展数据列」:复用 ext_data schema,按需添加字段 */ import React, { useState, useCallback, useEffect, useMemo } from 'react' import { motion, AnimatePresence } from 'framer-motion' 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 { X, GripVertical, Plus, ChevronDown, ChevronRight, Database, Settings2, Search, Eye, EyeOff } from 'lucide-react' import { api } from '@/lib/api' import { useQuery } from '@tanstack/react-query' import { QK } from '@/lib/queryKeys' import type { ColumnConfig, ColumnGroup, ExtColumnDisplayConfig, CandleColumnConfig } from '@/lib/list-columns' import { resolveCandleConfig } from '@/lib/list-columns' interface ListColumnCustomizerProps { columns: ColumnConfig[] groups: ColumnGroup[] onChange: (columns: ColumnConfig[]) => void open: boolean onClose: () => void title?: string builtinSectionLabel?: string extColumnAlign?: 'left' | 'center' | 'right' extFieldFilter?: (field: { name: string; label: string; type: string }) => boolean /** 是否显示扩展数据列区块(默认 true;信息条等无法渲染 ext 数据的场景设为 false)。 */ showExtColumns?: boolean /** 是否显示「单独显示」勾选项(默认 false;仅信息条场景启用,让某列独占一行)。 */ showStandaloneToggle?: boolean } function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, strategiesConfig, showStandaloneToggle, onToggleStandalone }: { col: ColumnConfig onRemove: (id: string) => void onConfig: (id: string | null) => void configOpen: boolean extTableLabel: string extConfig: React.ReactNode candleConfig: React.ReactNode strategiesConfig: React.ReactNode showStandaloneToggle?: boolean onToggleStandalone?: (id: string) => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: col.id }) const isExt = col.source.type === 'ext' const isCandle = col.source.type === 'builtin' && col.source.key === 'candle' const isStrategies = col.source.type === 'builtin' && col.source.key === 'strategies' const hasConfig = isExt || isCandle || isStrategies return ( <>
{isExt && col.source.type === 'ext' ? `${col.label}(${extTableLabel})` : col.label} {showStandaloneToggle && ( )} {hasConfig && ( )}
{hasConfig && configOpen && (isExt ? extConfig : isCandle ? candlePanel : strategiesConfig)} ) } export function ListColumnCustomizer({ columns, groups, onChange, open, onClose, title = '自定义列', builtinSectionLabel = '内置列', extColumnAlign = 'center', extFieldFilter, showExtColumns = true, showStandaloneToggle = false, }: ListColumnCustomizerProps) { const extSchema = useQuery({ queryKey: QK.extDataSchemaAll, queryFn: api.extDataSchemaAll, enabled: open && showExtColumns, staleTime: 60_000, }) const [searchQuery, setSearchQuery] = useState('') const [expandedGroups, setExpandedGroups] = useState>(new Set()) const [configOpenId, setConfigOpenId] = useState(null) const [expandedTables, setExpandedTables] = useState>(new Set()) const colById = useMemo(() => new Map(columns.map(c => [c.id, c])), [columns]) const keyToId = useMemo(() => { const m = new Map() for (const c of columns) { if (c.source.type === 'builtin' || c.source.type === 'computed') m.set(c.source.key, c.id) } return m }, [columns]) const activeCols = useMemo(() => columns.filter(c => !c.pinned && c.visible), [columns]) const toggleVisible = useCallback((colId: string) => { onChange(columns.map(c => c.id === colId && !c.pinned ? { ...c, visible: !c.visible } : c )) }, [columns, onChange]) const toggleStandalone = useCallback((colId: string) => { onChange(columns.map(c => c.id === colId ? { ...c, standalone: !c.standalone } : c )) }, [columns, onChange]) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return const reorderCols = columns.filter(c => !c.pinned) const pinnedCols = columns.filter(c => c.pinned) const ids = reorderCols.map(c => c.id) const oldIdx = ids.indexOf(active.id as string) const newIdx = ids.indexOf(over.id as string) if (oldIdx < 0 || newIdx < 0) return const reordered = arrayMove(reorderCols, oldIdx, newIdx) onChange([...pinnedCols, ...reordered]) }, [columns, onChange]) const addExtColumn = useCallback((configId: string, fieldName: string, fieldLabel?: string) => { const colId = `ext:${configId}:${fieldName}` if (columns.some(c => c.id === colId)) { toggleVisible(colId) return } const newCol: ColumnConfig = { id: colId, source: { type: 'ext', configId, fieldName, fieldLabel }, label: fieldLabel || fieldName, visible: true, align: extColumnAlign, } const actionIdx = columns.findIndex(c => c.id === 'builtin:action') if (actionIdx >= 0) { const next = [...columns] next.splice(actionIdx, 0, newCol) onChange(next) } else { onChange([...columns, newCol]) } }, [columns, extColumnAlign, onChange, toggleVisible]) const hideColumn = useCallback((colId: string) => { onChange(columns.map(c => c.id === colId ? { ...c, visible: false } : c)) }, [columns, onChange]) const updateExtDisplay = useCallback((colId: string, patch: Partial) => { onChange(columns.map(c => { if (c.id !== colId) return c return { ...c, extDisplay: { displayMode: 'tag', ...(c.extDisplay || {}), ...patch } } })) }, [columns, onChange]) const resetExtDisplay = useCallback((colId: string) => { onChange(columns.map(c => { if (c.id !== colId) return c const { extDisplay, ...rest } = c return rest })) }, [columns, onChange]) const updateCandleConfig = useCallback((colId: string, patch: Partial) => { onChange(columns.map(c => { if (c.id !== colId) return c return { ...c, candleConfig: { ...c.candleConfig, ...patch } } })) }, [columns, onChange]) const resetCandleConfig = useCallback((colId: string) => { onChange(columns.map(c => { if (c.id !== colId) return c const { candleConfig, ...rest } = c return rest })) }, [columns, onChange]) const toggleGroup = useCallback((groupId: string) => { setExpandedGroups(prev => { const next = new Set(prev) if (next.has(groupId)) next.delete(groupId) else next.add(groupId) return next }) }, []) const toggleTableExpand = useCallback((tableId: string) => { setExpandedTables(prev => { const next = new Set(prev) if (next.has(tableId)) next.delete(tableId) else next.add(tableId) return next }) }, []) const extTables = extSchema.data?.items ?? [] const extTableLabelMap = new Map(extTables.map(t => [t.id, t.label])) const query = searchQuery.trim().toLowerCase() const filteredGroups = useMemo(() => { if (!query) return groups return groups.filter(g => g.label.includes(query) || g.keys.some(k => { const id = keyToId.get(k) return id && (colById.get(id)?.label ?? '').toLowerCase().includes(query) }) ) }, [groups, query, keyToId, colById]) useEffect(() => { if (!open) { setConfigOpenId(null); setSearchQuery('') } }, [open]) useEffect(() => { if (query) setExpandedGroups(new Set(filteredGroups.map(g => g.id))) // eslint-disable-next-line react-hooks/exhaustive-deps }, [query]) const renderExtConfig = (col: ColumnConfig) => (
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && ( )} {(col.extDisplay?.displayMode ?? 'tag') === 'tag' && ( )} {(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (col.extDisplay?.maxTags ?? 0) > 0 && (
显示位置
{Array.from({ length: col.extDisplay!.maxTags! }, (_, i) => { const hidden = col.extDisplay?.hiddenIndices?.includes(i) return ( ) })}
)} {(col.extDisplay?.displayMode ?? 'tag') === 'tag' && ( )} {col.extDisplay && (
)}
) // 策略列配置(精简版:仅显示数量/位置/排列方向,复用 extDisplay 存储) const renderStrategiesConfig = (col: ColumnConfig) => (
{(col.extDisplay?.maxTags ?? 0) > 0 && (
显示位置
{Array.from({ length: col.extDisplay!.maxTags! }, (_, i) => { const hidden = col.extDisplay?.hiddenIndices?.includes(i) return ( ) })}
)} {col.extDisplay && (
)}
) const renderCandleConfig = (col: ColumnConfig) => { const cfg = resolveCandleConfig(col.candleConfig) // 数值输入:空字符串回退到默认值,便于清空重输 const numInput = ( field: keyof CandleColumnConfig, label: string, ) => ( ) return (
{numInput('days', '日k天数')} {numInput('enabledWidth', '开启宽度')} {numInput('enabledHeight', '开启高度')} {numInput('disabledWidth', '收起宽度')} {numInput('disabledHeight', '收起高度')}
宽度 40–300 / 高度 32–200 / 天数 1–60,越界自动钳制到边界
{col.candleConfig && (
)}
) } const renderCheckbox = (checked: boolean) => ( ) const renderBuiltinRow = (col: ColumnConfig) => (
{showStandaloneToggle && col.visible && ( )}
) const renderExtFieldRow = (configId: string, field: { name: string; label: string; type: string }) => { const colId = `ext:${configId}:${field.name}` const existing = columns.find(c => c.id === colId) const checked = !!existing?.visible return ( ) } return ( {open && (

{title}

setSearchQuery(e.target.value)} placeholder="搜索列名..." className="w-full h-8 pl-8 pr-3 rounded-lg bg-elevated border border-border text-xs text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 transition-colors" />
{activeCols.length > 0 && (
已启用 {activeCols.length} 列 · 拖拽排序
c.id)} strategy={verticalListSortingStrategy}> {activeCols.map(col => ( ))}
)}
{builtinSectionLabel}
{filteredGroups.map(group => { const isExpanded = expandedGroups.has(group.id) const groupCols = group.keys .map(k => keyToId.get(k)) .filter((id): id is string => !!id) .map(id => colById.get(id)) .filter((c): c is ColumnConfig => !!c) if (groupCols.length === 0) return null const visCount = groupCols.filter(c => c.visible).length return (
{isExpanded && (
{groupCols.map(col => renderBuiltinRow(col))}
)}
) })}
{showExtColumns && extTables.length > 0 && (
扩展数据列
{extTables.map(table => { const isExpanded = expandedTables.has(table.id) const dataFields = table.columns .filter(c => !['symbol', 'code', 'date'].includes(c.name)) .filter(c => extFieldFilter ? extFieldFilter(c) : true) const activeCount = dataFields.filter(f => { const colId = `ext:${table.id}:${f.name}` return columns.some(c => c.id === colId && c.visible) }).length return (
{isExpanded && (
{dataFields.map(field => renderExtFieldRow(table.id, field))}
)}
) })}
)} {showExtColumns && extTables.length === 0 && extSchema.isSuccess && (
暂无扩展数据表,可在「数据」页面创建
)}
)} ) }