import { useState, useRef, useEffect, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' import { RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { api, type MonitorRule, type AlertEvent, type MonitorCondition } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { fmtPrice, fmtPct } from '@/lib/format' import { cn } from '@/lib/cn' import { cnSignal } from '@/lib/signals' import { boardTag } from '@/components/stock-table/primitives' import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge' import { RuleEditor } from '@/components/monitor/RuleEditor' import { StockPreviewDialog } from '@/components/StockPreviewDialog' const TYPE_LABEL: Record = { signal: '个股信号', price: '价格/涨跌', market: '市场异动', strategy: '策略监控', } /** 严重级别 → 左侧色条 + 图标 */ const SEVERITY_CONFIG: Record = { info: { bar: 'bg-accent/40', icon: Bell, iconCls: 'text-accent' }, warn: { bar: 'bg-warning', icon: TrendingUp, iconCls: 'text-warning' }, critical: { bar: 'bg-danger', icon: Flame, iconCls: 'text-danger' }, } const SOURCE_BADGE_STYLE: Record = { strategy: 'bg-amber-400/10 text-amber-400 border-amber-400/20', signal: 'bg-accent/10 text-accent border-accent/20', price: 'bg-emerald-400/10 text-emerald-400 border-emerald-400/20', market: 'bg-purple-500/10 text-purple-400 border-purple-500/20', } /** * 渲染策略类消息 — 策略名黄色、新入选绿、移出红、其余白色。 */ function renderMessage(source: string, message: string) { if (source !== 'strategy') { return {message} } const m = message.match(/^(策略「)([^」]+)(」)(新入选|移出)( .*)$/) if (!m) return {message} const [, pre, strategyName, mid, direction, post] = m return ( <> {pre} {strategyName} {mid} {direction} {post} ) } export function Monitor() { const qc = useQueryClient() const [editorOpen, setEditorOpen] = useState(false) const [editingRule, setEditingRule] = useState(null) // 触发记录: 过滤 + 统计 (提升到主组件, 供 header 行使用) const [filter, setFilter] = useState<'all' | 'strategy' | 'signal' | 'price' | 'market'>('all') const [confirmClear, setConfirmClear] = useState(false) const [confirmClearRules, setConfirmClearRules] = useState(false) const alertsQuery = useQuery({ queryKey: QK.alerts(filter === 'all' ? undefined : filter), queryFn: () => api.alertsList({ days: 7, limit: 500, source: filter === 'all' ? undefined : filter }), refetchInterval: 10000, refetchIntervalInBackground: true, }) const total = alertsQuery.data?.total ?? 0 // 规则个数 const rulesQuery = useQuery({ queryKey: QK.monitorRules, queryFn: api.monitorRulesList }) const rulesCount = rulesQuery.data?.rules.length ?? 0 // 清除全部规则 (逐条删除) const clearRulesMut = useMutation({ mutationFn: async () => { const rules = rulesQuery.data?.rules ?? [] await Promise.all(rules.map(r => api.monitorRuleDelete(r.id))) }, onSuccess: () => { qc.invalidateQueries({ queryKey: QK.monitorRules }) setConfirmClearRules(false) }, }) // 进入监控页: 清零未读徽标 + 记录"进入时刻", 之后新增的记录会闪烁 // 离开监控页: 停止同步, 之后新增才计入未读 const enterTsRef = useRef(Date.now()) useEffect(() => { enterTsRef.current = Date.now() markSeen() return () => leaveMonitorPage() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return (
{/* 左栏: 触发记录 */}
{/* 过滤标签 */}
{(['all', 'strategy', 'signal', 'price', 'market'] as const).map(f => ( ))}
{/* 数量 + 清空 */}
{total} {total > 0 && ( )}
{/* 右栏: 监控规则 */}
{rulesCount}
{ setEditingRule(r); setEditorOpen(true) }} />
{ setEditorOpen(false); setEditingRule(null) }} /> setConfirmClearRules(false)} onConfirm={() => clearRulesMut.mutate()} pending={clearRulesMut.isPending} />
) } function SectionHeader({ icon: Icon, title }: { icon: any; title: string }) { return (

{title}

) } // ── 触发记录列表 ────────────────────────────────────── function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs }: { alertsQuery: ReturnType confirmClear: boolean setConfirmClear: (v: boolean) => void total: number enterTs: number }) { const qc = useQueryClient() const [confirmTs, setConfirmTs] = useState(null) const resetTimer = useRef | null>(null) const [previewEv, setPreviewEv] = useState(null) const clearMut = useMutation({ mutationFn: api.alertsClear, onSuccess: () => { qc.invalidateQueries({ queryKey: ['alerts'] }); setConfirmClear(false); resetBadge() }, }) const delMut = useMutation({ mutationFn: (ts: number) => api.alertDelete(ts), onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts'] }), }) // 点击删除: 第一次进入确认态, 第二次真删, 3 秒后自动复位 const handleClickDelete = (ts: number) => { if (confirmTs === ts) { // 第二次点击 → 真删 if (resetTimer.current) clearTimeout(resetTimer.current) setConfirmTs(null) delMut.mutate(ts) } else { // 第一次点击 → 进入确认态, 3 秒后自动复位 setConfirmTs(ts) if (resetTimer.current) clearTimeout(resetTimer.current) resetTimer.current = setTimeout(() => setConfirmTs(null), 3000) } } const events = (alertsQuery.data as any)?.alerts ?? [] return (
{events.length === 0 ? ( ) : (
{events .filter((ev: any) => !(ev.source === 'strategy' && !ev.symbol)) .map((ev: any, i: number) => { const sev = SEVERITY_CONFIG[ev.severity ?? 'info'] ?? SEVERITY_CONFIG.info const SevIcon = sev.icon const isNew = ev.ts > enterTs return (
{ev.source === 'strategy' ? (() => { const sm = ev.message?.match(/策略「([^」]+)」/) const sname = sm ? sm[1] : '' const isNew = ev.type === 'new_entry' const _pct = ev.change_pct ?? 0 return ( <>
{ev.symbol && (() => { const board = boardTag(ev.symbol) return ( ) })()} {ev.price != null && ( = 0 ? 'text-danger' : 'text-bear')}> {_pct >= 0 ? : } {fmtPrice(ev.price)} )} {ev.change_pct != null && ( = 0 ? 'text-danger' : 'text-bear')}> {fmtPct(_pct)} )} {sname}
{isNew ? '进入' : '移出'} 策略 「{sname}」
) })() : ( <>
{ev.symbol && (() => { const board = boardTag(ev.symbol) return ( ) })()} {ev.price != null && ( = 0 ? 'text-danger' : 'text-bear')}> {(ev.change_pct ?? 0) >= 0 ? : } {fmtPrice(ev.price)} )} {ev.change_pct != null && ( = 0 ? 'text-danger' : 'text-bear')}> {fmtPct(ev.change_pct)} )} {(() => { // 优先用规则名 (如 "策略监控 · 空中加油" → "空中加油"); 退回到 type 标签 const rn = ev.rule_name ?? '' const dotIdx = rn.indexOf(' · ') return dotIdx >= 0 ? rn.slice(dotIdx + 3) : (rn || (TYPE_LABEL[ev.source] ?? ev.source)) })()}
{/* 详情行: 命中条件 (signal/price/market) + 当前价 / 或默认消息 */} {(ev.conditions && ev.conditions.length > 0) ? (
命中 {ev.conditions.map((c: MonitorCondition, ci: number) => ( {ci > 0 && {ev.logic === 'or' ? '或' : '且'}} {c.op === 'truth' ? ( {cnSignal(c.field)} ) : ( {cnSignal(c.field)}{c.op}{c.value} )} ))} {ev.price != null && ( <> · 现价 {fmtPrice(ev.price)} )}
) : (
{renderMessage(ev.source, ev.message)}
)} {ev.signals && ev.signals.length > 0 && (
{ev.signals.map((s: string, j: number) => ( {cnSignal(s)} ))}
)} )}
{new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} {confirmTs === ev.ts ? ( // 确认态: 红色实心按钮 (原删除图标位置), 再点确认删除 ) : ( )}
) })}
)} setConfirmClear(false)} onConfirm={() => clearMut.mutate()} pending={clearMut.isPending} /> setPreviewEv(null)} />
) } // ── 监控规则列表 ────────────────────────────────────── function RulesList({ rulesQuery, onEdit }: { rulesQuery: ReturnType onEdit: (rule: MonitorRule) => void }) { const qc = useQueryClient() const [confirmId, setConfirmId] = useState(null) const [previewSymbol, setPreviewSymbol] = useState(null) const resetTimer = useRef | null>(null) const rules: MonitorRule[] = (rulesQuery.data as any)?.rules ?? [] // 收集所有规则的股票代码, 批量查名称 const allSymbols = useMemo(() => { const set = new Set() for (const r of rules) { if (r.scope === 'symbols') r.symbols.forEach(s => set.add(s)) } return Array.from(set) }, [rules]) const namesQuery = useQuery({ queryKey: ['instrument-names', allSymbols.join(',')], queryFn: () => api.instrumentNames(allSymbols), enabled: allSymbols.length > 0, staleTime: 300000, }) const symbolNames = namesQuery.data?.names ?? {} const del = useMutation({ mutationFn: api.monitorRuleDelete, onSuccess: () => qc.invalidateQueries({ queryKey: QK.monitorRules }), }) const toggleEnabled = (rule: MonitorRule) => { api.monitorRuleSave({ ...rule, enabled: !rule.enabled }).then(() => qc.invalidateQueries({ queryKey: QK.monitorRules }), ) } // 点击删除: 第一次进入确认态, 第二次真删, 3 秒后自动复位 const handleClickDelete = (id: string) => { if (confirmId === id) { if (resetTimer.current) clearTimeout(resetTimer.current) setConfirmId(null) del.mutate(id) } else { setConfirmId(id) if (resetTimer.current) clearTimeout(resetTimer.current) resetTimer.current = setTimeout(() => setConfirmId(null), 3000) } } return (
{rules.length === 0 ? ( ) : ( rules.map(r => { // 名称截取: "策略监控 · MACD金叉" → "MACD金叉", "个股信号监控 · 300750.SZ" → "个股信号监控" const dotIdx = r.name.indexOf(' · ') const displayName = dotIdx >= 0 ? r.name.slice(dotIdx + 3) : r.name return ( {/* 左侧状态条 */}
{/* 第一行: 分类标签 + 名称 + 操作按钮 */}
{TYPE_LABEL[r.type]} {/* 个股类型: 直接显示可点击的代码+名称; 其他类型显示规则名 */} {r.scope === 'symbols' && r.symbols.length > 0 ? ( ) : (

{displayName}

)} {!r.enabled && · 停用}
{confirmId === r.id ? ( ) : ( )}
{/* 第二行: 策略类型显示选股池变更监控 */} {r.type === 'strategy' && r.strategy_id ? (
选股池变更监控
) : r.conditions.length > 0 && (
条件 {r.conditions.slice(0, 3).map((c, i) => ( {i > 0 && {r.logic === 'and' ? '且' : '或'}} {c.op === 'truth' ? ( {cnSignal(c.field)} ) : ( {cnSignal(c.field)}{c.op}{c.value} )} ))} {r.conditions.length > 3 && +{r.conditions.length - 3}}
)} ) }) )} setPreviewSymbol(null)} />
) } // ── 规则编辑对话框 ──────────────────────────────────── function RuleEditorDialog({ open, rule, onClose }: { open: boolean; rule: MonitorRule | null; onClose: () => void }) { return ( {open && ( e.stopPropagation()} > )} ) } // ── 确认对话框 ──────────────────────────────────────── function ConfirmDialog({ open, title, message, confirmText, danger, pending, onCancel, onConfirm }: { open: boolean title: string message: string confirmText?: string danger?: boolean pending?: boolean onCancel: () => void onConfirm: () => void }) { return ( {open && ( e.stopPropagation()} >

{title}

{message}

)}
) }