mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
Merge pull request #210 from thinkbuf/feat/kline-preview-nav
feat(kline): 弹窗切股导航 + 邻股预取 + 悬停「至今」+ 外部链接
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
import { Activity, Building2, ChevronRight, Database, RefreshCw, Search, Tags, Users, X } from 'lucide-react'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { boardTag } from '@/components/stock-table/primitives'
|
||||
import { toNavItems, type NavItem } from '@/components/StockPreviewDialog'
|
||||
import { api, type DimensionIntradayPoint, type MarketSnapshotRow } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { fmtBigNum, fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
|
||||
@@ -39,7 +40,7 @@ export function dimensionKindForSourceField(sourceField: string): DimensionKind
|
||||
interface Props {
|
||||
target: DimensionMembersTarget | null
|
||||
onClose: () => void
|
||||
onStockClick?: (symbol: string, name?: string) => void
|
||||
onStockClick?: (symbol: string, name?: string, navList?: NavItem[]) => void
|
||||
}
|
||||
|
||||
interface ResolvedSource {
|
||||
@@ -279,7 +280,7 @@ function DimensionMembersDialogContent({ target, onClose, onStockClick }: Omit<P
|
||||
key={virtualRow.key}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
onClick={() => onStockClick?.(row.symbol, row.name)}
|
||||
onClick={() => onStockClick?.(row.symbol, row.name, toNavItems(visibleRows))}
|
||||
disabled={!onStockClick}
|
||||
className="absolute left-0 top-0 grid min-h-[54px] w-full grid-cols-[minmax(132px,1fr)_74px_74px_18px] items-center border-b border-border/60 px-4 text-left text-xs transition-colors hover:bg-elevated/50 disabled:cursor-default md:grid-cols-[minmax(180px,1fr)_90px_84px_88px_100px_18px]"
|
||||
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { chartTheme, getTheme, useTheme } from '@/lib/theme'
|
||||
import { fmtPct } from '@/lib/format'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts, EChartsOption } from 'echarts'
|
||||
|
||||
@@ -832,6 +833,8 @@ export function EChartsCandlestick({
|
||||
const infoIdxRef = useRef<number>(data.length - 1)
|
||||
const compactRef = useRef(false)
|
||||
const userZoomRef = useRef<{ start: number; end: number } | null>(null)
|
||||
// 竖虚线(crosshair)是否可见: 控制信息栏「至今」字段的显隐。鼠标移出图表区即 false。
|
||||
const hoverActiveRef = useRef(false)
|
||||
|
||||
// 需要在闭包中访问最新值的变量 — 先声明占位,后面赋值
|
||||
const activeIndicatorsRef = useRef(activeIndicators)
|
||||
@@ -911,7 +914,7 @@ export function EChartsCandlestick({
|
||||
const floatShares = stockInfo?.float_shares
|
||||
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
|
||||
|
||||
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;height:20px;flex-wrap:wrap">`
|
||||
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;min-height:20px;flex-wrap:wrap">`
|
||||
html += `<span style="color:${CT().text}">${d.date}</span>`
|
||||
html += `<span style="color:${CT().text}">开</span>`
|
||||
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
|
||||
@@ -930,11 +933,25 @@ export function EChartsCandlestick({
|
||||
html += `<span style="color:${CT().text}">换手</span>`
|
||||
html += `<span style="color:${CT().text}">${turnoverRate.toFixed(2)}%</span>`
|
||||
}
|
||||
// 至今: 仅当竖虚线(crosshair)在图上且鼠标悬停某根 K 线时显示。
|
||||
// 最新价取最后一根K线收盘 (后端 _maybe_inject_live_candle 盘中注入实时价, 收盘后即最近收盘)。
|
||||
// 基准取该K线昨收(前一日收盘), 与同花顺及全市场涨幅口径一致; 数据第一根K线无昨收则跳过。
|
||||
if (hoverActiveRef.current && prev && Number.isFinite(prev.close) && prev.close > 0) {
|
||||
const latestPrice = data[data.length - 1].close
|
||||
if (Number.isFinite(latestPrice)) {
|
||||
const sinceRatio = (latestPrice - prev.close) / prev.close
|
||||
const sinceClr = sinceRatio >= 0 ? THEME.bull : THEME.bear
|
||||
html += `<span style="color:${CT().text}">至今</span>`
|
||||
html += `<span style="color:${sinceClr}">${fmtPct(sinceRatio)}</span>`
|
||||
// 周期数: 从该K线(含)到最新一根K线共多少根; 悬停最后一根时为 1
|
||||
html += `<span style="color:${CT().text}">周期 ${data.length - idx}</span>`
|
||||
}
|
||||
}
|
||||
html += `</div>`
|
||||
|
||||
// 第二行: MA + BOLL
|
||||
if (showMA) {
|
||||
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;height:20px;flex-wrap:wrap">`
|
||||
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;min-height:20px;flex-wrap:wrap">`
|
||||
if (d.ma5 != null) html += `<span style="color:${THEME.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
|
||||
if (d.ma10 != null) html += `<span style="color:${THEME.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
|
||||
if (d.ma20 != null) html += `<span style="color:${THEME.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
|
||||
@@ -949,12 +966,17 @@ export function EChartsCandlestick({
|
||||
}, [data, stockInfo, showMA, activeIndicators])
|
||||
getInfoBarHTMLRef.current = getInfoBarHTML
|
||||
|
||||
// data 变化时重置 infoIdx
|
||||
// data/symbol 变化时重置 infoIdx:
|
||||
// symbol(_symbol) 进依赖是必要的——预取切股到同长度邻股时 data.length 不变,
|
||||
// 但悬停上下文来自上一只股票, 必须清掉 hoverActiveRef 以免「至今/周期」残留显示。
|
||||
// (同一股的实时刷新 symbol 不变, 不触发, 悬停位置与「至今」保持实时)
|
||||
useEffect(() => {
|
||||
infoIdxRef.current = data.length - 1
|
||||
compactRef.current = false
|
||||
userZoomRef.current = null
|
||||
}, [data.length])
|
||||
// 新数据无悬停上下文, 隐藏「至今」; 下次鼠标移动时由 updateAxisPointer 重新置位
|
||||
hoverActiveRef.current = false
|
||||
}, [_symbol, data.length])
|
||||
|
||||
// ===== 初始化 chart (只在 chartHeight 变化时重建) =====
|
||||
useEffect(() => {
|
||||
@@ -965,32 +987,36 @@ export function EChartsCandlestick({
|
||||
chartRef.current = chart
|
||||
|
||||
// 鼠标移动 → 只更新 ref + DOM,不触发 React re-render
|
||||
// 设计原则: 找不到有效数据时保持上次显示,永远不清空信息栏
|
||||
// 设计原则: 找不到有效数据时保持上次显示,永远不清空信息栏; 鼠标移出时仅隐藏「至今」。
|
||||
chart.on('updateAxisPointer', (event: any) => {
|
||||
const axesInfo = event.axesInfo
|
||||
if (!axesInfo) return // 鼠标移出图表区域,保持当前显示
|
||||
for (const info of Object.values(axesInfo)) {
|
||||
const val = (info as any)?.value
|
||||
if (val == null) continue
|
||||
const d = dataRef.current
|
||||
const idx = typeof val === 'number' ? val : d.findIndex(x => x.date === val)
|
||||
if (idx >= 0 && idx < d.length) {
|
||||
if (infoIdxRef.current === idx) return
|
||||
infoIdxRef.current = idx
|
||||
|
||||
// 直接更新信息栏 DOM (通过 ref 读取最新的生成函数)
|
||||
const infoEl = infoBarRef.current
|
||||
if (infoEl) {
|
||||
const html = getInfoBarHTMLRef.current()
|
||||
if (html) infoEl.innerHTML = html // 只在有内容时更新
|
||||
}
|
||||
|
||||
// 更新子图 graphic
|
||||
triggerInfoBarUpdate()
|
||||
return
|
||||
const d = dataRef.current
|
||||
// 竖虚线是否正落在某根有效 K 线上 (鼠标在图表数据区内)
|
||||
let foundIdx = -1
|
||||
if (axesInfo) {
|
||||
for (const info of Object.values(axesInfo)) {
|
||||
const val = (info as any)?.value
|
||||
if (val == null) continue
|
||||
const idx = typeof val === 'number' ? val : d.findIndex(x => x.date === val)
|
||||
if (idx >= 0 && idx < d.length) { foundIdx = idx; break }
|
||||
}
|
||||
}
|
||||
// 没有找到有效数据 — 不做任何操作,保持上次显示
|
||||
const active = foundIdx >= 0
|
||||
const idxChanged = foundIdx >= 0 && infoIdxRef.current !== foundIdx
|
||||
const visChanged = active !== hoverActiveRef.current
|
||||
hoverActiveRef.current = active
|
||||
if (idxChanged) infoIdxRef.current = foundIdx
|
||||
// 竖虚线显隐或悬停 K 线变化 → 重绘一次信息栏 (控制「至今」字段显隐 + 当前 K 线数据)
|
||||
if (visChanged || idxChanged) {
|
||||
const infoEl = infoBarRef.current
|
||||
if (infoEl) {
|
||||
const html = getInfoBarHTMLRef.current()
|
||||
if (html) infoEl.innerHTML = html // 只在有内容时更新
|
||||
}
|
||||
}
|
||||
if (foundIdx < 0) return
|
||||
// 更新子图 graphic (仅悬停 K 线变化时; 纯显隐切换不影响副图)
|
||||
if (idxChanged) triggerInfoBarUpdate()
|
||||
})
|
||||
|
||||
chart.on('click', (params: any) => {
|
||||
@@ -1159,7 +1185,7 @@ export function EChartsCandlestick({
|
||||
if (!d) return ''
|
||||
const floatShares = stockInfo?.float_shares
|
||||
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
|
||||
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;height:20px;flex-wrap:wrap">`
|
||||
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;min-height:20px;flex-wrap:wrap">`
|
||||
html += `<span style="color:${CT().text}">${d.date}</span>`
|
||||
html += `<span style="color:${CT().text}">开</span>`
|
||||
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
|
||||
@@ -1182,7 +1208,7 @@ export function EChartsCandlestick({
|
||||
}
|
||||
html += `</div>`
|
||||
if (showMA) {
|
||||
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;height:20px;flex-wrap:wrap">`
|
||||
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;min-height:20px;flex-wrap:wrap">`
|
||||
if (d.ma5 != null) html += `<span style="color:${THEME.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
|
||||
if (d.ma10 != null) html += `<span style="color:${THEME.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
|
||||
if (d.ma20 != null) html += `<span style="color:${THEME.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api, type KlineRow } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { type KlineRow } from '@/lib/api'
|
||||
import { klineDailyQueryOptions } from '@/lib/kline'
|
||||
import { storage } from '@/lib/storage'
|
||||
import {
|
||||
EChartsCandlestick,
|
||||
@@ -11,13 +11,11 @@ import {
|
||||
type ChartPriceLine,
|
||||
type ChartRange,
|
||||
type OHLC,
|
||||
type StockInfo,
|
||||
type VolumeCompareConfig,
|
||||
} from '@/components/EChartsCandlestick'
|
||||
|
||||
const SUB_INFO_H = 16
|
||||
const SUB_GAP = 4
|
||||
const MAX_DAYS = 2000
|
||||
const DEFAULT_VOLUME_COMPARE: VolumeCompareConfig = { enabled: true, days: 1 }
|
||||
|
||||
function normalizeVolumeCompare(config: VolumeCompareConfig): VolumeCompareConfig {
|
||||
@@ -27,13 +25,6 @@ function normalizeVolumeCompare(config: VolumeCompareConfig): VolumeCompareConfi
|
||||
}
|
||||
}
|
||||
|
||||
export interface StockDailyKChartResult {
|
||||
rows: OHLC[]
|
||||
rawRows: KlineRow[]
|
||||
stockInfo?: StockInfo
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
symbol: string
|
||||
height?: number
|
||||
@@ -51,7 +42,6 @@ interface Props {
|
||||
linkedPrice?: number | null
|
||||
onDateClick?: (date: string) => void
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
onDataChange?: (result: StockDailyKChartResult) => void
|
||||
/** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */
|
||||
extColumns?: string
|
||||
/** 日K自动刷新间隔(ms)。undefined = 不轮询(默认)。个股对话框实时刷新时传入, 盘中今日蜡烛随之更新 */
|
||||
@@ -113,12 +103,6 @@ export function getDefaultRange(): { start: string; end: string } {
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function rangeDays(range: { start: string; end: string }): number {
|
||||
const start = new Date(range.start)
|
||||
const end = new Date(range.end)
|
||||
return Math.min(Math.ceil((end.getTime() - start.getTime()) / 86400000) + 30, MAX_DAYS)
|
||||
}
|
||||
|
||||
export function StockDailyKChart({
|
||||
symbol,
|
||||
height = 520,
|
||||
@@ -136,7 +120,6 @@ export function StockDailyKChart({
|
||||
linkedPrice,
|
||||
onDateClick,
|
||||
onPriceDoubleClick,
|
||||
onDataChange,
|
||||
extColumns,
|
||||
refetchIntervalMs,
|
||||
}: Props) {
|
||||
@@ -146,16 +129,9 @@ export function StockDailyKChart({
|
||||
normalizeVolumeCompare(storage.stockVolumeCompare.get(DEFAULT_VOLUME_COMPARE)),
|
||||
)
|
||||
const dateRange = externalDateRange ?? getDefaultRange()
|
||||
const days = useMemo(() => rangeDays(dateRange), [dateRange])
|
||||
|
||||
// extColumns 纳入 query key:勾选/取消扩展字段时需重新请求(带 ext_columns 参数)
|
||||
const kline = useQuery({
|
||||
queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns),
|
||||
queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns),
|
||||
enabled: !!symbol,
|
||||
refetchInterval: refetchIntervalMs,
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
// 查询配置统一来自 klineDailyQueryOptions, 与 StockPanel 信息条/邻近预取共享同一 cache key (只发一次请求)
|
||||
const kline = useQuery({ ...klineDailyQueryOptions(symbol, dateRange, extColumns), enabled: !!symbol, refetchInterval: refetchIntervalMs })
|
||||
|
||||
const rows = useMemo(() => toOHLC(kline.data?.rows ?? []), [kline.data?.rows])
|
||||
const stockInfo = kline.data?.stock_info
|
||||
@@ -185,10 +161,6 @@ export function StockDailyKChart({
|
||||
if (activeSubDefs.length > 0) subExtraH += activeSubDefs.length * SUB_GAP + 14
|
||||
const chartHeight = height + subExtraH
|
||||
|
||||
useEffect(() => {
|
||||
onDataChange?.({ rows, rawRows: kline.data?.rows ?? [], stockInfo, name: kline.data?.name })
|
||||
}, [kline.data?.name, kline.data?.rows, onDataChange, rows, stockInfo])
|
||||
|
||||
if (!symbol) return null
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Settings2, RadioTower, Star } from 'lucide-react'
|
||||
import { Settings2, RadioTower, Star, ExternalLink } from 'lucide-react'
|
||||
import type { KlineRow, FinancialMetricRecord } from '@/lib/api'
|
||||
import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format'
|
||||
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
|
||||
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
|
||||
import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields'
|
||||
import { buildStockExternalUrl, loadStockExternalTemplate } from '@/lib/stock-external-link'
|
||||
|
||||
const BULL = '#C74040'
|
||||
const BEAR = '#2D9B65'
|
||||
@@ -122,7 +123,32 @@ export function StockInfoBar({
|
||||
})
|
||||
}
|
||||
|
||||
if (rows.length === 0) return null
|
||||
// 字段分组: 加载态预留高度与完整态渲染共用同一规则
|
||||
const visibleFields = fields.filter(f => f.visible)
|
||||
const inlineFields = visibleFields.filter(f => !f.standalone)
|
||||
const standaloneFields = visibleFields.filter(f => f.standalone)
|
||||
|
||||
// 无数据时保持信息条挂载 (切股/首次加载): 只留 symbol+名称+小 spinner 作为加载态,
|
||||
// 不渲染假占位值; 数据到位后价格/市值等原位填充, 避免整行消失造成布局跳动。
|
||||
// 同时按字段配置预留与完整态相同的行数, 切股瞬间弹窗整体高度不塌陷 (不抖动)。
|
||||
if (rows.length === 0) {
|
||||
const reserveLines = (inlineFields.length > 0 ? 1 : 0) + standaloneFields.length
|
||||
return (
|
||||
<div className="px-2 pb-3 font-mono text-[12px] select-none space-y-1">
|
||||
{/* 首行 min-h-7 对齐完整态的 text-lg 价格行高, 加载中不整体变矮 */}
|
||||
<div className="flex min-h-7 items-baseline gap-x-3 flex-wrap">
|
||||
<span className="text-foreground font-bold text-sm tracking-wide">{symbol}</span>
|
||||
{name && <span className="text-secondary font-medium">{name}</span>}
|
||||
<span className="ml-auto self-center text-muted">
|
||||
<span className="inline-block h-2.5 w-2.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent" />
|
||||
</span>
|
||||
</div>
|
||||
{Array.from({ length: reserveLines }).map((_, i) => (
|
||||
<div key={i} className="h-4" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const latest = rows[rows.length - 1]
|
||||
const prev = rows.length >= 2 ? rows[rows.length - 2] : null
|
||||
@@ -183,11 +209,6 @@ export function StockInfoBar({
|
||||
}
|
||||
}
|
||||
|
||||
const visibleFields = fields.filter(f => f.visible)
|
||||
// 按是否单独显示分组:普通列共一行,standalone 列各占一行
|
||||
const inlineFields = visibleFields.filter(f => !f.standalone)
|
||||
const standaloneFields = visibleFields.filter(f => f.standalone)
|
||||
|
||||
// 渲染单个字段(builtin / ext 通用)
|
||||
const renderField = (f: ColumnConfig): ReactNode => {
|
||||
if (f.source.type === 'ext') {
|
||||
@@ -215,6 +236,8 @@ export function StockInfoBar({
|
||||
)
|
||||
}
|
||||
|
||||
const extUrl = buildStockExternalUrl(loadStockExternalTemplate(), symbol)
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-3 font-mono text-[12px] select-none space-y-1">
|
||||
{/* Row 1: code, name, price, change, change% */}
|
||||
@@ -230,8 +253,19 @@ export function StockInfoBar({
|
||||
<span style={{ color: clr }} className="tabular-nums">
|
||||
{isUp ? '+' : ''}{fmtPrice(chgPct)}%
|
||||
</span>
|
||||
{/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
|
||||
{/* 右侧操作按钮:外链 + 加自选 + 加监控 + 信息条配置 */}
|
||||
<div className="ml-auto self-center flex items-center gap-1">
|
||||
{extUrl && (
|
||||
<a
|
||||
href={extUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={extUrl}
|
||||
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
{inWatchlist && onRemoveFromWatchlist ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { api, type MinuteKlineRow } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { klineMinuteQueryOptions } from '@/lib/kline'
|
||||
import { EChartsIntraday } from '@/components/EChartsIntraday'
|
||||
|
||||
interface Props {
|
||||
@@ -35,10 +36,9 @@ export function StockIntradayChart({
|
||||
const [minuteDismissed, setMinuteDismissed] = useState(false)
|
||||
|
||||
const minute = useQuery({
|
||||
queryKey: QK.klineMinute(symbol, date ?? ''),
|
||||
// 轮询上下文 (个股详情) 传 live: 当日盘中后端直接实时拉取最新K,
|
||||
// 避免读到分钟增量落盘的上一轮本地分区; 历史日期后端自行忽略 live。
|
||||
queryFn: () => api.klineMinute(symbol, date ?? undefined, refetchIntervalMs != null),
|
||||
...klineMinuteQueryOptions(symbol, date ?? undefined, refetchIntervalMs != null),
|
||||
enabled: !!symbol && !!date,
|
||||
refetchInterval: refetchIntervalMs,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Download, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { api, type MinuteKlineSession } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { klineMinuteQueryOptions, klineMinuteRangeQueryOptions } from '@/lib/kline'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { EChartsMultiDayIntraday } from '@/components/EChartsMultiDayIntraday'
|
||||
|
||||
@@ -29,16 +29,12 @@ export function StockMultiDayIntradayChart({
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const history = useQuery({
|
||||
queryKey: QK.klineMinuteRange(symbol, days),
|
||||
queryFn: () => api.klineMinuteRange(symbol, days),
|
||||
...klineMinuteRangeQueryOptions(symbol, days),
|
||||
enabled: !!symbol,
|
||||
placeholderData: (previous, previousQuery) =>
|
||||
previousQuery?.queryKey[1] === symbol ? previous : undefined,
|
||||
})
|
||||
const latest = useQuery({
|
||||
queryKey: QK.klineMinute(symbol, ''),
|
||||
// live: 当日盘中直接实时拉取, 不被分钟增量落盘的本地分区(≥60s一轮)拖慢
|
||||
queryFn: () => api.klineMinute(symbol, undefined, true),
|
||||
...klineMinuteQueryOptions(symbol, undefined, true),
|
||||
enabled: !!symbol,
|
||||
refetchInterval: refetchIntervalMs,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
import { type KlineRow, type FinancialMetricRecord } from '@/lib/api'
|
||||
import { klineDailyQueryOptions, klineMinuteQueryOptions, klineMinuteRangeQueryOptions, DEFAULT_INTRADAY_DAYS } from '@/lib/kline'
|
||||
import { StockInfoBar } from '@/components/StockInfoBar'
|
||||
import { StockDailyKChart, getDefaultRange, type StockDailyKChartResult } from '@/components/StockDailyKChart'
|
||||
import { StockDailyKChart, getDefaultRange, toOHLC } from '@/components/StockDailyKChart'
|
||||
import { StockIntradayChart } from '@/components/StockIntradayChart'
|
||||
import { useFinancialMetrics } from '@/lib/useFinancials'
|
||||
import { financialMetricsQueryOptions, useFinancialMetrics } from '@/lib/useFinancials'
|
||||
import { useCapabilities } from '@/lib/useSharedQueries'
|
||||
import type { ChartMarker, ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
|
||||
import {
|
||||
@@ -40,6 +42,12 @@ interface Props {
|
||||
refetchIntervalMs?: number
|
||||
/** 只渲染信息条, 隐藏图表 (用于分时 tab 共享信息条) */
|
||||
infoBarOnly?: boolean
|
||||
/** 邻近预取目标 (切股导航的左右邻股): 提前拉取其日K/财务/分时缓存, 切换瞬间免 loading */
|
||||
prefetchSymbols?: string[]
|
||||
/** 多日分时周期 (分时 tab 使用): 预取邻股 klineMinuteRange 时用同一 days, 保证 queryKey 命中 */
|
||||
intradayDays?: number
|
||||
/** 日K/分时并排时日K图占宽 (默认 1:1; 弹窗内图表信息栏较宽需更多空间时传 flex-[1.4] 之类) */
|
||||
dailyKlineFlex?: string
|
||||
}
|
||||
|
||||
export { getDefaultRange }
|
||||
@@ -64,11 +72,13 @@ export function StockPanel({
|
||||
watchlistPending,
|
||||
refetchIntervalMs,
|
||||
infoBarOnly = false,
|
||||
prefetchSymbols,
|
||||
intradayDays = DEFAULT_INTRADAY_DAYS,
|
||||
dailyKlineFlex = 'flex-1',
|
||||
}: Props) {
|
||||
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
const [intradayDismissed, setIntradayDismissed] = useState(false)
|
||||
const [dailyResult, setDailyResult] = useState<StockDailyKChartResult | null>(null)
|
||||
// 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据
|
||||
const [fields, setFields] = useState<ColumnConfig[]>(loadInfoFields)
|
||||
const extColumns = useMemo(() => buildInfoExtColumnsParam(fields), [fields])
|
||||
@@ -91,27 +101,66 @@ export function StockPanel({
|
||||
|
||||
const dateRange = externalDateRange ?? getDefaultRange()
|
||||
|
||||
// 日K查询由本组件持有 (与 StockDailyKChart 共享同一 cache key/配置, 只发一次请求)。
|
||||
// 信息条直接读 query data: 切股到已预取邻股时首帧即有数据, 配合 StockInfoBar 加载态占位,
|
||||
// 弹窗整体高度在切换瞬间不塌陷 (不抖动)。
|
||||
const kline = useQuery({ ...klineDailyQueryOptions(symbol, dateRange, extColumns), enabled: !!symbol })
|
||||
const rawRows: KlineRow[] = kline.data?.rows ?? []
|
||||
// OHLC 视图用于日期选中/昨收价推导 (与图表侧同口径)
|
||||
const rows = useMemo(() => toOHLC(rawRows), [rawRows])
|
||||
const stockInfo = kline.data?.stock_info
|
||||
const name = kline.data?.name
|
||||
|
||||
const handleDateClick = useCallback((date: string) => {
|
||||
setSelectedDate(date)
|
||||
setIntradayDismissed(false)
|
||||
onSelectDate?.(date)
|
||||
}, [onSelectDate])
|
||||
|
||||
const rows = dailyResult?.rows ?? []
|
||||
const stockInfo = dailyResult?.stockInfo
|
||||
const rawRows: KlineRow[] = dailyResult?.rawRows ?? []
|
||||
// 邻近预取: 对切股导航的左右邻股提前拉取缓存, 切股瞬间免 loading。
|
||||
// 日K/分时预取 staleTime 30s 防来回切换重复请求; 成为当前股后 useQuery(staleTime=0) 立即后台刷新,
|
||||
// SSE 也只按焦点股精准失效, 实时性不受影响。财务指标与正式查询同 staleTime, 5min 内不重复拉取。
|
||||
// prefetchKey 按内容 join: 自选页 navList 随行情 tick 重建但邻股集合通常不变, 避免 effect 每次 tick 重跑。
|
||||
const qc = useQueryClient()
|
||||
const prefetchKey = prefetchSymbols?.join(',') ?? ''
|
||||
// 守卫快速连续切股: 旧链路上异步回来的日K不再级联预取 (避免串股/浪费)
|
||||
const prefetchTickRef = useRef('')
|
||||
useEffect(() => {
|
||||
if (!prefetchKey) return
|
||||
prefetchTickRef.current = prefetchKey
|
||||
for (const s of prefetchKey.split(',')) {
|
||||
if (s === symbol) continue
|
||||
if (hasFinanceField && hasFinancialCap) {
|
||||
qc.prefetchQuery(financialMetricsQueryOptions(s))
|
||||
}
|
||||
// 分时 tab 的多日分时 + 最新分时: 切股后分时图也免 loading (与日K并行预取)
|
||||
qc.prefetchQuery({ ...klineMinuteRangeQueryOptions(s, intradayDays), staleTime: 30_000 })
|
||||
// latest 当日分时同样 live=true: 预取与渲染同读实时源 (历史日期后端忽略 live)
|
||||
qc.prefetchQuery({ ...klineMinuteQueryOptions(s, undefined, true), staleTime: 30_000 })
|
||||
// 日K用 fetchQuery (返回数据) 以便级联预取分时; 邻股预取失败静默, 不影响切股。
|
||||
void qc.fetchQuery({ ...klineDailyQueryOptions(s, dateRange, extColumns), staleTime: 30_000 })
|
||||
.then((res) => {
|
||||
if (prefetchTickRef.current !== prefetchKey) return
|
||||
// 日K到货后级联预取其默认选中日的分时数据: 日K视图并排展示分时图(默认选中最后交易日)。
|
||||
const lastDate = res?.rows?.at(-1)?.date
|
||||
if (lastDate) {
|
||||
const d = String(lastDate).slice(0, 10)
|
||||
// 同上 live=true: 该日若为当日即命中实时源 (历史日期后端忽略 live)
|
||||
qc.prefetchQuery({ ...klineMinuteQueryOptions(s, d, true), staleTime: 30_000 })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [prefetchKey, symbol, dateRange, extColumns, hasFinanceField, hasFinancialCap, intradayDays, qc])
|
||||
|
||||
// symbol 变化时重置分时相关状态,避免切股后残留旧日期。
|
||||
// 注意:必须跳过首次挂载——重开弹窗时 kline 命中 react-query 缓存,
|
||||
// 子组件 onDataChange effect(先于父 effect 执行)会把 dailyResult 置为有效数据,
|
||||
// 若此处再无条件清空,会把刚加载的数据抹掉,导致信息条整行消失。
|
||||
// 日K信息直接读 query data (切股到已预取邻股首帧即有), 无需清空或门控。
|
||||
const prevSymbol = useRef<string | null>(symbol)
|
||||
useEffect(() => {
|
||||
if (prevSymbol.current === symbol) return
|
||||
prevSymbol.current = symbol
|
||||
setSelectedDate(null)
|
||||
setLinkedPrice(null)
|
||||
setDailyResult(null)
|
||||
}, [symbol])
|
||||
|
||||
// 当分时开启、无选中日期时,自动选中最新日期
|
||||
@@ -136,7 +185,7 @@ export function StockPanel({
|
||||
<div className={className}>
|
||||
<StockInfoBar
|
||||
symbol={symbol}
|
||||
name={dailyResult?.name}
|
||||
name={name}
|
||||
stockInfo={stockInfo}
|
||||
rows={rawRows}
|
||||
fields={fields}
|
||||
@@ -154,7 +203,7 @@ export function StockPanel({
|
||||
<StockDailyKChart
|
||||
symbol={symbol}
|
||||
height={height}
|
||||
className="flex-1 min-w-0"
|
||||
className={`${dailyKlineFlex} min-w-0`}
|
||||
dateRange={dateRange}
|
||||
markers={markers}
|
||||
ranges={ranges}
|
||||
@@ -164,7 +213,6 @@ export function StockPanel({
|
||||
linkedPrice={linkedPrice}
|
||||
onDateClick={handleDateClick}
|
||||
onPriceDoubleClick={onPriceDoubleClick}
|
||||
onDataChange={setDailyResult}
|
||||
visibleBars={showIntraday ? 40 : 60}
|
||||
extColumns={extColumns}
|
||||
refetchIntervalMs={refetchIntervalMs}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2, Activity } from 'lucide-react'
|
||||
import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2, Activity, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/cn'
|
||||
@@ -18,6 +18,7 @@ import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream'
|
||||
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
|
||||
import { storage } from '@/lib/storage'
|
||||
import { DEFAULT_INTRADAY_DAYS } from '@/lib/kline'
|
||||
import { ExtensionSlot } from '@/extensions/ExtensionSlot'
|
||||
|
||||
interface Props {
|
||||
@@ -32,6 +33,35 @@ interface Props {
|
||||
signals?: string[]
|
||||
message?: string
|
||||
} | null
|
||||
/** 有序候选列表: 提供后支持左右键/顶栏按钮切股, 标题栏显示 n/N */
|
||||
navList?: NavItem[]
|
||||
/** 切股回调: 收到目标 symbol/name, 由调用方更新预览状态 */
|
||||
onNavigate?: (symbol: string, name?: string) => void
|
||||
}
|
||||
|
||||
/** 切股导航列表项 */
|
||||
export interface NavItem { symbol: string; name?: string }
|
||||
|
||||
/** 把 symbol+name 的列表转成切股导航列表项 (统一 name 归一化为 undefined, 免去各处重复 map + as 断言) */
|
||||
export function toNavItems<T extends { symbol: string; name?: string | null }>(xs: T[]): NavItem[] {
|
||||
return xs.map(x => ({ symbol: x.symbol, name: x.name ?? undefined }))
|
||||
}
|
||||
|
||||
/** 首↔尾循环的索引换算: go(delta) 与 邻近预取 共用, 保证换行规则单源 */
|
||||
function wrapNavIndex(navIdx: number, delta: number, navTotal: number): number {
|
||||
return (navIdx + delta + navTotal) % navTotal
|
||||
}
|
||||
|
||||
/** 榜单里同一标的可能多次出现 (多概念/行业 leader、监控重复触发), 去重以免切股/计数空跳; 保留首次出现。 */
|
||||
function uniqueNavItems(xs: NavItem[]): NavItem[] {
|
||||
const seen = new Set<string>()
|
||||
const out: NavItem[] = []
|
||||
for (const n of xs) {
|
||||
if (seen.has(n.symbol)) continue
|
||||
seen.add(n.symbol)
|
||||
out.push(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ===== 板块标识(与 Screener 列表一致)=====
|
||||
@@ -50,11 +80,11 @@ interface PriceAlertDraft {
|
||||
}
|
||||
const INTRADAY_DAY_OPTIONS = [1, 5, 10, 20] as const
|
||||
|
||||
function loadIntradayDays(): number | null {
|
||||
const saved = storage.stockPreviewIntradayDays.get(10)
|
||||
function loadIntradayDays(): number {
|
||||
const saved = storage.stockPreviewIntradayDays.get(DEFAULT_INTRADAY_DAYS)
|
||||
return INTRADAY_DAY_OPTIONS.includes(saved as typeof INTRADAY_DAY_OPTIONS[number])
|
||||
? saved
|
||||
: null
|
||||
: DEFAULT_INTRADAY_DAYS
|
||||
}
|
||||
|
||||
function boardTag(symbol: string): { label: string; color: string } | null {
|
||||
@@ -79,7 +109,7 @@ function fmtAbnormalCalcTime(asofSec: number): string {
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) {
|
||||
export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList: navListSource, onNavigate }: Props) {
|
||||
const [view, setView] = useState<PreviewView>('daily')
|
||||
const [intradayDays, setIntradayDays] = useState<number | null>(loadIntradayDays)
|
||||
const [dateRange, setDateRange] = useState(getDefaultRange)
|
||||
@@ -137,18 +167,83 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
},
|
||||
})
|
||||
|
||||
// ESC 关闭
|
||||
// ===== 切股导航 =====
|
||||
const navList = useMemo(() => uniqueNavItems(navListSource ?? []), [navListSource])
|
||||
|
||||
// 当前 symbol 在 navList 中的位置 (不在列表则为 -1, 此时不显示计数/按钮)
|
||||
const navIdx = navList.findIndex(n => n.symbol === symbol)
|
||||
const navTotal = navList.length
|
||||
const navEnabled = navTotal >= 2 && navIdx >= 0
|
||||
|
||||
// 首↔尾循环的弱提示 (自显 ~1.5s, 不引全局 Toast)
|
||||
const [wrapMsg, setWrapMsg] = useState<string | null>(null)
|
||||
const wrapTimer = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
return () => { if (wrapTimer.current) window.clearTimeout(wrapTimer.current) }
|
||||
}, [])
|
||||
|
||||
// 父级 onNavigate/onClose 多为内联 lambda, 用最新值 ref 承接, 避免每次父渲染重建 go/键盘监听
|
||||
const onNavigateRef = useRef(onNavigate)
|
||||
onNavigateRef.current = onNavigate
|
||||
const onCloseRef = useRef(onClose)
|
||||
onCloseRef.current = onClose
|
||||
|
||||
// 前后切股: 返回是否真正导航 (供键盘判断是否要 preventDefault)
|
||||
const go = useCallback((delta: 1 | -1): boolean => {
|
||||
if (!navEnabled) return false
|
||||
const nextIdx = wrapNavIndex(navIdx, delta, navTotal)
|
||||
const wrapped = nextIdx === (delta === 1 ? 0 : navTotal - 1)
|
||||
if (wrapped) {
|
||||
// 提示词描述切股后的落点 (而非起点)
|
||||
setWrapMsg(delta === 1 ? '已到榜首' : '已到末尾')
|
||||
if (wrapTimer.current) window.clearTimeout(wrapTimer.current)
|
||||
wrapTimer.current = window.setTimeout(() => setWrapMsg(null), 1500)
|
||||
}
|
||||
const next = navList[nextIdx]
|
||||
onNavigateRef.current?.(next.symbol, next.name)
|
||||
return true
|
||||
}, [navList, navIdx, navTotal])
|
||||
|
||||
// 邻近预取目标: 当前股左右相邻两只 (首↔尾循环), 交由 StockPanel 提前拉取日K/财务/分时缓存
|
||||
const prefetchSymbols = useMemo(() => {
|
||||
if (!navEnabled) return []
|
||||
return [
|
||||
navList[wrapNavIndex(navIdx, -1, navTotal)].symbol,
|
||||
navList[wrapNavIndex(navIdx, 1, navTotal)].symbol,
|
||||
]
|
||||
}, [navEnabled, navIdx, navTotal, navList])
|
||||
|
||||
// ESC 关闭 + 左右键切股
|
||||
useEffect(() => {
|
||||
if (!symbol) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !priceAlertDraft) onClose()
|
||||
if (e.key === 'Escape' && !priceAlertDraft) { onCloseRef.current(); return }
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
// 点位监控弹窗打开时方向键不切股 (与 ESC 的 !priceAlertDraft 守卫同层级)
|
||||
if (priceAlertDraft) return
|
||||
// 焦点在输入框/编辑器时方向键让位给光标/输入, 不切股
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return
|
||||
if (showMonitorEditor) return
|
||||
if (go(e.key === 'ArrowRight' ? 1 : -1)) {
|
||||
e.preventDefault()
|
||||
// 切股后清掉控件残留的键盘焦点: 点过分时tab/外链等控件后方向键切股,
|
||||
// 浏览器会给该控件显示 focus-visible 默认蓝色 outline, 切换后 blur 掉避免残留。
|
||||
// keydown 的 e.target 即聚焦元素, 复用已捕获的 t (已排除输入框/编辑器)。
|
||||
t?.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [symbol, onClose, priceAlertDraft])
|
||||
}, [symbol, go, showMonitorEditor, priceAlertDraft])
|
||||
|
||||
// 弹窗内切股时保留当前视图 (分时 tab 下切股不应跳回日K);
|
||||
// 仅当弹窗首次打开 (symbol 从 null 变非空) 时重置为日K。
|
||||
const prevSymbolRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (symbol) setView('daily')
|
||||
if (prevSymbolRef.current == null && symbol != null) setView('daily')
|
||||
prevSymbolRef.current = symbol
|
||||
setPriceAlertDraft(null)
|
||||
}, [symbol])
|
||||
|
||||
@@ -223,7 +318,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={cn(
|
||||
'relative rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col transition-all duration-200 ease-smooth',
|
||||
maximized ? 'w-screen h-screen max-w-none max-h-none' : 'w-[92vw] max-w-[1100px] max-h-[95vh]',
|
||||
maximized ? 'w-screen h-screen max-w-none max-h-none' : 'w-[92vw] max-w-[1200px] max-h-[95vh]',
|
||||
)}
|
||||
>
|
||||
{/* 顶栏 */}
|
||||
@@ -239,6 +334,32 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
})()}
|
||||
<span className="shrink-0 font-mono text-sm font-medium text-foreground">{symbol}</span>
|
||||
{name && <span className="truncate text-xs text-muted">{name}</span>}
|
||||
|
||||
{/* 切股导航: 上一只 / n·N / 下一只 */}
|
||||
{navEnabled && (
|
||||
<>
|
||||
<span className="mx-0.5 shrink-0 text-muted/20">|</span>
|
||||
<button
|
||||
onClick={() => go(-1)}
|
||||
title="上一只 (←)"
|
||||
aria-label="上一只"
|
||||
className="p-1 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors cursor-pointer"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="shrink-0 font-mono text-[11px] text-secondary tabular-nums whitespace-nowrap">
|
||||
{navIdx + 1} / {navTotal}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => go(1)}
|
||||
title="下一只 (→)"
|
||||
aria-label="下一只"
|
||||
className="p-1 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors cursor-pointer"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -491,6 +612,9 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
priceLines={monitorPriceLines}
|
||||
onPriceDoubleClick={openPriceAlert}
|
||||
refetchIntervalMs={intradayRefetchMs}
|
||||
prefetchSymbols={prefetchSymbols}
|
||||
intradayDays={effectiveIntradayDays}
|
||||
dailyKlineFlex="flex-[1.4]"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -498,6 +622,8 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
symbol={symbol}
|
||||
dateRange={dateRange}
|
||||
infoBarOnly
|
||||
prefetchSymbols={prefetchSymbols}
|
||||
intradayDays={effectiveIntradayDays}
|
||||
/>
|
||||
<StockMultiDayIntradayChart
|
||||
symbol={symbol}
|
||||
@@ -546,6 +672,21 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 首↔尾循环弱提示 */}
|
||||
<AnimatePresence>
|
||||
{wrapMsg && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="pointer-events-none absolute bottom-4 left-1/2 z-30 -translate-x-1/2 rounded-full border border-border bg-surface/95 px-3 py-1.5 text-[11px] text-secondary shadow-lg backdrop-blur"
|
||||
>
|
||||
{wrapMsg}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
dimensionKindForSourceField,
|
||||
type DimensionMembersTarget,
|
||||
} from '@/components/DimensionMembersDialog'
|
||||
import { toNavItems, type NavItem } from '@/components/StockPreviewDialog'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
interface ScreenerTableProps {
|
||||
rows: any[]
|
||||
@@ -30,7 +32,7 @@ interface ScreenerTableProps {
|
||||
symbolStrategyMap: Map<string, string[]>
|
||||
activeStrategy: string | null
|
||||
watchlistSet: Set<string>
|
||||
onPreview: (symbol: string, name: string) => void
|
||||
onPreview: (symbol: string, name?: string, navList?: NavItem[]) => void
|
||||
onAddToWatchlist: (symbol: string, groupId: string | null) => void
|
||||
onRemoveFromWatchlist: (symbol: string) => void
|
||||
watchlistPending: boolean
|
||||
@@ -57,6 +59,8 @@ interface ScreenerTableProps {
|
||||
/** 表头排序(受控,由 Screener.tsx 传入) */
|
||||
sort?: SortState | null
|
||||
onSortToggle?: (colId: string) => void
|
||||
/** 正在 K 线弹窗预览中的 symbol → 高亮该行 */
|
||||
activeSymbol?: string | null
|
||||
}
|
||||
|
||||
/** 渲染标签数组(含 maxTags 折叠/展开、横竖排列)。策略列与 ext 列共用。
|
||||
@@ -161,7 +165,7 @@ export function ScreenerTable({
|
||||
minuteData = {}, intradayChartVisible = true, onToggleIntradayChart,
|
||||
intradayAutoRefresh = false, onRefreshIntraday, intradayRefreshing = false,
|
||||
strategyTagsExpanded = false, onToggleStrategyTags,
|
||||
sort, onSortToggle,
|
||||
sort, onSortToggle, activeSymbol,
|
||||
}: ScreenerTableProps) {
|
||||
const [expandedCells, setExpandedCells] = useState<Set<string>>(new Set())
|
||||
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
|
||||
@@ -244,7 +248,7 @@ export function ScreenerTable({
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPreview(r.symbol, r.name ?? '')}
|
||||
onClick={() => onPreview(r.symbol, r.name ?? '', toNavItems(rows))}
|
||||
className={`flex items-center gap-2 text-left ${isExpired ? 'cursor-default' : ''}`}
|
||||
>
|
||||
{board ? (
|
||||
@@ -393,10 +397,12 @@ export function ScreenerTable({
|
||||
onSortToggle={onSortToggle}
|
||||
minWidth={Math.max(900, columns.filter(c => c.visible).length * 110)}
|
||||
rowKey={(r: any) => `${r.symbol}${r._expired ? '-expired' : ''}`}
|
||||
rowClassName={(r: any) => r._expired
|
||||
? 'border-border/50 opacity-40'
|
||||
: 'border-border hover:bg-elevated/50'
|
||||
}
|
||||
rowClassName={(r: any) => cn(
|
||||
r._expired
|
||||
? 'border-border/50 opacity-40'
|
||||
: 'border-border hover:bg-elevated/50',
|
||||
r.symbol === activeSymbol && 'bg-accent/10',
|
||||
)}
|
||||
// 日k / 分时列表头:标签 + 显示/隐藏的眼睛按钮(与自选页一致)
|
||||
renderHeaderContent={(col) => {
|
||||
if (col.source.type !== 'builtin') return undefined
|
||||
@@ -487,9 +493,9 @@ export function ScreenerTable({
|
||||
<DimensionMembersDialog
|
||||
target={dimensionTarget}
|
||||
onClose={() => setDimensionTarget(null)}
|
||||
onStockClick={(symbol, name) => {
|
||||
onStockClick={(symbol, name, navList) => {
|
||||
setDimensionTarget(null)
|
||||
onPreview(symbol, name ?? '')
|
||||
onPreview(symbol, name ?? '', navList)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 日K查询配置 — klineDaily 的唯一权威 options。
|
||||
*
|
||||
* StockDailyKChart(图表) 与 StockPanel(信息条) 各自 useQuery 共享同一 cache key,
|
||||
* React Query 按 key 去重只发一次请求; 邻近预取 prefetchQuery 也复用本配置, 三处不会漂移。
|
||||
*
|
||||
* placeholderData 内置"仅同 symbol 占位"守卫: 改日期范围/扩展字段时旧数据可暂显(不闪),
|
||||
* 切股时不透传上一只股票的数据(不误显示)。
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
|
||||
/** 分时 tab 多日分时默认周期 (StockPanel 预取与弹窗存储回退共用, 避免魔数两处漂移) */
|
||||
export const DEFAULT_INTRADAY_DAYS = 10
|
||||
|
||||
export function klineDailyQueryOptions(
|
||||
symbol: string,
|
||||
dateRange: { start: string; end: string },
|
||||
extColumns?: string,
|
||||
) {
|
||||
return {
|
||||
queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns),
|
||||
queryFn: () => api.klineDaily(symbol, undefined, dateRange, extColumns),
|
||||
// 工厂无 TData 泛型, 参数用 any 以便 useQuery/prefetchQuery 共用
|
||||
placeholderData: (prev: any, prevQuery: any) => {
|
||||
const prevKey = prevQuery?.queryKey as readonly unknown[] | undefined
|
||||
return prevKey?.[1] === symbol ? prev : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单日分时查询配置 — 与 klineDailyQueryOptions 同风格的单源 options (date 为空 = 最新日内)。
|
||||
*
|
||||
* live 仅当日盘中生效: 传 true 时后端实时拉取最新K, 不被分钟增量落盘(≥60s 一轮)拖慢;
|
||||
* 历史日期后端自行忽略 live, 故多日图与预取恒传 true 亦不影响历史读取。
|
||||
*/
|
||||
export function klineMinuteQueryOptions(symbol: string, date?: string, live?: boolean) {
|
||||
return {
|
||||
queryKey: QK.klineMinute(symbol, date ?? ''),
|
||||
queryFn: () => api.klineMinute(symbol, date ?? undefined, live),
|
||||
}
|
||||
}
|
||||
|
||||
/** 多日分时查询配置 — 分时 tab 的 StockMultiDayIntradayChart 与 邻近预取 共用。
|
||||
* 内嵌「仅同 symbol 占位」守卫 (key 结构 ['kline-minute-range', symbol, days], index 1 为 symbol),
|
||||
* 与 klineDailyQueryOptions 同源, 调用点不再各自手写。 */
|
||||
export function klineMinuteRangeQueryOptions(symbol: string, days: number) {
|
||||
return {
|
||||
queryKey: QK.klineMinuteRange(symbol, days),
|
||||
queryFn: () => api.klineMinuteRange(symbol, days),
|
||||
placeholderData: (prev: any, prevQuery: any) => {
|
||||
const prevKey = prevQuery?.queryKey as readonly unknown[] | undefined
|
||||
return prevKey?.[1] === symbol ? prev : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 个股详情外链 — 可配置 URL 模板 + 证券代码。
|
||||
*
|
||||
* 占位符保持独立, 用户自行排列 (市场前缀/后缀因站而异):
|
||||
* {code} 纯 6 位代码, 如 000001
|
||||
* {market} 市场前缀小写, 如 sz / sh / bj
|
||||
* {symbol} 完整 symbol (含交易所后缀), 如 000001.SZ
|
||||
* 模板留空 = 关闭外链。
|
||||
*/
|
||||
import { storage } from '@/lib/storage'
|
||||
|
||||
/** 形状守卫: 只放行 6位数字 + 沪深北后缀 的 symbol (信息条场景下即股票) */
|
||||
const STOCK_SYMBOL_RE = /^(\d{6})\.(SH|SZ|BJ)$/
|
||||
|
||||
/** 未设置或留空 → 返回 '' 即关闭外链 */
|
||||
export function loadStockExternalTemplate(): string {
|
||||
return storage.stockExternalTemplate.get('')
|
||||
}
|
||||
|
||||
export function saveStockExternalTemplate(tpl: string): void {
|
||||
storage.stockExternalTemplate.set(tpl.trim())
|
||||
}
|
||||
|
||||
export function buildStockExternalUrl(template: string, symbol: string): string | null {
|
||||
if (!template) return null
|
||||
// scheme 白名单: 只放行 http/https, 挡掉 javascript:/data: 等危险 scheme
|
||||
if (!/^https?:\/\//i.test(template.trim())) return null
|
||||
const m = symbol.match(STOCK_SYMBOL_RE)
|
||||
if (!m) return null
|
||||
const code = m[1]
|
||||
const market = m[2].toLowerCase()
|
||||
return template
|
||||
.replaceAll('{code}', code)
|
||||
.replaceAll('{market}', market)
|
||||
.replaceAll('{symbol}', symbol)
|
||||
}
|
||||
@@ -44,6 +44,9 @@ export const storage = {
|
||||
/** 个股详情多日分时周期 */
|
||||
stockPreviewIntradayDays: kv<number>('stock_preview_intraday_days'),
|
||||
|
||||
/** 个股详情外链 URL 模板 (支持 {code}/{market}/{symbol}; 留空关闭) */
|
||||
stockExternalTemplate: kv<string>('stock_external_template'),
|
||||
|
||||
/** 策略结果列表列配置 */
|
||||
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
|
||||
|
||||
|
||||
@@ -20,13 +20,16 @@ export function useFinancialStatus() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinancialMetrics(symbol?: string) {
|
||||
return useQuery({
|
||||
export function financialMetricsQueryOptions(symbol?: string) {
|
||||
return {
|
||||
queryKey: FINANCIAL_QK.metrics(symbol),
|
||||
queryFn: () => api.financialMetrics(symbol),
|
||||
enabled: !!symbol,
|
||||
staleTime: 300_000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function useFinancialMetrics(symbol?: string) {
|
||||
return useQuery({ ...financialMetricsQueryOptions(symbol), enabled: !!symbol })
|
||||
}
|
||||
|
||||
export function useFinancialIncome(symbol?: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { AnalysisConfigDialog, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog'
|
||||
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
|
||||
import { api, type MarketSnapshotRow } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
@@ -241,6 +241,12 @@ export function ConceptAnalysis() {
|
||||
const [sortMode, setSortMode] = useState<SortMode>('heat')
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const [previewName, setPreviewName] = useState<string>('')
|
||||
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
|
||||
const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => {
|
||||
setPreviewSymbol(symbol)
|
||||
setPreviewName(name ?? '')
|
||||
setPreviewNavList(navList ?? [])
|
||||
}, [])
|
||||
const [showRps, setShowRps] = useState(false)
|
||||
|
||||
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
|
||||
@@ -393,7 +399,8 @@ export function ConceptAnalysis() {
|
||||
falling={falling}
|
||||
selectedKey={selected?.key ?? null}
|
||||
onSelect={setSelectedKey}
|
||||
onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }}
|
||||
activeSymbol={previewSymbol}
|
||||
onStockClick={handleStockClick}
|
||||
/>
|
||||
|
||||
{stats.length > 0 ? (
|
||||
@@ -407,7 +414,7 @@ export function ConceptAnalysis() {
|
||||
onSort={setSortMode}
|
||||
onSelect={setSelectedKey}
|
||||
/>
|
||||
<ConceptFocus stat={selected} onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} />
|
||||
<ConceptFocus stat={selected} activeSymbol={previewSymbol} onStockClick={handleStockClick} />
|
||||
</div>
|
||||
) : rowsQuery.isLoading ? (
|
||||
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">正在计算概念强度...</div>
|
||||
@@ -433,7 +440,9 @@ export function ConceptAnalysis() {
|
||||
<StockPreviewDialog
|
||||
symbol={previewSymbol}
|
||||
name={previewName}
|
||||
onClose={() => { setPreviewSymbol(null); setPreviewName('') }}
|
||||
onClose={() => { setPreviewSymbol(null); setPreviewName(''); setPreviewNavList([]) }}
|
||||
navList={previewNavList}
|
||||
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -509,17 +518,19 @@ function MarketPulse({
|
||||
selectedKey,
|
||||
onSelect,
|
||||
onStockClick,
|
||||
activeSymbol,
|
||||
}: {
|
||||
leading: ConceptStat[]
|
||||
falling: ConceptStat[]
|
||||
selectedKey: string | null
|
||||
onSelect: (key: string) => void
|
||||
onStockClick: (symbol: string, name?: string) => void
|
||||
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
|
||||
activeSymbol: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
|
||||
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
|
||||
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
|
||||
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -531,13 +542,15 @@ function PulseList({
|
||||
selectedKey,
|
||||
onSelect,
|
||||
onStockClick,
|
||||
activeSymbol,
|
||||
}: {
|
||||
title: string
|
||||
items: ConceptStat[]
|
||||
mode: 'up' | 'down'
|
||||
selectedKey: string | null
|
||||
onSelect: (key: string) => void
|
||||
onStockClick: (symbol: string, name?: string) => void
|
||||
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
|
||||
activeSymbol: string | null
|
||||
}) {
|
||||
const toneText = mode === 'up' ? 'text-bull' : 'text-bear'
|
||||
const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20'
|
||||
@@ -556,7 +569,8 @@ function PulseList({
|
||||
<div className="space-y-1">
|
||||
{items.map((item, idx) => {
|
||||
const active = selectedKey === item.key
|
||||
const leaders = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, 3)
|
||||
const sortedStocks = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore)
|
||||
const leaders = sortedStocks.slice(0, 3)
|
||||
const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0
|
||||
const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0
|
||||
const flatPct = Math.max(0, 100 - upPct - downPct)
|
||||
@@ -597,7 +611,7 @@ function PulseList({
|
||||
{Array.from({ length: 3 }).map((_, i) => {
|
||||
const stock = leaders[i]
|
||||
return stock ? (
|
||||
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary')}>
|
||||
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined, toNavItems(sortedStocks.slice(0, MAX_RENDERED_STOCKS))) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
|
||||
</span>
|
||||
@@ -674,10 +688,11 @@ function ConceptRail({
|
||||
)
|
||||
}
|
||||
|
||||
function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStockClick: (symbol: string, name?: string) => void }) {
|
||||
function ConceptFocus({ stat, onStockClick, activeSymbol }: { stat: ConceptStat | null; onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void; activeSymbol: string | null }) {
|
||||
if (!stat) return null
|
||||
const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS)
|
||||
const topLeaders = stocks.slice(0, 3)
|
||||
const focusNav: NavItem[] = toNavItems(stocks)
|
||||
return (
|
||||
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
|
||||
<div className="shrink-0 border-b border-border px-5 py-4">
|
||||
@@ -706,7 +721,7 @@ function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStoc
|
||||
</div>
|
||||
|
||||
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
|
||||
<LeaderStage stocks={topLeaders} onStockClick={onStockClick} />
|
||||
<LeaderStage stocks={topLeaders} activeSymbol={activeSymbol} onStockClick={(sym, name) => onStockClick(sym, name, focusNav)} />
|
||||
<ScoreExplain stock={topLeaders[0]} />
|
||||
</div>
|
||||
|
||||
@@ -726,7 +741,7 @@ function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStoc
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/70">
|
||||
{stocks.map((s, idx) => (
|
||||
<tr key={`${s.symbol}-${idx}`} className="hover:bg-elevated/30 cursor-pointer" onClick={() => onStockClick(s.symbol, s.name || undefined)}>
|
||||
<tr key={`${s.symbol}-${idx}`} className={cn('cursor-pointer', s.symbol === activeSymbol ? 'bg-accent/10 hover:bg-accent/15' : 'hover:bg-elevated/30')} onClick={() => onStockClick(s.symbol, s.name || undefined, focusNav)}>
|
||||
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="font-medium text-foreground">{s.name || '—'}</div>
|
||||
@@ -757,7 +772,7 @@ function MiniStat({ label, value, cls }: { label: string; value: string; cls: st
|
||||
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
|
||||
}
|
||||
|
||||
function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void }) {
|
||||
function LeaderStage({ stocks, onStockClick, activeSymbol }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void; activeSymbol: string | null }) {
|
||||
if (!stocks.length) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted">暂无龙头候选</div>
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-surface p-3">
|
||||
@@ -767,7 +782,7 @@ function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStoc
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
{stocks.map((stock, idx) => (
|
||||
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35')}>
|
||||
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
|
||||
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { fmtBigNum, fmtPct } from '@/lib/format'
|
||||
import { DimensionMembersDialog, dimensionKindForSourceField, type DimensionMembersTarget } from '@/components/DimensionMembersDialog'
|
||||
import { useDataStatus, useCapabilities, useSettings, usePreferences } from '@/lib/useSharedQueries'
|
||||
import { SealedBadge } from '@/components/SealedBadge'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog'
|
||||
import { SettingsModal } from '@/components/data/SettingsModal'
|
||||
import { useAdjFactorSyncGate } from '@/components/AdjFactorSyncGate'
|
||||
import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
|
||||
@@ -98,7 +98,10 @@ const _SEVERITY_BAR: Record<string, string> = {
|
||||
info: 'bg-accent/40', warn: 'bg-warning', critical: 'bg-danger',
|
||||
}
|
||||
|
||||
function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) => void }) {
|
||||
function MonitorWidget({ onStockClick, activeSymbol }: {
|
||||
onStockClick: (event: AlertEvent, navList?: NavItem[]) => void
|
||||
activeSymbol?: string
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const alerts = useQuery({
|
||||
queryKey: ['alerts', ''],
|
||||
@@ -106,6 +109,8 @@ function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) =>
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
const events: AlertEvent[] = alerts.data?.alerts ?? []
|
||||
// 切股导航列表: 有 symbol 的触发记录
|
||||
const alertNav = toNavItems(events.filter((ev): ev is AlertEvent & { symbol: string } => !!ev.symbol))
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
@@ -129,13 +134,13 @@ function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) =>
|
||||
initial={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
|
||||
className="relative overflow-hidden rounded-md border border-border/40 bg-surface/60 pl-2.5 pr-2 py-1.5 hover:border-border hover:bg-surface transition-colors"
|
||||
className={`relative overflow-hidden rounded-md border pl-2.5 pr-2 py-1.5 transition-colors ${ev.symbol && ev.symbol === activeSymbol ? 'border-accent/40 bg-accent/5' : 'border-border/40 bg-surface/60 hover:border-border hover:bg-surface'}`}
|
||||
>
|
||||
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
|
||||
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 (点击代码/名称弹日K) */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => isSector ? navigate('/monitor') : ev.symbol && onStockClick(ev)}
|
||||
onClick={() => isSector ? navigate('/monitor') : ev.symbol && onStockClick(ev, alertNav)}
|
||||
title={isSector ? '在监控中心查看板块告警' : ev.symbol ? `查看 ${ev.symbol} 日K` : undefined}
|
||||
className={`inline-flex items-center gap-1 min-w-0 shrink-0 rounded hover:bg-elevated/60 transition-colors -mx-0.5 px-0.5 ${isSector || ev.symbol ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
>
|
||||
@@ -409,9 +414,10 @@ function MiniMetric({ label, value, cls = 'text-foreground' }: { label: string;
|
||||
)
|
||||
}
|
||||
|
||||
function StockList({ title, rows, mode, onStockClick }: {
|
||||
function StockList({ title, rows, mode, onStockClick, activeSymbol }: {
|
||||
title: string; rows: MarketSnapshotRow[]; mode: 'gain' | 'loss' | 'amount' | 'active';
|
||||
onStockClick?: (symbol: string, name?: string) => void;
|
||||
activeSymbol?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-surface/80 p-1.5 shadow-[0_1px_2px_hsl(var(--border)/0.4)] backdrop-blur-sm transition-shadow hover:shadow-[0_2px_8px_hsl(var(--border)/0.5)]">
|
||||
@@ -423,7 +429,7 @@ function StockList({ title, rows, mode, onStockClick }: {
|
||||
{rows.slice(0, 8).map((r, idx) => (
|
||||
<div
|
||||
key={`${r.symbol}-${idx}`}
|
||||
className="grid grid-cols-[18px_1fr_auto] items-center gap-1.5 rounded-md bg-elevated/40 px-1.5 py-1 cursor-pointer hover:bg-elevated hover:brightness-110 transition-colors border border-transparent hover:border-border/60"
|
||||
className={`grid grid-cols-[18px_1fr_auto] items-center gap-1.5 rounded-md px-1.5 py-1 cursor-pointer transition-colors border ${r.symbol === activeSymbol ? 'bg-accent/10 border-accent/30' : 'bg-elevated/40 border-transparent hover:bg-elevated hover:brightness-110 hover:border-border/60'}`}
|
||||
onClick={() => onStockClick?.(r.symbol, r.name ?? undefined)}
|
||||
>
|
||||
<span className="text-center font-mono text-[10px] text-muted">{idx + 1}</span>
|
||||
@@ -467,10 +473,11 @@ function StockList({ title, rows, mode, onStockClick }: {
|
||||
)
|
||||
}
|
||||
|
||||
function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
|
||||
function RankColumn({ title, rows, tone, onStockClick, onDimensionClick, activeSymbol }: {
|
||||
title: string; rows: OverviewDimensionRankItem[]; tone: 'bull' | 'bear';
|
||||
onStockClick?: (symbol: string, name?: string) => void
|
||||
onDimensionClick?: (target: DimensionMembersTarget) => void
|
||||
onStockClick?: (symbol: string, name?: string) => void;
|
||||
onDimensionClick?: (target: DimensionMembersTarget) => void;
|
||||
activeSymbol?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-1">
|
||||
@@ -478,6 +485,7 @@ function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
|
||||
{rows.slice(0, 5).map((r, idx) => {
|
||||
const kind = r.source_field ? dimensionKindForSourceField(r.source_field) : null
|
||||
const clickable = !!(r.source_field && kind && onDimensionClick)
|
||||
const isActive = r.leader?.symbol != null && r.leader.symbol === activeSymbol
|
||||
return (
|
||||
<div
|
||||
key={`${title}-${r.name}-${idx}`}
|
||||
@@ -487,9 +495,9 @@ function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
|
||||
sourceField: r.source_field!,
|
||||
})}
|
||||
title={clickable ? `查看「${r.name}」成分股` : undefined}
|
||||
className={`grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded-md bg-elevated/40 px-1.5 py-1 border border-transparent transition-colors ${
|
||||
clickable ? 'cursor-pointer hover:border-accent/40 hover:bg-elevated/70' : 'hover:border-border/60'
|
||||
}`}
|
||||
className={`grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded-md px-1.5 py-1 border transition-colors ${
|
||||
isActive ? 'border-accent/30 bg-accent/10' : 'border-transparent bg-elevated/40'
|
||||
} ${clickable ? 'cursor-pointer hover:border-accent/40 hover:bg-elevated/70' : 'hover:border-border/60'}`}
|
||||
>
|
||||
<span className="text-center font-mono text-[9px] text-muted">{idx + 1}</span>
|
||||
<div className="min-w-0">
|
||||
@@ -533,10 +541,11 @@ function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
|
||||
)
|
||||
}
|
||||
|
||||
function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick }: {
|
||||
function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick, activeSymbol }: {
|
||||
title: string; rank?: OverviewMarket['concept_rank']; configUrl: string;
|
||||
onStockClick?: (symbol: string, name?: string) => void
|
||||
onDimensionClick?: (target: DimensionMembersTarget) => void
|
||||
onStockClick?: (symbol: string, name?: string) => void;
|
||||
onDimensionClick?: (target: DimensionMembersTarget) => void;
|
||||
activeSymbol?: string;
|
||||
}) {
|
||||
const hasData = (rank?.leading?.length ?? 0) > 0 || (rank?.lagging?.length ?? 0) > 0
|
||||
return (
|
||||
@@ -544,8 +553,8 @@ function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick }:
|
||||
<SectionTitle icon={Flame} title={title} hint="领涨/领跌 · 点击板块看成分股" />
|
||||
{hasData ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<RankColumn title="领涨" rows={rank?.leading ?? []} tone="bull" onStockClick={onStockClick} onDimensionClick={onDimensionClick} />
|
||||
<RankColumn title="领跌" rows={rank?.lagging ?? []} tone="bear" onStockClick={onStockClick} onDimensionClick={onDimensionClick} />
|
||||
<RankColumn title="领涨" rows={rank?.leading ?? []} tone="bull" onStockClick={onStockClick} onDimensionClick={onDimensionClick} activeSymbol={activeSymbol} />
|
||||
<RankColumn title="领跌" rows={rank?.lagging ?? []} tone="bear" onStockClick={onStockClick} onDimensionClick={onDimensionClick} activeSymbol={activeSymbol} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center">
|
||||
@@ -562,11 +571,30 @@ function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick }:
|
||||
)
|
||||
}
|
||||
|
||||
// 切股导航列表构建 (与列表展示行一致: StockList 只显示前 8)
|
||||
function stockListNav(rows: MarketSnapshotRow[]): NavItem[] {
|
||||
return toNavItems(rows.slice(0, 8))
|
||||
}
|
||||
function rankNav(rank?: OverviewMarket['concept_rank']): NavItem[] {
|
||||
const leaders = [...(rank?.leading ?? []), ...(rank?.lagging ?? [])]
|
||||
.map(r => r.leader)
|
||||
.filter((l): l is NonNullable<typeof l> & { symbol: string } => !!l?.symbol)
|
||||
return toNavItems(leaders)
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const qc = useQueryClient()
|
||||
const [selectedDate, setSelectedDate] = useState<string | undefined>()
|
||||
const [manualFetching, setManualFetching] = useState(false)
|
||||
const [previewStock, setPreviewStock] = useState<{symbol: string; name?: string; alert?: AlertEvent} | null>(null)
|
||||
const [previewStock, setPreviewStock] = useState<{
|
||||
symbol: string
|
||||
name?: string
|
||||
alert?: AlertEvent
|
||||
/** 打开来源榜: 仅高亮来源榜的行 */
|
||||
source?: 'gain' | 'loss' | 'amount' | 'active' | 'concept' | 'industry' | 'alert'
|
||||
/** 切股导航列表 (来自来源榜) */
|
||||
navList?: NavItem[]
|
||||
} | null>(null)
|
||||
// 板块成分股弹窗 (概念/行业热度卡片行点击)
|
||||
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
|
||||
// 首次使用(无数据 + 未完成引导)自动弹窗: 同一会话只弹一次
|
||||
@@ -855,19 +883,19 @@ export function Dashboard() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 md:grid-cols-2">
|
||||
<HotRankCard title="概念热度" rank={data.concept_rank} configUrl="/concept-analysis"
|
||||
onStockClick={(symbol, name) => setPreviewStock({symbol, name})}
|
||||
<HotRankCard title="概念热度" rank={data.concept_rank} configUrl="/concept-analysis" activeSymbol={previewStock?.source === 'concept' ? previewStock.symbol : undefined}
|
||||
onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'concept', navList: rankNav(data.concept_rank) })}
|
||||
onDimensionClick={setDimensionTarget} />
|
||||
<HotRankCard title="行业热度" rank={data.industry_rank} configUrl="/industry-analysis"
|
||||
onStockClick={(symbol, name) => setPreviewStock({symbol, name})}
|
||||
<HotRankCard title="行业热度" rank={data.industry_rank} configUrl="/industry-analysis" activeSymbol={previewStock?.source === 'industry' ? previewStock.symbol : undefined}
|
||||
onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'industry', navList: rankNav(data.industry_rank) })}
|
||||
onDimensionClick={setDimensionTarget} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StockList title="涨幅榜" rows={data.top_gainers} mode="gain" onStockClick={(symbol, name) => setPreviewStock({symbol, name})} />
|
||||
<StockList title="跌幅榜" rows={data.top_losers} mode="loss" onStockClick={(symbol, name) => setPreviewStock({symbol, name})} />
|
||||
<StockList title="成交额榜" rows={data.turnover_leaders} mode="amount" onStockClick={(symbol, name) => setPreviewStock({symbol, name})} />
|
||||
<StockList title="活跃换手" rows={data.active_leaders} mode="active" onStockClick={(symbol, name) => setPreviewStock({symbol, name})} />
|
||||
<StockList title="涨幅榜" rows={data.top_gainers} mode="gain" activeSymbol={previewStock?.source === 'gain' ? previewStock.symbol : undefined} onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'gain', navList: stockListNav(data.top_gainers) })} />
|
||||
<StockList title="跌幅榜" rows={data.top_losers} mode="loss" activeSymbol={previewStock?.source === 'loss' ? previewStock.symbol : undefined} onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'loss', navList: stockListNav(data.top_losers) })} />
|
||||
<StockList title="成交额榜" rows={data.turnover_leaders} mode="amount" activeSymbol={previewStock?.source === 'amount' ? previewStock.symbol : undefined} onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'amount', navList: stockListNav(data.turnover_leaders) })} />
|
||||
<StockList title="活跃换手" rows={data.active_leaders} mode="active" activeSymbol={previewStock?.source === 'active' ? previewStock.symbol : undefined} onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'active', navList: stockListNav(data.active_leaders) })} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -887,9 +915,12 @@ export function Dashboard() {
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<MonitorWidget onStockClick={(event) => {
|
||||
if (event.symbol) setPreviewStock({ symbol: event.symbol, name: event.name ?? undefined, alert: event })
|
||||
}} />
|
||||
<MonitorWidget
|
||||
activeSymbol={previewStock?.source === 'alert' ? previewStock.symbol : undefined}
|
||||
onStockClick={(event, navList) => {
|
||||
if (event.symbol) setPreviewStock({ symbol: event.symbol, name: event.name ?? undefined, alert: event, source: 'alert', navList })
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -904,6 +935,8 @@ export function Dashboard() {
|
||||
signals: previewStock.alert.signals,
|
||||
message: previewStock.alert.message,
|
||||
} : null}
|
||||
navList={previewStock?.navList}
|
||||
onNavigate={(sym, n) => setPreviewStock(prev => prev ? { ...prev, symbol: sym, name: n, alert: undefined } : prev)}
|
||||
onClose={() => setPreviewStock(null)}
|
||||
/>
|
||||
<DimensionMembersDialog
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { AnalysisConfigDialog, DimensionHeatmap, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog'
|
||||
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
|
||||
import { api, type MarketSnapshotRow } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
@@ -276,6 +276,12 @@ export function IndustryAnalysis() {
|
||||
const [sortMode, setSortMode] = useState<SortMode>('heat')
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const [previewName, setPreviewName] = useState<string>('')
|
||||
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
|
||||
const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => {
|
||||
setPreviewSymbol(symbol)
|
||||
setPreviewName(name ?? '')
|
||||
setPreviewNavList(navList ?? [])
|
||||
}, [])
|
||||
const [showRps, setShowRps] = useState(false)
|
||||
|
||||
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
|
||||
@@ -446,7 +452,8 @@ export function IndustryAnalysis() {
|
||||
falling={falling}
|
||||
selectedKey={selected?.key ?? null}
|
||||
onSelect={setSelectedKey}
|
||||
onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }}
|
||||
activeSymbol={previewSymbol}
|
||||
onStockClick={handleStockClick}
|
||||
/>
|
||||
|
||||
{/* 热力图 */}
|
||||
@@ -471,7 +478,7 @@ export function IndustryAnalysis() {
|
||||
onSort={setSortMode}
|
||||
onSelect={setSelectedKey}
|
||||
/>
|
||||
<IndustryFocus stat={selected} onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} />
|
||||
<IndustryFocus stat={selected} activeSymbol={previewSymbol} onStockClick={handleStockClick} />
|
||||
</div>
|
||||
) : rowsQuery.isLoading ? (
|
||||
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">正在计算行业强度...</div>
|
||||
@@ -497,7 +504,9 @@ export function IndustryAnalysis() {
|
||||
<StockPreviewDialog
|
||||
symbol={previewSymbol}
|
||||
name={previewName}
|
||||
onClose={() => { setPreviewSymbol(null); setPreviewName('') }}
|
||||
onClose={() => { setPreviewSymbol(null); setPreviewName(''); setPreviewNavList([]) }}
|
||||
navList={previewNavList}
|
||||
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
|
||||
/>
|
||||
)}
|
||||
{showRps && <RpsRotationDialog onClose={() => setShowRps(false)} kind="industry" />}
|
||||
@@ -574,17 +583,19 @@ function MarketPulse({
|
||||
selectedKey,
|
||||
onSelect,
|
||||
onStockClick,
|
||||
activeSymbol,
|
||||
}: {
|
||||
leading: IndustryStat[]
|
||||
falling: IndustryStat[]
|
||||
selectedKey: string | null
|
||||
onSelect: (key: string) => void
|
||||
onStockClick: (symbol: string, name?: string) => void
|
||||
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
|
||||
activeSymbol: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
|
||||
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
|
||||
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
|
||||
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -596,13 +607,15 @@ function PulseList({
|
||||
selectedKey,
|
||||
onSelect,
|
||||
onStockClick,
|
||||
activeSymbol,
|
||||
}: {
|
||||
title: string
|
||||
items: IndustryStat[]
|
||||
mode: 'up' | 'down'
|
||||
selectedKey: string | null
|
||||
onSelect: (key: string) => void
|
||||
onStockClick: (symbol: string, name?: string) => void
|
||||
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
|
||||
activeSymbol: string | null
|
||||
}) {
|
||||
const toneText = mode === 'up' ? 'text-bull' : 'text-bear'
|
||||
const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20'
|
||||
@@ -621,7 +634,8 @@ function PulseList({
|
||||
<div className="space-y-1">
|
||||
{items.map((item, idx) => {
|
||||
const active = selectedKey === item.key
|
||||
const leaders = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, 3)
|
||||
const sortedStocks = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore)
|
||||
const leaders = sortedStocks.slice(0, 3)
|
||||
const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0
|
||||
const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0
|
||||
const flatPct = Math.max(0, 100 - upPct - downPct)
|
||||
@@ -662,7 +676,7 @@ function PulseList({
|
||||
{Array.from({ length: 3 }).map((_, i) => {
|
||||
const stock = leaders[i]
|
||||
return stock ? (
|
||||
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary')}>
|
||||
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined, toNavItems(sortedStocks.slice(0, MAX_RENDERED_STOCKS))) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
|
||||
</span>
|
||||
@@ -743,10 +757,11 @@ function IndustryRail({
|
||||
|
||||
// ===== IndustryFocus(右侧聚焦面板) =====
|
||||
|
||||
function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onStockClick: (symbol: string, name?: string) => void }) {
|
||||
function IndustryFocus({ stat, onStockClick, activeSymbol }: { stat: IndustryStat | null; onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void; activeSymbol: string | null }) {
|
||||
if (!stat) return null
|
||||
const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS)
|
||||
const topLeaders = stocks.slice(0, 3)
|
||||
const focusNav: NavItem[] = toNavItems(stocks)
|
||||
return (
|
||||
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
|
||||
<div className="shrink-0 border-b border-border px-5 py-4">
|
||||
@@ -775,7 +790,7 @@ function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onSt
|
||||
</div>
|
||||
|
||||
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
|
||||
<LeaderStage stocks={topLeaders} onStockClick={onStockClick} />
|
||||
<LeaderStage stocks={topLeaders} activeSymbol={activeSymbol} onStockClick={(sym, name) => onStockClick(sym, name, focusNav)} />
|
||||
<ScoreExplain stock={topLeaders[0]} />
|
||||
</div>
|
||||
|
||||
@@ -795,7 +810,7 @@ function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onSt
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/70">
|
||||
{stocks.map((s, idx) => (
|
||||
<tr key={`${s.symbol}-${idx}`} className="hover:bg-elevated/30 cursor-pointer" onClick={() => onStockClick(s.symbol, s.name || undefined)}>
|
||||
<tr key={`${s.symbol}-${idx}`} className={cn('cursor-pointer', s.symbol === activeSymbol ? 'bg-accent/10 hover:bg-accent/15' : 'hover:bg-elevated/30')} onClick={() => onStockClick(s.symbol, s.name || undefined, focusNav)}>
|
||||
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="font-medium text-foreground">{s.name || '—'}</div>
|
||||
@@ -826,7 +841,7 @@ function MiniStat({ label, value, cls }: { label: string; value: string; cls: st
|
||||
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
|
||||
}
|
||||
|
||||
function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void }) {
|
||||
function LeaderStage({ stocks, onStockClick, activeSymbol }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void; activeSymbol: string | null }) {
|
||||
if (!stocks.length) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted">暂无龙头候选</div>
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-surface p-3">
|
||||
@@ -836,7 +851,7 @@ function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStoc
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
{stocks.map((stock, idx) => (
|
||||
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35')}>
|
||||
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
|
||||
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { RefreshCw, ChevronDown, Flame, Settings2, X, Bell, BellOff, AlertCircle } from 'lucide-react'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { api, type LimitLadderTier, type LimitLadderStock, type MonitorRule } from '@/lib/api'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog'
|
||||
import { DimensionMembersDialog, type DimensionKind, type DimensionMembersTarget } from '@/components/DimensionMembersDialog'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { storage } from '@/lib/storage'
|
||||
@@ -220,7 +220,7 @@ function useSealedDegrade(asOf: string, latestDate: string | undefined, sealedRe
|
||||
|
||||
// ===== 单只股票卡片 =====
|
||||
|
||||
const StockCard = React.memo(function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick, onDimensionClick }: {
|
||||
const StockCard = React.memo(function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick, onDimensionClick, active }: {
|
||||
stock: LimitLadderStock
|
||||
extFields: ExtFieldConfig
|
||||
direction: Direction
|
||||
@@ -231,6 +231,8 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s
|
||||
hasDepth: boolean
|
||||
onClick: (symbol: string, name?: string) => void
|
||||
onDimensionClick: (kind: DimensionKind, value: string, sourceField?: string) => void
|
||||
/** 正在 K 线弹窗预览中 → 高亮卡片 */
|
||||
active?: boolean
|
||||
}) {
|
||||
const [showMonitorMenu, setShowMonitorMenu] = useState(false)
|
||||
const [menuAnchor, setMenuAnchor] = useState<DOMRect | null>(null)
|
||||
@@ -298,7 +300,7 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s
|
||||
event.preventDefault()
|
||||
onClick(stock.symbol, stock.name ?? undefined)
|
||||
}}
|
||||
className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''}`}
|
||||
className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''} ${active ? 'ring-1 ring-accent/60 ring-inset' : ''}`}
|
||||
style={style.cardStyle ? { ...style.cardStyle } : undefined}
|
||||
onMouseEnter={e => {
|
||||
if (!style.cardStyle || !style.hoverShadow) return
|
||||
@@ -926,7 +928,53 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
|
||||
|
||||
// ===== 梯队分组 =====
|
||||
|
||||
function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, onDimensionClick, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth }: {
|
||||
/** 与 TierGroup 卡片展示一致的过滤+排序 (监控优先 → 状态 → 封单量), 供切股导航列表复用 */
|
||||
function sortLadderStocks(
|
||||
stocks: LimitLadderStock[],
|
||||
opts: {
|
||||
monitoredSymbols: Set<string>
|
||||
sealMode: 'vol' | 'amount'
|
||||
selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null
|
||||
extFields: ExtFieldConfig
|
||||
},
|
||||
): LimitLadderStock[] {
|
||||
return [...stocks]
|
||||
.filter(s => {
|
||||
if (!opts.selectedTag) return true
|
||||
const item = opts.extFields[opts.selectedTag.fieldKey]
|
||||
if (!item) return true
|
||||
const tags = getExtTags(s, item)
|
||||
return tags.includes(opts.selectedTag.tag)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// 开启监控的卡片排到分组最前
|
||||
const ma = opts.monitoredSymbols.has(a.symbol) ? 0 : 1
|
||||
const mb = opts.monitoredSymbols.has(b.symbol) ? 0 : 1
|
||||
if (ma !== mb) return ma - mb
|
||||
const ord = (s: string) => {
|
||||
if (s === 'limit_up' || s === 'limit_down' || !s) return 0
|
||||
if (s === 'broken' || s === 'recovery') return 1
|
||||
return 2
|
||||
}
|
||||
const oa = ord(a.status ?? '')
|
||||
const ob = ord(b.status ?? '')
|
||||
if (oa !== ob) return oa - ob
|
||||
// 同状态(主状态=涨停/跌停)内: 按封单从高到低排, 无封单排末尾。
|
||||
// 封单额 = sealed_vol(手) × 100 × close, 与展示口径一致。
|
||||
if (oa === 0) {
|
||||
const sealVal = (s: LimitLadderStock) => {
|
||||
if (s.sealed_vol == null) return -1
|
||||
return opts.sealMode === 'amount' && s.close
|
||||
? s.sealed_vol * 100 * s.close
|
||||
: s.sealed_vol
|
||||
}
|
||||
return sealVal(b) - sealVal(a)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, onDimensionClick, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth, activeSymbol }: {
|
||||
tier: LimitLadderTier
|
||||
defaultOpen: boolean
|
||||
extFields: ExtFieldConfig
|
||||
@@ -942,6 +990,8 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
|
||||
ladderRules: Map<string, MonitorRule>
|
||||
onMonitorChange: () => void
|
||||
hasDepth: boolean
|
||||
/** 正在 K 线弹窗预览中 → 高亮对应卡片 */
|
||||
activeSymbol?: string | null
|
||||
}) {
|
||||
const isDarkTheme = useTheme() === 'dark'
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
@@ -1085,40 +1135,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 px-3 pb-3">
|
||||
{[...tier.stocks]
|
||||
.filter(s => {
|
||||
if (!selectedTag) return true
|
||||
const item = extFields[selectedTag.fieldKey]
|
||||
if (!item) return true
|
||||
const tags = getExtTags(s, item)
|
||||
return tags.includes(selectedTag.tag)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// 开启监控的卡片排到分组最前
|
||||
const ma = monitoredSymbols.has(a.symbol) ? 0 : 1
|
||||
const mb = monitoredSymbols.has(b.symbol) ? 0 : 1
|
||||
if (ma !== mb) return ma - mb
|
||||
const ord = (s: string) => {
|
||||
if (s === 'limit_up' || s === 'limit_down' || !s) return 0
|
||||
if (s === 'broken' || s === 'recovery') return 1
|
||||
return 2
|
||||
}
|
||||
const oa = ord(a.status ?? '')
|
||||
const ob = ord(b.status ?? '')
|
||||
if (oa !== ob) return oa - ob
|
||||
// 同状态(主状态=涨停/跌停)内: 按封单从高到低排, 无封单排末尾。
|
||||
// 封单额 = sealed_vol(手) × 100 × close, 与展示口径一致。
|
||||
if (oa === 0) {
|
||||
const sealVal = (s: typeof a) => {
|
||||
if (s.sealed_vol == null) return -1
|
||||
return sealMode === 'amount' && s.close
|
||||
? s.sealed_vol * 100 * s.close
|
||||
: s.sealed_vol
|
||||
}
|
||||
return sealVal(b) - sealVal(a)
|
||||
}
|
||||
return 0
|
||||
}).map(s => (
|
||||
{tier.stocks.map(s => (
|
||||
<StockCard
|
||||
key={`${s.symbol}-${s.status}`}
|
||||
stock={s}
|
||||
@@ -1131,6 +1148,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
|
||||
hasDepth={hasDepth}
|
||||
onClick={onStockClick}
|
||||
onDimensionClick={onDimensionClick}
|
||||
active={activeSymbol === s.symbol}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1490,6 +1508,7 @@ export function LimitUpLadder() {
|
||||
}, [showConcept])
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const [previewName, setPreviewName] = useState('')
|
||||
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
|
||||
const [selectedTag, setSelectedTag] = useState<{ fieldKey: 'concept' | 'industry'; tag: string } | null>(null)
|
||||
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
|
||||
const handleSelectTag = useCallback((sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => {
|
||||
@@ -1511,11 +1530,6 @@ export function LimitUpLadder() {
|
||||
storage.limitLadderExtFields.set(f)
|
||||
}, [])
|
||||
|
||||
const handleStockClick = useCallback((symbol: string, name?: string) => {
|
||||
setPreviewSymbol(symbol)
|
||||
setPreviewName(name ?? '')
|
||||
}, [])
|
||||
|
||||
const extColumnsParam = useMemo(() => buildExtColumnsParam(extFields), [extFields])
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
@@ -1532,9 +1546,33 @@ export function LimitUpLadder() {
|
||||
}, [asOf, data?.as_of])
|
||||
|
||||
const rawTiers = data?.tiers ?? []
|
||||
const tiers = filterTiers(rawTiers, filterKeys, extFields.bf)
|
||||
// filterTiers 每次返回新数组, 不 memo 会破坏 React.memo(StockCard) 且全梯队二次排序
|
||||
const tiers = useMemo(() => filterTiers(rawTiers, filterKeys, extFields.bf), [rawTiers, filterKeys, extFields.bf])
|
||||
const displayDate = data?.as_of ?? asOf
|
||||
|
||||
// 单源: 梯队按展示同款过滤+排序一次 (监控优先 → 状态 → 封单量),
|
||||
// 卡片渲染(TierGroup)与切股导航(ladderNavItems)共用, 避免每 tick 二次排序。
|
||||
const resolvedExtFields = useMemo(
|
||||
() => resolveExtFields(extFields, showConcept, showIndustry),
|
||||
[extFields, showConcept, showIndustry],
|
||||
)
|
||||
const sortedTiers = useMemo(
|
||||
() => tiers.map(t => ({ ...t, stocks: sortLadderStocks(t.stocks, { monitoredSymbols, sealMode, selectedTag, extFields: resolvedExtFields }) })),
|
||||
[tiers, monitoredSymbols, sealMode, selectedTag, resolvedExtFields],
|
||||
)
|
||||
|
||||
// 切股导航列表: 由 sortedTiers 展平 (顺序 = 卡片展示顺序)
|
||||
const ladderNavItems = useMemo(
|
||||
() => toNavItems(sortedTiers.flatMap(t => t.stocks)),
|
||||
[sortedTiers],
|
||||
)
|
||||
|
||||
const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => {
|
||||
setPreviewSymbol(symbol)
|
||||
setPreviewName(name ?? '')
|
||||
setPreviewNavList(navList ?? ladderNavItems)
|
||||
}, [ladderNavItems])
|
||||
|
||||
// sealed 降级判定
|
||||
const sealedDegrade = useSealedDegrade(asOf, data?.as_of, data?.sealed_ready, data?.sealed_counts)
|
||||
|
||||
@@ -1710,7 +1748,7 @@ export function LimitUpLadder() {
|
||||
<TagStats
|
||||
title="概念分布"
|
||||
tiers={tiers}
|
||||
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
|
||||
extFields={resolvedExtFields}
|
||||
fieldKey="concept"
|
||||
color={{ text: [250, 204, 21], textLight: [161, 98, 7], bg: [234, 179, 8] }}
|
||||
selectedTag={selectedTag}
|
||||
@@ -1724,7 +1762,7 @@ export function LimitUpLadder() {
|
||||
<TagStats
|
||||
title="行业分布"
|
||||
tiers={tiers}
|
||||
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
|
||||
extFields={resolvedExtFields}
|
||||
fieldKey="industry"
|
||||
color={{ text: [96, 165, 250], textLight: [29, 78, 216], bg: [59, 130, 246] }}
|
||||
selectedTag={selectedTag}
|
||||
@@ -1736,12 +1774,12 @@ export function LimitUpLadder() {
|
||||
|
||||
{/* 梯队列表 */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
|
||||
{tiers.map(t => (
|
||||
{sortedTiers.map(t => (
|
||||
<TierGroup
|
||||
key={t.boards}
|
||||
tier={t}
|
||||
defaultOpen={t.boards >= 1 || t.count <= 8}
|
||||
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
|
||||
extFields={resolvedExtFields}
|
||||
filterKeys={filterKeys}
|
||||
bf={extFields.bf}
|
||||
onStockClick={handleStockClick}
|
||||
@@ -1754,6 +1792,7 @@ export function LimitUpLadder() {
|
||||
ladderRules={ladderRules}
|
||||
onMonitorChange={refetchMonitorRules}
|
||||
hasDepth={sealedDegrade.hasDepth}
|
||||
activeSymbol={previewSymbol}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1761,9 +1800,9 @@ export function LimitUpLadder() {
|
||||
<DimensionMembersDialog
|
||||
target={dimensionTarget}
|
||||
onClose={() => setDimensionTarget(null)}
|
||||
onStockClick={(symbol, name) => {
|
||||
onStockClick={(symbol, name, navList) => {
|
||||
setDimensionTarget(null)
|
||||
handleStockClick(symbol, name)
|
||||
handleStockClick(symbol, name, navList)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1771,7 +1810,9 @@ export function LimitUpLadder() {
|
||||
<StockPreviewDialog
|
||||
symbol={previewSymbol}
|
||||
name={previewName}
|
||||
onClose={() => setPreviewSymbol(null)}
|
||||
onClose={() => { setPreviewSymbol(null); setPreviewNavList([]) }}
|
||||
navList={previewNavList}
|
||||
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
|
||||
/>
|
||||
|
||||
{/* 字段配置弹窗 */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
@@ -17,7 +17,7 @@ import { boardTag } from '@/components/stock-table/primitives'
|
||||
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
|
||||
import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge'
|
||||
import { RuleEditor } from '@/components/monitor/RuleEditor'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog'
|
||||
import { DimensionMembersDialog, type DimensionKind, type DimensionMembersTarget } from '@/components/DimensionMembersDialog'
|
||||
import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
|
||||
|
||||
@@ -339,6 +339,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(null)
|
||||
const [memberPreview, setMemberPreview] = useState<{ symbol: string; name?: string } | null>(null)
|
||||
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
|
||||
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
|
||||
|
||||
const clearMut = useMutation({
|
||||
@@ -367,6 +368,22 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
|
||||
|
||||
const events = (alertsQuery.data as any)?.alerts ?? []
|
||||
|
||||
// 切股导航列表: 有 symbol 的触发记录 (按展示顺序)
|
||||
const alertsNavItems = useMemo(
|
||||
() => toNavItems(events.filter((ev: AlertEvent) => ev.symbol)),
|
||||
[events],
|
||||
)
|
||||
const handlePreviewEvent = useCallback((ev: AlertEvent) => {
|
||||
setPreviewEv(ev)
|
||||
setPreviewNavList(alertsNavItems)
|
||||
}, [alertsNavItems])
|
||||
// 弹窗内切股: 来自成分弹窗则更新 memberPreview, 否则按 symbol 找到对应事件 (保住 triggerInfo)
|
||||
const handleNavigate = useCallback((sym: string, name?: string) => {
|
||||
if (memberPreview) { setMemberPreview({ symbol: sym, name }); return }
|
||||
const ev = events.find((e: AlertEvent) => e.symbol === sym)
|
||||
if (ev) setPreviewEv(ev)
|
||||
}, [memberPreview, events])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{alertsQuery.isLoading ? (
|
||||
@@ -418,7 +435,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
|
||||
const board = boardTag(ev.symbol)
|
||||
return (
|
||||
<button
|
||||
onClick={() => setPreviewEv(ev)}
|
||||
onClick={() => handlePreviewEvent(ev)}
|
||||
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
|
||||
title="点击查看日K"
|
||||
>
|
||||
@@ -497,7 +514,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
|
||||
const board = boardTag(ev.symbol)
|
||||
return (
|
||||
<button
|
||||
onClick={() => setPreviewEv(ev)}
|
||||
onClick={() => handlePreviewEvent(ev)}
|
||||
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
|
||||
title="点击查看日K"
|
||||
>
|
||||
@@ -627,15 +644,18 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
|
||||
signals: previewEv.signals,
|
||||
message: previewEv.message,
|
||||
} : null}
|
||||
onClose={() => { setPreviewEv(null); setMemberPreview(null) }}
|
||||
navList={previewNavList}
|
||||
onNavigate={handleNavigate}
|
||||
onClose={() => { setPreviewEv(null); setMemberPreview(null); setPreviewNavList([]) }}
|
||||
/>
|
||||
|
||||
<DimensionMembersDialog
|
||||
target={dimensionTarget}
|
||||
onClose={() => setDimensionTarget(null)}
|
||||
onStockClick={(symbol, name) => {
|
||||
onStockClick={(symbol, name, navList) => {
|
||||
setDimensionTarget(null)
|
||||
setMemberPreview({ symbol, name })
|
||||
setPreviewNavList(navList ?? alertsNavItems)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -695,6 +715,14 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
})
|
||||
const symbolNames = namesQuery.data?.names ?? {}
|
||||
|
||||
// 切股导航列表: 个股规则 (取第一个 symbol, 按展示顺序)
|
||||
const rulesNavItems = useMemo(
|
||||
() => rules
|
||||
.filter(r => r.scope === 'symbols' && r.symbols.length > 0)
|
||||
.map(r => ({ symbol: r.symbols[0], name: symbolNames[r.symbols[0]] ?? undefined })),
|
||||
[rules, symbolNames],
|
||||
)
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: api.monitorRuleDelete,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: QK.monitorRules }),
|
||||
@@ -930,6 +958,8 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
<StockPreviewDialog
|
||||
symbol={previewSymbol}
|
||||
name={previewSymbol ? symbolNames[previewSymbol] : undefined}
|
||||
navList={rulesNavItems}
|
||||
onNavigate={(sym) => setPreviewSymbol(sym)}
|
||||
onClose={() => setPreviewSymbol(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { storage } from '@/lib/storage'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { StockPreviewDialog, type NavItem } from '@/components/StockPreviewDialog'
|
||||
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
|
||||
import { useStrategyPool } from '@/lib/useStrategyPool'
|
||||
import { StrategyCard, CardSize, loadCardSize, cardWrapCls } from '@/components/screener/StrategyCard'
|
||||
@@ -50,7 +50,12 @@ export function Screener() {
|
||||
const [batchMsg, setBatchMsg] = useState<string>('')
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const [previewName, setPreviewName] = useState<string>('')
|
||||
const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, [])
|
||||
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
|
||||
const closePreview = useCallback(() => {
|
||||
setPreviewSymbol(null)
|
||||
setPreviewName('')
|
||||
setPreviewNavList([])
|
||||
}, [])
|
||||
const [settingsStrategyId, setSettingsStrategyId] = useState<string | null>(null)
|
||||
const [showPoolDialog, setShowPoolDialog] = useState(false)
|
||||
const [showBuilder, setShowBuilder] = useState(false)
|
||||
@@ -982,8 +987,9 @@ export function Screener() {
|
||||
strategyIdToName={strategyIdToName}
|
||||
symbolStrategyMap={symbolStrategyMap}
|
||||
activeStrategy={activeStrategy}
|
||||
activeSymbol={previewSymbol}
|
||||
watchlistSet={watchlistSet}
|
||||
onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }}
|
||||
onPreview={(symbol, name, navList) => { setPreviewSymbol(symbol); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }}
|
||||
onAddToWatchlist={(symbol, groupId) => toggleWatchlist.mutate({ symbol, action: 'add', groupId })}
|
||||
onRemoveFromWatchlist={symbol => toggleWatchlist.mutate({ symbol, action: 'remove' })}
|
||||
watchlistPending={toggleWatchlist.isPending}
|
||||
@@ -1035,6 +1041,8 @@ export function Screener() {
|
||||
symbol={previewSymbol}
|
||||
name={previewName}
|
||||
onClose={closePreview}
|
||||
navList={previewNavList}
|
||||
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
|
||||
/>
|
||||
|
||||
<StrategySettingsDialog
|
||||
|
||||
@@ -9,10 +9,11 @@ import { fetchMinuteBatchIncremental } from '@/lib/minuteBatchIncremental'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { storage } from '@/lib/storage'
|
||||
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass, formatExtNumber } from '@/lib/format'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { computeGroupPcts, loadGroupStatsConfig, type GroupStatsConfigPatch } from '@/lib/watchlistGroupStats'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog'
|
||||
import {
|
||||
DimensionMembersDialog,
|
||||
dimensionKindForSourceField,
|
||||
@@ -468,6 +469,7 @@ const StockCard = React.memo(function StockCard({
|
||||
onToggleExpand,
|
||||
onDimensionClick,
|
||||
isMonitored,
|
||||
active,
|
||||
groups,
|
||||
onToggleMember,
|
||||
groupChangePending,
|
||||
@@ -485,6 +487,8 @@ const StockCard = React.memo(function StockCard({
|
||||
onToggleExpand: (key: string) => void
|
||||
onDimensionClick: (target: DimensionMembersTarget) => void
|
||||
isMonitored?: boolean
|
||||
/** 正在 K 线弹窗预览中 → 高亮卡片 */
|
||||
active?: boolean
|
||||
groups: WatchlistGroup[]
|
||||
onToggleMember: (symbol: string, groupId: string, member: boolean) => void
|
||||
groupChangePending: boolean
|
||||
@@ -510,7 +514,7 @@ const StockCard = React.memo(function StockCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative rounded-lg border border-border bg-surface hover:border-border/80 transition-all duration-200 group cursor-pointer overflow-hidden ${bgGlow}`}
|
||||
className={`relative rounded-lg border border-border bg-surface hover:border-border/80 transition-all duration-200 group cursor-pointer overflow-hidden ${bgGlow} ${active ? 'ring-2 ring-accent/60' : ''}`}
|
||||
onClick={() => onPreview(r.symbol, name ?? '')}
|
||||
>
|
||||
{/* 左侧彩色指示条 */}
|
||||
@@ -776,11 +780,14 @@ export function Watchlist() {
|
||||
}, [])
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const [previewName, setPreviewName] = useState<string>('')
|
||||
// 切股导航: 默认用 previewNavItems(自选列表); 从成分弹窗打开时用成分列表覆盖
|
||||
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
|
||||
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
|
||||
const [expandedCells, setExpandedCells] = useState<Set<string>>(new Set())
|
||||
const closePreview = useCallback(() => {
|
||||
setPreviewSymbol(null)
|
||||
setPreviewName('')
|
||||
setPreviewNavList([])
|
||||
}, [])
|
||||
|
||||
const handleToggleExpand = useCallback((cellKey: string) => {
|
||||
@@ -1255,6 +1262,13 @@ export function Watchlist() {
|
||||
[filteredRows, sortRows, columns],
|
||||
)
|
||||
|
||||
// 切股导航列表: 按列表当前展示顺序 (与 sortedRows 一致, 排序/筛选后行序随之变化)。
|
||||
// 弹窗未打开时跳过构建 — sortedRows 随行情 tick 重建, 避免无谓分配。
|
||||
const previewNavItems = useMemo(
|
||||
() => previewSymbol ? toNavItems(sortedRows) : [],
|
||||
[previewSymbol, sortedRows],
|
||||
)
|
||||
|
||||
const cardColumns = useCardColumnCount()
|
||||
const cardGridRef = useRef<HTMLDivElement>(null)
|
||||
const virtualizeCards = viewMode === 'card' && !groupCardsOpen && sortedRows.length > VIRTUAL_LIST_THRESHOLD
|
||||
@@ -1341,6 +1355,7 @@ export function Watchlist() {
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onDimensionClick={setDimensionTarget}
|
||||
isMonitored={monitoredSymbols.has(r.symbol)}
|
||||
active={previewSymbol === r.symbol}
|
||||
groups={groups}
|
||||
onToggleMember={handleToggleMember}
|
||||
groupChangePending={addGroupMember.isPending || removeGroupMember.isPending}
|
||||
@@ -1668,7 +1683,7 @@ export function Watchlist() {
|
||||
onSortToggle={handleSortToggle}
|
||||
extraSortableKeys={INTRADAY_SORTABLE_KEYS}
|
||||
rowKey={(r: any) => r.symbol}
|
||||
rowClassName={() => 'border-t border-border hover:bg-elevated/50 transition-colors duration-150 ease-smooth'}
|
||||
rowClassName={(r: any) => cn('border-t border-border transition-colors duration-150 ease-smooth hover:bg-elevated/50', r.symbol === previewSymbol && 'bg-accent/10 hover:bg-accent/15')}
|
||||
// 日k列表头:标签 + 显示/隐藏眼睛按钮
|
||||
renderHeaderContent={(col) => {
|
||||
if (col.source.type === 'builtin' && col.source.key === 'candle') {
|
||||
@@ -1990,15 +2005,19 @@ export function Watchlist() {
|
||||
symbol={previewSymbol}
|
||||
name={previewName}
|
||||
onClose={closePreview}
|
||||
navList={previewNavList.length > 0 ? previewNavList : previewNavItems}
|
||||
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
|
||||
/>
|
||||
|
||||
<DimensionMembersDialog
|
||||
target={dimensionTarget}
|
||||
onClose={() => setDimensionTarget(null)}
|
||||
onStockClick={(symbol, name) => {
|
||||
onStockClick={(symbol, name, navList) => {
|
||||
setDimensionTarget(null)
|
||||
setPreviewSymbol(symbol)
|
||||
setPreviewName(name ?? '')
|
||||
// 成分列表作为切股导航 (成员可能不在自选列表, 不能退回 previewNavItems)
|
||||
setPreviewNavList(navList ?? previewNavItems)
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react'
|
||||
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info, ExternalLink } from 'lucide-react'
|
||||
import { usePreferences, useVersion } from '@/lib/useSharedQueries'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
@@ -15,6 +15,7 @@ import { SOUND_OPTIONS, previewSound } from '@/lib/notificationSound'
|
||||
import {
|
||||
listZhVoices, previewVoice, activateVoice, getCurrentVoiceURI,
|
||||
} from '@/lib/voiceBroadcast'
|
||||
import { loadStockExternalTemplate, saveStockExternalTemplate } from '@/lib/stock-external-link'
|
||||
|
||||
export function SettingsSystemPanel() {
|
||||
const qc = useQueryClient()
|
||||
@@ -23,6 +24,7 @@ export function SettingsSystemPanel() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const screenerAutoRun = prefs?.screener_auto_run ?? true
|
||||
const [extTpl, setExtTpl] = useState(() => loadStockExternalTemplate())
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [toastEnabled, setToastEnabled] = useState(() => {
|
||||
try { return localStorage.getItem('alert_toast_enabled') !== '0' } catch { return true }
|
||||
@@ -288,6 +290,30 @@ export function SettingsSystemPanel() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-card border border-border bg-surface p-5 mt-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ExternalLink className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-medium text-foreground">个股详情外链</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-foreground">详情页 URL 模板</div>
|
||||
<div className="text-[11px] text-muted truncate">{"支持 {code} {market} {symbol} · 留空关闭外链"}</div>
|
||||
</div>
|
||||
<input
|
||||
value={extTpl}
|
||||
onChange={(e) => {
|
||||
setExtTpl(e.target.value)
|
||||
saveStockExternalTemplate(e.target.value)
|
||||
}}
|
||||
placeholder="https://..."
|
||||
spellCheck={false}
|
||||
className="w-[26rem] max-w-[60%] h-8 px-2.5 rounded-btn border border-border bg-base text-xs font-mono text-foreground focus:border-accent/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-card border border-border bg-surface p-5 mt-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Trash2 className="h-4 w-4 text-accent" />
|
||||
|
||||
Reference in New Issue
Block a user