feat(screener): 策略页新增分时列 + 按套餐截断 + 手动/自动刷新

- screener-columns.ts: 注册 builtin:intraday 列, 加入 signal 分组
- ScreenerTable.tsx: intraday 单元格渲染(MiniIntraday) + 表头眼睛开关
  + 手动刷新按钮(自动轮询关闭时显示) + 列间 border-l 分隔
- Screener.tsx: 分时数据全量加载, 按套餐 batch 上限截断(Pro=100/Expert=200);
  截断提示内联工具栏可关闭; 复用 minute_intraday_refresh 偏好控制
  盘中轮询(开关+实时行情运行双条件)
- Monitoring.tsx: 分时刷新开关描述补上策略页
- storage.ts: 新增 screenerIntraday 持久化键
This commit is contained in:
shy3130
2026-07-11 16:55:22 +08:00
parent 86d31e31f5
commit 7e4f29169b
5 changed files with 177 additions and 14 deletions
@@ -6,14 +6,15 @@
* score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。
*/
import { useState, type CSSProperties, type ReactNode } from 'react'
import { Check, Plus, Eye, EyeOff } from 'lucide-react'
import type { KlineRow } from '@/lib/api'
import { Check, Plus, Eye, EyeOff, RefreshCw } from 'lucide-react'
import type { KlineRow, MinuteKlineRow } from '@/lib/api'
import { fmtPrice } from '@/lib/format'
import type { ColumnConfig } from '@/lib/screener-columns'
import { getSignals, signalCls } from '@/lib/stock-table'
import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives'
import { resolveCandleConfig } from '@/lib/list-columns'
import { resolveCandleConfig, resolveIntradayConfig } from '@/lib/list-columns'
import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { MiniIntraday } from '@/components/stock-table/MiniIntraday'
import { StockDataTable, type SortState } from '@/components/stock-table/StockDataTable'
interface ScreenerTableProps {
@@ -31,6 +32,17 @@ interface ScreenerTableProps {
/** 日k蜡烛图是否显示(表头眼睛开关) */
dailyKChartVisible?: boolean
onToggleDailyKChart?: () => void
/** symbol → 分时数据,仅当启用分时列时传入 */
minuteData?: Record<string, MinuteKlineRow[]>
/** 分时图是否显示(表头眼睛开关) */
intradayChartVisible?: boolean
onToggleIntradayChart?: () => void
/** 分时是否正在自动轮询 (true 时隐藏手动刷新按钮, 避免重复请求) */
intradayAutoRefresh?: boolean
/** 手动刷新分时数据 */
onRefreshIntraday?: () => void
/** 分时数据正在刷新中 (按钮 loading 态) */
intradayRefreshing?: boolean
/** 表头排序(受控,由 Screener.tsx 传入) */
sort?: SortState | null
onSortToggle?: (colId: string) => void
@@ -116,6 +128,8 @@ export function ScreenerTable({
rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy,
watchlistSet, onPreview, onToggleWatchlist, watchlistPending, klineData = {},
dailyKChartVisible = true, onToggleDailyKChart,
minuteData = {}, intradayChartVisible = true, onToggleIntradayChart,
intradayAutoRefresh = false, onRefreshIntraday, intradayRefreshing = false,
sort, onSortToggle,
}: ScreenerTableProps) {
const [expandedCells, setExpandedCells] = useState<Set<string>>(new Set())
@@ -127,6 +141,13 @@ export function ScreenerTable({
? { width: candleResolved.enabledWidth, height: candleResolved.enabledHeight }
: { width: candleResolved.disabledWidth, height: candleResolved.disabledHeight }
// 分时列渲染尺寸(开启用配置宽高,收起用 40×40 占位,与自选页一致)
const intradayCol = columns.find(c => c.source.type === 'builtin' && c.source.key === 'intraday' && c.visible)
const intradayResolved = resolveIntradayConfig(intradayCol?.intradayConfig)
const intradaySize = intradayChartVisible
? { width: intradayResolved.width, height: intradayResolved.height }
: { width: 40, height: 40 }
const toggleExpand = (key: string) => {
setExpandedCells(prev => {
const next = new Set(prev)
@@ -262,16 +283,36 @@ export function ScreenerTable({
case 'candle': {
const candleRows = klineData[r.symbol] ?? []
// 锁定列宽与行高:minWidth=maxWidth 防止 kline 加载前后整列宽度跳动(闪烁)
// padding/宽度与自选页一致(width+4 留内边距余量)
return (
<td
key={col.id}
className="px-3 py-2"
style={{ width: candleSize.width, minWidth: candleSize.width, maxWidth: candleSize.width, height: candleSize.height }}
className="pl-2 pr-3 py-1.5"
style={{ width: candleSize.width + 4, minWidth: candleSize.width + 4, maxWidth: candleSize.width + 4, height: candleSize.height }}
>
<MiniCandlestick rows={candleRows} width={candleSize.width} height={candleSize.height} />
</td>
)
}
case 'intraday': {
const rows: MinuteKlineRow[] = minuteData[r.symbol] ?? []
const iw = intradaySize.width
const ih = intradaySize.height
// border-l 与自选页一致:当日k/分时相邻时提供视觉分隔
return (
<td
key={col.id}
className="pl-3 pr-2 py-1.5 border-l border-border/30"
style={{ width: iw + 4, minWidth: iw + 4, maxWidth: iw + 4, height: ih }}
>
<div className="flex items-center justify-center">
{intradayChartVisible
? <MiniIntraday rows={rows} prevClose={r.prev_close} changePct={r.change_pct} width={iw - 4} height={ih} />
: <span className="text-[10px] text-muted">分时</span>}
</div>
</td>
)
}
default:
// 纯数据列 → 共享原语
return renderBuiltinDataCell(r, col)
@@ -291,9 +332,12 @@ export function ScreenerTable({
? 'border-border/50 opacity-40'
: 'border-border hover:bg-elevated/50'
}
// 日k列表头:标签 + 显示/隐藏蜡烛图眼睛按钮(与自选页一致)
renderHeaderContent={onToggleDailyKChart ? (col) => {
if (col.source.type === 'builtin' && col.source.key === 'candle') {
// 日k / 分时列表头:标签 + 显示/隐藏的眼睛按钮(与自选页一致)
renderHeaderContent={(col) => {
if (col.source.type !== 'builtin') return undefined
const key = col.source.key
// 日k 蜡烛图开关
if (key === 'candle' && onToggleDailyKChart) {
return (
<span className="inline-flex items-center justify-center gap-1.5">
<span>{col.label}</span>
@@ -313,8 +357,46 @@ export function ScreenerTable({
</span>
)
}
// 分时图开关 + 手动刷新按钮 (自动轮询开启时不显示, 避免重复请求)
if (key === 'intraday' && onToggleIntradayChart) {
return (
<span className="inline-flex items-center justify-center gap-1.5">
<span>{col.label}</span>
<button
type="button"
onClick={(event) => { event.stopPropagation(); onToggleIntradayChart() }}
className={`inline-flex items-center justify-center w-5 h-5 rounded transition-colors ${
intradayChartVisible
? 'text-accent bg-accent/10 hover:bg-accent/20'
: 'text-muted hover:text-foreground hover:bg-elevated'
}`}
title={intradayChartVisible ? '隐藏分时图' : '显示分时图'}
aria-label={intradayChartVisible ? '隐藏分时图' : '显示分时图'}
>
{intradayChartVisible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
{/* 分时图显示 且 未开自动轮询时, 提供手动刷新按钮 */}
{intradayChartVisible && !intradayAutoRefresh && onRefreshIntraday && (
<button
type="button"
onClick={(event) => { event.stopPropagation(); onRefreshIntraday() }}
disabled={intradayRefreshing}
className="inline-flex items-center justify-center w-5 h-5 rounded text-muted hover:text-accent hover:bg-accent/10 transition-colors disabled:opacity-40"
title="刷新分时数据"
aria-label="刷新分时数据"
>
<RefreshCw className={`h-3.5 w-3.5 ${intradayRefreshing ? 'animate-spin' : ''}`} />
</button>
)}
{/* 自动轮询中: 显示旋转图标提示正在实时刷新 */}
{intradayChartVisible && intradayAutoRefresh && (
<RefreshCw className="h-3 w-3 text-accent/60 animate-spin" aria-label="实时刷新中" />
)}
</span>
)
}
return undefined
} : undefined}
}}
/>
)
}
+2 -1
View File
@@ -71,6 +71,7 @@ export const SCREENER_BUILTIN_COLUMNS: ColumnConfig[] = [
// 信号 & 图表
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'left' },
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
{ id: 'builtin:intraday', source: { type: 'builtin', key: 'intraday' }, label: '分时', visible: false, align: 'center' },
// 财务指标 (与自选页对齐; 当前后端 enriched 未返回这些字段,默认隐藏)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'right' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'right' },
@@ -93,7 +94,7 @@ export const SCREENER_COLUMN_GROUPS: ColumnGroup[] = [
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle', 'intraday'] },
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
]
+3
View File
@@ -48,6 +48,9 @@ export const storage = {
/** 策略结果列表日K蜡烛图显示状态 */
screenerCandle: kv<boolean>('screener_showCandle'),
/** 策略结果列表分时图显示状态 */
screenerIntraday: kv<boolean>('screener_showIntraday'),
/** 自选列表板块筛选 */
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
+79 -2
View File
@@ -1,11 +1,12 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store, RotateCcw } from 'lucide-react'
import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store, RotateCcw, X } from 'lucide-react'
import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
import { toast } from '@/components/Toast'
import { useDataStatus, usePreferences } from '@/lib/useSharedQueries'
import { useDataStatus, usePreferences, useCapabilities, useQuoteStatus } from '@/lib/useSharedQueries'
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
import { isExpertOrAbove } from '@/lib/capability-labels'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { PageHeader } from '@/components/PageHeader'
@@ -57,6 +58,17 @@ export function Screener() {
return next
})
}, [])
// 分时图显示开关(仅当 intraday 列可见时才有意义;持久化)
const [intradayChartVisible, setIntradayChartVisible] = useState<boolean>(() => storage.screenerIntraday.get(true))
const toggleIntradayChart = useCallback(() => {
setIntradayChartVisible(v => {
const next = !v
storage.screenerIntraday.set(next)
return next
})
}, [])
// 截断提示可关闭 (仅本次会话, 不持久化)
const [intradayCapDismissed, setIntradayCapDismissed] = useState(false)
const [showAll, setShowAll] = useState(false)
const [showFilter, setShowFilter] = useState(false)
const [filter, setFilter] = useState<ScreenerFilterType>(defaultFilter)
@@ -339,6 +351,50 @@ export function Screener() {
})
const klineData = dailyKVisible ? (klineBatch.data?.data ?? {}) : {}
// 分时列是否启用 → 决定是否加载批量分时数据 (需 kline.minute.batch 能力)
const intradayColumn = useMemo(() =>
columns.find(c => c.source.type === 'builtin' && c.source.key === 'intraday' && c.visible),
[columns],
)
// 分时图需 Pro+ (kline.minute.batch), 低档用户开了列也不拉数据
const caps = useCapabilities()
const hasMinuteBatch = !!caps.data?.capabilities?.['kline.minute.batch']
const intradayVisible = !!intradayColumn && hasMinuteBatch && intradayChartVisible
// 分时数据加载策略 (与自选页一致, 简洁优先):
// - 全量加载当前列表 symbol, 但按套餐 batch 上限截断 (Pro=100 / Expert=200),
// 超出时只取前 batch 只并提示用户, 避免一次性发太多请求打爆 rpm 配额
// - 刷新: minute_intraday_refresh 偏好开启时盘中 15s 轮询; 否则仅首次加载,
// 用户可点表头刷新按钮手动更新
const minuteBatchCap = caps.data?.capabilities?.['kline.minute.batch']?.batch ?? 100
const quoteStatus = useQuoteStatus()
const realtimeRunning = quoteStatus.data?.running ?? false
const intradayRefreshEnabled = prefs?.minute_intraday_refresh ?? false
const allIntradaySymbols = useMemo(
() => displayRows.map((r: any) => r.symbol),
[displayRows],
)
const intradayTruncated = intradayVisible && allIntradaySymbols.length > minuteBatchCap
// 是否已是最高档 (Expert+): 最高档时截断提示不再建议"升级套餐"
const isMaxTier = isExpertOrAbove(caps.data?.label ?? '')
// 截断到 batch 上限 (Pro=100 / Expert=200), 一次请求 = 一次 TickFlow 调用
const intradaySymbols = useMemo(
() => intradayTruncated ? allIntradaySymbols.slice(0, minuteBatchCap) : allIntradaySymbols,
[allIntradaySymbols, intradayTruncated, minuteBatchCap],
)
const intradaySymbolsKey = intradaySymbols.join(',')
const minuteBatch = useQuery({
queryKey: QK.minuteBatch(intradaySymbolsKey),
queryFn: () => api.klineMinuteBatch(intradaySymbols),
enabled: intradayVisible && intradaySymbols.length > 0,
staleTime: 10_000,
// 仅当开启分时刷新偏好 且 盘中实时行情运行时 才轮询 (省 rpm)
refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? 15_000 : false,
})
const minuteData = intradayVisible ? (minuteBatch.data?.data ?? {}) : {}
// asOf 确定后 + 策略列表就绪 + 策略池非空 → 自动跑一次 (受系统设置开关控制)
// 缓存命中时秒加载; 未命中时, 仅当 screener_auto_run 开启才自动触发 runAll
useEffect(() => {
@@ -764,6 +820,21 @@ export function Screener() {
<span className="num">{result.elapsed_ms.toFixed(1)} ms</span>
</div>
)}
{/* 分时截断提示: 超套餐上限时在工具栏内联显示, 可关闭 */}
{intradayTruncated && !intradayCapDismissed && (
<span className="inline-flex items-center gap-1 text-xs text-warning/90">
分时仅前 {minuteBatchCap}/{allIntradaySymbols.length}
{!isMaxTier && ', 可升级'}
<button
type="button"
onClick={() => setIntradayCapDismissed(true)}
className="text-warning/50 hover:text-warning transition-colors"
title="关闭提示"
>
<X className="h-3 w-3" />
</button>
</span>
)}
</div>
</div>
@@ -803,6 +874,12 @@ export function Screener() {
klineData={klineData}
dailyKChartVisible={dailyKChartVisible}
onToggleDailyKChart={toggleDailyKChart}
minuteData={minuteData}
intradayChartVisible={intradayChartVisible}
onToggleIntradayChart={toggleIntradayChart}
intradayAutoRefresh={intradayRefreshEnabled && realtimeRunning}
onRefreshIntraday={() => minuteBatch.refetch()}
intradayRefreshing={minuteBatch.isFetching}
sort={sort}
onSortToggle={toggle}
/>
+2 -2
View File
@@ -390,8 +390,8 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
{/* 自选列表分时图实时刷新 (默认关闭, 开启后盘中 15s 轮询刷新分时数据) */}
<Card icon={Activity} title="分时图刷新">
<ToggleRow
label="自选分时图实时刷新"
desc="开启后自选列表的分时图盘中每 15 秒自动刷新(需 Pro+ 权限)。关闭时仅打开页面时拉取一次。"
label="自选/策略分时图实时刷新"
desc="开启后自选与策略列表的分时图盘中每 15 秒自动刷新(需 Pro+ 权限 + 实时行情运行)。关闭时仅打开页面时拉取一次, 可点表头刷新按钮手动更新。"
checked={prefs?.minute_intraday_refresh ?? false}
onChange={(v) => save({ minute_intraday_refresh: v })}
/>