From 33a7be0debe77fcc2e3172af127a9a330639f39a Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Thu, 2 Jul 2026 17:22:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=9E=E6=9D=BF=E6=A2=AF=E9=98=9F?= =?UTF-8?q?=E5=B0=81=E5=8D=95=E7=9B=91=E6=8E=A7=E5=8D=A1=E7=89=87=20UI=20?= =?UTF-8?q?=E4=B8=8E=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - monitor_rules API: RuleModel 加 metric/threshold 字段; ladder 类型 需 DEPTH5_BATCH 能力校验; 加模拟触发与真实触发端点供验证 - 连板梯队卡片: 右上角监控按钮 + 设置菜单 (段控指标/阈值+单位/ 胶囊推送/权限提示); 开启监控的卡片置顶 + 背景区分 - 推送默认取全局设置; 单位默认万手/亿元 - 非付费用户可见功能入口但保存禁用, 提示后续适配免费数据源 --- backend/app/api/monitor_rules.py | 264 +++++++++++++++++++++++ frontend/src/lib/api.ts | 77 ++++++- frontend/src/pages/LimitUpLadder.tsx | 308 ++++++++++++++++++++++++++- 3 files changed, 639 insertions(+), 10 deletions(-) diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index 475d685..b5e332c 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -50,6 +50,9 @@ class RuleModel(BaseModel): webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定) webhook_enabled: bool = False message: str = "" + # ladder 专属 (连板梯队封单监控) + metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元) + threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元) # ── 字段选项 ───────────────────────────────────────────── @@ -128,6 +131,16 @@ def list_rules(request: Request): @router.post("") def save_rule(req: RuleModel, request: Request): rule = monitor_rules.normalize(req.model_dump()) + # 连板梯队封单监控 (type=ladder) 依赖五档盘口数据, 需 Pro+ (DEPTH5_BATCH 能力)。 + # 无能力时拒绝创建, 避免规则存了却永远无法触发。 + if rule.get("type") == "ladder": + from app.tickflow.capabilities import Cap + capset = getattr(request.app.state, "capabilities", None) + if capset is None or not capset.has(Cap.DEPTH5_BATCH): + raise HTTPException( + status_code=403, + detail="封单监控需要 Pro+ 套餐 (批量五档能力),请升级后在「设置」页配置", + ) # 编辑现有规则时, 保留原 created_at (避免按时间排序时位置跳动) existing = monitor_rules.load_one(_data_dir(request), rule["id"]) if existing and existing.get("created_at"): @@ -233,3 +246,254 @@ def seed_demo_rules(request: Request): i += 1 _sync_engine(request) return {"ok": True, "generated": len(created), "ids": created} + + +# ── 封单监控模拟触发 (Dev 调试用) ───────────────────── +@router.post("/test-ladder") +def test_ladder(request: Request): + """模拟触发所有 ladder 规则, 返回命中结果 (不落盘、不推送飞书)。 + + 用当前 depth_service 的封单数据 + enriched 最新日 close 构造 mock DataFrame, + 跑 _evaluate_ladder 判断哪些规则会触发。供 Dev 页面调试验证。 + """ + import polars as pl + + repo = request.app.state.repo + depth_svc = getattr(request.app.state, "depth_service", None) + engine = getattr(request.app.state, "monitor_engine", None) + + if not depth_svc: + raise HTTPException(status_code=503, detail="depth 服务未初始化") + if not engine or not engine.has_rule_type("ladder"): + raise HTTPException(status_code=400, detail="无 ladder 类型监控规则") + + # 最新交易日 + latest = repo.enriched_latest_date() + if not latest: + raise HTTPException(status_code=400, detail="无 enriched 数据") + + # 取涨停+跌停封单 {symbol: vol} + sealed: dict[str, int] = {} + for is_down in (False, True): + m = depth_svc.get_sealed_map(latest, is_down=is_down) + for sym, info in m.items(): + vol = (info or {}).get("vol") + if vol and vol > 0: + sealed[sym] = vol + + if not sealed: + raise HTTPException(status_code=400, detail="无封单数据 (depth 未拉取或无涨停/跌停股)") + + # 取这些 symbol 的 close (算封单额用) + enriched_today, _ = repo.get_enriched_latest() + cols = ["symbol", "close", "change_pct"] + avail = [c for c in cols if c in enriched_today.columns] + mock = enriched_today.select(avail).filter(pl.col("symbol").is_in(list(sealed.keys()))) + + # 注入 _sealed_vol + sealed_df = pl.DataFrame({ + "symbol": list(sealed.keys()), + "_sealed_vol": list(sealed.values()), + }) + mock = mock.join(sealed_df, on="symbol", how="inner") + + # 取所有 ladder 规则, 逐条纯条件判断 (绕过引擎 cooldown, 不污染 _last_fire) + ladder_rules = [r for r in engine.rules.values() if r.get("type") == "ladder" and r.get("enabled", True)] + all_events = [] + not_triggered = [] + + for rule in ladder_rules: + syms = rule.get("symbols", []) + sym = syms[0] if syms else None + metric = rule.get("metric", "sealed_vol") + thr = rule.get("threshold", 0) + direction = rule.get("direction", "up") + warn_label = "炸板预警" if direction == "up" else "翘板预警" + + # 取该 symbol 的封单数据 + cur_vol = sealed.get(sym) if sym else None + row = mock.filter(pl.col("symbol") == sym) if sym else mock.clear() + cur_close = row["close"][0] if len(row) and "close" in row.columns else None + cur_amt = (cur_vol * 100 * cur_close) if (cur_vol and cur_close) else None + cur_val = cur_amt if metric == "sealed_amount" else cur_vol + + # 条件判断: 封单 > 0 且 比较值 <= 阈值 + if cur_val is not None and cur_val > 0 and cur_val <= thr: + if metric == "sealed_amount": + sv_text = f"{cur_val / 1e4:.0f}万元" + th_text = f"{thr / 1e4:.0f}万元" + else: + sv_text = f"{cur_val:,.0f} 手" + th_text = f"{thr:,.0f} 手" + all_events.append({ + "rule_id": rule["id"], + "rule_name": rule.get("name", ""), + "symbol": sym, + "name": sym, + "type": warn_label, + "message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}", + "severity": rule.get("severity", "warn"), + "sealed_value": cur_val, + "sealed_metric": metric, + "current_sealed_vol": cur_vol, + "current_sealed_amount": cur_amt, + }) + else: + reason = "封单数据缺失" if cur_val is None else ( + f"封单 {cur_val:,.0f} > 阈值 {thr:,.0f}" if cur_val > thr else "封单为 0" + ) + not_triggered.append({ + "rule_id": rule["id"], + "rule_name": rule.get("name", ""), + "symbol": sym, + "metric": metric, + "threshold": thr, + "current_value": cur_val, + "current_sealed_vol": cur_vol, + "current_sealed_amount": cur_amt, + "reason": reason, + }) + + return { + "ok": True, + "as_of": str(latest), + "sealed_count": len(sealed), + "triggered": all_events, + "not_triggered": not_triggered, + } + + +@router.post("/trigger-ladder") +def trigger_ladder(request: Request): + """真实触发一次 ladder 预警 (落盘 + 飞书推送 + SSE), 供 Dev 调试验证完整效果。 + + 与 test-ladder 区别: 本端点会真的把预警写入 alerts.jsonl、推送飞书、触发 SSE, + 让用户看到真实的预警通知。绕过 cooldown 强制触发。 + """ + import time + from app.services import alert_store + + repo = request.app.state.repo + depth_svc = getattr(request.app.state, "depth_service", None) + engine = getattr(request.app.state, "monitor_engine", None) + quote_svc = getattr(request.app.state, "quote_service", None) + + if not depth_svc: + raise HTTPException(status_code=503, detail="depth 服务未初始化") + if not engine or not engine.has_rule_type("ladder"): + raise HTTPException(status_code=400, detail="无 ladder 类型监控规则") + + latest = repo.enriched_latest_date() + if not latest: + raise HTTPException(status_code=400, detail="无 enriched 数据") + + # 取封单 + sealed: dict[str, int] = {} + for is_down in (False, True): + m = depth_svc.get_sealed_map(latest, is_down=is_down) + for sym, info in m.items(): + vol = (info or {}).get("vol") + if vol and vol > 0: + sealed[sym] = vol + if not sealed: + raise HTTPException(status_code=400, detail="无封单数据") + + # 构造真实 rule_events (与 _evaluate_ladder 产出格式一致) + import polars as pl + enriched_today, _ = repo.get_enriched_latest() + cols = [c for c in ["symbol", "close", "change_pct"] if c in enriched_today.columns] + mock = enriched_today.select(cols).filter(pl.col("symbol").is_in(list(sealed.keys()))) + sealed_df = pl.DataFrame({"symbol": list(sealed.keys()), "_sealed_vol": list(sealed.values())}) + mock = mock.join(sealed_df, on="symbol", how="inner") + + now = time.time() + rule_events: list[dict] = [] + name_map = {} + try: + inst = repo.get_instruments() + if not inst.is_empty() and "name" in inst.columns: + name_map = {r["symbol"]: r["name"] for r in inst.select(["symbol", "name"]).iter_rows(named=True) if r.get("name")} + except Exception: # noqa: BLE001 + pass + + for rule in engine.rules.values(): + if rule.get("type") != "ladder" or not rule.get("enabled", True): + continue + sym = rule.get("symbols", [""])[0] if rule.get("symbols") else "" + metric = rule.get("metric", "sealed_vol") + thr = rule.get("threshold", 0) + direction = rule.get("direction", "up") + warn_label = "炸板预警" if direction == "up" else "翘板预警" + + row = mock.filter(pl.col("symbol") == sym) + if row.is_empty(): + continue + cur_vol = row["_sealed_vol"][0] + close_v = row["close"][0] if "close" in row.columns else None + cur_val = cur_vol * 100 * close_v if metric == "sealed_amount" else cur_vol + if not cur_val or cur_val <= 0 or cur_val > thr: + continue # 不满足条件, 跳过 + + if metric == "sealed_amount": + sv_text = f"{cur_val / 1e4:.0f}万元" + th_text = f"{thr / 1e4:.0f}万元" + else: + sv_text = f"{cur_val:,.0f} 手" + th_text = f"{thr:,.0f} 手" + + rule_events.append({ + "ts": int(now * 1000), + "rule_id": rule["id"], + "rule_name": rule.get("name", ""), + "source": "ladder", + "type": warn_label, + "symbol": sym, + "name": name_map.get(sym, sym), + "message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}", + "price": close_v, + "change_pct": row["change_pct"][0] if "change_pct" in row.columns else None, + "signals": [], + "severity": rule.get("severity", "warn"), + "conditions": [], + "logic": "and", + "sealed_value": cur_val, + "sealed_metric": metric, + }) + + if not rule_events: + raise HTTPException(status_code=400, detail="当前无 ladder 规则满足触发条件 (封单均 > 阈值)") + + # 1. 落盘到 alerts.jsonl + try: + alert_store.append_many(repo.store.data_dir, rule_events) + except Exception as e: # noqa: BLE001 + pass # 落盘失败不阻断推送 + + # 2. SSE 推送 (入 pending_alerts 队列) + if quote_svc: + sse_alerts = [{ + "source": ev["source"], "type": ev["type"], "rule_id": ev["rule_id"], + "strategy_id": None, "symbol": ev["symbol"], "name": ev["name"], + "message": ev["message"], "price": ev["price"], "change_pct": ev["change_pct"], + "signals": ev["signals"], "severity": ev["severity"], + "conditions": ev["conditions"], "logic": ev["logic"], + } for ev in rule_events] + try: + with quote_svc._lock: + quote_svc._pending_alerts.extend(sse_alerts) + quote_svc._alert_event.set() + except Exception: # noqa: BLE001 + pass + + # 3. 飞书推送 + if quote_svc: + try: + quote_svc._maybe_send_webhook(rule_events, engine) + except Exception: # noqa: BLE001 + pass + + return { + "ok": True, + "triggered": len(rule_events), + "events": [{"symbol": ev["symbol"], "name": ev["name"], "message": ev["message"]} for ev in rule_events], + } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2590afc..43c7b16 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -409,12 +409,12 @@ export interface MonitorRule { id: string name: string enabled: boolean - type: 'strategy' | 'signal' | 'price' | 'market' + type: 'strategy' | 'signal' | 'price' | 'market' | 'ladder' scope: 'symbols' | 'all' | 'sector' symbols: string[] sector?: string | null strategy_id?: string | null - direction: 'entry' | 'exit' | 'both' + direction: 'entry' | 'exit' | 'both' | 'up' | 'down' conditions: MonitorCondition[] logic: 'and' | 'or' cooldown_seconds: number @@ -423,6 +423,9 @@ export interface MonitorRule { webhook_url?: string webhook_enabled?: boolean created_at?: string + // ladder 专属: 封单监控 + metric?: 'sealed_vol' | 'sealed_amount' // 量(手) / 额(元) + threshold?: number // 封单 <= 此值时报警 } export interface MonitorRuleOptions { @@ -1561,6 +1564,48 @@ export const api = { } }, + /** AI 概念轮动分析 — 流式 NDJSON。 */ + async *rotationAnalyzeStream(days: number, focus?: string): AsyncGenerator<{ + type: 'meta' | 'delta' | 'error' | 'done' + days?: number + summary?: string + content?: string + message?: string + }> { + const res = await fetch('/api/rps/rotation-analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ days, focus: focus ?? '' }), + }) + if (!res.ok) { + let detail = '' + try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ } + const msg = detail || `${res.status} ${res.statusText}` + toast(msg, 'error') + throw new Error(msg) + } + if (!res.body) throw new Error('响应无 body') + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buf = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + const lines = buf.split('\n') + buf = lines.pop() ?? '' + for (const line of lines) { + const s = line.trim() + if (!s) continue + try { yield JSON.parse(s) } catch { /* ignore */ } + } + } + if (buf.trim()) { + try { yield JSON.parse(buf.trim()) } catch { /* ignore */ } + } + }, + // ===== Strategy Engine ===== strategyList: () => request<{ strategies: StrategyDetail[] }>('/api/strategies'), @@ -1628,6 +1673,34 @@ export const api = { monitorRuleDelete: (id: string) => request<{ ok: boolean }>(`/api/monitor-rules/${encodeURIComponent(id)}`, { method: 'DELETE' }), + /** 模拟触发 ladder 封单监控 (Dev 调试, 不落盘不推送) */ + monitorRuleTestLadder: () => + request<{ + ok: boolean + as_of: string + sealed_count: number + triggered: Array<{ + rule_id: string; rule_name: string; symbol: string; name?: string + type: string; message: string; severity: string + sealed_value: number; sealed_metric: string + current_sealed_vol?: number; current_sealed_amount?: number + }> + not_triggered: Array<{ + rule_id: string; rule_name: string; symbol: string + metric: string; threshold: number; current_value: number | null + current_sealed_vol?: number; current_sealed_amount?: number | null + reason: string + }> + }>('/api/monitor-rules/test-ladder', { method: 'POST' }), + + /** 真实触发 ladder 预警 (落盘+飞书+SSE), Dev 调试用 */ + monitorRuleTriggerLadder: () => + request<{ + ok: boolean + triggered: number + events: Array<{ symbol: string; name: string; message: string }> + }>('/api/monitor-rules/trigger-ladder', { method: 'POST' }), + /** 生成演示监控规则 (Dev 页用) */ monitorRuleSeed: () => request<{ ok: boolean; generated: number }>('/api/monitor-rules/seed', { method: 'POST' }), diff --git a/frontend/src/pages/LimitUpLadder.tsx b/frontend/src/pages/LimitUpLadder.tsx index 5da55fd..9ff5cfa 100644 --- a/frontend/src/pages/LimitUpLadder.tsx +++ b/frontend/src/pages/LimitUpLadder.tsx @@ -1,16 +1,16 @@ import { useState, useCallback, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' -import { RefreshCw, ChevronDown, Flame, Settings2, X } from 'lucide-react' +import { RefreshCw, ChevronDown, Flame, Settings2, X, Bell, BellOff, AlertCircle } from 'lucide-react' import { DatePicker } from '@/components/DatePicker' -import { api, type LimitLadderTier, type LimitLadderStock } from '@/lib/api' +import { api, type LimitLadderTier, type LimitLadderStock, type MonitorRule } from '@/lib/api' import { StockPreviewDialog } from '@/components/StockPreviewDialog' import { QK } from '@/lib/queryKeys' import { storage } from '@/lib/storage' import { fmtPct, priceColorClass } from '@/lib/format' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' -import { useCapabilities } from '@/lib/useSharedQueries' +import { useCapabilities, usePreferences } from '@/lib/useSharedQueries' import { SealedBadge } from '@/components/SealedBadge' import type { ExtColumnDisplayConfig } from '@/lib/watchlist-columns' @@ -216,13 +216,19 @@ function useSealedDegrade(asOf: string, latestDate: string | undefined, sealedRe // ===== 单只股票卡片 ===== -function StockCard({ stock, extFields, direction, sealMode, onClick }: { +function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick }: { stock: LimitLadderStock extFields: ExtFieldConfig direction: Direction sealMode: 'vol' | 'amount' + monitored: boolean + monitorRule?: MonitorRule + onMonitorChange: () => void + hasDepth: boolean onClick: () => void }) { + const [showMonitorMenu, setShowMonitorMenu] = useState(false) + const [menuAnchor, setMenuAnchor] = useState(null) const code = stock.symbol.replace(/\.BJ$/, '').replace(/\.SZ$/, '').replace(/\.SH$/, '') const tag = boardTag(stock.symbol) const status = stock.status || (direction === 'down' ? 'limit_down' : 'limit_up') @@ -247,10 +253,40 @@ function StockCard({ stock, extFields, direction, sealMode, onClick }: { const hasTags = conceptTags.length > 0 || industryTags.length > 0 + // 齿轮始终可见: 让免费用户也能看到功能入口, 点开后在菜单内提示权限不足。 + // Pro+ 用户正常设置; 免费用户保存按钮禁用 + 显示升级提示。 return ( - + {/* 监控菜单 */} + {showMonitorMenu && menuAnchor && ( + setShowMonitorMenu(false)} + onChanged={onMonitorChange} + /> + )} + + + ) +} + +// ===== 封单监控菜单 ===== + +function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasDepth, onClose, onChanged }: { + stock: LimitLadderStock + direction: Direction + sealMode: 'vol' | 'amount' + monitorRule?: MonitorRule + anchorRect: DOMRect + hasDepth: boolean + onClose: () => void + onChanged: () => void +}) { + const ruleId = `mr_ladder_${stock.symbol.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}` + const existing = monitorRule + + // 推送外部开关默认值: 取偏好设置中的全局默认 (已有规则沿用其值) + const { data: prefs } = usePreferences() + const webhookDefault = prefs?.webhook_enabled_default ?? false + + // 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元) + const VOL_UNITS = [ + { key: '1', label: '手', mult: 1 }, + { key: '10000', label: '万手', mult: 10000 }, + ] + const AMT_UNITS = [ + { key: '1', label: '元', mult: 1 }, + { key: '10000', label: '万元', mult: 10000 }, + { key: '100000000', label: '亿元', mult: 100000000 }, + ] + + const [metric, setMetric] = useState<'sealed_vol' | 'sealed_amount'>(existing?.metric ?? (sealMode === 'amount' ? 'sealed_amount' : 'sealed_vol')) + const units = metric === 'sealed_amount' ? AMT_UNITS : VOL_UNITS + // 已有规则: 反算到最大便捷单位 (选能整除的最大倍率); 新建: 额默认亿元, 量默认万手 + const initUnit = (() => { + if (!existing || !existing.threshold) return metric === 'sealed_amount' ? '100000000' : '10000' + const thr = existing.threshold + const matched = [...units].reverse().find(u => thr >= u.mult && thr % u.mult === 0) + return matched ? matched.key : units[0].key + })() + const [unitKey, setUnitKey] = useState(initUnit) + const [threshold, setThreshold] = useState(() => { + if (!existing || !existing.threshold) return '' + const mult = units.find(u => u.key === initUnit)?.mult ?? 1 + return String(existing.threshold / mult) + }) + const [pushExternal, setPushExternal] = useState(existing?.webhook_enabled ?? webhookDefault) + const [saving, setSaving] = useState(false) + + const warnLabel = direction === 'down' ? '翘板预警' : '炸板预警' + + // 切 metric 时重置单位 (额默认亿元, 量默认万手) + 清空阈值 + const switchMetric = (m: 'sealed_vol' | 'sealed_amount') => { + setMetric(m) + // 额选亿元(key=100000000), 量选万手(key=10000) + const defaultKey = m === 'sealed_amount' ? '100000000' : '10000' + setUnitKey(defaultKey) + setThreshold('') + } + + const handleSave = async () => { + const inputValue = Number(threshold) + if (!threshold || isNaN(inputValue) || inputValue < 0) return + const mult = units.find(u => u.key === unitKey)?.mult ?? 1 + const thr = Math.round(inputValue * mult) // 换算回原始单位 (量=手, 额=元) + setSaving(true) + try { + await api.monitorRuleSave({ + id: ruleId, + name: `封单监控 · ${stock.name ?? stock.symbol}`, + enabled: true, + type: 'ladder', + scope: 'symbols', + symbols: [stock.symbol], + direction: direction === 'down' ? 'down' : 'up', + metric, + threshold: thr, + conditions: [], + logic: 'and', + cooldown_seconds: existing?.cooldown_seconds ?? 600, + severity: 'warn', + message: '', + webhook_enabled: pushExternal, + } as MonitorRule) + onChanged() + onClose() + } catch { /* toast 已在 api 层处理 */ } + finally { setSaving(false) } + } + + const handleRemove = async () => { + setSaving(true) + try { + await api.monitorRuleDelete(ruleId) + onChanged() + onClose() + } catch { /* ignore */ } + finally { setSaving(false) } + } + + // 基于齿轮按钮位置算菜单坐标 (fixed 定位, 脱离父级 overflow-hidden 裁剪) + const MENU_W = 240 // w-60 = 15rem = 240px + const MENU_H = 340 // 预估高度 (含标题栏 + 4 行设置 + 权限提示 + 按钮区) + const anchorRight = anchorRect.right + const anchorBottom = anchorRect.bottom + // 水平: 默认右对齐齿轮; 超出右边则左移 + const left = Math.max(8, Math.min(anchorRight - MENU_W, window.innerWidth - MENU_W - 8)) + // 垂直: 默认在齿轮下方; 超出底部则上方 + const top = anchorBottom + MENU_H > window.innerHeight + ? Math.max(8, anchorRect.top - MENU_H) + : anchorBottom + 4 + + return ( + <> +
+
+ {/* 标题栏: 股票名 + 预警类型 */} +
+
+ + {stock.name ?? stock.symbol} +
+ +
+ +
+ {/* 预警类型徽章 */} +
+ 类型 + + {warnLabel} + +
+ + {/* 监控指标: 段控风格 */} +
+ 指标 +
+ + +
+
+ + {/* 阈值: 输入 + 单位 */} +
+ 阈值 + setThreshold(e.target.value)} + placeholder="≤ 报警" + className="flex-1 min-w-0 h-7 px-2 rounded bg-base border border-border text-foreground text-center tabular-nums placeholder:text-muted/40 focus:outline-none focus:border-accent/50" + /> + +
+ + {/* 推送渠道: 胶囊标签 (后续可扩展钉钉/企微等), 选中带强调色 */} +
+ 推送 + +
+ + {/* 权限提示 (免费用户) */} + {!hasDepth && ( +
+ + 当前 Key 权限无法获取五档行情,后续会适配免费数据源 +
+ )} +
+ + {/* 底部按钮区 */} +
+ {existing && ( + + )} + +
+
+ ) } @@ -609,7 +867,7 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel // ===== 梯队分组 ===== -function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, direction, sealMode }: { +function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth }: { tier: LimitLadderTier defaultOpen: boolean extFields: ExtFieldConfig @@ -620,6 +878,10 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, onSelectTag: (sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => void direction: Direction sealMode: 'vol' | 'amount' + monitoredSymbols: Set + ladderRules: Map + onMonitorChange: () => void + hasDepth: boolean }) { const [open, setOpen] = useState(defaultOpen) const cfg = { ...DEFAULT_BF, ...bf } @@ -757,6 +1019,10 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, return tags.includes(selectedTag.tag) }) .sort((a, b) => { + // 开启监控的卡片排到分组最前 + const ma = monitoredSymbols.has(a.symbol) ? 0 : 1 + const mb = monitoredSymbols.has(b.symbol) ? 0 : 1 + if (ma !== mb) return ma - mb const ord = (s: string) => { if (s === 'limit_up' || s === 'limit_down' || !s) return 0 if (s === 'broken' || s === 'recovery') return 1 @@ -784,6 +1050,10 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, extFields={extFields} direction={direction} sealMode={sealMode} + monitored={monitoredSymbols.has(s.symbol)} + monitorRule={ladderRules.get(s.symbol)} + onMonitorChange={onMonitorChange} + hasDepth={hasDepth} onClick={() => onStockClick(s.symbol, s.name ?? undefined)} /> ))} @@ -1096,6 +1366,24 @@ export function LimitUpLadder() { const [showConcept, setShowConcept] = useState(() => storage.limitLadderShowExt.get({ concept: true, industry: true }).concept) const [showIndustry, setShowIndustry] = useState(() => storage.limitLadderShowExt.get({ concept: true, industry: true }).industry) + // 连板梯队封单监控规则 (type=ladder): {symbol → rule} 映射 + const { data: monitorRulesData, refetch: refetchMonitorRules } = useQuery({ + queryKey: ['monitor-rules'], + queryFn: () => api.monitorRulesList(), + staleTime: 30 * 1000, + }) + const ladderRules = useMemo(() => { + const all = monitorRulesData?.rules ?? [] + const m = new Map() + for (const r of all) { + if (r.type === 'ladder' && r.enabled && r.symbols[0]) { + m.set(r.symbols[0], r) + } + } + return m + }, [monitorRulesData]) + const monitoredSymbols = useMemo(() => new Set(ladderRules.keys()), [ladderRules]) + const toggleDirection = useCallback((d: Direction) => { setDirection(d) storage.limitLadderDirection.set(d) @@ -1374,6 +1662,10 @@ export function LimitUpLadder() { onSelectTag={handleSelectTag} direction={direction} sealMode={sealMode} + monitoredSymbols={monitoredSymbols} + ladderRules={ladderRules} + onMonitorChange={refetchMonitorRules} + hasDepth={sealedDegrade.hasDepth} /> ))}