mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
- 信息条(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
758 lines
32 KiB
TypeScript
758 lines
32 KiB
TypeScript
/**
|
||
* 通用列表列自定义组件。
|
||
*
|
||
* 布局:
|
||
* - 上半区「已启用」:已启用的列,@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">
|
||
宽度 40–300 / 高度 32–200 / 天数 1–60,越界自动钳制到边界
|
||
</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>
|
||
)
|
||
}
|