mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
feat: 连板梯队封单监控卡片 UI 与 API
- monitor_rules API: RuleModel 加 metric/threshold 字段; ladder 类型 需 DEPTH5_BATCH 能力校验; 加模拟触发与真实触发端点供验证 - 连板梯队卡片: 右上角监控按钮 + 设置菜单 (段控指标/阈值+单位/ 胶囊推送/权限提示); 开启监控的卡片置顶 + 背景区分 - 推送默认取全局设置; 单位默认万手/亿元 - 非付费用户可见功能入口但保存禁用, 提示后续适配免费数据源
This commit is contained in:
@@ -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
@@ -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' }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user