Files
stock/serve/frontend/src/pages/Dashboard.tsx
T
fish 431aae0882 看板跌停梯队改为真实跌停数据展示
移除监控中心告警组件,新增 DownLadderMini 组件调用
limit-ladder API(direction=down)获取真实跌停梯队数据,
配色用 red/bear 色系,与涨停梯队布局一致。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:25:33 +08:00

757 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 text-accent" />
<h2 className="text-xs font-semibold text-foreground">{title}</h2>
</div>
{hint && <span className="font-mono text-[10px] text-muted">{hint}</span>}
</div>
)
}
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 (
<div className="min-w-0 rounded-lg border border-border bg-surface/80 px-3 py-2">
<div className="flex items-center gap-1 text-[11px] text-muted">{label}</div>
<div className={`mt-1 truncate font-mono text-lg font-semibold leading-none tabular-nums ${isPlain ? color : 'text-foreground'}`}>{value}</div>
{sub && <div className="mt-1 truncate text-[10px] text-muted">{sub}</div>}
</div>
)
}
function IndexTicker({ item }: { item: OverviewMarket['indices'][number] }) {
const pct = item.change_pct
const isUp = (n(pct) ?? 0) >= 0
return (
<Link
to={`/indices?symbol=${encodeURIComponent(item.symbol)}`}
className="grid min-w-0 grid-cols-[1fr_auto] items-center gap-x-2 gap-y-0.5 rounded-lg border border-border bg-elevated/45 px-2.5 py-1.5 transition-colors hover:border-accent/40 hover:bg-elevated"
>
<div className="truncate text-xs font-medium text-foreground">{item.name || item.symbol}</div>
<div className={`font-mono text-xs font-semibold ${pctClass(pct)}`}>{fmtIndexPct(pct)}</div>
<div className="font-mono text-[10px] text-muted">{item.symbol}</div>
<div className={`flex items-center gap-1 font-mono text-[11px] ${pctClass(pct)}`}>
{isUp ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
{fmtPrice(item.last_price)}
</div>
</Link>
)
}
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 (
<div className="space-y-2">
<div className="flex h-2.5 overflow-hidden rounded-full bg-elevated">
<div className="bg-bull/85" style={{ width: `${upW}%` }} />
<div className="bg-muted/45" style={{ width: `${flatW}%` }} />
<div className="bg-bear/85" style={{ width: `${downW}%` }} />
</div>
<div className="grid grid-cols-3 gap-1.5 text-[11px]">
<div className="rounded bg-bull/8 px-2 py-1 text-bull"> <span className="font-mono">{data.up}</span></div>
<div className="rounded bg-elevated/70 px-2 py-1 text-muted"> <span className="font-mono">{data.flat}</span></div>
<div className="rounded bg-bear/8 px-2 py-1 text-bear"> <span className="font-mono">{data.down}</span></div>
</div>
</div>
)
}
function DistributionBars({ rows }: { rows: OverviewMarket['distribution'] }) {
const maxCount = Math.max(...rows.map(r => r.count), 1)
return (
<div className="grid h-24 grid-cols-8 items-end gap-1 pt-1">
{rows.map((r, i) => {
const positive = i >= 4
return (
<div key={r.label} className="flex h-full min-w-0 flex-col items-center justify-end gap-0.5">
<div className="font-mono text-[9px] text-muted">{r.count || ''}</div>
<div
className={`w-2 rounded-full ${positive ? 'bg-gradient-to-t from-bull/45 to-bull/90' : 'bg-gradient-to-t from-bear/45 to-bear/90'}`}
style={{ height: `${Math.max(4, r.count / maxCount * 86)}%` }}
title={`${r.label}: ${r.count}只`}
/>
<div className="truncate text-[9px] text-muted">{r.label}</div>
</div>
)
})}
</div>
)
}
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 <div className="flex h-52 items-center justify-center text-xs text-muted">暂无雷达数据</div>
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 (
<div className="flex justify-center">
<svg viewBox={`0 0 ${size} ${size}`} className="h-56 w-full">
<defs>
<radialGradient id="emotionRadarFill" cx="50%" cy="45%" r="70%">
<stop offset="0%" stopColor={`${color}57`} />
<stop offset="100%" stopColor={`${color}1f`} />
</radialGradient>
<radialGradient id="emotionRadarCenter" cx="50%" cy="50%" r="55%">
<stop offset="0%" stopColor="rgba(15,23,42,0.92)" />
<stop offset="68%" stopColor="rgba(15,23,42,0.70)" />
<stop offset="100%" stopColor="rgba(15,23,42,0)" />
</radialGradient>
</defs>
{gridPolygons.map(g => (
<polygon
key={g.level}
points={g.points}
fill={g.idx % 2 === 0 ? 'rgba(30,41,59,0.26)' : 'rgba(15,23,42,0.16)'}
stroke={g.level === 1 ? 'rgba(148,163,184,0.22)' : 'rgba(148,163,184,0.12)'}
strokeWidth={g.level === 1 ? 1.2 : 0.8}
/>
))}
{points.map(p => <line key={p.key} x1={cx} y1={cy} x2={p.gx} y2={p.gy} stroke="rgba(148,163,184,0.08)" />)}
<polygon points={polygon} fill="url(#emotionRadarFill)" stroke={color} strokeWidth="2" />
{points.map(p => <circle key={p.key} cx={p.x} cy={p.y} r="2.8" fill={color} stroke="rgba(15,23,42,0.9)" strokeWidth="1" />)}
<circle cx={cx} cy={cy} r="29" fill="url(#emotionRadarCenter)" />
<text x={cx} y={cy + 7} textAnchor="middle" className="fill-foreground font-mono text-[24px] font-bold">{score}</text>
{points.map(p => (
<text key={`${p.key}-label`} x={p.lx} y={p.ly + 4} textAnchor="middle" className="fill-secondary text-[10px] font-medium">{p.label}</text>
))}
</svg>
</div>
)
}
function LadderMini({ limit }: { limit: OverviewMarket['limit'] }) {
const tiers = limit.tiers.filter(t => t.boards >= 2).slice(0, 6)
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between rounded bg-elevated/55 px-2 py-1.5 text-[11px]">
<span className="text-muted">封板率</span>
<span className="font-mono text-accent">{(limit.seal_rate ?? 0).toFixed(0)}%</span>
</div>
{tiers.length === 0 && <div className="rounded border border-dashed border-border py-5 text-center text-xs text-muted">暂无 2 板以上</div>}
{tiers.map(t => (
<div key={t.boards} className="grid grid-cols-[42px_1fr_auto] items-center gap-2 rounded bg-elevated/35 px-2 py-1.5">
<span className={`font-mono text-sm font-bold ${t.boards >= 5 ? 'text-bull' : t.boards >= 3 ? 'text-accent' : 'text-secondary'}`}>{t.boards}</span>
<div className="h-1.5 overflow-hidden rounded-full bg-base">
<div className="h-full rounded-full bg-bull/70" style={{ width: `${Math.min(100, t.count * 12)}%` }} />
</div>
<span className="font-mono text-xs text-foreground">{t.count}</span>
</div>
))}
</div>
)
}
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 (
<div className="space-y-1.5">
{isLoading && <div className="rounded border border-dashed border-border py-5 text-center text-xs text-muted">加载中…</div>}
{!isLoading && tiers.length === 0 && <div className="rounded border border-dashed border-border py-5 text-center text-xs text-muted">暂无 2 板以上</div>}
{tiers.map(t => (
<div key={t.boards} className="grid grid-cols-[42px_1fr_auto] items-center gap-2 rounded bg-elevated/35 px-2 py-1.5">
<span className={`font-mono text-sm font-bold ${t.boards >= 5 ? 'text-bear' : t.boards >= 3 ? 'text-warning' : 'text-secondary'}`}>{t.boards}</span>
<div className="h-1.5 overflow-hidden rounded-full bg-base">
<div className="h-full rounded-full bg-bear/70" style={{ width: `${Math.min(100, t.count * 12)}%` }} />
</div>
<span className="font-mono text-xs text-foreground">{t.count}</span>
</div>
))}
</div>
)
}
function MiniMetric({ label, value, cls = 'text-foreground' }: { label: string; value: string; cls?: string }) {
return (
<div className="rounded bg-elevated/45 px-2 py-1.5">
<div className="text-[10px] text-muted">{label}</div>
<div className={`mt-0.5 font-mono text-xs font-semibold ${cls}`}>{value}</div>
</div>
)
}
function StockList({ title, rows, mode }: { title: string; rows: MarketSnapshotRow[]; mode: 'gain' | 'loss' | 'amount' | 'active' }) {
return (
<div className="rounded-card border border-border bg-surface/80 p-2.5">
<div className="mb-1.5 flex items-center justify-between">
<h3 className="text-xs font-semibold text-foreground">{title}</h3>
<span className="text-[9px] text-muted">TOP {Math.min(rows.length, 8)}</span>
</div>
<div className="space-y-1">
{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 bg-elevated/40 px-1.5 py-1">
<span className="text-center font-mono text-[10px] text-muted">{idx + 1}</span>
<div className="min-w-0">
<div className="truncate text-[11px] text-foreground">{r.name || r.symbol}</div>
<div className="font-mono text-[9px] text-muted">{r.symbol}</div>
</div>
<div className="text-right">
{mode === 'amount' ? (
<>
<div className="font-mono text-[11px] text-foreground">{fmtBigNum(r.amount)}</div>
<div className={`font-mono text-[9px] ${pctClass(r.change_pct)}`}>{fmtStockPct(r.change_pct)}</div>
</>
) : mode === 'active' ? (
<>
{/* overview 的 turnover_rate 为小数制, 需 ×100 转百分数显示 */}
<div className="font-mono text-[11px] text-accent">{fmtPrice(r.turnover_rate != null ? r.turnover_rate * 100 : null, 1)}%</div>
<div className={`font-mono text-[9px] ${pctClass(r.change_pct)}`}>{fmtStockPct(r.change_pct)}</div>
</>
) : (
<>
<div className={`font-mono text-[11px] font-semibold ${pctClass(r.change_pct)}`}>{fmtStockPct(r.change_pct)}</div>
<div className="font-mono text-[9px] text-muted">{fmtPrice(r.close)}</div>
</>
)}
</div>
</div>
))}
{rows.length === 0 && <div className="py-5 text-center text-xs text-muted">暂无数据</div>}
</div>
</div>
)
}
function RankColumn({ title, rows, tone }: { title: string; rows: OverviewDimensionRankItem[]; tone: 'bull' | 'bear' }) {
return (
<div className="min-w-0 space-y-1">
<div className={`text-[10px] font-medium ${tone === 'bull' ? 'text-bull' : 'text-bear'}`}>{title}</div>
{rows.slice(0, 5).map((r, idx) => (
<div key={`${title}-${r.name}-${idx}`} className="grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded bg-elevated/40 px-1.5 py-1">
<span className="text-center font-mono text-[9px] text-muted">{idx + 1}</span>
<div className="min-w-0">
<div className="truncate text-[11px] text-foreground" title={r.name}>{r.name}</div>
<div className="truncate text-[9px] text-muted">{r.count} · {r.leader?.name ?? '—'}</div>
</div>
<div className={`font-mono text-[10px] font-semibold ${pctClass(r.avg_pct)}`}>{fmtStockPct(r.avg_pct)}</div>
</div>
))}
{rows.length === 0 && <div className="rounded border border-dashed border-border py-4 text-center text-xs text-muted">暂无数据</div>}
</div>
)
}
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 (
<section className="rounded-card border border-border bg-surface/80 p-2.5">
<SectionTitle icon={Flame} title={title} hint="领涨/领跌" />
{hasData ? (
<div className="grid grid-cols-2 gap-2">
<RankColumn title="领涨" rows={rank?.leading ?? []} tone="bull" />
<RankColumn title="领跌" rows={rank?.lagging ?? []} tone="bear" />
</div>
) : (
<div className="py-4 text-center">
<p className="text-[11px] text-muted">未配置扩展数据源</p>
<Link
to={configUrl}
className="mt-1.5 inline-block text-[11px] text-accent hover:text-accent/80 transition-colors"
>
前往配置
</Link>
</div>
)}
</section>
)
}
export function Dashboard() {
const qc = useQueryClient()
const [selectedDate, setSelectedDate] = useState<string | undefined>()
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<string | null>(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 (
<div className="flex h-full items-center justify-center bg-base">
<div className="flex items-center gap-2 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin" /> 加载市场看板…
</div>
</div>
)
}
if (!data) {
return (
<div className="flex h-full items-center justify-center bg-base p-6">
<div className="rounded-card border border-border bg-surface p-6 text-center">
<div className="text-sm text-danger">看板加载失败</div>
<button onClick={() => overview.refetch()} className="mt-3 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-base">重试</button>
</div>
</div>
)
}
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 (
<div className="min-h-full bg-base p-3">
{/* 无本地数据常驻引导卡片 —— 一键触发盘后管道获取数据(无 Key 也可) */}
{hasNoData && (
<FetchDataCard
isFetching={isFetching}
isStarting={startFetch.isPending}
fetchFailed={fetchFailed}
stage={fetchStatus.data?.stage}
fetchPct={fetchStatus.data?.progress}
onStart={() => startFetch.mutate()}
isNoKey={isNoKey}
/>
)}
{/* 首次使用自动弹窗(同会话仅一次) */}
<AnimatePresence>
{showWelcomeModal && (
<WelcomeFetchModal
isNoKey={isNoKey}
onClose={() => setShowWelcomeModal(false)}
onStart={() => {
startFetch.mutate()
setShowWelcomeModal(false)
}}
/>
)}
</AnimatePresence>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-card border border-border bg-surface/85 px-3 py-2">
<div className="flex items-center gap-2">
<Gauge className="h-4 w-4 text-accent" />
<h1 className="text-base font-semibold text-foreground">市场看板</h1>
<span
className="rounded-full border px-2 py-0.5 text-[10px] font-medium"
style={{
color: scoreColor(score),
borderColor: `${scoreColor(score)}40`,
background: `${scoreColor(score)}14`,
}}
>
{data.emotion.label} · {score}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-muted">
{currentDate ? (
<DatePicker
value={currentDate}
onChange={setSelectedDate}
min={dataStatus.data?.enriched?.earliest_date ?? undefined}
max={latestDate ?? undefined}
className="w-32"
/>
) : (
<span className="font-mono text-secondary"></span>
)}
<span className="flex items-center gap-1"><Timer className="h-3 w-3" />{quoteAge(data.quote_status?.quote_age_ms)}</span>
<button
onClick={handleRefresh}
disabled={manualFetching}
className="inline-flex items-center gap-1 rounded-btn border border-border bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${manualFetching ? 'animate-spin' : ''}`} />刷新
</button>
</div>
</div>
<div className="mb-3 grid grid-cols-4 gap-2">
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
</div>
<div className="mb-3 grid grid-cols-6 gap-2">
<KpiCell label="个股涨 / 平 / 跌" value={<><span className="text-bull">{data.breadth.up}</span><span className="text-muted">/</span><span className="text-muted">{data.breadth.flat}</span><span className="text-muted">/</span><span className="text-bear">{data.breadth.down}</span></>} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} />
<KpiCell label="强势 / 弱势" value={<><span className="text-bull">{strongUp}</span><span className="text-muted">/</span><span className="text-bear">{strongDown}</span></>} sub="涨跌 ≥3%" />
<KpiCell label={<span className="inline-flex items-center gap-1">涨停 / 跌停<SealedBadge degraded={isSealedDegrade} hasDepth={hasDepth} isHistorical={false} sealedReady={sealedReady} sealedCountsUp={{ real: data.limit.limit_up, fake: data.limit.fake_up ?? 0, pending: 0 }} sealedCountsDown={{ real: data.limit.limit_down, fake: data.limit.fake_down ?? 0, pending: 0 }} rawUp={data.limit.limit_up + (data.limit.fake_up ?? 0)} rawDown={data.limit.limit_down + (data.limit.fake_down ?? 0)} invalidateKeys={['overview-market', 'limit-ladder']} /></span>} value={<><span className="text-bull">{data.limit.limit_up}</span><span className="text-muted">/</span><span className="text-bear">{data.limit.limit_down}</span></>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} />
<KpiCell label="最高连板" value={`${data.limit.max_boards || 0}板`} sub={`梯队 ${data.limit.tiers.length}`} tone="accent" />
<KpiCell label="成交额" value={fmtBigNum(data.amount.total)} sub={`均额 ${fmtBigNum(data.amount.avg)}`} />
<KpiCell label="换手 / 量比" value={`${fmtPrice(data.activity.avg_turnover, 1)}% / ${fmtPrice(data.activity.vol_ratio, 2)}`} sub={`高换手 ${data.activity.high_turnover} · 放量占比 ${fmtPrice(data.activity.high_vol_ratio, 1)}%`} tone="accent" />
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-[minmax(0,1fr)_20rem]">
<main className="min-w-0 space-y-3">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<section className="rounded-card border border-border bg-surface/80 p-2.5">
<SectionTitle icon={BarChart3} title="涨跌分布 / 广度" hint={`${data.breadth.total}只`} />
<DistributionBars rows={data.distribution} />
<div className="mt-2">
<BreadthBar data={data.breadth} />
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5">
<MiniMetric label="平均涨跌" value={fmtStockPct(data.breadth.avg_pct)} cls={pctClass(data.breadth.avg_pct)} />
<MiniMetric label="中位涨跌" value={fmtStockPct(data.breadth.median_pct)} cls={pctClass(data.breadth.median_pct)} />
</div>
</section>
<section
className="rounded-card border bg-surface/80 p-2.5"
style={{ borderColor: `${scoreColor(score)}40` }}
>
<SectionTitle icon={Sparkles} title="情绪雷达" hint={`情绪评分 ${score}`} />
<EmotionRadar radar={data.radar} score={score} />
</section>
<section className="flex flex-col rounded-card border border-border bg-surface/80 p-2.5">
<div>
<SectionTitle icon={LineChart} title="趋势强度" hint="均线/新高低" />
<div className="grid grid-cols-3 gap-1.5">
<MiniMetric label="站上MA5" value={`${data.trend.above_ma5_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="站上MA20" value={`${data.trend.above_ma20_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="站上MA60" value={`${data.trend.above_ma60_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="60日新高" value={compactCount(data.trend.new_high)} cls="text-bull" />
<MiniMetric label="60日新低" value={compactCount(data.trend.new_low)} cls="text-bear" />
<MiniMetric label="高低比" value={`${data.trend.new_high + data.trend.new_low > 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'} />
</div>
</div>
<div className="mt-3 border-t border-border pt-2.5">
<SectionTitle icon={Target} title="实用监控" hint="盘中观察" />
<div className="grid grid-cols-3 gap-1.5">
<MiniMetric label="炸板" value={`${data.limit.broken ?? 0}`} cls="text-warning" />
<MiniMetric label="跌停" value={`${data.limit.limit_down ?? 0}`} cls="text-bear" />
<MiniMetric label="站上MA60" value={`${data.trend.above_ma60_pct.toFixed(0)}%`} cls="text-accent" />
<MiniMetric label="新高/新低" value={`${compactCount(data.trend.new_high)}/${compactCount(data.trend.new_low)}`} cls={data.trend.new_high >= data.trend.new_low ? 'text-bull' : 'text-bear'} />
<MiniMetric label="高换手数" value={`${data.activity.high_turnover}`} cls="text-accent" />
<MiniMetric label="放量占比" value={`${fmtPrice(data.activity.high_vol_ratio, 1)}%`} cls="text-accent" />
</div>
</div>
</section>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<HotRankCard title="概念热度" rank={data.concept_rank} configUrl="/concept-analysis" />
<HotRankCard title="行业热度" rank={data.industry_rank} configUrl="/industry-analysis" />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StockList title="涨幅榜" rows={data.top_gainers} mode="gain" />
<StockList title="跌幅榜" rows={data.top_losers} mode="loss" />
<StockList title="成交额榜" rows={data.turnover_leaders} mode="amount" />
<StockList title="活跃换手" rows={data.active_leaders} mode="active" />
</div>
</main>
<aside className="min-w-0 space-y-3">
<section className="rounded-card border border-border bg-surface/80 p-3">
<SectionTitle icon={Flame} title="涨停梯队" hint={<span className="inline-flex items-center gap-1">{`涨停 ${data.limit.limit_up}`}{isSealedDegrade && <span className="text-[9px] px-1 rounded bg-yellow-500/10 text-yellow-600 dark:text-yellow-500">{hasDepth ? '未修正' : '降级'}</span>}</span>} />
<LadderMini limit={data.limit} />
</section>
<section className="rounded-card border border-border bg-surface/80 p-3">
<SectionTitle icon={ArrowDownRight} title="跌停梯队" hint={<span className="text-bear">{data.limit.limit_down} </span>} />
<DownLadderMini />
</section>
</aside>
</div>
</div>
)
}
// ===== 无数据常驻引导卡片: 一键触发盘后管道获取行情数据(无 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 (
<div className="mb-3 rounded-card border border-border bg-surface/85 p-3.5">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-accent/10 p-2 shrink-0">
<Database className="h-4 w-4 text-accent" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">当前暂无数据</div>
<p className="mt-1 text-xs text-secondary leading-relaxed">
首次使用需获取行情数据后才能查看看板。系统将从免费数据源拉取近 1 年全 A 股日K( 5500 ),预计 1-3 分钟,期间可继续浏览其他页面。
</p>
{isNoKey && (
<p className="mt-1 text-[11px] text-warning/80 leading-relaxed">
无需 API Key,当前为 None 档即可获取历史日K,可制定策略+回测。配置免费 Key 可解锁实时行情监控能力。
</p>
)}
{isFetching ? (
<div className="mt-3">
<div className="flex items-center justify-between text-[11px] text-muted mb-1.5">
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
{isStarting ? '正在启动同步任务…' : stageText}
</span>
<span className="font-mono tabular">
{typeof fetchPct === 'number' ? `${Math.round(fetchPct)}%` : ''}
</span>
</div>
<div className="h-1.5 rounded-full bg-elevated overflow-hidden">
<motion.div
className="h-full bg-accent"
initial={{ width: 0 }}
animate={{ width: `${Math.max(2, Math.min(100, fetchPct ?? 0))}%` }}
transition={{ duration: 0.4, ease: 'easeOut' }}
/>
</div>
</div>
) : fetchFailed ? (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-danger">同步失败,请重试</span>
<button
onClick={onStart}
className="inline-flex items-center gap-1.5 px-3 h-8 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 transition-colors"
>
<Play className="h-3.5 w-3.5" />重新获取
</button>
</div>
) : (
<div className="mt-3 flex items-center gap-3">
<button
onClick={onStart}
className="inline-flex items-center gap-1.5 px-4 h-8 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 transition-colors"
>
<Play className="h-3.5 w-3.5" />立即获取数据
</button>
<Link
to="/data"
className="inline-flex items-center gap-0.5 text-xs text-secondary hover:text-accent transition-colors"
>
前往数据页
<ArrowUpRight className="h-3 w-3 self-center" />
</Link>
</div>
)}
</div>
</div>
</div>
)
}
// ===== 首次使用自动弹窗: 询问用户后触发盘后管道 =====
function WelcomeFetchModal({
isNoKey, onClose, onStart,
}: {
isNoKey: boolean
onClose: () => void
onStart: () => void
}) {
return (
<SettingsModal title="欢迎首次使用 · 获取行情数据" onClose={onClose}>
<div className="text-center">
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="mx-auto w-fit rounded-2xl bg-accent/10 p-3.5"
>
<Sparkles className="h-7 w-7 text-accent" />
</motion.div>
<h3 className="mt-4 text-base font-semibold text-foreground">首次使用,需先获取行情数据</h3>
<p className="mt-2 text-xs text-secondary leading-relaxed">
系统将从免费数据源拉取近 1 年全 A 股日K( 5500 ),预计 1-3 分钟。
同步期间可继续浏览其他页面,完成后看板自动刷新。
</p>
{isNoKey && (
<div className="mt-3 rounded-btn bg-elevated/60 px-3 py-2 text-[11px] text-muted leading-relaxed">
当前无需 API Key,None 档即可获取历史日K数据
</div>
)}
<div className="mt-5 flex items-center justify-center gap-2.5">
<button
onClick={onClose}
className="px-4 h-9 rounded-btn text-sm text-secondary hover:text-foreground hover:bg-elevated transition-colors"
>
稍后再说
</button>
<button
onClick={onStart}
className="inline-flex items-center gap-2 px-5 h-9 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 transition-all"
>
<Play className="h-4 w-4" />开始获取
</button>
</div>
</div>
</SettingsModal>
)
}