feat(screener): 策略列标签折叠 — 表头全表开关 + 行级展开

- 默认收起: 每行显示前 N 个策略 + 计数 (跟随列设置"显示前N个", 未配置默认 3)
- 表头图标切换全表展开/收起, 收起时清除行级展开状态, 状态持久化
- 排列方向始终跟随列设置 tagLayout (展开不再强制横向)
This commit is contained in:
shy3130
2026-08-31 22:30:45 +08:00
parent 2fc7d3f439
commit bdad0fefb5
3 changed files with 65 additions and 5 deletions
@@ -6,7 +6,7 @@
* score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。 * score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。
*/ */
import { useState, type CSSProperties, type ReactNode } from 'react' import { useState, type CSSProperties, type ReactNode } from 'react'
import { Check, Plus, Eye, EyeOff, RefreshCw } from 'lucide-react' import { Check, Plus, Eye, EyeOff, RefreshCw, ListCollapse, ListTree } from 'lucide-react'
import type { KlineRow, MinuteKlineRow } from '@/lib/api' import type { KlineRow, MinuteKlineRow } from '@/lib/api'
import { fmtPrice, formatExtNumber } from '@/lib/format' import { fmtPrice, formatExtNumber } from '@/lib/format'
import type { ColumnConfig } from '@/lib/screener-columns' import type { ColumnConfig } from '@/lib/screener-columns'
@@ -50,12 +50,17 @@ interface ScreenerTableProps {
onRefreshIntraday?: () => void onRefreshIntraday?: () => void
/** 分时数据正在刷新中 (按钮 loading 态) */ /** 分时数据正在刷新中 (按钮 loading 态) */
intradayRefreshing?: boolean intradayRefreshing?: boolean
/** 策略列标签全表展开状态 (false=默认收起: 每行仅显示首个策略+计数) */
strategyTagsExpanded?: boolean
/** 表头策略列图标: 切换全表展开/收起 */
onToggleStrategyTags?: () => void
/** 表头排序(受控,由 Screener.tsx 传入) */ /** 表头排序(受控,由 Screener.tsx 传入) */
sort?: SortState | null sort?: SortState | null
onSortToggle?: (colId: string) => void onSortToggle?: (colId: string) => void
} }
/** 渲染标签数组(含 maxTags 折叠/展开、横竖排列)。策略列与 ext 列共用。 */ /** 渲染标签数组(含 maxTags 折叠/展开、横竖排列)。策略列与 ext 列共用。
* maxTagsOverride: 调用方直接指定折叠上限 (策略列用: 全局展开=0 不折叠, 收起=1 只显首个)。 */
function renderTagList( function renderTagList(
tags: string[], tags: string[],
col: ColumnConfig, col: ColumnConfig,
@@ -63,11 +68,12 @@ function renderTagList(
onToggle: () => void, onToggle: () => void,
tagClassName: string, tagClassName: string,
onTagClick?: (tag: string) => void, onTagClick?: (tag: string) => void,
maxTagsOverride?: number,
): ReactNode { ): ReactNode {
if (tags.length === 0) return <span className="text-muted"></span> if (tags.length === 0) return <span className="text-muted"></span>
const cfg = col.extDisplay const cfg = col.extDisplay
const maxTags = cfg?.maxTags ?? 0 const maxTags = maxTagsOverride ?? cfg?.maxTags ?? 0
const showAll = maxTags <= 0 || expanded || tags.length <= maxTags const showAll = maxTags <= 0 || expanded || tags.length <= maxTags
const sliced = showAll ? tags : tags.slice(0, maxTags) const sliced = showAll ? tags : tags.slice(0, maxTags)
const hiddenIndices = maxTags > 0 ? cfg?.hiddenIndices : undefined const hiddenIndices = maxTags > 0 ? cfg?.hiddenIndices : undefined
@@ -75,7 +81,8 @@ function renderTagList(
? sliced.filter((_, i) => !hiddenIndices.includes(i)) ? sliced.filter((_, i) => !hiddenIndices.includes(i))
: sliced : sliced
const hiddenCount = tags.length - visibleTags.length const hiddenCount = tags.length - visibleTags.length
const isVertical = cfg?.tagLayout === 'vertical' && !expanded // 排列方向始终跟随列设置: 竖向时收起/展开都竖排, 展开不再强制横向
const isVertical = cfg?.tagLayout === 'vertical'
return ( return (
<div className={isVertical ? 'flex flex-col items-start gap-0.5' : 'flex flex-wrap gap-0.5'}> <div className={isVertical ? 'flex flex-col items-start gap-0.5' : 'flex flex-wrap gap-0.5'}>
@@ -153,6 +160,7 @@ export function ScreenerTable({
dailyKChartVisible = true, onToggleDailyKChart, dailyKChartVisible = true, onToggleDailyKChart,
minuteData = {}, intradayChartVisible = true, onToggleIntradayChart, minuteData = {}, intradayChartVisible = true, onToggleIntradayChart,
intradayAutoRefresh = false, onRefreshIntraday, intradayRefreshing = false, intradayAutoRefresh = false, onRefreshIntraday, intradayRefreshing = false,
strategyTagsExpanded = false, onToggleStrategyTags,
sort, onSortToggle, sort, onSortToggle,
}: ScreenerTableProps) { }: ScreenerTableProps) {
const [expandedCells, setExpandedCells] = useState<Set<string>>(new Set()) const [expandedCells, setExpandedCells] = useState<Set<string>>(new Set())
@@ -181,6 +189,19 @@ export function ScreenerTable({
}) })
} }
// 策略列全表切换: 收起时清除该列的行级展开状态, 保证"收起"立即对全表生效
const strategiesColId = columns.find(c => c.source.type === 'builtin' && c.source.key === 'strategies')?.id
const handleToggleStrategyTags = () => {
if (strategyTagsExpanded && strategiesColId) {
const suffix = `::${strategiesColId}`
setExpandedCells(prev => {
const next = new Set([...prev].filter(k => !k.endsWith(suffix)))
return next.size === prev.size ? prev : next
})
}
onToggleStrategyTags?.()
}
const renderCell = (r: any, col: ColumnConfig): ReactNode => { const renderCell = (r: any, col: ColumnConfig): ReactNode => {
// ext 列 // ext 列
if (col.source.type === 'ext') { if (col.source.type === 'ext') {
@@ -278,9 +299,13 @@ export function ScreenerTable({
const tags = strats.map(sid => strategyIdToName[sid] ?? sid) const tags = strats.map(sid => strategyIdToName[sid] ?? sid)
const cellKey = `${r.symbol}::${col.id}` const cellKey = `${r.symbol}::${col.id}`
const expanded = expandedCells.has(cellKey) const expanded = expandedCells.has(cellKey)
// 收起时每行显示前N个 + "+N" 计数 (跟随列设置"显示前N个", 未配置默认 3),
// 点击计数/收起按钮可单独展开/收起本行; 全局展开: maxTags=0 全部显示不折叠
const cfgMaxTags = col.extDisplay?.maxTags ?? 0
const maxTags = strategyTagsExpanded ? 0 : (cfgMaxTags > 0 ? cfgMaxTags : 3)
return ( return (
<td key={col.id} className="px-3 py-2"> <td key={col.id} className="px-3 py-2">
{renderTagList(tags, col, expanded, () => toggleExpand(cellKey), STRATEGY_TAG_CLS)} {renderTagList(tags, col, expanded, () => toggleExpand(cellKey), STRATEGY_TAG_CLS, undefined, maxTags)}
</td> </td>
) )
} }
@@ -435,6 +460,27 @@ export function ScreenerTable({
</span> </span>
) )
} }
// 策略列标签展开/收起开关 (命中多策略时行会很高, 默认收起只显首个+计数)
if (key === 'strategies' && onToggleStrategyTags) {
return (
<span className="inline-flex items-center justify-center gap-1.5">
<span>{col.label}</span>
<button
type="button"
onClick={(event) => { event.stopPropagation(); handleToggleStrategyTags() }}
className={`inline-flex items-center justify-center w-5 h-5 rounded transition-colors ${
strategyTagsExpanded
? 'text-accent bg-accent/10 hover:bg-accent/20'
: 'text-muted hover:text-foreground hover:bg-elevated'
}`}
title={strategyTagsExpanded ? '收起策略标签(每行仅显示前几个)' : '展开全部策略标签'}
aria-label={strategyTagsExpanded ? '收起策略标签' : '展开全部策略标签'}
>
{strategyTagsExpanded ? <ListTree className="h-3.5 w-3.5" /> : <ListCollapse className="h-3.5 w-3.5" />}
</button>
</span>
)
}
return undefined return undefined
}} }}
/> />
+3
View File
@@ -62,6 +62,9 @@ export const storage = {
/** 策略结果列表分时图显示状态 */ /** 策略结果列表分时图显示状态 */
screenerIntraday: kv<boolean>('screener_showIntraday'), screenerIntraday: kv<boolean>('screener_showIntraday'),
/** 策略结果列表"策略"列标签展开状态 (false=默认收起: 每行首个+计数, 行内可单独展开) */
screenerStrategyTags: kv<boolean>('screener_strategyTagsExpanded'),
/** 自选列表板块筛选 */ /** 自选列表板块筛选 */
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'), watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
+11
View File
@@ -77,6 +77,15 @@ export function Screener() {
return next return next
}) })
}, []) }, [])
// 策略列标签全表展开/收起(命中多策略时行会很高;默认收起, 每行可单独展开;持久化)
const [strategyTagsExpanded, setStrategyTagsExpanded] = useState<boolean>(() => storage.screenerStrategyTags.get(false))
const toggleStrategyTags = useCallback(() => {
setStrategyTagsExpanded(v => {
const next = !v
storage.screenerStrategyTags.set(next)
return next
})
}, [])
// 截断提示可关闭 (仅本次会话, 不持久化) // 截断提示可关闭 (仅本次会话, 不持久化)
const [intradayCapDismissed, setIntradayCapDismissed] = useState(false) const [intradayCapDismissed, setIntradayCapDismissed] = useState(false)
const [showAll, setShowAll] = useState(false) const [showAll, setShowAll] = useState(false)
@@ -979,6 +988,8 @@ export function Screener() {
intradayAutoRefresh={intradayRefreshEnabled && realtimeRunning} intradayAutoRefresh={intradayRefreshEnabled && realtimeRunning}
onRefreshIntraday={() => minuteBatch.refetch()} onRefreshIntraday={() => minuteBatch.refetch()}
intradayRefreshing={minuteBatch.isFetching} intradayRefreshing={minuteBatch.isFetching}
strategyTagsExpanded={strategyTagsExpanded}
onToggleStrategyTags={toggleStrategyTags}
sort={sort} sort={sort}
onSortToggle={toggle} onSortToggle={toggle}
/> />