/**
* 通用列表列自定义组件。
*
* 布局:
* - 上半区「已启用」:已启用的列,@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}
{activeCols.length > 0 && (
已启用
{activeCols.length} 列 · 拖拽排序
c.id)} strategy={verticalListSortingStrategy}>
{activeCols.map(col => (
))}
)}
{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 && (
暂无扩展数据表,可在「数据」页面创建
)}
)}
)
}