import { useState, useEffect, useRef, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, Info, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct } from '@/lib/format'
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { SettingsModal } from '@/components/data/SettingsModal'
import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
import { cn } from '@/lib/cn'
import { cnSignal } from '@/lib/signals'
import { boardTag } from '@/components/stock-table/primitives'
function n(v: number | null | undefined) {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function scoreColor(v: number) {
// A 股惯例: 强势=红, 弱式=绿
if (v >= 70) return '#F04438'
if (v >= 55) return '#FB923C'
if (v >= 45) return '#F59E0B'
if (v >= 30) return '#84CC16'
return '#12B76A'
}
function fmtPrice(v: number | null | undefined, digits = 2) {
const x = n(v)
return x == null ? '—' : x.toFixed(digits)
}
function fmtIndexPct(v: number | null | undefined) {
const x = n(v)
if (x == null) return '—'
return `${x >= 0 ? '+' : ''}${x.toFixed(2)}%`
}
function fmtStockPct(v: number | null | undefined) {
const x = n(v)
if (x == null) return '—'
return `${x >= 0 ? '+' : ''}${(x * 100).toFixed(2)}%`
}
function pctClass(v: number | null | undefined) {
const x = n(v)
if (x == null || x === 0) return 'text-muted'
return x > 0 ? 'text-bull' : 'text-bear'
}
function quoteAge(ms?: number | null) {
if (ms == null) return '—'
if (ms < 1000) return `${Math.round(ms)}ms`
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m${s % 60}s`
}
function compactCount(v: number | null | undefined) {
const x = n(v)
if (x == null) return '—'
if (x >= 1000) return `${(x / 1000).toFixed(1)}k`
return x.toFixed(0)
}
function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; title: string; hint?: ReactNode }) {
return (
)
}
// 看板监控中心小组件 — 显示前 10 条触发记录 + 更多按钮
const _SOURCE_BADGE: Record = {
strategy: 'bg-amber-400/10 text-amber-400',
signal: 'bg-accent/10 text-accent',
price: 'bg-emerald-400/10 text-emerald-400',
market: 'bg-purple-500/10 text-purple-400',
}
const _SOURCE_LABEL: Record = {
strategy: '策略', signal: '信号', price: '价格', market: '异动',
}
const _SEVERITY_BAR: Record = {
info: 'bg-accent/40', warn: 'bg-warning', critical: 'bg-danger',
}
function MonitorWidget() {
const [previewEv, setPreviewEv] = useState(null)
const alerts = useQuery({
queryKey: ['alerts', ''],
queryFn: () => api.alertsList({ days: 7, limit: 10 }),
refetchInterval: 10000,
refetchIntervalInBackground: true,
})
const events: AlertEvent[] = alerts.data?.alerts ?? []
if (events.length === 0) {
return (
暂无触发记录
)
}
return (
<>
{events
.filter((ev: AlertEvent) => !(ev.source === 'strategy' && !ev.symbol))
.map((ev, i) => {
const sev = _SEVERITY_BAR[ev.severity ?? 'info'] ?? _SEVERITY_BAR.info
const pct = ev.change_pct ?? 0
const isStrategy = ev.source === 'strategy'
const sm = isStrategy ? ev.message?.match(/策略「([^」]+)」/) : null
const sname = sm ? sm[1] : ''
const isNew = ev.type === 'new_entry'
return (
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 (点击代码/名称弹日K) */}
{ev.price != null && (
{fmtPrice(ev.price)}
)}
{ev.change_pct != null && (
= 0 ? 'text-danger' : 'text-bear')}>
{fmtPct(pct)}
)}
{/* 第二行: 策略类型走新格式, 其他走旧格式 */}
{isStrategy ? (
{isNew ? '进入' : '移出'}
策略
「{sname}」
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
) : (
<>
{_SOURCE_LABEL[ev.source] ?? ev.source}
{ev.message && (
{ev.message}
)}
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
{ev.signals && ev.signals.length > 0 && (
{ev.signals.map((s, j) => (
{cnSignal(s)}
))}
)}
>
)}
)
})}
setPreviewEv(null)}
/>
>
)
}
function KpiCell({ label, value, sub, tone = 'neutral' }: { label: ReactNode; value: ReactNode; sub?: string; tone?: 'bull' | 'bear' | 'accent' | 'neutral' }) {
const isPlain = typeof value === 'string' || typeof value === 'number'
const color = tone === 'bull' ? 'text-bull' : tone === 'bear' ? 'text-bear' : tone === 'accent' ? 'text-accent' : 'text-foreground'
return (
{label}
{value}
{sub &&
{sub}
}
)
}
function IndexTicker({ item }: { item: OverviewMarket['indices'][number] }) {
const pct = item.change_pct
const isUp = (n(pct) ?? 0) >= 0
return (
{item.name || item.symbol}
{fmtIndexPct(pct)}
{item.symbol}
{isUp ?
:
}
{fmtPrice(item.last_price)}
)
}
function BreadthBar({ data }: { data: OverviewMarket['breadth'] }) {
const denom = Math.max(data.total, 1)
const upW = data.up / denom * 100
const downW = data.down / denom * 100
const flatW = Math.max(0, 100 - upW - downW)
return (
涨 {data.up}
平 {data.flat}
跌 {data.down}
)
}
function DistributionBars({ rows }: { rows: OverviewMarket['distribution'] }) {
const maxCount = Math.max(...rows.map(r => r.count), 1)
return (
{rows.map((r, i) => {
const positive = i >= 4
return (
{r.count || ''}
{r.label}
)
})}
)
}
function EmotionRadar({ radar, score }: { radar: OverviewMarket['radar']; score: number }) {
const size = 240
const cx = size / 2
const cy = size / 2
const maxR = 78
const color = scoreColor(score)
if (!radar.length) return 暂无雷达数据
const points = radar.map((r, i) => {
const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length
const radius = maxR * Math.max(0, Math.min(100, r.value)) / 100
return {
...r,
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
lx: cx + Math.cos(angle) * (maxR + 27),
ly: cy + Math.sin(angle) * (maxR + 27),
gx: cx + Math.cos(angle) * maxR,
gy: cy + Math.sin(angle) * maxR,
}
})
const polygon = points.map(p => `${p.x},${p.y}`).join(' ')
const gridPolygons = [1, 0.66, 0.33].map((level, idx) => ({
level,
idx,
points: radar.map((_, i) => {
const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length
return `${cx + Math.cos(angle) * maxR * level},${cy + Math.sin(angle) * maxR * level}`
}).join(' '),
}))
return (
)
}
function LadderMini({ limit }: { limit: OverviewMarket['limit'] }) {
const tiers = limit.tiers.filter(t => t.boards >= 2).slice(0, 6)
return (
封板率
{(limit.seal_rate ?? 0).toFixed(0)}%
{tiers.length === 0 &&
暂无 2 板以上
}
{tiers.map(t => (
= 5 ? 'text-bull' : t.boards >= 3 ? 'text-accent' : 'text-secondary'}`}>{t.boards}板
{t.count}
))}
)
}
function MiniMetric({ label, value, cls = 'text-foreground' }: { label: string; value: string; cls?: string }) {
return (
)
}
function StockList({ title, rows, mode }: { title: string; rows: MarketSnapshotRow[]; mode: 'gain' | 'loss' | 'amount' | 'active' }) {
return (
{title}
TOP {Math.min(rows.length, 8)}
{rows.slice(0, 8).map((r, idx) => (
{idx + 1}
{r.name || r.symbol}
{r.symbol}
{mode === 'amount' ? (
<>
{fmtBigNum(r.amount)}
{fmtStockPct(r.change_pct)}
>
) : mode === 'active' ? (
<>
{/* overview 的 turnover_rate 为小数制, 需 ×100 转百分数显示 */}
{fmtPrice(r.turnover_rate != null ? r.turnover_rate * 100 : null, 1)}%
{fmtStockPct(r.change_pct)}
>
) : (
<>
{fmtStockPct(r.change_pct)}
{fmtPrice(r.close)}
>
)}
))}
{rows.length === 0 &&
暂无数据
}
)
}
function RankColumn({ title, rows, tone }: { title: string; rows: OverviewDimensionRankItem[]; tone: 'bull' | 'bear' }) {
return (
{title}
{rows.slice(0, 5).map((r, idx) => (
{idx + 1}
{r.name}
{r.count}只 · {r.leader?.name ?? '—'}
{fmtStockPct(r.avg_pct)}
))}
{rows.length === 0 &&
暂无数据
}
)
}
function HotRankCard({ title, rank, configUrl }: { title: string; rank?: OverviewMarket['concept_rank']; configUrl: string }) {
const hasData = (rank?.leading?.length ?? 0) > 0 || (rank?.lagging?.length ?? 0) > 0
return (
)
}
export function Dashboard() {
const qc = useQueryClient()
const [selectedDate, setSelectedDate] = useState()
const [manualFetching, setManualFetching] = useState(false)
// 首次使用(无数据 + 未完成引导)自动弹窗: 同一会话只弹一次
const [showWelcomeModal, setShowWelcomeModal] = useState(false)
const dataStatus = useDataStatus({ staleTime: 60_000 })
const overview = useQuery({
queryKey: QK.overviewMarket(selectedDate),
queryFn: () => api.overviewMarket(selectedDate),
staleTime: 5_000,
placeholderData: (prev) => prev,
})
const data = overview.data
const caps = useCapabilities()
const settings = useSettings()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const sealedReady = !!data?.limit?.sealed_ready
const isSealedDegrade = !hasDepth || !sealedReady
// none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限
const isNoKey = settings.data?.mode === 'none'
// 无本地数据(enriched/daily 都没有)→ 常驻引导卡片
// 注: 后端 status 的 rows 为性能刻意返回 0, 用 trading_days 判断是否有数据
const ds = dataStatus.data
const hasNoData = !!ds
&& (ds.enriched?.trading_days ?? 0) === 0
&& (ds.daily?.trading_days ?? 0) === 0
// ===== 盘后管道触发(看板内一键获取数据) =====
const [fetchJobId, setFetchJobId] = useState(null)
const fetchStatus = useQuery({
queryKey: QK.pipelineJob(fetchJobId ?? ''),
queryFn: () => api.pipelineJob(fetchJobId!),
enabled: !!fetchJobId,
refetchInterval: (q: any) => {
const j = q.state.data
return j && (j.status === 'succeeded' || j.status === 'failed') ? false : 1_000
},
})
const startFetch = useMutation({
mutationFn: api.pipelineRun,
onSuccess: ({ job_id }) => setFetchJobId(job_id),
})
const isFetching = startFetch.isPending
|| fetchStatus.data?.status === 'running'
|| fetchStatus.data?.status === 'pending'
const fetchFailed = fetchStatus.data?.status === 'failed'
const fetchSucceeded = fetchStatus.data?.status === 'succeeded'
// 首次使用且无数据 → 自动弹一次引导弹窗(同会话只弹一次)
useEffect(() => {
if (!hasNoData) return
if (settings.data?.onboarding_completed === false) return // 还在引导流程中,不重复弹
if (sessionStorage.getItem('tf_welcome_shown')) return
sessionStorage.setItem('tf_welcome_shown', '1')
setShowWelcomeModal(true)
}, [hasNoData, settings.data?.onboarding_completed])
// 同步完成后刷新看板数据
useEffect(() => {
if (fetchSucceeded) {
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.overviewMarket(undefined) })
}
}, [fetchSucceeded, qc])
// 组件重新挂载时(从其他页面切回)恢复正在运行的同步任务进度。
// 原因: fetchJobId 是组件内状态, 切走页面时组件卸载、状态丢失, 切回后进度卡片消失。
// 修复: 挂载时若无本地数据且未跟踪任何 job, 查一次后端是否有 active job, 有则接管。
const resumeTriedRef = useRef(false)
useEffect(() => {
if (resumeTriedRef.current) return
if (!hasNoData) return
if (fetchJobId) return
resumeTriedRef.current = true
api.pipelineJobs(1).then(({ active_id }) => {
if (active_id) setFetchJobId(active_id)
}).catch(() => { /* 查询失败不阻塞, 用户仍可手动点击获取 */ })
}, [hasNoData, fetchJobId])
// 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感
const handleRefresh = () => {
setManualFetching(true)
overview.refetch().finally(() => setManualFetching(false))
}
if (overview.isLoading && !data) {
return (
)
}
if (!data) {
return (
看板加载失败
)
}
const score = data.emotion?.score ?? 50
const strongUp = data.breadth.strong_up ?? 0
const strongDown = data.breadth.strong_down ?? 0
const latestDate = dataStatus.data?.enriched?.latest_date ?? null
const currentDate = selectedDate ?? data.as_of ?? ''
const quoteRunning = (!selectedDate || selectedDate === latestDate) && data.quote_status?.running
// 实时模式: none / watchlist / full_market。
// watchlist (Free 档) 仅自选 ≤5 只实时, 看板呈现的大盘数据实为盘后快照, 需提示避免误读。
const quoteMode = data.quote_status?.mode as ('none' | 'watchlist' | 'full_market') | undefined
return (
{/* 无本地数据常驻引导卡片 —— 一键触发盘后管道获取数据(无 Key 也可) */}
{hasNoData && (
startFetch.mutate()}
isNoKey={isNoKey}
/>
)}
{/* 首次使用自动弹窗(同会话仅一次) */}
{showWelcomeModal && (
setShowWelcomeModal(false)}
onStart={() => {
startFetch.mutate()
setShowWelcomeModal(false)
}}
/>
)}
市场看板
{data.emotion.label} · {score}
{currentDate ? (
) : (
—
)}
{quoteAge(data.quote_status?.quote_age_ms)}
{quoteRunning ? '实时' : '非实时'}
{/* Free 档提示: 大盘看板为盘后数据, 仅自选股实时。避免用户误读为全市场实时。 */}
{quoteMode === 'watchlist' && (
当前为「自选实时」模式,看板展示的大盘数据为盘后快照(最新有数据日),并非盘中实时;
仅自选股({data.quote_status?.watchlist_symbol_count ?? 0} 只)支持实时监控。
全市场实时需 Starter+
)}
{data.indices.map(item => )}
{data.breadth.up}/{data.breadth.flat}/{data.breadth.down}>} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} />
{strongUp}/{strongDown}>} sub="涨跌 ≥3%" />
涨停 / 跌停} value={<>{data.limit.limit_up}/{data.limit.limit_down}>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} />
0 ? Math.round(data.trend.new_high / (data.trend.new_high + data.trend.new_low) * 100) : 50}%`} cls={data.trend.new_high >= data.trend.new_low ? 'text-bull' : 'text-bear'} />
= data.trend.new_low ? 'text-bull' : 'text-bear'} />
)
}
// ===== 无数据常驻引导卡片: 一键触发盘后管道获取行情数据(无 Key 也可) =====
function FetchDataCard({
isFetching, isStarting, fetchFailed, stage, fetchPct, onStart, isNoKey,
}: {
isFetching: boolean
isStarting: boolean
fetchFailed: boolean
stage?: string
fetchPct?: number
onStart: () => void
isNoKey: boolean
}) {
const stageText = stage ? (STAGE_LABELS[stage] ?? stage) : '正在同步行情数据…'
return (
当前暂无数据
首次使用需获取行情数据后才能查看看板。系统将从免费数据源拉取近 1 年全 A 股日K(约 5500 只),预计 1-3 分钟,期间可继续浏览其他页面。
{isNoKey && (
ⓘ 无需 API Key,当前为 None 档即可获取历史日K,可制定策略+回测。配置免费 Key 可解锁实时行情监控能力。
)}
{isFetching ? (
{isStarting ? '正在启动同步任务…' : stageText}
{typeof fetchPct === 'number' ? `${Math.round(fetchPct)}%` : ''}
) : fetchFailed ? (
) : (
)}
)
}
// ===== 首次使用自动弹窗: 询问用户后触发盘后管道 =====
function WelcomeFetchModal({
isNoKey, onClose, onStart,
}: {
isNoKey: boolean
onClose: () => void
onStart: () => void
}) {
return (
首次使用,需先获取行情数据
系统将从免费数据源拉取近 1 年全 A 股日K(约 5500 只),预计 1-3 分钟。
同步期间可继续浏览其他页面,完成后看板自动刷新。
{isNoKey && (
ⓘ 当前无需 API Key,None 档即可获取历史日K数据。
)}
)
}