mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
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:
@@ -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}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'] },
|
||||
]
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ export const storage = {
|
||||
/** 策略结果列表日K蜡烛图显示状态 */
|
||||
screenerCandle: kv<boolean>('screener_showCandle'),
|
||||
|
||||
/** 策略结果列表分时图显示状态 */
|
||||
screenerIntraday: kv<boolean>('screener_showIntraday'),
|
||||
|
||||
/** 自选列表板块筛选 */
|
||||
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user