看板跌停梯队改为真实跌停数据展示

移除监控中心告警组件,新增 DownLadderMini 组件调用
limit-ladder API(direction=down)获取真实跌停梯队数据,
配色用 red/bear 色系,与涨停梯队布局一致。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-06 12:25:33 +08:00
parent 0861e68fe4
commit 431aae0882
+30 -152
View File
@@ -2,19 +2,16 @@ import { useState, useEffect, useRef, type ReactNode } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react' import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, Database, Flame, Gauge, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker' import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api' import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket } from '@/lib/api'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct } from '@/lib/format' import { fmtBigNum } from '@/lib/format'
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries' import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge' import { SealedBadge } from '@/components/SealedBadge'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { SettingsModal } from '@/components/data/SettingsModal' import { SettingsModal } from '@/components/data/SettingsModal'
import { STAGE_LABELS } from '@/components/data/ActiveJobCard' 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) { function n(v: number | null | undefined) {
return typeof v === 'number' && Number.isFinite(v) ? v : null return typeof v === 'number' && Number.isFinite(v) ? v : null
@@ -79,140 +76,6 @@ function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; titl
) )
} }
// 看板监控中心小组件 — 显示前 10 条触发记录 + 更多按钮
const _SOURCE_BADGE: Record<string, string> = {
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<string, string> = {
strategy: '策略', signal: '信号', price: '价格', market: '异动',
}
const _SEVERITY_BAR: Record<string, string> = {
info: 'bg-accent/40', warn: 'bg-warning', critical: 'bg-danger',
}
function MonitorWidget() {
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(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 (
<div className="mt-1 py-6 text-center text-[11px] text-muted"></div>
)
}
return (
<>
<div className="mt-1 space-y-1.5">
{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 (
<motion.div
key={`${ev.ts}-${i}`}
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"
>
<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={() => ev.symbol && setPreviewEv(ev)}
title={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 cursor-pointer"
>
<span className="font-mono text-[10px] font-medium text-foreground/80 hover:text-accent">{ev.symbol?.replace(/\.(SH|SZ|BJ)$/, '')}</span>
{ev.symbol && (() => {
const board = boardTag(ev.symbol)
return board && (
<span className={`inline-flex items-center justify-center h-3 w-3 rounded text-[7px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
)
})()}
{ev.name && <span className="text-[10px] text-secondary truncate max-w-[5rem] hover:text-foreground">{ev.name}</span>}
</button>
<span className="flex-1" />
{ev.price != null && (
<span className="text-[10px] font-mono text-foreground/60 shrink-0">{fmtPrice(ev.price)}</span>
)}
{ev.change_pct != null && (
<span className={cn('text-[10px] font-mono font-medium shrink-0 w-12 text-right', pct >= 0 ? 'text-danger' : 'text-bear')}>
{fmtPct(pct)}
</span>
)}
</div>
{/* 第二行: 策略类型走新格式, 其他走旧格式 */}
{isStrategy ? (
<div className="mt-0.5 flex items-center gap-1.5">
<span className={cn('text-[9px] font-medium', isNew ? 'text-danger' : 'text-emerald-400')}>
{isNew ? '进入' : '移出'}
</span>
<span className="text-[9px] text-muted"></span>
<span className="text-[9px] font-medium text-amber-400">{sname}</span>
<span className="flex-1" />
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
) : (
<>
<div className="mt-0.5 flex items-center gap-1.5">
<span className={cn('shrink-0 rounded px-1 py-px text-[8px] font-medium', _SOURCE_BADGE[ev.source] ?? 'bg-elevated text-muted')}>
{_SOURCE_LABEL[ev.source] ?? ev.source}
</span>
{ev.message && (
<span className="text-[9px] text-muted truncate flex-1">{ev.message}</span>
)}
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{ev.signals.map((s, j) => (
<span key={j} className="rounded bg-accent/8 px-1 py-px text-[8px] text-accent/80">{cnSignal(s)}</span>
))}
</div>
)}
</>
)}
</motion.div>
)
})}
</div>
<StockPreviewDialog
symbol={previewEv?.symbol ?? null}
name={previewEv?.name ?? undefined}
triggerInfo={previewEv ? {
price: previewEv.price ?? null,
changePct: previewEv.change_pct ?? null,
ts: previewEv.ts,
signals: previewEv.signals,
message: previewEv.message,
} : null}
onClose={() => setPreviewEv(null)}
/>
</>
)
}
function KpiCell({ label, value, sub, tone = 'neutral' }: { label: ReactNode; value: ReactNode; sub?: string; tone?: 'bull' | 'bear' | 'accent' | 'neutral' }) { 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 isPlain = typeof value === 'string' || typeof value === 'number'
@@ -375,6 +238,30 @@ function LadderMini({ limit }: { limit: OverviewMarket['limit'] }) {
) )
} }
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 }) { function MiniMetric({ label, value, cls = 'text-foreground' }: { label: string; value: string; cls?: string }) {
return ( return (
<div className="rounded bg-elevated/45 px-2 py-1.5"> <div className="rounded bg-elevated/45 px-2 py-1.5">
@@ -728,17 +615,8 @@ export function Dashboard() {
<LadderMini limit={data.limit} /> <LadderMini limit={data.limit} />
</section> </section>
<section className="rounded-card border border-border bg-surface/80 p-3"> <section className="rounded-card border border-border bg-surface/80 p-3">
<div className="mb-2 flex items-center justify-between gap-2"> <SectionTitle icon={ArrowDownRight} title="跌停梯队" hint={<span className="text-bear">{data.limit.limit_down} </span>} />
<div className="flex items-center gap-1.5"> <DownLadderMini />
<BellRing className="h-3.5 w-3.5 text-accent" />
<h2 className="text-xs font-semibold text-foreground"></h2>
<span className="font-mono text-[10px] text-muted"></span>
</div>
<Link to="/monitor" className="inline-flex items-center justify-center h-5 w-5 rounded text-muted hover:text-accent hover:bg-accent/10 transition-colors" title="进入跌停梯队">
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</div>
<MonitorWidget />
</section> </section>
</aside> </aside>
</div> </div>