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, Database, Flame, Gauge, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react' import { DatePicker } from '@/components/DatePicker' import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { fmtBigNum } from '@/lib/format' import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries' import { SealedBadge } from '@/components/SealedBadge' import { SettingsModal } from '@/components/data/SettingsModal' import { STAGE_LABELS } from '@/components/data/ActiveJobCard' 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 (

{title}

{hint && {hint}}
) } 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 (
{gridPolygons.map(g => ( ))} {points.map(p => )} {points.map(p => )} {score} {points.map(p => ( {p.label} ))}
) } 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 DownLadderMini() { const { data, isLoading } = useQuery({ queryKey: ['limit-ladder-down'], queryFn: () => api.limitLadder(undefined, undefined, 'down'), refetchInterval: 30_000, }) const tiers = data?.tiers?.filter(t => t.boards >= 2).slice(0, 6) ?? [] return (
{isLoading &&
加载中…
} {!isLoading && tiers.length === 0 &&
暂无 2 板以上
} {tiers.map(t => (
= 5 ? 'text-bear' : t.boards >= 3 ? 'text-warning' : 'text-secondary'}`}>{t.boards}板
{t.count}
))}
) } function MiniMetric({ label, value, cls = 'text-foreground' }: { label: string; value: string; cls?: string }) { return (
{label}
{value}
) } 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 (
{hasData ? (
) : (

未配置扩展数据源

前往配置 →
)}
) } 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]) 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 ?? '' 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)}
{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数据。
)}
) }