feat: 连板梯队封单监控卡片 UI 与 API

- monitor_rules API: RuleModel 加 metric/threshold 字段; ladder 类型
  需 DEPTH5_BATCH 能力校验; 加模拟触发与真实触发端点供验证
- 连板梯队卡片: 右上角监控按钮 + 设置菜单 (段控指标/阈值+单位/
  胶囊推送/权限提示); 开启监控的卡片置顶 + 背景区分
- 推送默认取全局设置; 单位默认万手/亿元
- 非付费用户可见功能入口但保存禁用, 提示后续适配免费数据源
This commit is contained in:
shy3130
2026-07-02 17:22:42 +08:00
parent 000a9ead77
commit 33a7be0deb
3 changed files with 639 additions and 10 deletions
+264
View File
@@ -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],
}
+75 -2
View File
@@ -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' }),
+300 -8
View File
@@ -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<DOMRect | null>(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 (
<button
<div className="relative group w-full">
{/* 监控设置按钮 (右上角): 不能嵌在卡片 button 内 */}
<button
onClick={e => {
e.stopPropagation()
setMenuAnchor(e.currentTarget.getBoundingClientRect())
setShowMonitorMenu(v => !v)
}}
title={monitored ? '封单监控已开启' : '开启封单监控'}
className={`absolute top-1 right-1 z-20 p-0.5 rounded transition-opacity cursor-pointer ${
monitored ? 'opacity-100 text-amber-400' : 'opacity-0 group-hover:opacity-70 text-muted hover:!opacity-100'
}`}
>
{monitored ? <Bell className="h-3 w-3" /> : <BellOff className="h-3 w-3" />}
</button>
{/* 监控菜单 */}
{showMonitorMenu && menuAnchor && (
<MonitorMenu
stock={stock}
direction={direction}
sealMode={sealMode}
monitorRule={monitorRule}
anchorRect={menuAnchor}
hasDepth={hasDepth}
onClose={() => setShowMonitorMenu(false)}
onChanged={onMonitorChange}
/>
)}
<button
onClick={onClick}
className={`group flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar}`}
className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''}`}
style={style.cardStyle ? { ...style.cardStyle } : undefined}
onMouseEnter={e => {
if (!style.cardStyle || !style.hoverShadow) return
@@ -262,7 +298,7 @@ function StockCard({ stock, extFields, direction, sealMode, onClick }: {
}}
>
{/* 名称行 */}
<div className="flex items-center gap-1.5 w-full min-w-0">
<div className="flex items-center gap-1.5 w-full min-w-0 pr-4">
<span className={`${style.nameCls} font-medium truncate`}>{stock.name}</span>
{tag && (
<span className={`shrink-0 text-[9px] px-1 py-px rounded-full border leading-none ${tag.cls}`}>{tag.label}</span>
@@ -317,6 +353,228 @@ function StockCard({ stock, extFields, direction, sealMode, onClick }: {
</div>
)}
</button>
</div>
)
}
// ===== 封单监控菜单 =====
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<string>(() => {
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 (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div
className="fixed z-50 w-60 rounded-lg bg-surface border border-border shadow-xl text-xs overflow-hidden"
style={{ left, top }}
>
{/* 标题栏: 股票名 + 预警类型 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-elevated/40">
<div className="flex items-center gap-1.5 min-w-0">
<Bell className="h-3.5 w-3.5 text-amber-400 shrink-0" />
<span className="font-medium text-foreground truncate">{stock.name ?? stock.symbol}</span>
</div>
<button onClick={onClose} className="text-muted hover:text-foreground shrink-0"><X className="h-3.5 w-3.5" /></button>
</div>
<div className="px-3 py-2.5 space-y-2.5">
{/* 预警类型徽章 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0"></span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${direction === 'down' ? 'bg-bear/15 text-bear' : 'bg-bull/15 text-bull'}`}>
{warnLabel}
</span>
</div>
{/* 监控指标: 段控风格 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0 w-8"></span>
<div className="flex gap-0.5 flex-1 bg-elevated/50 rounded p-0.5">
<button
onClick={() => switchMetric('sealed_vol')}
className={`flex-1 px-2 py-1 rounded text-[11px] transition-colors ${metric === 'sealed_vol' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'}`}
></button>
<button
onClick={() => switchMetric('sealed_amount')}
className={`flex-1 px-2 py-1 rounded text-[11px] transition-colors ${metric === 'sealed_amount' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'}`}
></button>
</div>
</div>
{/* 阈值: 输入 + 单位 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0 w-8"></span>
<input
type="number"
value={threshold}
onChange={e => 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"
/>
<select
value={unitKey}
onChange={e => setUnitKey(e.target.value)}
className="h-7 px-1.5 rounded bg-base border border-border text-secondary text-[11px] focus:outline-none focus:border-accent/50 cursor-pointer"
>
{units.map(u => (
<option key={u.key} value={u.key}>{u.label}</option>
))}
</select>
</div>
{/* 推送渠道: 胶囊标签 (后续可扩展钉钉/企微等), 选中带强调色 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0 w-8"></span>
<button
type="button"
onClick={() => setPushExternal(v => !v)}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-medium transition-colors border cursor-pointer ${
pushExternal
? 'bg-accent/15 text-accent border-accent/40'
: 'bg-elevated/40 text-muted border-border hover:text-secondary'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${pushExternal ? 'bg-accent' : 'bg-muted/50'}`} />
</button>
</div>
{/* 权限提示 (免费用户) */}
{!hasDepth && (
<div className="flex items-start gap-1.5 rounded border border-amber-400/30 bg-amber-400/5 px-2 py-1.5 text-[10px] leading-relaxed text-amber-400/90">
<AlertCircle className="h-3 w-3 shrink-0 mt-px" />
<span> Key ,</span>
</div>
)}
</div>
{/* 底部按钮区 */}
<div className="flex items-center gap-2 px-3 py-2.5 border-t border-border bg-elevated/30">
{existing && (
<button
onClick={handleRemove}
disabled={saving || !hasDepth}
className="shrink-0 h-7 px-2.5 rounded text-[11px] text-muted hover:text-danger hover:bg-danger/5 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
></button>
)}
<button
onClick={handleSave}
disabled={saving || !threshold || !hasDepth}
title={!hasDepth ? '需 Pro+ 套餐 (批量五档能力)' : ''}
className="flex-1 h-7 rounded text-[11px] font-medium transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed bg-accent text-white hover:bg-accent/90 active:scale-[0.98] disabled:active:scale-100"
>
{saving ? '保存中…' : !hasDepth ? '需 Pro+ 套餐' : existing ? '更新监控' : '开启监控'}
</button>
</div>
</div>
</>
)
}
@@ -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<string>
ladderRules: Map<string, MonitorRule>
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<string, typeof all[number]>()
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}
/>
))}
</div>