Files
tick-stock-panel/frontend/src/components/ListColumnCustomizer.tsx
T
shy3130 358c04e916 feat: 个股日K信息条支持自定义指标(扩展数据/财务/单独显示)
- 信息条(StockInfoBar)行末加配置齿轮,复用自选列表抽屉自定义显示项
- 新增指标: 成交量/振幅/开高低 + 扩展数据(ext) + 财务(EPS/BPS/ROE/PE/PB等)
- 后端 klineDaily 新增 ext_columns 参数,按 symbol JOIN 扩展数据
- 财务数据前端直拉 useFinancialMetrics, PE/PB 用现价现算
- 每列可设单独显示(standalone),独占一行渲染
- ext 标签支持 maxTags 截断 + 点击 +N 同页展开/收起
- 修复重开弹窗信息条消失(symbol 重置与子组件 onDataChange 竞态)
- 修复 ext 勾选不显示(query key 未含 extColumns 导致缓存命中)
- 配置按钮左侧加监控通知图标占位(BellRing)
- ListColumnCustomizer 新增 showExtColumns/showStandaloneToggle prop
2026-06-20 14:42:54 +08:00

758 lines
32 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.
/**
* 通用列表列自定义组件。
*
* 布局:
* - 上半区「已启用」:已启用的列,@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 (
<>
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}}
className={`flex items-center gap-2 px-2 py-1.5 rounded group ${
isDragging ? 'bg-elevated shadow-lg' : 'hover:bg-elevated/50'
}`}
>
<span
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors shrink-0"
>
<GripVertical className="h-3.5 w-3.5" />
</span>
<span className="flex-1 text-xs text-foreground truncate">
{isExt && col.source.type === 'ext'
? `${col.label}${extTableLabel}`
: col.label}
</span>
{showStandaloneToggle && (
<button
onClick={() => onToggleStandalone?.(col.id)}
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] transition-colors shrink-0 ${
col.standalone
? 'text-accent bg-accent/10'
: 'text-muted hover:text-secondary opacity-0 group-hover:opacity-100'
}`}
title={col.standalone ? '取消单独显示' : '单独一行显示'}
>
{col.standalone ? '单独' : '单行'}
</button>
)}
{hasConfig && (
<button
onClick={() => onConfig(configOpen ? null : col.id)}
className={`transition-all ${configOpen ? 'text-accent' : 'opacity-0 group-hover:opacity-100 text-muted hover:text-accent'}`}
title="配置"
>
<Settings2 className="h-3 w-3" />
</button>
)}
<button
onClick={() => onRemove(col.id)}
className="opacity-0 group-hover:opacity-100 text-muted hover:text-danger transition-all shrink-0"
title="隐藏"
>
<EyeOff className="h-3 w-3" />
</button>
</div>
{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<Set<string>>(new Set())
const [configOpenId, setConfigOpenId] = useState<string | null>(null)
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set())
const colById = useMemo(() => new Map(columns.map(c => [c.id, c])), [columns])
const keyToId = useMemo(() => {
const m = new Map<string, string>()
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<ExtColumnDisplayConfig>) => {
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<CandleColumnConfig>) => {
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) => (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="pl-10 pr-3 py-2 space-y-2 border-l-2 border-accent/20 ml-[18px]">
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">显示模式</span>
<select
value={col.extDisplay?.displayMode ?? 'tag'}
onChange={e => updateExtDisplay(col.id, { displayMode: e.target.value as 'tag' | 'text' })}
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 focus:outline-none focus:border-accent/50"
>
<option value="tag">标签</option>
<option value="text">纯文本</option>
</select>
</label>
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">分隔符</span>
<input
type="text"
value={col.extDisplay?.separator ?? ''}
onChange={e => updateExtDisplay(col.id, { separator: e.target.value })}
placeholder="默认:、,;-"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
)}
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">最大列宽</span>
<input
type="text"
value={col.extDisplay?.maxWidth ?? ''}
onChange={e => updateExtDisplay(col.id, { maxWidth: e.target.value })}
placeholder="如 200px"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">显示前N个</span>
<input
type="number" min={0}
value={col.extDisplay?.maxTags ?? ''}
onChange={e => {
const v = e.target.value ? Number(e.target.value) : undefined
updateExtDisplay(col.id, { maxTags: v, ...(v ? {} : { hiddenIndices: undefined }) })
}}
placeholder="0=全部"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
)}
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (col.extDisplay?.maxTags ?? 0) > 0 && (
<div className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">显示位置</span>
<div className="flex flex-wrap gap-1">
{Array.from({ length: col.extDisplay!.maxTags! }, (_, i) => {
const hidden = col.extDisplay?.hiddenIndices?.includes(i)
return (
<button
key={i}
onClick={() => {
const cur = col.extDisplay?.hiddenIndices ?? []
const next = hidden ? cur.filter(x => x !== i) : [...cur, i]
updateExtDisplay(col.id, { hiddenIndices: next.length ? next : undefined })
}}
className={`w-6 h-6 rounded text-[10px] font-medium transition-colors ${
hidden ? 'bg-elevated text-muted line-through' : 'bg-accent/15 text-accent'
}`}
>
{i + 1}
</button>
)
})}
</div>
</div>
)}
{(col.extDisplay?.displayMode ?? 'tag') === 'tag' && (
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">排列方向</span>
<div className="flex rounded overflow-hidden border border-border">
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'horizontal' })}
className={`px-3 py-1 text-xs transition-colors ${
(col.extDisplay?.tagLayout ?? 'horizontal') === 'horizontal'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
>横向</button>
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'vertical' })}
className={`px-3 py-1 text-xs transition-colors border-l border-border ${
col.extDisplay?.tagLayout === 'vertical'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
>竖向</button>
</div>
</label>
)}
{col.extDisplay && (
<div className="flex justify-end pt-1">
<button onClick={() => resetExtDisplay(col.id)} className="text-[10px] text-muted hover:text-foreground transition-colors">
恢复默认
</button>
</div>
)}
</div>
</motion.div>
)
// 策略列配置(精简版:仅显示数量/位置/排列方向,复用 extDisplay 存储)
const renderStrategiesConfig = (col: ColumnConfig) => (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="pl-10 pr-3 py-2 space-y-2 border-l-2 border-accent/20 ml-[18px]">
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">显示前N个</span>
<input
type="number" min={0}
value={col.extDisplay?.maxTags ?? ''}
onChange={e => {
const v = e.target.value ? Number(e.target.value) : undefined
updateExtDisplay(col.id, { maxTags: v, ...(v ? {} : { hiddenIndices: undefined }) })
}}
placeholder="0=全部"
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
{(col.extDisplay?.maxTags ?? 0) > 0 && (
<div className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">显示位置</span>
<div className="flex flex-wrap gap-1">
{Array.from({ length: col.extDisplay!.maxTags! }, (_, i) => {
const hidden = col.extDisplay?.hiddenIndices?.includes(i)
return (
<button
key={i}
onClick={() => {
const cur = col.extDisplay?.hiddenIndices ?? []
const next = hidden ? cur.filter(x => x !== i) : [...cur, i]
updateExtDisplay(col.id, { hiddenIndices: next.length ? next : undefined })
}}
className={`w-6 h-6 rounded text-[10px] font-medium transition-colors ${
hidden ? 'bg-elevated text-muted line-through' : 'bg-accent/15 text-accent'
}`}
>
{i + 1}
</button>
)
})}
</div>
</div>
)}
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">排列方向</span>
<div className="flex rounded overflow-hidden border border-border">
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'horizontal' })}
className={`px-3 py-1 text-xs transition-colors ${
(col.extDisplay?.tagLayout ?? 'horizontal') === 'horizontal'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
>横向</button>
<button
onClick={() => updateExtDisplay(col.id, { tagLayout: 'vertical' })}
className={`px-3 py-1 text-xs transition-colors border-l border-border ${
col.extDisplay?.tagLayout === 'vertical'
? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary hover:text-foreground'
}`}
>竖向</button>
</div>
</label>
{col.extDisplay && (
<div className="flex justify-end pt-1">
<button onClick={() => resetExtDisplay(col.id)} className="text-[10px] text-muted hover:text-foreground transition-colors">
恢复默认
</button>
</div>
)}
</div>
</motion.div>
)
const renderCandleConfig = (col: ColumnConfig) => {
const cfg = resolveCandleConfig(col.candleConfig)
// 数值输入:空字符串回退到默认值,便于清空重输
const numInput = (
field: keyof CandleColumnConfig,
label: string,
) => (
<label key={field} className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0">{label}</span>
<input
type="number"
value={cfg[field]}
onChange={e => {
const raw = e.target.value
// 调用 resolveCandleConfig 钳制:过大取上限、过小取最小值
const merged = resolveCandleConfig({ ...col.candleConfig, [field]: raw === '' ? undefined : Number(raw) })
updateCandleConfig(col.id, { [field]: merged[field] })
}}
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 focus:outline-none focus:border-accent/50 tabular-nums"
/>
</label>
)
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="pl-10 pr-3 py-2 space-y-2 border-l-2 border-accent/20 ml-[18px]">
{numInput('days', '日k天数')}
{numInput('enabledWidth', '开启宽度')}
{numInput('enabledHeight', '开启高度')}
{numInput('disabledWidth', '收起宽度')}
{numInput('disabledHeight', '收起高度')}
<div className="text-[10px] text-muted leading-relaxed pt-0.5">
宽度 40300 / 高度 32200 / 天数 160,越界自动钳制到边界
</div>
{col.candleConfig && (
<div className="flex justify-end pt-1">
<button onClick={() => resetCandleConfig(col.id)} className="text-[10px] text-muted hover:text-foreground transition-colors">
恢复默认
</button>
</div>
)}
</div>
</motion.div>
)
}
const renderCheckbox = (checked: boolean) => (
<span className={`shrink-0 w-4 h-4 rounded flex items-center justify-center transition-colors ${
checked ? 'bg-accent text-white' : 'border border-border text-transparent group-hover:border-muted'
}`}>
<svg viewBox="0 0 16 16" className="w-2.5 h-2.5" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8.5L6.5 12L13 4" />
</svg>
</span>
)
const renderBuiltinRow = (col: ColumnConfig) => (
<div
key={col.id}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-elevated/50 text-left group transition-colors"
>
<button onClick={() => toggleVisible(col.id)} className="flex items-center gap-2 flex-1 min-w-0">
{renderCheckbox(col.visible)}
<span className={`flex-1 text-xs truncate ${col.visible ? 'text-foreground' : 'text-muted'}`}>{col.label}</span>
</button>
{showStandaloneToggle && col.visible && (
<button
onClick={() => toggleStandalone(col.id)}
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] transition-colors shrink-0 ${
col.standalone
? 'text-accent bg-accent/10'
: 'text-muted hover:text-secondary'
}`}
title={col.standalone ? '取消单独显示' : '单独一行显示'}
>
{col.standalone ? '单独' : '单行'}
</button>
)}
</div>
)
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 (
<button
key={field.name}
onClick={() => addExtColumn(configId, field.name, field.label)}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-elevated/50 text-left group transition-colors"
>
{renderCheckbox(checked)}
<span className={`flex-1 text-xs truncate ${checked ? 'text-foreground' : 'text-muted'}`}>{field.label || field.name}</span>
<span className="text-[10px] text-muted shrink-0">{field.type}</span>
</button>
)
}
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex justify-end">
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<motion.div
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[360px] max-w-[90vw] h-full bg-base border-l border-border shadow-2xl flex flex-col"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-elevated text-muted hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-3 pt-3 pb-1">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted" />
<input
type="text"
value={searchQuery}
onChange={e => 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"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{activeCols.length > 0 && (
<div>
<div className="flex items-center gap-1.5 px-1 py-1.5">
<Eye className="h-3 w-3 text-accent/70" />
<span className="text-[10px] font-semibold text-accent/80 uppercase tracking-wider">已启用</span>
<span className="text-[10px] text-muted">{activeCols.length} · 拖拽排序</span>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={activeCols.map(c => c.id)} strategy={verticalListSortingStrategy}>
{activeCols.map(col => (
<SortableActiveCol
key={col.id}
col={col}
onRemove={hideColumn}
onConfig={setConfigOpenId}
configOpen={configOpenId === col.id}
extTableLabel={col.source.type === 'ext' ? (extTableLabelMap.get(col.source.configId) || col.source.configId) : ''}
extConfig={renderExtConfig(col)}
candleConfig={renderCandleConfig(col)}
strategiesConfig={renderStrategiesConfig(col)}
showStandaloneToggle={showStandaloneToggle}
onToggleStandalone={toggleStandalone}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
<div className="border-t border-border my-1" />
<div>
<div className="flex items-center gap-1.5 px-1 py-1.5">
<Plus className="h-3 w-3 text-muted" />
<span className="text-[10px] font-semibold text-muted uppercase tracking-wider">{builtinSectionLabel}</span>
</div>
{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 (
<div key={group.id}>
<button
onClick={() => toggleGroup(group.id)}
className="flex items-center gap-1.5 w-full px-1 py-1.5 rounded hover:bg-elevated/30 text-left transition-colors"
>
{isExpanded ? <ChevronDown className="h-3 w-3 text-muted" /> : <ChevronRight className="h-3 w-3 text-muted" />}
{group.icon && <span className="text-[11px]">{group.icon}</span>}
<span className="text-[11px] font-medium text-secondary">{group.label}</span>
<span className="text-[10px] text-muted ml-auto">{visCount}/{groupCols.length}</span>
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.12 }}
className="overflow-hidden"
>
<div className="space-y-0.5 pb-0.5">
{groupCols.map(col => renderBuiltinRow(col))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
{showExtColumns && extTables.length > 0 && (
<div className="pt-1 border-t border-border mt-1">
<div className="flex items-center gap-1.5 px-1 py-1.5">
<Database className="h-3 w-3 text-accent/70" />
<span className="text-[10px] font-semibold text-accent/80 uppercase tracking-wider">扩展数据列</span>
</div>
<div className="space-y-0.5">
{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 (
<div key={table.id}>
<button
onClick={() => toggleTableExpand(table.id)}
className="flex items-center gap-1.5 w-full px-1 py-1.5 rounded hover:bg-elevated/30 text-left transition-colors"
>
{isExpanded ? <ChevronDown className="h-3 w-3 text-muted" /> : <ChevronRight className="h-3 w-3 text-muted" />}
<Database className="h-3 w-3 text-accent shrink-0" />
<span className="text-[11px] font-medium text-secondary flex-1">{table.label}</span>
<span className="text-[10px] text-muted">{table.mode === 'snapshot' ? '快照' : '时序'}</span>
{activeCount > 0 && (
<span className="text-[10px] text-accent font-medium">{activeCount}</span>
)}
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="space-y-0.5 pb-0.5">
{dataFields.map(field => renderExtFieldRow(table.id, field))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
</div>
)}
{showExtColumns && extTables.length === 0 && extSchema.isSuccess && (
<div className="text-xs text-muted text-center py-4">
暂无扩展数据表,可在「数据」页面创建
</div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}