feat: 统一监控引擎 + AlertToast通知 + 声音/角标/看板/Dev页 + 股票名称批量查询

监控引擎 (backend):
- MonitorRuleEngine 统一规则引擎,支持策略/信号/价格/行情四种类型
- JSONL 追加存储 (alert_store.py),支持分页 + 清理
- alert/monitor_rules CRUD API + seed 演示数据
- POST /api/kline/instruments/names 批量股票名称查询
- SSE strategy_alert 事件触发前端通知

前端通知体系:
- AlertToast 自定义弹窗 (Framer Motion + AnimatePresence)
- Web Audio API 合成音效 (12种预设,无需音频文件)
- 侧边栏角标 (monitorBadge.ts) localStorage 持久化未读数
- pendingSeen 机制解决 markSeen/setCurrentTotal 竞态
- 系统设置页: 通知开关 + 最大条数 + 音效选择
- 菜单设置页: 角标数字开关

监控中心 (Monitor.tsx):
- 双栏布局: 左侧实时告警列表 + 右侧规则管理
- RulesList 股票代码后显示中文名称
- RuleEditor 完整规则编辑器组件
- 告警支持查看详情 (StockPreviewDialog)

Dashboard:
- MonitorWidget: Top 10 实时告警卡片

Dev 页面:
- 一键填充演示告警 + 规则 + 分钟探测
- 可视化 SSE 事件流查看

其他:
- v0.1.19 → v0.1.28 (VERSION 从 pyproject.toml 读取)
- dev.ps1/dev.sh 添加 --host 0.0.0.0 (局域网访问)
- 修复 Screener.tsx presets.length 可选链
- README 截图表格更新 (6张) + 监控章节重写
- 删除 MinuteDataProbe 页面 (合并至 Dev)
This commit is contained in:
shy3130
2026-06-21 14:18:08 +08:00
parent 9e7ce2b5d6
commit b5780b5e30
44 changed files with 3668 additions and 577 deletions
+22 -8
View File
@@ -35,11 +35,19 @@
<table>
<tr>
<td width="50%" align="center"><b>看板 Dashboard</b></td>
<td width="50%" align="center"><b>选股 Screener</b></td>
<td width="50%" align="center"><b>策略 Screener</b></td>
</tr>
<tr>
<td width="50%"><img src="./docs/screenshots/dashboard.png" alt="看板页面" title="看板页面"></td>
<td width="50%"><img src="./docs/screenshots/screener.png" alt="选股/策略页" title="选股/策略页"></td>
<td width="50%"><img src="./docs/screenshots/screener.png" alt="策略页" title="策略页"></td>
</tr>
<tr>
<td width="50%" align="center"><b>监控中心 Monitor</b></td>
<td width="50%" align="center"><b>概念分析 Concept</b></td>
</tr>
<tr>
<td width="50%"><img src="./docs/screenshots/monitor.png" alt="监控中心" title="监控中心"></td>
<td width="50%"><img src="./docs/screenshots/concept-analysis.png" alt="概念分析" title="概念分析"></td>
</tr>
<tr>
<td width="50%" align="center"><b>回测 Backtest</b></td>
@@ -112,11 +120,16 @@
- **SSE 流式进度**:长任务实时推送进度,支持刷新 / 切页后**重连恢复**(相同参数任务只启动一次)
- **统计输出**:净值曲线 · 夏普 · 最大回撤 · 胜率 · 每笔交易明细
### 📡 实时监控(Strategy Monitor)
### 📡 监控中心(Monitor)
- **盘中 SSE 推送**:行情刷新(`quotes_updated`)+ 策略告警(`strategy_alert`)双事件流,前端实时弹通知
- **策略监控**:订阅策略的 entry / exit 信号 + 自定义提醒条件(如 `rsi_14 > 80`),命中即推送
- **Webhook 告警**:命中规则后可选推送外部 webhook
**统一监控规则引擎** —— 一个页面管理所有类型的监控,实时推送 + 持久化触发记录:
- **四类监控**:策略监控 · 个股信号监控(选信号即加) · 个股价格/涨跌监控 · 全市场异动监控
- **灵活条件**:多条件 AND/OR 组合 + 冷却期去重(防刷屏) + 严重级别(info/warn/critical)
- **多入口配置**:监控中心页面新建规则 · 个股详情页「加监控」· 策略卡片一键开启
- **实时 SSE 推送**:命中规则后右下角弹窗通知(可配声效) + 持久化到 `alerts.jsonl`
- **触发记录**:时间倒序展示,支持按来源过滤 · 单条删除 · 清空 · 点击查看个股日K
- **菜单未读徽标**:离开监控中心后有新触发,菜单显示未读数;进入页面后清零
### 🤖 AI 策略生成(可选)
@@ -207,7 +220,7 @@ pnpm dev # http://localhost:3011
3. **自选**页:添加跟踪标的;点代码进 **K 线**页看蜡烛图 + 买卖点
4. **选股**页:点任一内置策略卡片即时扫描;或用自定义信号组合条件
5. **回测**页:选策略 / 信号 + 时间区间 → 跑回测 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
6. **监控**页:配置告警规则,盘中 SSE 推送行情与策略信号;命中后写入告警日志(可选 webhook)
6. **监控中心**页:配置监控规则(策略/个股信号/价格/市场异动),盘中 SSE 实时弹窗通知 + 持久化触发记录;或在个股详情页点「加监控」快速添加
---
@@ -273,7 +286,8 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
| **2** | Polars enriched 流水线 + Screener + 信号扫描 | ✅ |
| **3** | vectorbt 回测 + T+1 + 手续费 + 止损 + max-hold | ✅ |
| **4** | 监控引擎 + 告警规则 + Webhook + APScheduler 盘后定时 | ✅ |
| **v2** | 自定义信号 / 策略商店 / AI 策略生成 / 外部数据源插件 / 早晚报 / Onboarding | 🚧 |
| **5** | 统一监控中心 + 四类监控规则 + 实时推送 + 持久化触发记录 + 声效通知 | |
| **v2** | Webhook 推送(QMT/掘金下单) · 板块异动 · 早晚报 · 更多扩展 | 🚧 |
---
+1 -1
View File
@@ -1 +1 @@
v0.1.19
v0.1.28
+1 -1
View File
@@ -1,3 +1,3 @@
"""TickFlow Stock Panel backend."""
__version__ = "0.1.0"
__version__ = "0.1.28"
+129
View File
@@ -0,0 +1,129 @@
"""告警触发记录 API — 查询/清空/生成演示数据 alerts.jsonl。"""
from __future__ import annotations
import random
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from app.services import alert_store
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
def _data_dir(request: Request) -> Path:
return request.app.state.repo.store.data_dir
@router.get("")
def list_alerts(
request: Request,
days: int = 7,
limit: int = 5000,
source: str | None = None,
type: str | None = None,
):
"""查询触发记录 (时间倒序)。"""
events = alert_store.list_recent(
_data_dir(request), days=days, limit=limit, source=source, type=type,
)
total = alert_store.count(_data_dir(request))
return {"alerts": events, "total": total}
@router.delete("")
def clear_alerts(request: Request):
"""清空全部触发记录。"""
n = alert_store.clear(_data_dir(request))
return {"ok": True, "cleared": n}
@router.delete("/{ts}")
def delete_alert(ts: int, request: Request):
"""删除单条触发记录 (按 ts 毫秒时间戳)。"""
deleted = alert_store.delete_one(_data_dir(request), ts)
if not deleted:
raise HTTPException(status_code=404, detail="记录不存在")
return {"ok": True}
# ── 演示数据生成 (仅 Dev 页用) ─────────────────────────
_DEMO_STOCKS = [
("600519.SH", "贵州茅台"), ("000001.SZ", "平安银行"), ("300750.SZ", "宁德时代"),
("002594.SZ", "比亚迪"), ("000858.SZ", "五粮液"), ("601318.SH", "中国平安"),
("002475.SZ", "立讯精密"), ("600036.SH", "招商银行"), ("000725.SZ", "京东方A"),
("300059.SZ", "东方财富"),
]
_DEMO_TEMPLATES = [
("signal", "MA金叉触发", ["signal_ma_golden_5_20"], "info"),
("signal", "放量突破新高", ["signal_volume_surge", "signal_n_day_high"], "warn"),
("signal", "MACD金叉", ["signal_macd_golden"], "info"),
("signal", "跌破MA20", ["signal_ma20_breakdown"], "info"),
("price", "涨幅超 5%", [], "warn"),
("price", "RSI 极度超卖", [], "warn"),
("price", "跌幅超 3%", [], "info"),
("market", "涨停封板", ["signal_limit_up"], "critical"),
("market", "连板异动", ["signal_limit_up"], "warn"),
("market", "炸板", ["signal_broken_limit_up"], "warn"),
("strategy", "策略「趋势突破」买入信号", ["signal_n_day_high", "signal_volume_surge"], "info"),
("strategy", "策略「趋势突破」卖出信号", ["signal_ma20_breakdown"], "info"),
("strategy", "策略「新低反转」买入信号", ["signal_n_day_low"], "warn"),
]
@router.post("/seed")
def seed_demo_alerts(request: Request, count: int = 12, recent: bool = True):
"""生成演示触发记录 (Dev 页用)。
Args:
count: 生成条数 (1-50)
recent: True=时间戳设为"刚刚"(用于测试闪烁效果); False=分散在近3天
"""
count = max(1, min(50, count))
now_ms = int(time.time() * 1000)
events = []
for i in range(count):
source, message, signals, severity = _DEMO_TEMPLATES[i % len(_DEMO_TEMPLATES)]
sym, name = _DEMO_STOCKS[i % len(_DEMO_STOCKS)]
# recent 模式: 时间戳从现在往前每条错开 30 秒 (最新在前)
ts = now_ms - (i * 30000) if recent else now_ms - random.randint(60, 4320) * 60 * 1000
events.append({
"ts": ts,
"rule_id": f"demo_rule_{i}",
"rule_name": message,
"source": source,
"type": source,
"symbol": sym,
"name": name,
"message": message,
"price": round(random.uniform(8, 1800), 2),
"change_pct": round(random.uniform(-0.06, 0.098), 4),
"signals": signals,
"severity": severity,
})
alert_store.append_many(_data_dir(request), events)
# 同步推入 SSE 队列, 让所有连着 SSE 的客户端实时收到 (不依赖轮询)
qs = getattr(request.app.state, "quote_service", None)
if qs:
# 转成 SSE 推送格式 (和 _evaluate_monitors 一致)
sse_alerts = [{
"source": ev["source"],
"type": ev["type"],
"rule_id": ev.get("rule_id"),
"symbol": ev["symbol"],
"name": ev["name"],
"message": ev["message"],
"price": ev["price"],
"change_pct": ev["change_pct"],
"signals": ev["signals"],
"severity": ev.get("severity", "info"),
} for ev in events]
with qs._lock:
qs._pending_alerts.extend(sse_alerts)
qs._alert_event.set()
return {"ok": True, "generated": len(events)}
+24 -5
View File
@@ -701,10 +701,29 @@ def table_schema(request: Request, table: str) -> list[dict]:
@router.get("/version")
def get_version(request: Request) -> dict:
"""返回当前项目版本号(读取项目根目录 VERSION 文件)。"""
"""返回当前项目版本号
优先从 pyproject.toml 读取 (项目权威版本源),
回退到 VERSION 文件, 最后兜底 v0.0.0。
"""
from app.config import settings
version_file = Path(settings.data_dir).parent / "VERSION"
version = "v0.0.0"
project_root = Path(settings.data_dir).parent
# 1. 优先读 pyproject.toml
pyproject = project_root / "pyproject.toml"
if pyproject.exists():
for line in pyproject.read_text(encoding="utf-8").splitlines():
if line.strip().startswith("version"):
# version = "0.1.28"
v = line.split("=", 1)[1].strip().strip('"').strip("'")
if v:
return {"version": f"v{v}" if not v.startswith("v") else v}
# 2. 回退到 VERSION 文件
version_file = project_root / "VERSION"
if version_file.exists():
version = version_file.read_text(encoding="utf-8").strip() or version
return {"version": version}
v = version_file.read_text(encoding="utf-8").strip()
if v:
return {"version": v}
return {"version": "v0.0.0"}
+15
View File
@@ -55,6 +55,21 @@ def search_instruments(
return {"results": rows}
@router.post("/instruments/names")
def instruments_names(request: Request, symbols: list[str]):
"""批量查股票名称。传入 symbol 列表, 返回 {symbol: name}。"""
if not symbols:
return {"names": {}}
repo = request.app.state.repo
df = repo.get_instruments()
if df.is_empty():
return {"names": {}}
import polars as pl
matched = df.filter(pl.col("symbol").is_in(symbols)).select(["symbol", "name"])
names = {row["symbol"]: row["name"] for row in matched.iter_rows(named=True)}
return {"names": names}
def _get_stock_info(repo, symbol: str) -> dict:
"""从 instruments 视图查标的名称 + 股本。"""
try:
+212
View File
@@ -0,0 +1,212 @@
"""监控规则 API 路由 — HTTP 请求 → 调用 monitor_rules 模块 → 同步引擎内存态。
只做胶水: 校验 → 持久化 → 失效引擎内存态。不含评估逻辑。
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from app.strategy import monitor_rules
router = APIRouter(prefix="/api/monitor-rules", tags=["monitor-rules"])
def _data_dir(request: Request) -> Path:
return request.app.state.repo.store.data_dir
def _sync_engine(request: Request) -> None:
"""保存/删除后,把最新规则集 reload 到引擎内存态。"""
engine = getattr(request.app.state, "monitor_engine", None)
if engine is not None:
rules = monitor_rules.load_all(_data_dir(request))
engine.set_rules(rules)
# ── Pydantic 模型 ───────────────────────────────────────
class ConditionModel(BaseModel):
field: str
op: str # truth | > >= < <= == !=
value: float | None = None # op 非 truth 时必填
class RuleModel(BaseModel):
id: str
name: str
enabled: bool = True
type: str # strategy | signal | price | market
scope: str = "symbols" # symbols | all | sector
symbols: list[str] = []
sector: str | None = None
strategy_id: str | None = None
direction: str = "entry" # entry | exit | both
conditions: list[ConditionModel] = []
logic: str = "and" # and | or
cooldown_seconds: int = 3600
severity: str = "info" # info | warn | critical
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 开发中)
webhook_enabled: bool = False
message: str = ""
# ── 字段选项 ─────────────────────────────────────────────
@router.get("/options")
def get_options(request: Request):
"""返回可选字段、信号列、运算符、枚举,供前端表单使用。"""
from app.indicators.pipeline import ENRICHED_COLUMNS
from app.strategy.custom_signals import ALLOWED_FIELDS, load_all as load_csg
# 阈值字段 (带中文标签)
threshold_fields = [
{"key": f, "label": ENRICHED_COLUMNS.get(f, f)}
for f in sorted(ALLOWED_FIELDS)
]
# 内置信号列 (布尔, 用于 op=truth)
builtin_signals = [
{"key": k, "label": v}
for k, v in ENRICHED_COLUMNS.items()
if k.startswith("signal_")
]
# 自定义信号列 (csg_)
custom_sigs = []
try:
for cs in load_csg(_data_dir(request)):
if cs.get("enabled") is not False:
custom_sigs.append({
"key": f"csg_{cs['id']}",
"label": cs.get("name", cs["id"]),
})
except Exception:
pass
return {
"threshold_fields": threshold_fields,
"builtin_signals": builtin_signals,
"custom_signals": custom_sigs,
"operators": [">", ">=", "<", "<=", "==", "!="],
"types": [
{"key": "signal", "label": "个股信号"},
{"key": "price", "label": "价格/涨跌"},
{"key": "market", "label": "市场异动"},
{"key": "strategy", "label": "策略监控"},
],
"scopes": [
{"key": "symbols", "label": "指定股票"},
{"key": "all", "label": "全市场"},
{"key": "sector", "label": "板块"},
],
"logics": [
{"key": "and", "label": "全部满足 (AND)"},
{"key": "or", "label": "任一满足 (OR)"},
],
"severities": [
{"key": "info", "label": "普通"},
{"key": "warn", "label": "警告"},
{"key": "critical", "label": "重要"},
],
"directions": [
{"key": "entry", "label": "买入"},
{"key": "exit", "label": "卖出"},
{"key": "both", "label": "买卖都报"},
],
}
# ── 列表 ───────────────────────────────────────────────
@router.get("")
def list_rules(request: Request):
rules = monitor_rules.load_all(_data_dir(request))
# 按 created_at 倒序
rules.sort(key=lambda r: r.get("created_at", ""), reverse=True)
return {"rules": rules}
# ── 新建 / 更新 ────────────────────────────────────────
@router.post("")
def save_rule(req: RuleModel, request: Request):
rule = monitor_rules.normalize(req.model_dump())
# 编辑现有规则时, 保留原 created_at (避免按时间排序时位置跳动)
existing = monitor_rules.load_one(_data_dir(request), rule["id"])
if existing and existing.get("created_at"):
rule["created_at"] = existing["created_at"]
try:
monitor_rules.validate(rule)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
monitor_rules.save_one(_data_dir(request), rule)
_sync_engine(request)
return {"ok": True, "rule": rule}
# ── 删除 ───────────────────────────────────────────────
@router.delete("/{rule_id}")
def delete_rule(rule_id: str, request: Request):
if not monitor_rules.ID_RE.match(rule_id):
raise HTTPException(status_code=400, detail="规则 id 非法")
deleted = monitor_rules.delete_one(_data_dir(request), rule_id)
if not deleted:
raise HTTPException(status_code=404, detail="规则不存在")
_sync_engine(request)
return {"ok": True}
# ── 演示数据生成 (仅 Dev 页用) ─────────────────────────
import time as _time
from datetime import datetime, timezone
def _demo_rule(rule_id: str, name: str, rtype: str, scope: str, symbols: list[str],
conditions: list[dict], logic: str = "or", cooldown: int = 3600,
severity: str = "info", message: str = "") -> dict:
return monitor_rules.normalize({
"id": rule_id,
"name": name,
"type": rtype,
"scope": scope,
"symbols": symbols,
"conditions": conditions,
"logic": logic,
"cooldown_seconds": cooldown,
"severity": severity,
"message": message,
"enabled": True,
})
_DEMO_RULES_TEMPLATE = [
("个股信号 · 茅台放量突破", "signal", "symbols", ["600519.SH"],
[{"field": "signal_volume_surge", "op": "truth"},
{"field": "signal_n_day_high", "op": "truth"}], "or", "info"),
("个股信号 · 宁德金叉", "signal", "symbols", ["300750.SZ"],
[{"field": "signal_ma_golden_5_20", "op": "truth"}], "or", "info"),
("价格 · 平安跌幅监控", "price", "symbols", ["000001.SZ"],
[{"field": "change_pct", "op": "<", "value": -0.03}], "or", "warn", "warn"),
("价格 · 比亚迪RSI超卖", "price", "symbols", ["002594.SZ"],
[{"field": "rsi_14", "op": "<", "value": 30}], "and", "warn", "warn"),
("市场异动 · 全市场涨停", "market", "all", [],
[{"field": "signal_limit_up", "op": "truth"}], "or", "critical", "critical"),
("市场异动 · 全市场炸板", "market", "all", [],
[{"field": "signal_broken_limit_up", "op": "truth"}], "or", "warn", "warn"),
("市场异动 · 跌幅超5%", "market", "all", [],
[{"field": "change_pct", "op": "<", "value": -0.05}], "or", "warn", "warn"),
("个股信号 · 茅台跌破MA20", "signal", "symbols", ["600519.SH"],
[{"field": "signal_ma20_breakdown", "op": "truth"}], "or", "info"),
]
@router.post("/seed")
def seed_demo_rules(request: Request):
"""生成演示监控规则 (Dev 页用)。覆盖 signal/price/market 三类。"""
ts = int(_time.time() * 1000)
created = []
for i, (name, rtype, scope, symbols, conditions, logic, severity, sev) in enumerate(_DEMO_RULES_TEMPLATE):
rule_id = f"demo_{ts}_{i}"
rule = _demo_rule(rule_id, name, rtype, scope, symbols, conditions, logic, 3600, sev)
monitor_rules.save_one(_data_dir(request), rule)
created.append(rule_id)
_sync_engine(request)
return {"ok": True, "generated": len(created), "ids": created}
+17 -20
View File
@@ -352,34 +352,31 @@ class RealtimeMonitorConfigIn(BaseModel):
@router.put("/preferences/realtime-monitor")
def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Request) -> dict:
"""更新实时监控配置。"""
"""更新实时监控配置。策略监控统一迁移为 MonitorRule,由监控引擎评估。"""
from app.services import preferences
cfg = req.model_dump(exclude_none=True)
result = preferences.set_realtime_monitor_config(cfg)
# 如果策略监控开关变化,更新 StrategyMonitorService 的监控池
# 策略监控开关/池变化 → 同步迁移为 type=strategy 规则 + reload 引擎
if req.strategy_monitor_ids is not None or req.strategy_monitor_enabled is not None:
monitor = getattr(request.app.state, "strategy_monitor", None)
if monitor:
if preferences.get_strategy_monitor_enabled():
# 从策略引擎加载监控配置
engine = getattr(request.app.state, "strategy_engine", None)
ids = preferences.get_strategy_monitor_ids()
if engine and ids:
monitor.stop_all()
for sid in ids:
monitor_engine = getattr(request.app.state, "monitor_engine", None)
strategy_engine = getattr(request.app.state, "strategy_engine", None)
data_dir = request.app.state.repo.store.data_dir
if monitor_engine is not None and strategy_engine is not None:
from app.strategy import monitor_rules as mr_store
try:
s = engine.get(sid)
monitor.start(sid, {
"entry_signals": s.entry_signals,
"exit_signals": s.exit_signals,
"alerts": s.alerts,
})
except ValueError:
pass
if preferences.get_strategy_monitor_enabled():
ids = preferences.get_strategy_monitor_ids()
names = {s.id: s.name for s in strategy_engine.list_strategies()}
mr_store.migrate_strategy_monitors(data_dir, ids, names)
else:
monitor.stop_all()
# 关闭策略监控: 停用所有策略规则
mr_store.migrate_strategy_monitors(data_dir, [], {})
# reload 规则到引擎
monitor_engine.set_rules(mr_store.load_all(data_dir))
except Exception:
pass
return result
+2 -30
View File
@@ -426,36 +426,8 @@ def delete_strategy(strategy_id: str, request: Request):
# ── 监控 ─────────────────────────────────────────────────────────────
@router.post("/monitor/start")
def monitor_start(req: MonitorStartRequest, request: Request):
engine = _get_engine(request)
monitor = _get_monitor(request)
try:
s = engine.get(req.strategy_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
monitor.start(req.strategy_id, {
"entry_signals": s.entry_signals,
"exit_signals": s.exit_signals,
"alerts": s.alerts,
})
return {"ok": True, "watching": list(monitor.watching.keys())}
@router.post("/monitor/stop/{strategy_id}")
def monitor_stop(strategy_id: str, request: Request):
monitor = _get_monitor(request)
monitor.stop(strategy_id)
return {"ok": True, "watching": list(monitor.watching.keys())}
@router.get("/monitor/status")
def monitor_status(request: Request):
monitor = _get_monitor(request)
return {"watching": list(monitor.watching.keys())}
# 注: 策略监控已统一迁移到 MonitorRuleEngine (监控通知页), 旧的 start/stop/status
# 路由已移除。StrategyMonitorService 类保留 (其 _check_signals 被 MonitorRuleEngine 复用)。
# ── 热重载 ───────────────────────────────────────────────────────────
+34 -4
View File
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app import __version__
from app.api import analysis, backtest, data, ext_data, financials, indices, intraday, kline, overview, pipeline, screener, settings as settings_api, signals, strategy, watchlist
from app.api import analysis, backtest, data, ext_data, financials, indices, intraday, kline, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, strategy, watchlist
from app.api.routes import router as core_router
from app.config import settings
from app.jobs import daily_pipeline
@@ -113,6 +113,32 @@ async def lifespan(app: FastAPI):
app.state.strategy_engine = strategy_engine
logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies()))
# 通用监控规则引擎: 启动时 reload 规则到内存态 (修复重启后告警失效)
from app.strategy.monitor import MonitorRuleEngine
from app.strategy import monitor_rules as mr_store
from app.services import preferences
monitor_engine = MonitorRuleEngine()
monitor_engine.set_strategy_engine(strategy_engine)
# 自动迁移: 把旧 strategy_monitor_ids 同步为 type=strategy 规则 (统一到监控页)
try:
if preferences.get_strategy_monitor_enabled():
ids = preferences.get_strategy_monitor_ids()
if ids:
names = {s.id: s.name for s in strategy_engine.list_strategies()}
mr_store.migrate_strategy_monitors(store.data_dir, ids, names)
logger.info("strategy monitor migrated: %d strategies", len(ids))
except Exception as e: # noqa: BLE001
logger.warning("strategy monitor migration failed: %s", e)
try:
rules = mr_store.load_all(store.data_dir)
monitor_engine.set_rules(rules)
logger.info("monitor engine loaded: %d rules", monitor_engine.rule_count)
except Exception as e: # noqa: BLE001
logger.warning("monitor engine load failed: %s", e)
app.state.monitor_engine = monitor_engine
yield
if app.state.scheduler:
@@ -139,11 +165,13 @@ app = FastAPI(
lifespan=lifespan,
)
# 开发期 CORS 允许 Vite dev server
# CORS: 允许局域网访问 (自托管场景, 放开所有来源)
# 注: allow_credentials=True 与 allow_origins=['*'] 不能共存 (浏览器规范),
# 本项目认证走 header (API Key), 不依赖 cookie, 故关闭 credentials 换取通配来源。
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3011", "http://127.0.0.1:3011"],
allow_credentials=True,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -165,6 +193,8 @@ app.include_router(financials.router)
app.include_router(settings_api.router)
app.include_router(strategy.router)
app.include_router(signals.router)
app.include_router(monitor_rules.router)
app.include_router(alerts.router)
# 生产期静态文件(前端 dist)
_static = Path(settings.static_dir)
+209
View File
@@ -0,0 +1,209 @@
"""告警触发记录存储 — JSONL 追加写 + 滚动清理。
职责:
- 把每次触发的 AlertEvent 追加写入 data/user_data/alerts.jsonl
- 提供查询 (按来源/类型过滤、时间倒序、限量)
- 滚动清理: 保留近 N 天 + 上限 M 条 (取交集)
设计:
- JSONL 每行一个 JSON 对象,便于增量追加和流式读取
- 清理策略: 追加后按需 prune (按 ts 删旧),避免文件无限膨胀
- 读时全量加载到内存过滤 (记录量受上限约束, 5000 条量级无压力)
"""
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
logger = logging.getLogger(__name__)
# 保留策略
MAX_DAYS = 7
MAX_RECORDS = 5000
# 每隔多少次写入触发一次清理 (避免每次写都 prune)
PRUNE_EVERY = 20
_lock = threading.Lock()
_write_count = 0
def _path(data_dir: Path) -> Path:
p = data_dir / "user_data" / "alerts.jsonl"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def append(data_dir: Path, event: dict) -> None:
"""追加一条触发记录。event 应含 ts(毫秒)、rule_id、source 等字段。"""
line = json.dumps(event, ensure_ascii=False)
with _lock:
p = _path(data_dir)
with p.open("a", encoding="utf-8") as f:
f.write(line + "\n")
global _write_count
_write_count += 1
if _write_count >= PRUNE_EVERY:
_write_count = 0
_prune_locked(p)
def append_many(data_dir: Path, events: list[dict]) -> None:
"""批量追加。"""
if not events:
return
with _lock:
p = _path(data_dir)
with p.open("a", encoding="utf-8") as f:
for ev in events:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
global _write_count
_write_count += len(events)
if _write_count >= PRUNE_EVERY:
_write_count = 0
_prune_locked(p)
def list_recent(
data_dir: Path,
days: int = MAX_DAYS,
limit: int = MAX_RECORDS,
source: str | None = None,
type: str | None = None,
) -> list[dict]:
"""读取近 N 天记录,按时间倒序,支持按 source/type 过滤。"""
import time
cutoff = (time.time() - days * 86400) * 1000 # 毫秒
out: list[dict] = []
p = _path(data_dir)
if not p.exists():
return []
try:
with p.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except Exception:
continue
if ev.get("ts", 0) < cutoff:
continue
if source and ev.get("source") != source:
continue
if type and ev.get("type") != type:
continue
out.append(ev)
except Exception as e:
logger.warning("alert_store read failed: %s", e)
return []
# 时间倒序 + 截断
out.sort(key=lambda x: x.get("ts", 0), reverse=True)
return out[:limit]
def clear(data_dir: Path) -> int:
"""清空全部记录,返回清除的条数。"""
with _lock:
p = _path(data_dir)
if not p.exists():
return 0
count = 0
try:
with p.open("r", encoding="utf-8") as f:
count = sum(1 for line in f if line.strip())
except Exception:
pass
p.write_text("", encoding="utf-8")
return count
def delete_one(data_dir: Path, ts: int) -> bool:
"""删除指定 ts 的单条记录,返回是否删除成功。
JSONL 无主键, 用 ts(毫秒时间戳) 作为标识。
若存在多条同 ts, 只删第一条。
"""
with _lock:
p = _path(data_dir)
if not p.exists():
return False
kept: list[dict] = []
deleted = False
try:
with p.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except Exception:
continue
if not deleted and ev.get("ts") == ts:
deleted = True
continue
kept.append(ev)
except Exception as e:
logger.warning("alert_store delete_one read failed: %s", e)
return False
if not deleted:
return False
try:
with p.open("w", encoding="utf-8") as f:
for ev in kept:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
except Exception as e:
logger.warning("alert_store delete_one write failed: %s", e)
return False
return True
return count
def count(data_dir: Path) -> int:
"""返回当前记录总数。"""
p = _path(data_dir)
if not p.exists():
return 0
try:
with p.open("r", encoding="utf-8") as f:
return sum(1 for line in f if line.strip())
except Exception:
return 0
def _prune_locked(p: Path) -> None:
"""(调用方需持锁) 保留近 MAX_DAYS 天 + 上限 MAX_RECORDS 条。"""
import time
cutoff = (time.time() - MAX_DAYS * 86400) * 1000
kept: list[dict] = []
try:
with p.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except Exception:
continue
if ev.get("ts", 0) >= cutoff:
kept.append(ev)
except FileNotFoundError:
return
except Exception as e:
logger.warning("alert_store prune read failed: %s", e)
return
# 上限截断 (保留最新的)
if len(kept) > MAX_RECORDS:
kept.sort(key=lambda x: x.get("ts", 0))
kept = kept[-MAX_RECORDS:]
# 重写文件
try:
with p.open("w", encoding="utf-8") as f:
for ev in kept:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
except Exception as e:
logger.warning("alert_store prune write failed: %s", e)
+36 -21
View File
@@ -57,6 +57,7 @@ class QuoteService:
self._update_event = threading.Event() # SSE 通知: 行情更新后 set
self._alert_event = threading.Event() # SSE 通知: 有告警时 set
self._pending_alerts: list[dict] = [] # 待推送的告警
self._max_pending_alerts: int = 1000 # 背压上限: 超出丢弃最旧
self._strategy_monitor = None # 延迟注入
self._app_state = None # 延迟注入 (FastAPI app.state)
@@ -448,9 +449,7 @@ class QuoteService:
# ================================================================
def _evaluate_monitors(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame | None) -> None:
"""行情更新后评估策略监控,并刷新策略结果缓存。"""
from app.services import preferences
"""行情更新后评估统一监控规则引擎,并刷新策略结果缓存。"""
try:
# 获取 enriched 数据 (刚算好的)
enriched_today, enriched_date = self.get_enriched_today()
@@ -459,34 +458,50 @@ class QuoteService:
all_alerts: list[dict] = []
# 1. 策略监控评估
if preferences.get_strategy_monitor_enabled():
monitor = getattr(self._app_state, "strategy_monitor", None) if self._app_state else None
if monitor and monitor.watching:
strategy_alerts = monitor.on_quote_update(enriched_today)
for a in strategy_alerts:
# 通用监控规则评估 (统一引擎: signal/price/market/strategy)
if self._app_state:
engine = getattr(self._app_state, "monitor_engine", None)
if engine and engine.rule_count > 0:
rule_events = engine.evaluate(enriched_today)
if rule_events:
# 落盘到 alerts.jsonl
try:
from app.services import alert_store
alert_store.append_many(
self._app_state.repo.store.data_dir, rule_events,
)
except Exception as e: # noqa: BLE001
logger.warning("告警落盘失败: %s", e)
# 转为 SSE 推送格式 (兼容旧 alert schema)
for ev in rule_events:
all_alerts.append({
"source": "strategy",
"type": a.type,
"strategy_id": a.strategy_id,
"symbol": a.symbol,
"name": a.name,
"message": a.message,
"price": a.price,
"change_pct": a.change_pct,
"signals": a.signals,
"source": ev["source"],
"type": ev["type"],
"rule_id": ev.get("rule_id"),
"strategy_id": ev.get("rule_id") if ev["source"] == "strategy" else None,
"symbol": ev["symbol"],
"name": ev["name"],
"message": ev["message"],
"price": ev["price"],
"change_pct": ev["change_pct"],
"signals": ev["signals"],
"severity": ev.get("severity", "info"),
})
# 2. 刷新策略结果缓存 (实时行情开启时,每轮行情更新后自动重算)
# 刷新策略结果缓存 (实时行情开启时,每轮行情更新后自动重算)
if self._enabled and self._app_state:
self._refresh_strategy_cache(enriched_today, enriched_date)
# 推入待推送队列 + 通知 SSE
# 推入待推送队列 + 通知 SSE (含背压保护)
if all_alerts:
with self._lock:
self._pending_alerts.extend(all_alerts)
# 背压: 超出上限丢弃最旧
if len(self._pending_alerts) > self._max_pending_alerts:
overflow = len(self._pending_alerts) - self._max_pending_alerts
self._pending_alerts = self._pending_alerts[overflow:]
self._alert_event.set()
logger.info("策略监控评估完成: %d 条通知", len(all_alerts))
logger.info("监控评估完成: %d 条通知", len(all_alerts))
except Exception as e: # noqa: BLE001
logger.warning("监控评估失败: %s", e)
+264
View File
@@ -3,15 +3,23 @@
职责: 接收实时行情 DataFrame → 检查监控中策略的信号/提醒 → 推送告警。
不知道: 策略加载逻辑、AI、API、配置持久化、回测。
依赖: 外部调用 on_quote_update() 传入实时数据。
本模块含两个评估器:
1. StrategyMonitorService — 旧的策略监控 (type=strategy),第二步迁移到 MonitorRuleEngine
2. MonitorRuleEngine — 通用规则引擎,覆盖 signal/price/market/strategy 四类,
支持 scope (symbols/all/sector) + 多条件 AND/OR + cooldown 去重
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable
import polars as pl
from app.strategy.custom_signals import _OP_BUILDERS # type: ignore # 复用运算符构造器
logger = logging.getLogger(__name__)
@@ -202,3 +210,259 @@ class StrategyMonitorService:
row.get("change_pct"),
))
return results
# ================================================================
# 通用监控规则引擎 MonitorRuleEngine
# ================================================================
_SIGNAL_PREFIXES = ("signal_", "csg_")
def _is_signal_field(field: str) -> bool:
return any(field.startswith(p) for p in _SIGNAL_PREFIXES)
def _build_condition_mask(df: pl.DataFrame, conditions: list[dict], logic: str) -> pl.DataFrame:
"""根据 conditions + logic 构建过滤后的命中 DataFrame。
conditions: [{"field","op","value"?}] — op=truth 为布尔信号, 否则阈值比较
logic: "and" | "or"
返回命中行 (含 symbol/name/close/change_pct + 各信号列)
"""
cols = set(df.columns)
parts: list[pl.Expr] = []
for c in conditions:
field = c["field"]
if field not in cols:
return df.head(0) # 字段缺失,无法判定 → 空结果
op = c["op"]
if op == "truth":
parts.append(pl.col(field).fill_null(False))
elif op in _OP_BUILDERS:
parts.append(_OP_BUILDERS[op](pl.col(field), c["value"]))
else:
return df.head(0)
if not parts:
return df.head(0)
if logic == "or":
mask = pl.any_horizontal(parts)
else:
mask = pl.all_horizontal(parts)
return df.filter(mask)
class MonitorRuleEngine:
"""通用监控规则引擎 — 接收实时行情 DataFrame,评估所有规则,返回 AlertEvent。
与 StrategyMonitorService 的区别:
- 规则来自 monitor_rules 存储 (用户可配), 而非写死的 strategy config
- 支持 scope (symbols/all/sector) 过滤作用域
- 支持 conditions + logic (AND/OR) 任意组合
- ★ cooldown 去重: 同一 (rule_id, symbol) 在冷却期内不重复触发
"""
def __init__(self, alert_handler: Callable[[dict], None] | None = None):
self._alert_handler = alert_handler
self._rules: dict[str, dict] = {} # rule_id → rule
# (rule_id, symbol) → 上次触发时间戳(秒)。用于 cooldown 去重。
self._last_fire: dict[tuple[str, str], float] = {}
self._strategy_engine = None # 延迟注入, type=strategy 规则用它读策略信号
def set_strategy_engine(self, engine) -> None:
"""注入 StrategyEngine, type=strategy 规则据此读策略的 entry/exit_signals。"""
self._strategy_engine = engine
# ── 规则管理 ───────────────────────────────────────
def set_rules(self, rules: list[dict]) -> None:
"""批量设置规则 (覆盖)。用于启动时 reload。"""
self._rules = {}
for r in rules:
if r.get("enabled") is not False:
self._rules[r["id"]] = r
logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules))
def add_rule(self, rule: dict) -> None:
if rule.get("enabled") is not False:
self._rules[rule["id"]] = rule
else:
self._rules.pop(rule["id"], None)
def remove_rule(self, rule_id: str) -> None:
self._rules.pop(rule_id, None)
# 清理对应的 cooldown 记录
self._last_fire = {k: v for k, v in self._last_fire.items() if k[0] != rule_id}
def clear(self) -> None:
self._rules.clear()
self._last_fire.clear()
@property
def rules(self) -> dict[str, dict]:
return dict(self._rules)
@property
def rule_count(self) -> int:
return len(self._rules)
# ── 评估 ───────────────────────────────────────────
def evaluate(self, df: pl.DataFrame) -> list[dict]:
"""行情更新后评估所有规则。
Args:
df: 实时 enriched 数据 (~5500行, 含 signal_/csg_/指标列)
Returns:
触发的 AlertEvent dict 列表 (含 ts/rule_id/source/type/symbol/...)
"""
if not self._rules or df.is_empty():
return []
now = time.time()
events: list[dict] = []
for rule_id, rule in self._rules.items():
try:
events.extend(self._evaluate_rule(df, rule, now))
except Exception as e:
logger.warning("规则评估失败 %s: %s", rule_id, e)
return events
def _evaluate_rule(self, df: pl.DataFrame, rule: dict, now: float) -> list[dict]:
"""评估单条规则,返回触发的 events。"""
# 1. 按 scope 过滤作用域
scoped = self._apply_scope(df, rule)
if scoped.is_empty():
return []
# 2. 根据 type 构建命中集
hit_rows: list[tuple[str, Any, Any, Any, list[str]]] = [] # (symbol,name,price,pct,signals)
rtype = rule.get("type", "signal")
if rtype == "strategy":
# 策略类型: 从 StrategyEngine 读策略的 entry/exit_signals, 按 direction 评估
hit_rows = self._match_strategy(scoped, rule)
else:
# signal / price / market: 通用条件匹配
hit_rows = self._match_conditions(scoped, rule)
if not hit_rows:
return []
# 3. cooldown 去重 + 生成 events
cooldown = rule.get("cooldown_seconds", 3600)
severity = rule.get("severity", "info")
message = rule.get("message", "") or self._default_message(rule)
source = rtype if rtype != "strategy" else "strategy"
ev_type = rule.get("direction", "entry") if rtype == "strategy" else rtype
events: list[dict] = []
for sym, name, price, pct, hit_sigs in hit_rows:
key = (rule["id"], sym)
last = self._last_fire.get(key)
if last is not None and (now - last) < cooldown:
continue # 冷却期内, 跳过
self._last_fire[key] = now
ev = {
"ts": int(now * 1000),
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"source": source,
"type": ev_type,
"symbol": sym,
"name": name,
"message": message,
"price": price,
"change_pct": pct,
"signals": hit_sigs,
"severity": severity,
}
events.append(ev)
if self._alert_handler:
try:
self._alert_handler(ev)
except Exception as e:
logger.warning("alert handler failed: %s", e)
return events
@staticmethod
def _apply_scope(df: pl.DataFrame, rule: dict) -> pl.DataFrame:
"""按 scope 过滤 DataFrame。"""
scope = rule.get("scope", "symbols")
if scope == "all":
return df
if scope == "symbols":
syms = rule.get("symbols", [])
if not syms:
return df.head(0)
return df.filter(pl.col("symbol").is_in(syms))
if scope == "sector":
# sector 过滤: 需 df 含板块列 (后续接入 ext_data JOIN)
# 当前先返回全量, sector 精确过滤第二步完善
return df
return df
def _match_strategy(
self, df: pl.DataFrame, rule: dict,
) -> list[tuple[str, Any, Any, Any, list[str]]]:
"""策略类型评估: 从 StrategyEngine 读策略信号, 按 direction 用 OR 匹配。
direction=entry → 策略 entry_signals
direction=exit → 策略 exit_signals
direction=both → entry + exit 合并 (命中信号名区分来源)
"""
if self._strategy_engine is None:
return []
sid = rule.get("strategy_id")
if not sid:
return []
try:
s = self._strategy_engine.get(sid)
except Exception:
return []
if s is None:
return []
direction = rule.get("direction", "entry")
# 收集要评估的信号 (OR 组合), 与旧 StrategyMonitorService 行为一致
sigs: list[str] = []
if direction in ("entry", "both"):
sigs.extend(s.entry_signals or [])
if direction in ("exit", "both"):
sigs.extend(s.exit_signals or [])
if not sigs:
return []
# 复用旧的 _check_signals 静态方法 (已支持 signal_/csg_ 前缀)
return StrategyMonitorService._check_signals(df, sigs)
@staticmethod
def _match_conditions(
df: pl.DataFrame, rule: dict,
) -> list[tuple[str, Any, Any, Any, list[str]]]:
"""按 conditions + logic 匹配,返回命中行 [(symbol,name,price,pct,signals)]。"""
conditions = rule.get("conditions", [])
logic = rule.get("logic", "and")
if not conditions:
return []
hit_df = _build_condition_mask(df, conditions, logic)
results = []
for row in hit_df.iter_rows(named=True):
sym = row.get("symbol", "")
name = row.get("name")
price = row.get("close")
pct = row.get("change_pct")
# 收集命中的信号列名 (仅 op=truth 且为真的)
hit_sigs = [
c["field"] for c in conditions
if c.get("op") == "truth" and row.get(c["field"])
]
results.append((sym, name, price, pct, hit_sigs))
return results
@staticmethod
def _default_message(rule: dict) -> str:
rtype = rule.get("type", "signal")
name_map = {"signal": "信号触发", "price": "价格触发", "market": "市场异动", "strategy": "策略触发"}
return name_map.get(rtype, "监控触发")
+241
View File
@@ -0,0 +1,241 @@
"""监控规则 — 统一的 MonitorRule 模型,覆盖策略/个股信号/个股价格/市场异动四类。
职责:
- 从 data/user_data/monitor_rules/*.json 加载规则定义
- 校验规则字段合法性
- 提供 CRUD (load_all / save_one / delete_one)
不知道: 行情评估引擎、API、告警落盘。纯函数 + 文件存储。
设计 (镜像 custom_signals.py 的写法):
- 一对象一文件 + glob 全扫 + 全量重写
- 字段白名单复用 custom_signals.ALLOWED_FIELDS (阈值条件) + 信号列清单 (布尔条件)
- id 正则与 custom_signals 一致,保证可纳入同一索引体系
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timezone
from pathlib import Path
from app.strategy.custom_signals import ALLOWED_FIELDS
logger = logging.getLogger(__name__)
# ── 常量 ────────────────────────────────────────────────
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
RULE_TYPES = {"strategy", "signal", "price", "market"}
SCOPES = {"symbols", "all", "sector"}
LOGICS = {"and", "or"}
DIRECTIONS = {"entry", "exit", "both"}
SEVERITIES = {"info", "warn", "critical"}
OPS = {">", ">=", "<", "<=", "==", "!="}
# 布尔信号列前缀 (op=truth 时 field 取这些)
_SIGNAL_PREFIXES = ("signal_", "csg_")
# ── 持久化 (镜像 custom_signals.py) ─────────────────────
def _dir(data_dir: Path) -> Path:
d = data_dir / "user_data" / "monitor_rules"
d.mkdir(parents=True, exist_ok=True)
return d
def _path(data_dir: Path, rule_id: str) -> Path:
return _dir(data_dir) / f"{rule_id}.json"
def load_all(data_dir: Path) -> list[dict]:
"""读取全部监控规则。损坏的文件被跳过。"""
d = _dir(data_dir)
out: list[dict] = []
for f in sorted(d.glob("*.json")):
try:
out.append(json.loads(f.read_text(encoding="utf-8")))
except Exception as e:
logger.warning("monitor rule load failed %s: %s", f.name, e)
return out
def load_one(data_dir: Path, rule_id: str) -> dict | None:
p = _path(data_dir, rule_id)
if not p.exists():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("monitor rule load failed %s: %s", rule_id, e)
return None
def save_one(data_dir: Path, rule: dict) -> None:
p = _path(data_dir, rule["id"])
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(rule, ensure_ascii=False, indent=2), encoding="utf-8")
def delete_one(data_dir: Path, rule_id: str) -> bool:
p = _path(data_dir, rule_id)
if p.exists():
p.unlink()
return True
return False
# ── 校验 ────────────────────────────────────────────────
def _is_signal_field(field: str) -> bool:
"""判断 field 是否为布尔信号列 (signal_ / csg_ 前缀)。"""
return any(field.startswith(p) for p in _SIGNAL_PREFIXES)
def validate(rule: dict) -> None:
"""校验一条监控规则,非法则抛 ValueError (含中文信息)。"""
rid = rule.get("id", "")
if not isinstance(rid, str) or not ID_RE.match(rid):
raise ValueError(f"规则 id 非法 (仅小写字母数字下划线, 1-40字符): {rid!r}")
if not isinstance(rule.get("name"), str) or not rule["name"].strip():
raise ValueError("规则 name 不能为空")
if rule.get("type") not in RULE_TYPES:
raise ValueError(f"type 必须是 {RULE_TYPES} 之一")
# 策略类型: 需要 strategy_id + direction,conditions 可空
if rule.get("type") == "strategy":
if not rule.get("strategy_id"):
raise ValueError("策略类型规则必须指定 strategy_id")
if rule.get("direction", "entry") not in DIRECTIONS:
raise ValueError(f"direction 必须是 {DIRECTIONS} 之一")
else:
# 信号/价格/市场类型: 需要 conditions
conds = rule.get("conditions")
if not isinstance(conds, list) or len(conds) == 0:
raise ValueError("conditions 不能为空")
if len(conds) > 8:
raise ValueError("conditions 最多 8 条")
if rule.get("logic", "and") not in LOGICS:
raise ValueError(f"logic 必须是 {LOGICS} 之一")
for i, c in enumerate(conds):
if not isinstance(c, dict):
raise ValueError(f"{i+1} 个条件格式错误")
field = c.get("field", "")
op = c.get("op", "")
if op == "truth":
# 布尔信号: field 必须是 signal_/csg_ 前缀
if not _is_signal_field(field):
raise ValueError(f"{i+1} 个条件: op=truth 时 field 必须是信号列 (signal_/csg_ 前缀): {field!r}")
elif op in OPS:
# 阈值比较: field 必须在白名单, 需要 value
if field not in ALLOWED_FIELDS:
raise ValueError(f"{i+1} 个条件: 阈值字段 {field!r} 不在白名单")
if not isinstance(c.get("value"), (int, float)):
raise ValueError(f"{i+1} 个条件: value 必须是数字")
else:
raise ValueError(f"{i+1} 个条件: op {op!r} 非法 (应为 truth 或 {OPS})")
# scope 校验
if rule.get("scope", "symbols") not in SCOPES:
raise ValueError(f"scope 必须是 {SCOPES} 之一")
if rule.get("scope") == "symbols":
syms = rule.get("symbols")
if not isinstance(syms, list) or len(syms) == 0:
raise ValueError("scope=symbols 时 symbols 不能为空")
# 其余枚举
if rule.get("severity", "info") not in SEVERITIES:
raise ValueError(f"severity 必须是 {SEVERITIES} 之一")
cd = rule.get("cooldown_seconds", 3600)
if not isinstance(cd, int) or cd < 0:
raise ValueError("cooldown_seconds 必须是非负整数")
def normalize(rule: dict) -> dict:
"""补全默认字段,返回规范化后的规则 (不校验)。"""
r = dict(rule)
r.setdefault("enabled", True)
r.setdefault("scope", "symbols")
r.setdefault("symbols", [])
r.setdefault("sector", None)
r.setdefault("strategy_id", None)
r.setdefault("direction", "entry")
r.setdefault("conditions", [])
r.setdefault("logic", "and")
r.setdefault("cooldown_seconds", 3600)
r.setdefault("severity", "info")
r.setdefault("message", "")
r.setdefault("webhook_url", "")
r.setdefault("webhook_enabled", False)
r.setdefault("created_at", datetime.now(timezone.utc).isoformat())
return r
# 策略监控自动迁移的规则 id 前缀 (固定, 保证幂等)
STRATEGY_RULE_PREFIX = "mr_strategy_"
def strategy_rule_id(strategy_id: str) -> str:
"""策略监控规则 id = mr_strategy_{strategy_id}"""
return f"{STRATEGY_RULE_PREFIX}{strategy_id}"
def migrate_strategy_monitors(data_dir: Path, strategy_ids: list[str], strategy_names: dict[str, str]) -> list[dict]:
"""把 preferences.strategy_monitor_ids 里的策略,同步生成/更新 type=strategy 规则。
幂等: 已存在的策略规则会被更新 (方向/名称),不会重复创建。
已从 strategy_ids 移除的策略, 其规则会被停用 (enabled=False) 而非删除 (保留历史触发记录的关联)。
Args:
data_dir: 数据目录
strategy_ids: 当前监控池中的策略 id 列表
strategy_names: {strategy_id: 策略名} 用于规则显示名
Returns:
本次生成/更新的规则列表
"""
desired = set(strategy_ids)
existing = load_all(data_dir)
# 已存在的策略规则 {strategy_id: rule}
existing_strategy_rules: dict[str, dict] = {}
for r in existing:
rid = r.get("id", "")
if rid.startswith(STRATEGY_RULE_PREFIX):
sid = rid[len(STRATEGY_RULE_PREFIX):]
if sid:
existing_strategy_rules[sid] = r
touched: list[dict] = []
# 1. 为当前监控池的策略 upsert 规则
for sid in desired:
rule_id = strategy_rule_id(sid)
name = strategy_names.get(sid, sid)
rule = existing_strategy_rules.get(sid)
if rule is None:
rule = normalize({
"id": rule_id,
"name": f"策略监控 · {name}",
"type": "strategy",
"scope": "all",
"strategy_id": sid,
"direction": "entry",
"conditions": [],
"cooldown_seconds": 3600,
"enabled": True,
})
else:
rule = dict(rule)
rule["enabled"] = True
rule["strategy_id"] = sid
rule["name"] = f"策略监控 · {name}"
rule.setdefault("scope", "all")
rule.setdefault("direction", "entry")
save_one(data_dir, rule)
touched.append(rule)
# 2. 不在监控池的策略 → 停用其规则 (不删除)
for sid, rule in existing_strategy_rules.items():
if sid not in desired and rule.get("enabled") is not False:
rule = dict(rule)
rule["enabled"] = False
save_one(data_dir, rule)
return touched
+2 -2
View File
@@ -148,14 +148,14 @@ $backendJob = Start-Job -Name 'backend' -ScriptBlock {
$PID | Out-File -FilePath $pidFile -Encoding ascii -Force
$env:PYTHONUNBUFFERED = '1'
Set-Location $dir
& .\.venv\Scripts\python.exe -m uvicorn app.main:app --reload --port $port 2>&1
& .\.venv\Scripts\python.exe -m uvicorn app.main:app --reload --host 0.0.0.0 --port $port 2>&1
} -ArgumentList $backendPidFile, $BackendDir, $BackendPort
$frontendJob = Start-Job -Name 'frontend' -ScriptBlock {
param($pidFile, $dir, $port)
$PID | Out-File -FilePath $pidFile -Encoding ascii -Force
Set-Location $dir
& pnpm dev --port $port 2>&1
& pnpm dev --host 0.0.0.0 --port $port 2>&1
} -ArgumentList $frontendPidFile, $FrontendDir, $FrontendPort
# Wait up to 5 seconds for the PID files to materialise
+2 -2
View File
@@ -120,14 +120,14 @@ echo
(
cd "$BACKEND_DIR"
uv run uvicorn app.main:app --reload --port "$BACKEND_PORT" 2>&1 \
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port "$BACKEND_PORT" 2>&1 \
| prefix_awk "$(printf "${BLUE}[backend ]${NC} ")"
) &
PIDS+=("$!")
(
cd "$FRONTEND_DIR"
pnpm dev --port "$FRONTEND_PORT" 2>&1 \
pnpm dev --host 0.0.0.0 --port "$FRONTEND_PORT" 2>&1 \
| prefix_awk "$(printf "${GREEN}[frontend]${NC} ")"
) &
PIDS+=("$!")
Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 267 KiB

+142
View File
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { Bell, TrendingUp, TrendingDown, X } from 'lucide-react'
import type { AlertEvent } from '@/lib/api'
import { fmtPct, fmtPrice } from '@/lib/format'
import { cn } from '@/lib/cn'
import { playNotificationSound } from '@/lib/notificationSound'
// ===== 全局状态 (模块级, 仿 Toast.tsx 模式) =====
type Item = { id: number; alert: AlertEvent }
let _id = 0
let _queue: Item[] = []
const AUTO_DISMISS = 5000 // 5 秒自动消失
const _listeners: Set<(items: Item[]) => void> = new Set()
/** 从 localStorage 读取配置 */
function getEnabled(): boolean {
try {
const v = localStorage.getItem('alert_toast_enabled')
return v === null ? true : v === '1' // 默认开启
} catch { return true }
}
function getMaxVisible(): number {
try {
const v = parseInt(localStorage.getItem('alert_toast_max') || '', 10)
return v >= 1 && v <= 10 ? v : 3 // 默认 3, 范围 1-10
} catch { return 3 }
}
/** 通知外部配置变更后刷新 (设置页改了配置后调用) */
export function refreshAlertToastConfig() {
_emit()
}
function _emit() { _listeners.forEach(fn => fn([..._queue])) }
/** 推入监控告警通知 (外部调用) */
export function pushAlertToast(alert: AlertEvent) {
if (!getEnabled()) return // 开关关闭: 不弹
const maxVisible = getMaxVisible()
const item = { id: ++_id, alert }
_queue = [..._queue, item]
// 超出上限: 丢弃最旧的
if (_queue.length > maxVisible) {
_queue = _queue.slice(-maxVisible)
}
_emit()
setTimeout(() => dismiss(item.id), AUTO_DISMISS)
playNotificationSound() // 播放声效
}
/** 手动关闭 */
export function dismiss(id: number) {
_queue = _queue.filter(t => t.id !== id)
_emit()
}
// ===== 配色 =====
const SEVERITY_BAR: Record<string, string> = {
info: 'bg-accent', warn: 'bg-warning', critical: 'bg-danger',
}
const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
strategy: { label: '策略', cls: 'bg-amber-400/15 text-amber-400' },
signal: { label: '信号', cls: 'bg-accent/15 text-accent' },
price: { label: '价格', cls: 'bg-emerald-400/15 text-emerald-400' },
market: { label: '异动', cls: 'bg-purple-500/15 text-purple-400' },
}
// ===== 容器 — 挂在 Layout =====
export function AlertToastContainer() {
const [items, setItems] = useState<Item[]>([])
const navigate = useNavigate()
const sub = useCallback(() => {
_listeners.add(setItems)
return () => { _listeners.delete(setItems) }
}, [])
useEffect(sub, [sub])
// 点击通知 → 跳转监控中心 + 关闭当前通知
const handleClick = (id: number) => {
dismiss(id)
navigate('/monitor')
}
if (!items.length) return null
return (
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 w-[320px] pointer-events-none">
<AnimatePresence>
{items.map(item => {
const ev = item.alert
const sev = SEVERITY_BAR[ev.severity ?? 'info'] ?? SEVERITY_BAR.info
const badge = SOURCE_BADGE[ev.source] ?? { label: ev.source, cls: 'bg-elevated text-muted' }
const pct = ev.change_pct ?? 0
return (
<motion.div
key={item.id}
layout
initial={{ opacity: 0, x: 60, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 60, scale: 0.9 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
onClick={() => handleClick(item.id)}
className="pointer-events-auto relative overflow-hidden rounded-xl border border-border/60 bg-surface/95 backdrop-blur-md shadow-2xl pl-3 pr-2 py-2.5 cursor-pointer hover:border-accent/40 hover:shadow-accent/10 transition-all"
>
{/* 左侧色条 */}
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
{/* 顶行: 分类标签 + 代码/名称 + 涨跌幅 + 关闭 */}
<div className="flex items-center gap-2">
<span className={cn('shrink-0 rounded px-1 py-px text-[9px] font-medium', badge.cls)}>
{badge.label}
</span>
{ev.symbol && <span className="font-mono text-xs font-medium text-foreground shrink-0">{ev.symbol}</span>}
{ev.name && <span className="text-xs text-secondary truncate flex-1">{ev.name}</span>}
{ev.change_pct != null && (
<span className={cn('inline-flex items-center gap-0.5 text-[10px] font-mono font-medium shrink-0', pct >= 0 ? 'text-danger' : 'text-bear')}>
{pct >= 0 ? <TrendingUp className="h-2.5 w-2.5" /> : <TrendingDown className="h-2.5 w-2.5" />}
{fmtPct(pct)}
</span>
)}
<button onClick={(e) => { e.stopPropagation(); dismiss(item.id) }} className="shrink-0 p-0.5 rounded text-muted/50 hover:text-foreground hover:bg-elevated transition-colors cursor-pointer">
<X className="h-3 w-3" />
</button>
</div>
{/* 底行: 触发消息 + 价格 */}
<div className="mt-1 flex items-center gap-2 pl-0.5">
<Bell className={cn('h-3 w-3 shrink-0', sev.replace('bg-', 'text-'))} />
{ev.message && <span className="text-[11px] text-foreground/70 truncate flex-1">{ev.message}</span>}
{ev.price != null && <span className="text-[10px] font-mono text-muted shrink-0">{fmtPrice(ev.price)}</span>}
</div>
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}
+13 -1
View File
@@ -828,6 +828,11 @@ export function EChartsCandlestick({
html += `<span style="color:${THEME.bear}">${d.low.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">收</span>`
html += `<span style="color:${clr};font-weight:600">${d.close.toFixed(2)}</span>`
// 涨跌幅 (收盘后, 换手前; 和收间隔一些距离)
if (prev) {
const chgPct = (chg / prev.close * 100)
html += `<span style="color:${clr};margin-left:8px">${isUp ? '+' : ''}${chgPct.toFixed(2)}%</span>`
}
if (turnoverRate != null) {
html += `<span style="color:${THEME.text}">换手</span>`
html += `<span style="color:${THEME.text}">${turnoverRate.toFixed(2)}%</span>`
@@ -1051,7 +1056,14 @@ export function EChartsCandlestick({
html += `<span style="color:${THEME.text}">低</span>`
html += `<span style="color:${THEME.bear}">${d.low.toFixed(2)}</span>`
html += `<span style="color:${THEME.text}">收</span>`
html += `<span style="color:${d.close >= (data[idx-1]?.close ?? d.close) ? THEME.bull : THEME.bear};font-weight:600">${d.close.toFixed(2)}</span>`
const prevClose0 = data[idx-1]?.close ?? d.close
const clr0 = d.close >= prevClose0 ? THEME.bull : THEME.bear
html += `<span style="color:${clr0};font-weight:600">${d.close.toFixed(2)}</span>`
// 涨跌幅 (收盘后, 换手前; 和收间隔一些距离)
if (idx > 0) {
const chgPct0 = ((d.close - prevClose0) / prevClose0 * 100)
html += `<span style="color:${clr0};margin-left:8px">${chgPct0 >= 0 ? '+' : ''}${chgPct0.toFixed(2)}%</span>`
}
if (turnoverRate != null) {
html += `<span style="color:${THEME.text}">换手</span>`
html += `<span style="color:${THEME.text}">${turnoverRate.toFixed(2)}%</span>`
+41 -2
View File
@@ -1,8 +1,10 @@
import { useEffect } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useQuoteStream } from '@/lib/useQuoteStream'
import { ToastContainer } from '@/components/Toast'
import { AlertToastContainer } from '@/components/AlertToast'
import {
useCapabilities,
useSettings,
@@ -37,6 +39,7 @@ import {
import { Logo } from './Logo'
import { api, type IndexQuote } from '@/lib/api'
import { cn } from '@/lib/cn'
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
const BRAND = '#8B5CF6'
@@ -62,7 +65,7 @@ const nav = [
{ to: '/financials', label: '财务', icon: FileText },
{ to: '/indices', label: '指数', icon: BarChart3 },
{ to: '/trading', label: '交易', icon: Cable },
{ to: '/monitor', label: '监控通知', icon: RadioTower },
{ to: '/monitor', label: '监控中心', icon: RadioTower },
{ to: '/data', label: '数据', icon: Database },
] as const
@@ -81,6 +84,21 @@ function indexPctClass(v: number | null | undefined) {
return Number(v) >= 0 ? 'text-bull' : 'text-bear'
}
/** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */
function MonitorBadge({ active }: { active: boolean }) {
const unread = useUnreadAlerts()
// 尊重用户设置: 可在菜单设置里关闭数字提示
const badgeEnabled = (() => {
try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true }
})()
if (active || unread <= 0 || !badgeEnabled) return null
return (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[9px] font-bold text-white animate-pulse">
{unread > 99 ? '99+' : unread}
</span>
)
}
function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; items: CoreIndex[] }) {
if (items.length === 0) return null
const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q]))
@@ -253,6 +271,20 @@ export function Layout() {
const isTrading = quoteStatus?.is_trading_hours ?? false
const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free')
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
const alertsTotalQuery = useQuery({
queryKey: ['alerts-total'],
queryFn: () => api.alertsList({ days: 7, limit: 1 }),
refetchInterval: 15000,
refetchIntervalInBackground: true,
select: (data) => data.total,
})
// 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen)
const alertsTotal = alertsTotalQuery.data
useEffect(() => {
if (alertsTotal != null) setAlertTotal(alertsTotal)
}, [alertsTotal])
// 合并内置页面 + 可见的扩展分析菜单
const analysisNav = (analysisMenus?.items ?? [])
.filter(m => m.visible)
@@ -344,8 +376,14 @@ export function Layout() {
)
}
>
{({ isActive }) => (
<>
<Icon className="h-4 w-4 shrink-0" />
<span>{label}</span>
<span className="flex-1">{label}</span>
{/* 监控中心徽标: 仅非监控页且有未读时显示 */}
{to === '/monitor' && <MonitorBadge active={isActive} />}
</>
)}
</NavLink>
))}
</nav>
@@ -445,6 +483,7 @@ export function Layout() {
<Outlet />
</motion.main>
<ToastContainer />
<AlertToastContainer />
</div>
)
}
+23 -6
View File
@@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react'
import { Settings2, BellRing } from 'lucide-react'
import { Settings2, RadioTower, Star } from 'lucide-react'
import type { KlineRow, FinancialMetricRecord } from '@/lib/api'
import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format'
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
@@ -18,6 +18,11 @@ interface Props {
onFieldsChange: (fields: ColumnConfig[]) => void
/** 财务指标最新一期(来自 useFinancialMetrics,受 Cap.FINANCIAL 门控) */
financialMetrics?: FinancialMetricRecord
/** 加监控回调 (个股弹窗传入, 有值时渲染 RadioTower 图标) */
onMonitor?: () => void
/** 加自选回调 + 是否已自选 (有 onToggle 时渲染 Star 图标) */
inWatchlist?: boolean
onToggleWatchlist?: () => void
}
/**
@@ -86,7 +91,7 @@ function renderExtInline(
)
}
export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics }: Props) {
export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics, onMonitor, inWatchlist, onToggleWatchlist }: Props) {
// 弹窗开关:纯本地状态,与数据/配置无关,放早期 return 之前
const [customizerOpen, setCustomizerOpen] = useState(false)
// ext 标签展开状态:按 symbol::colId,切股/切字段时互不干扰
@@ -209,14 +214,26 @@ export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsCh
<span style={{ color: clr }} className="tabular-nums">
{isUp ? '+' : ''}{fmtPrice(chgPct)}%
</span>
{/* 右侧操作按钮:监控通知 + 信息条配置 */}
{/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
<div className="ml-auto self-center flex items-center gap-1">
{onToggleWatchlist && (
<button
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
title="监控通知(开发中)"
onClick={onToggleWatchlist}
className={`p-1 rounded-btn transition-colors cursor-pointer ${inWatchlist ? 'text-[#FACC15]' : 'text-muted hover:text-foreground hover:bg-elevated'}`}
title={inWatchlist ? '移出自选' : '加自选'}
>
<BellRing className="h-3.5 w-3.5" />
<Star className="h-3.5 w-3.5" />
</button>
)}
{onMonitor && (
<button
onClick={onMonitor}
className="p-1 rounded-btn text-amber-400 hover:bg-amber-400/10 transition-colors cursor-pointer"
title="加监控"
>
<RadioTower className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => setCustomizerOpen(true)}
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
+11
View File
@@ -26,6 +26,11 @@ interface Props {
priceLines?: ChartPriceLine[]
showLimitMarkers?: boolean
showMarkerToggle?: boolean
/** 加监控回调 (传入后信息条显示 RadioTower 图标) */
onMonitor?: () => void
/** 加自选 (传入后信息条显示 Star 图标) */
inWatchlist?: boolean
onToggleWatchlist?: () => void
}
export { getDefaultRange }
@@ -42,6 +47,9 @@ export function StockPanel({
priceLines,
showLimitMarkers = true,
showMarkerToggle = true,
onMonitor,
inWatchlist,
onToggleWatchlist,
}: Props) {
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
@@ -115,6 +123,9 @@ export function StockPanel({
fields={fields}
onFieldsChange={handleFieldsChange}
financialMetrics={financialMetrics}
onMonitor={onMonitor}
inWatchlist={inWatchlist}
onToggleWatchlist={onToggleWatchlist}
/>
<div className="flex gap-3 items-start">
+85 -18
View File
@@ -1,16 +1,26 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { X, RefreshCw, Clock, Star } from 'lucide-react'
import { X, RefreshCw, Clock } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cnSignal } from '@/lib/signals'
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
import { DatePicker } from '@/components/DatePicker'
import { RuleEditor } from '@/components/monitor/RuleEditor'
interface Props {
symbol: string | null
name?: string
onClose: () => void
/** 触发信息 (来自监控触发记录, 有值时在顶栏下方显示) */
triggerInfo?: {
price?: number | null
changePct?: number | null
ts?: number
signals?: string[]
message?: string
} | null
}
// ===== 板块标识(与 Screener 列表一致)=====
@@ -28,9 +38,10 @@ function boardTag(symbol: string): { label: string; color: string } | null {
return null
}
export function StockPreviewDialog({ symbol, name, onClose }: Props) {
export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) {
const [showIntraday, setShowIntraday] = useState(false)
const [dateRange, setDateRange] = useState(getDefaultRange)
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
const qc = useQueryClient()
const watchlist = useQuery({
@@ -159,22 +170,6 @@ export function StockPreviewDialog({ symbol, name, onClose }: Props) {
<span className="text-muted/20 mx-0.5">|</span>
{/* 加自选 */}
<button
onClick={() => toggleWatchlist.mutate()}
disabled={toggleWatchlist.isPending}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors cursor-pointer ${
inWatchlist
? 'bg-[#FACC15]/15 text-[#FACC15] border border-[#FACC15]/30'
: 'bg-elevated text-secondary border border-border hover:border-accent/30'
}`}
>
<Star className="h-3 w-3" />
{inWatchlist ? '移出自选' : '加自选'}
</button>
<span className="text-muted/20 mx-0.5">|</span>
{/* 刷新 */}
<button
onClick={handleRefresh}
@@ -194,6 +189,47 @@ export function StockPreviewDialog({ symbol, name, onClose }: Props) {
</div>
</div>
{/* 触发信息条 (来自监控触发记录) */}
{triggerInfo && (
<div className="flex items-center gap-4 border-b border-amber-400/20 bg-amber-400/[0.06] px-5 py-2 shrink-0">
{/* 左: 触发标记 + 时间 */}
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-semibold text-amber-400"> </span>
{triggerInfo.ts && (
<span className="text-[11px] text-secondary font-mono">
{new Date(triggerInfo.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
{/* 中: 价格 + 涨跌幅 */}
<div className="flex items-center gap-2 shrink-0">
{triggerInfo.price != null && (
<span className="text-[11px] font-mono text-foreground/80">{triggerInfo.price.toFixed(2)}</span>
)}
{triggerInfo.changePct != null && (
<span className={`text-[11px] font-mono font-medium ${triggerInfo.changePct >= 0 ? 'text-danger' : 'text-bear'}`}>
{triggerInfo.changePct >= 0 ? '+' : ''}{(triggerInfo.changePct * 100).toFixed(2)}%
</span>
)}
</div>
{/* 右: 消息 + 信号标签 */}
<div className="flex items-center gap-2 flex-wrap min-w-0">
{triggerInfo.message && (
<span className="text-[11px] text-foreground/70 truncate">{triggerInfo.message}</span>
)}
{triggerInfo.signals && triggerInfo.signals.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{triggerInfo.signals.map((s, j) => (
<span key={j} className="rounded bg-accent/10 px-1.5 py-0.5 text-[9px] text-accent/80">{cnSignal(s)}</span>
))}
</div>
)}
</div>
</div>
)}
{/* K 线内容 */}
<div className="flex-1 overflow-auto p-4">
<StockPanel
@@ -202,8 +238,39 @@ export function StockPreviewDialog({ symbol, name, onClose }: Props) {
showIntraday={showIntraday}
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
dateRange={dateRange}
onMonitor={() => setShowMonitorEditor(true)}
inWatchlist={inWatchlist}
onToggleWatchlist={() => toggleWatchlist.mutate()}
/>
</div>
{/* 加监控编辑器弹层 */}
<AnimatePresence>
{showMonitorEditor && symbol && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-20 flex items-start justify-center overflow-auto bg-black/40 p-4"
onClick={() => setShowMonitorEditor(false)}
>
<div className="mt-8 w-full max-w-2xl" onClick={e => e.stopPropagation()}>
<RuleEditor
rule={null}
simple
preset={{
scope: 'symbols',
symbols: [symbol],
type: 'signal',
logic: 'or',
}}
onClose={() => setShowMonitorEditor(false)}
onSaved={() => setShowMonitorEditor(false)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
)}
@@ -0,0 +1,365 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Save, X, Plus, Search } from 'lucide-react'
import { api, genRuleId, type MonitorRule, type MonitorCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { SignalPicker } from '@/components/screener/SignalPicker'
interface Props {
/** 编辑现有规则;null=新建 */
rule: MonitorRule | null
/** 新建时的预填值 (如个股弹窗传入 symbol/scope) */
preset?: Partial<MonitorRule>
/** 极简模式: 个股场景, 隐藏 type/scope/阈值等, 只显示信号点选 */
simple?: boolean
onClose: () => void
onSaved?: () => void
}
const TYPE_DEFAULT_NAME: Record<string, string> = {
signal: '个股信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控',
}
const emptyRule = (preset?: Partial<MonitorRule>): MonitorRule => ({
id: genRuleId(),
name: '',
enabled: true,
type: 'signal',
scope: 'symbols',
symbols: [],
sector: null,
strategy_id: null,
direction: 'entry',
conditions: [],
logic: 'or',
cooldown_seconds: 3600,
severity: 'info',
message: '',
...preset,
})
export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const qc = useQueryClient()
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
const [editing] = useState(!!rule)
const [draft, setDraft] = useState<MonitorRule>(
rule ? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) } : emptyRule(preset),
)
const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('')
const symbolSearch = useQuery({
queryKey: QK.instrumentSearch(symbolQuery),
queryFn: () => api.instrumentSearch(symbolQuery, 20),
enabled: symbolQuery.length > 0,
})
const save = useMutation({
mutationFn: () => {
const d = { ...draft }
// name 为空时用默认名
if (!d.name.trim()) {
const base = TYPE_DEFAULT_NAME[d.type] ?? '监控规则'
d.name = d.scope === 'symbols' && d.symbols.length > 0
? `${base} · ${d.symbols[0]}${d.symbols.length > 1 ? `${d.symbols.length}` : ''}`
: base
}
if (d.type === 'strategy') {
if (!d.strategy_id) throw new Error('策略监控必须选择一个策略')
} else {
if (d.conditions.length === 0) throw new Error('至少选择一个触发条件')
for (const c of d.conditions) {
if (!c.field || !c.op) throw new Error('条件填写不完整')
if (c.op !== 'truth' && (c.value === null || c.value === undefined)) throw new Error('阈值条件需要数值')
}
}
if (d.scope === 'symbols' && d.symbols.length === 0) throw new Error('请选择至少一只股票')
return api.monitorRuleSave(d)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.monitorRules })
onSaved?.()
onClose()
},
onError: err => setError(String((err as any)?.message ?? err)),
})
// 条件编辑
const updateCond = (idx: number, patch: Partial<MonitorCondition>) =>
setDraft(d => ({ ...d, conditions: d.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c) }))
const addCond = (op: 'truth' | 'threshold') =>
setDraft(d => ({
...d,
conditions: [...d.conditions, op === 'truth'
? { field: 'signal_volume_surge', op: 'truth' }
: { field: 'rsi_14', op: '<', value: 30 }],
}))
const removeCond = (idx: number) =>
setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
const addSymbol = (sym: string) => {
if (!draft.symbols.includes(sym)) {
setDraft(d => ({ ...d, symbols: [...d.symbols, sym] }))
}
setSymbolQuery('')
}
const thresholdFields = options.data?.threshold_fields ?? []
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
const selectedSignals = draft.conditions.filter(c => c.op === 'truth').map(c => c.field)
const thresholdConds = draft.conditions.filter(c => c.op !== 'truth')
const onSignalPickerChange = (next: string[]) => {
const nonTruthConds = draft.conditions.filter(c => c.op !== 'truth')
const truthConds: MonitorCondition[] = next.map(field => ({ field, op: 'truth' }))
setDraft(d => ({ ...d, conditions: [...nonTruthConds, ...truthConds] }))
}
// ── 极简模式: 只显示信号点选 + 可选描述 ──
if (simple) {
return (
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-medium text-foreground">{editing ? '编辑监控' : '加入监控'}</h3>
<button onClick={onClose} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground cursor-pointer">
<X className="h-4 w-4" />
</button>
</div>
{draft.symbols.length > 0 && (
<div className="flex flex-wrap gap-1">
{draft.symbols.map(s => (
<span key={s} className="rounded bg-elevated px-1.5 py-0.5 text-[10px] text-secondary font-mono">{s}</span>
))}
</div>
)}
<div>
<div className="mb-1.5 text-[11px] text-muted"> ()</div>
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
</div>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"> ()</span>
<input value={draft.message} onChange={e => setDraft(d => ({ ...d, message: e.target.value }))} placeholder="给这条监控加个备注" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs cursor-pointer"></button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
// ── 完整模式: 监控页新建/编辑 ──
return (
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-foreground">{editing ? '编辑监控规则' : '新建监控规则'}</h3>
<p className="mt-1 text-[11px] text-muted">,</p>
</div>
<button onClick={onClose} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground cursor-pointer">
<X className="h-4 w-4" />
</button>
</div>
{/* 描述 (可选) + 类型 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="md:col-span-2 space-y-1.5">
<span className="text-[11px] text-muted"> ()</span>
<input value={draft.name} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="留空用默认名称" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={draft.type} onChange={e => setDraft(d => ({ ...d, type: e.target.value as MonitorRule['type'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.types ?? []).map(t => <option key={t.key} value={t.key}>{t.label}</option>)}
</select>
</label>
</div>
{/* 作用范围 */}
<div className="space-y-2">
<span className="text-[11px] text-muted"></span>
<div className="flex items-center gap-2">
<select value={draft.scope} onChange={e => setDraft(d => ({ ...d, scope: e.target.value as MonitorRule['scope'] }))} className="h-9 w-32 rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.scopes ?? []).map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
{draft.scope === 'symbols' && (
<div className="flex-1 flex flex-wrap items-center gap-1.5">
{draft.symbols.map(sym => (
<span key={sym} className="inline-flex items-center gap-1 rounded bg-elevated px-1.5 py-0.5 text-[10px] text-secondary">
{sym}
<button onClick={() => setDraft(d => ({ ...d, symbols: d.symbols.filter(s => s !== sym) }))} className="text-muted hover:text-danger cursor-pointer">
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
<div className="relative">
<input
value={symbolQuery}
onChange={e => setSymbolQuery(e.target.value)}
placeholder="搜索股票..."
className="h-7 w-32 rounded border border-border bg-base pl-6 pr-2 text-[11px] text-foreground focus:outline-none focus:border-accent/50"
/>
<Search className="absolute left-1.5 top-1.5 h-3.5 w-3.5 text-muted" />
{symbolSearch.data && symbolSearch.data.results.length > 0 && (
<div className="absolute z-10 mt-1 max-h-48 w-48 overflow-auto rounded border border-border bg-surface shadow-lg">
{symbolSearch.data.results.map(r => (
<button key={r.symbol} onClick={() => addSymbol(r.symbol)} className="block w-full px-2 py-1 text-left text-[11px] hover:bg-elevated cursor-pointer">
<span className="font-mono text-foreground/80">{r.symbol}</span>
<span className="ml-1 text-muted">{r.name}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
{draft.scope === 'all' && <span className="text-[11px] text-muted"></span>}
{draft.scope === 'sector' && <span className="text-[11px] text-muted/60">(,)</span>}
</div>
</div>
{/* 触发条件 (非 strategy) */}
{draft.type !== 'strategy' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"></span>
<div className="flex items-center gap-2">
<select value={draft.logic} onChange={e => setDraft(d => ({ ...d, logic: e.target.value as MonitorRule['logic'] }))} className="h-7 rounded border border-border bg-base px-1.5 text-[11px] text-foreground">
{(options.data?.logics ?? []).map(l => <option key={l.key} value={l.key}>{l.label}</option>)}
</select>
<button onClick={() => addCond('truth')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
<button onClick={() => addCond('threshold')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
</div>
</div>
{selectedSignals.length > 0 || (options.data?.builtin_signals ?? []).length > 0 ? (
<div>
<div className="mb-1.5 text-[10px] text-muted/70"> ()</div>
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
</div>
) : null}
{thresholdConds.length > 0 && (
<div className="space-y-1.5">
{thresholdConds.map((c, i) => {
const realIdx = draft.conditions.indexOf(c)
return (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted/60 w-6 text-right shrink-0">{i === 0 && selectedSignals.length === 0 ? '当' : draft.logic === 'and' ? '且' : '或'}</span>
<select value={c.field} onChange={e => updateCond(realIdx, { field: e.target.value })} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
{thresholdFields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select value={c.op} onChange={e => updateCond(realIdx, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
{operators.map(op => <option key={op} value={op}>{op}</option>)}
</select>
<input type="number" value={c.value ?? 0} onChange={e => updateCond(realIdx, { value: parseFloat(e.target.value) })} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<button onClick={() => removeCond(realIdx)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
<X className="h-3 w-3" />
</button>
</div>
)
})}
</div>
)}
{draft.conditions.length === 0 && (
<div className="rounded border border-dashed border-border px-3 py-4 text-center text-[11px] text-muted">
</div>
)}
</div>
)}
{/* strategy 类型: 选策略 + 方向 */}
{draft.type === 'strategy' && (
<div className="space-y-2">
<span className="text-[11px] text-muted"></span>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<label className="md:col-span-2 space-y-1.5">
<span className="text-[10px] text-muted/70"></span>
<select
value={draft.strategy_id ?? ''}
onChange={e => setDraft(d => ({ ...d, strategy_id: e.target.value || null }))}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
<option value=""> </option>
{(strategies.data?.presets ?? []).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<label className="space-y-1.5">
<span className="text-[10px] text-muted/70"></span>
<select
value={draft.direction}
onChange={e => setDraft(d => ({ ...d, direction: e.target.value as MonitorRule['direction'] }))}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
{(options.data?.directions ?? []).map(d => <option key={d.key} value={d.key}>{d.label}</option>)}
</select>
</label>
</div>
<p className="text-[10px] leading-4 text-muted/70">
entry=,exit=,both=
</p>
</div>
)}
{/* 通知设置 */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted">()</span>
<input type="number" value={draft.cooldown_seconds} onChange={e => setDraft(d => ({ ...d, cooldown_seconds: parseInt(e.target.value) || 0 }))} min={0} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<select value={draft.severity} onChange={e => setDraft(d => ({ ...d, severity: e.target.value as MonitorRule['severity'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.severities ?? []).map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
</label>
<label className="space-y-1.5 md:col-span-1">
<span className="text-[11px] text-muted">()</span>
<input value={draft.message} onChange={e => setDraft(d => ({ ...d, message: e.target.value }))} placeholder="留空用默认文案" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
</div>
{/* Webhook 推送 (占位, 后续开发) */}
<div className="rounded-btn border border-border/40 bg-base/40 p-3 space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-[11px] font-medium text-foreground">Webhook </span>
<span className="ml-1.5 rounded bg-muted/10 px-1 py-px text-[9px] text-muted"></span>
</div>
<label className="flex items-center gap-1.5 cursor-not-allowed opacity-50">
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[10px] text-muted"></span>
</label>
</div>
<p className="text-[10px] leading-relaxed text-muted">
( QMT),
</p>
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs cursor-pointer"></button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
@@ -1,4 +1,4 @@
import { Settings2, TrendingDown } from 'lucide-react'
import { Settings2, TrendingDown, RadioTower } from 'lucide-react'
import { motion } from 'framer-motion'
import { storage } from '@/lib/storage'
@@ -30,7 +30,7 @@ const CARD_STYLES: Record<CardSize, {
},
normal: {
wrap: 'gap-2',
card: 'relative inline-flex items-center gap-2 pl-3 pr-7 py-1.5 rounded-lg',
card: 'relative inline-flex items-center gap-2 pl-3 pr-12 py-1.5 rounded-lg',
name: 'text-xs',
count: 'text-xs',
desc: 'text-[10px] text-muted leading-tight mt-0.5 line-clamp-1 max-w-[120px]',
@@ -38,7 +38,7 @@ const CARD_STYLES: Record<CardSize, {
},
large: {
wrap: 'gap-2',
card: 'relative inline-flex flex-col items-start px-3.5 py-2.5 rounded-btn min-w-[100px]',
card: 'relative inline-flex flex-col items-start pl-3.5 pr-12 py-2.5 rounded-btn min-w-[100px]',
name: 'text-xs',
count: 'text-lg font-mono font-bold tabular-nums',
desc: 'text-[10px] text-muted leading-tight mt-0.5 line-clamp-2 max-w-[140px]',
@@ -87,12 +87,16 @@ interface StrategyCardProps {
onRun: () => void
disabled: boolean
onSettings: () => void
/** 是否已加入策略监控 */
monitored?: boolean
/** 切换策略监控 (点击 RadioTower 图标) */
onToggleMonitor?: () => void
}
export function StrategyCard({
name, description, source, active, count, expiredCount,
loading, cardSize,
onRun, disabled, onSettings,
onRun, disabled, onSettings, monitored, onToggleMonitor,
}: StrategyCardProps) {
const cs = CARD_STYLES[cardSize]
const activeCls = active
@@ -118,9 +122,9 @@ export function StrategyCard({
<>
<button onClick={onRun} disabled={disabled}
className="flex flex-col items-start cursor-pointer disabled:opacity-50 disabled:cursor-wait w-full">
<div className="flex items-center gap-1.5">
<span className={`text-[9px] px-1 py-px rounded border font-medium leading-tight ${badgeCls}`}>{srcLabel}</span>
<span className="text-xs font-medium whitespace-nowrap text-foreground">{name}</span>
<div className="flex items-center gap-1.5 max-w-full">
<span className={`text-[9px] px-1 py-px rounded border font-medium leading-tight shrink-0 ${badgeCls}`}>{srcLabel}</span>
<span className="text-xs font-medium truncate text-foreground">{name}</span>
</div>
{description && (
<span className="text-[10px] text-muted leading-tight mt-0.5 line-clamp-1">{description}</span>
@@ -145,18 +149,25 @@ export function StrategyCard({
className="absolute top-1.5 right-1.5 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title="策略设置">
<Settings2 className="h-3 w-3 text-muted hover:text-accent transition-colors" />
</button>
{onToggleMonitor && (
<button onClick={(e) => { e.stopPropagation(); onToggleMonitor() }}
className="absolute top-1.5 right-7 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title={monitored ? '取消策略监控' : '开启策略监控'}>
<RadioTower className={`relative h-3 w-3 transition-colors ${monitored ? 'text-accent' : 'text-muted hover:text-accent'}`} />
{monitored && <span className="absolute inset-0 rounded animate-ping bg-accent/20" />}
</button>
)}
</>
) : cardSize === 'normal' ? (
<>
<button onClick={onRun} disabled={disabled}
className="flex flex-col items-start cursor-pointer disabled:opacity-50 disabled:cursor-wait">
<div className="flex items-center gap-1.5">
<span className={`text-[9px] px-1 py-px rounded border font-medium leading-tight ${badgeCls}`}>{srcLabel}</span>
<span className="text-xs font-medium whitespace-nowrap text-foreground">{name}</span>
className="flex flex-col items-start cursor-pointer disabled:opacity-50 disabled:cursor-wait min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className={`text-[9px] px-1 py-px rounded border font-medium leading-tight shrink-0 ${badgeCls}`}>{srcLabel}</span>
<span className="text-xs font-medium truncate text-foreground">{name}</span>
{count != null && (
<span className={`text-xs font-mono font-bold tabular-nums ${countCls}`}>{count}</span>
<span className={`text-xs font-mono font-bold tabular-nums shrink-0 ${countCls}`}>{count}</span>
)}
{loading && <span className="w-5 h-3 rounded bg-elevated animate-pulse" />}
{loading && <span className="w-5 h-3 rounded bg-elevated animate-pulse shrink-0" />}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{description && (
@@ -171,6 +182,13 @@ export function StrategyCard({
className="absolute top-1.5 right-1.5 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title="策略设置">
<Settings2 className="h-3 w-3 text-muted hover:text-accent transition-colors" />
</button>
{onToggleMonitor && (
<button onClick={(e) => { e.stopPropagation(); onToggleMonitor() }}
className="absolute top-1.5 right-7 p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title={monitored ? '取消策略监控' : '开启策略监控'}>
<RadioTower className={`relative h-3 w-3 transition-colors ${monitored ? 'text-accent' : 'text-muted hover:text-accent'}`} />
{monitored && <span className="absolute inset-0 rounded animate-ping bg-accent/20" />}
</button>
)}
</>
) : (
/* mini */
@@ -187,6 +205,13 @@ export function StrategyCard({
)}
{loading && <span className="w-4 h-2.5 rounded bg-elevated animate-pulse" />}
</button>
{onToggleMonitor && (
<button onClick={(e) => { e.stopPropagation(); onToggleMonitor() }}
className="relative p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title={monitored ? '取消策略监控' : '开启策略监控'}>
<RadioTower className={`h-3 w-3 transition-colors ${monitored ? 'text-accent' : 'text-muted hover:text-accent'}`} />
{monitored && <span className="absolute inset-0 rounded animate-ping bg-accent/20" />}
</button>
)}
<button onClick={(e) => { e.stopPropagation(); onSettings() }}
className="p-0.5 rounded hover:bg-elevated transition-colors cursor-pointer" title="策略设置">
<Settings2 className="h-3 w-3 text-muted hover:text-accent transition-colors" />
+110
View File
@@ -319,6 +319,68 @@ export interface CustomSignalOptions {
kinds: { key: string; label: string }[]
}
// ===== Monitor (监控规则 + 触发记录) =====
export interface MonitorCondition {
field: string
op: string // truth | > >= < <= == !=
value?: number | null // op 非 truth 时必填
}
export interface MonitorRule {
id: string
name: string
enabled: boolean
type: 'strategy' | 'signal' | 'price' | 'market'
scope: 'symbols' | 'all' | 'sector'
symbols: string[]
sector?: string | null
strategy_id?: string | null
direction: 'entry' | 'exit' | 'both'
conditions: MonitorCondition[]
logic: 'and' | 'or'
cooldown_seconds: number
severity: 'info' | 'warn' | 'critical'
message: string
webhook_url?: string
webhook_enabled?: boolean
created_at?: string
}
export interface MonitorRuleOptions {
threshold_fields: { key: string; label: string }[]
builtin_signals: { key: string; label: string }[]
custom_signals: { key: string; label: string }[]
operators: string[]
types: { key: string; label: string }[]
scopes: { key: string; label: string }[]
logics: { key: string; label: string }[]
severities: { key: string; label: string }[]
directions: { key: string; label: string }[]
}
export interface AlertEvent {
ts: number
rule_id?: string
rule_name?: string
source: string
type: string
symbol?: string
name?: string | null
message: string
price?: number | null
change_pct?: number | null
signals?: string[]
severity?: string
strategy_id?: string
}
/** 生成监控规则 id (时间戳 + 随机后缀), 用户无需手动填写。 */
export function genRuleId(): string {
const ts = Date.now().toString(36)
const rand = Math.random().toString(36).slice(2, 6)
return `mr_${ts}_${rand}`
}
// ===== Limit Ladder =====
export interface LimitLadderStock {
symbol: string
@@ -719,6 +781,13 @@ export const api = {
request<{ results: { symbol: string; name: string; code: string }[] }>(
`/api/kline/instruments/search?q=${encodeURIComponent(q)}&limit=${limit}`,
),
/** 批量查股票名称 (传入 symbol 列表, 返回 {symbol: name}) */
instrumentNames: (symbols: string[]) =>
request<{ names: Record<string, string> }>('/api/kline/instruments/names', {
method: 'POST',
body: JSON.stringify(symbols),
}),
klineMinute: (symbol: string, date?: string) =>
request<{
symbol: string
@@ -1132,6 +1201,47 @@ export const api = {
customSignalDelete: (id: string) =>
request<{ ok: boolean }>(`/api/custom-signals/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// ===== Monitor Rules (监控规则) =====
monitorRulesList: () =>
request<{ rules: MonitorRule[] }>('/api/monitor-rules'),
monitorRuleOptions: () =>
request<MonitorRuleOptions>('/api/monitor-rules/options'),
monitorRuleSave: (rule: MonitorRule) =>
request<{ ok: boolean; rule: MonitorRule }>('/api/monitor-rules', {
method: 'POST',
body: JSON.stringify(rule),
}),
monitorRuleDelete: (id: string) =>
request<{ ok: boolean }>(`/api/monitor-rules/${encodeURIComponent(id)}`, { method: 'DELETE' }),
/** 生成演示监控规则 (Dev 页用) */
monitorRuleSeed: () =>
request<{ ok: boolean; generated: number }>('/api/monitor-rules/seed', { method: 'POST' }),
// ===== Alerts (触发记录) =====
alertsList: (params?: { days?: number; limit?: number; source?: string; type?: string }) => {
const qs = new URLSearchParams()
if (params?.days) qs.set('days', String(params.days))
if (params?.limit) qs.set('limit', String(params.limit))
if (params?.source) qs.set('source', params.source)
if (params?.type) qs.set('type', params.type)
const s = qs.toString()
return request<{ alerts: AlertEvent[]; total: number }>(`/api/alerts${s ? `?${s}` : ''}`)
},
alertsClear: () =>
request<{ ok: boolean; cleared: number }>('/api/alerts', { method: 'DELETE' }),
alertDelete: (ts: number) =>
request<{ ok: boolean }>(`/api/alerts/${ts}`, { method: 'DELETE' }),
/** 生成演示触发记录 (Dev 页用) */
alertSeed: (count = 12, recent = true) =>
request<{ ok: boolean; generated: number }>(`/api/alerts/seed?count=${count}&recent=${recent}`, { method: 'POST' }),
/** 检查 AI 配置状态 */
strategyAiStatus: () =>
request<{ configured: boolean; has_key: boolean; has_model: boolean }>('/api/strategies/ai/status'),
+116
View File
@@ -0,0 +1,116 @@
import { useSyncExternalStore } from 'react'
/**
* 监控中心未读触发记录徽标 — 全局 store + localStorage 持久化。
*
* 核心逻辑:
* - 在监控中心页面时, 每次收到新推送都同步更新 lastSeen (看到=已读)
* - 离开监控中心后, 新推送才计入未读
* - 刷新页面从 localStorage 恢复 lastSeen, 未读 = 期间新增
*/
const STORAGE_KEY = 'monitor_last_seen_total'
let currentTotal = 0
let lastSeenTotal = readSeen()
let onMonitorPage = false // 当前是否在监控中心页面
let pendingSeen = false // Monitor mount 请求 markSeen, 等 currentTotal 就绪
const listeners = new Set<() => void>()
function readSeen(): number {
try {
const v = localStorage.getItem(STORAGE_KEY)
return v ? parseInt(v, 10) || 0 : -1 // -1 = 未初始化
} catch {
return -1
}
}
function writeSeen(v: number) {
try { localStorage.setItem(STORAGE_KEY, String(v)) } catch { /* ignore */ }
}
function syncSeen() {
if (lastSeenTotal !== currentTotal) {
lastSeenTotal = currentTotal
writeSeen(currentTotal)
}
}
function emit() {
listeners.forEach(fn => fn())
}
function subscribe(fn: () => void) {
listeners.add(fn)
return () => { listeners.delete(fn) }
}
function getSnapshot() {
return Math.max(0, currentTotal - lastSeenTotal)
}
/** 轮询更新最新总数 (Layout 层调用)。 */
export function setCurrentTotal(total: number): void {
// total=0 视为未初始化, 不更新 (避免渲染期 data=undefined 传 0 重置 lastSeen)
if (total <= 0) return
// 首次初始化: 把已读基线设为当前总数
if (lastSeenTotal < 0) {
lastSeenTotal = total
writeSeen(total)
}
// 总数减少 (清空) → 同步重置
if (total < lastSeenTotal) {
lastSeenTotal = total
writeSeen(total)
}
const changed = total !== currentTotal
currentTotal = total
// 消费 pending markSeen
if (pendingSeen) {
pendingSeen = false
syncSeen()
}
// ★ 在监控中心页面期间: 收到新推送立即同步 (看到=已读, 不计入未读)
else if (onMonitorPage && changed) {
syncSeen()
}
emit()
}
/** 进入监控页时调用。 */
export function markSeen(): void {
onMonitorPage = true
if (currentTotal > 0) {
syncSeen()
emit()
} else {
pendingSeen = true
}
}
/** 离开监控页时调用 (停止同步, 之后新增才计入未读)。 */
export function leaveMonitorPage(): void {
onMonitorPage = false
pendingSeen = false
// 不在此处 syncSeen — lastSeen 保持页面期间最后一次同步的值即可
// (避免 currentTotal 此刻还没刷新到最新, 写入偏小的值)
}
/** 记录被清空时调用。 */
export function resetBadge(): void {
currentTotal = 0
lastSeenTotal = 0
pendingSeen = false
writeSeen(0)
emit()
}
/** 读取当前未读数。 */
export function useUnreadAlerts(): number {
return useSyncExternalStore(subscribe, getSnapshot, () => 0)
}
+154
View File
@@ -0,0 +1,154 @@
/**
* 通知声效 — 用 Web Audio API 合成, 无需音频文件。
*
* 声效列表 (纯代码合成, 不同频率/波形/节奏):
* - ding: 清脆"叮" (默认, 适合一般提醒)
* - chime: 两音阶风铃 (适合策略信号)
* - alert: 急促警报 (适合重要/异动)
* - soft: 柔和低音 (适合价格提醒)
* - none: 无声
*/
let _audioCtx: AudioContext | null = null
function getCtx(): AudioContext | null {
try {
if (!_audioCtx) {
_audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
}
if (_audioCtx.state === 'suspended') _audioCtx.resume()
return _audioCtx
} catch {
return null
}
}
/** 播放单个音符 */
function playTone(ctx: AudioContext, freq: number, start: number, duration: number, type: OscillatorType = 'sine', gain: number = 0.15) {
const osc = ctx.createOscillator()
const g = ctx.createGain()
osc.type = type
osc.frequency.value = freq
osc.connect(g)
g.connect(ctx.destination)
const t = ctx.currentTime + start
g.gain.setValueAtTime(0, t)
g.gain.linearRampToValueAtTime(gain, t + 0.01)
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
osc.start(t)
osc.stop(t + duration + 0.05)
}
const SOUND_PRESETS: Record<string, (ctx: AudioContext) => void> = {
// 清脆"叮" — 一个正弦波短音
ding: (ctx) => {
playTone(ctx, 880, 0, 0.3, 'sine', 0.2)
},
// 两音阶风铃 — 高低两个音
chime: (ctx) => {
playTone(ctx, 660, 0, 0.25, 'sine', 0.15)
playTone(ctx, 990, 0.12, 0.35, 'sine', 0.15)
},
// 急促警报 — 三个快速方波
alert: (ctx) => {
playTone(ctx, 800, 0, 0.1, 'square', 0.12)
playTone(ctx, 800, 0.15, 0.1, 'square', 0.12)
playTone(ctx, 1000, 0.3, 0.15, 'square', 0.12)
},
// 柔和低音 — 低频三角波
soft: (ctx) => {
playTone(ctx, 440, 0, 0.5, 'triangle', 0.15)
playTone(ctx, 330, 0.2, 0.5, 'triangle', 0.12)
},
// 上升音阶 — C-E-G-C 递进 (积极感)
rise: (ctx) => {
playTone(ctx, 523, 0, 0.12, 'sine', 0.18) // C5
playTone(ctx, 659, 0.1, 0.12, 'sine', 0.18) // E5
playTone(ctx, 784, 0.2, 0.12, 'sine', 0.18) // G5
playTone(ctx, 1047, 0.3, 0.3, 'sine', 0.2) // C6
},
// 下降音阶 — C-A-F-D (消极/警示感)
fall: (ctx) => {
playTone(ctx, 523, 0, 0.15, 'sine', 0.18) // C5
playTone(ctx, 440, 0.12, 0.15, 'sine', 0.18) // A4
playTone(ctx, 349, 0.24, 0.15, 'sine', 0.18) // F4
playTone(ctx, 294, 0.36, 0.3, 'sine', 0.18) // D4
},
// 电子提示音 — 锯齿波短促
electronic: (ctx) => {
playTone(ctx, 1200, 0, 0.08, 'sawtooth', 0.1)
playTone(ctx, 1600, 0.06, 0.08, 'sawtooth', 0.1)
playTone(ctx, 1200, 0.12, 0.15, 'sawtooth', 0.1)
},
// 水滴 — 极高频短音, 清脆
drop: (ctx) => {
playTone(ctx, 1800, 0, 0.06, 'sine', 0.15)
playTone(ctx, 2400, 0.04, 0.1, 'sine', 0.12)
},
// 钟声 — 低频持续共鸣
bell: (ctx) => {
playTone(ctx, 523, 0, 0.8, 'sine', 0.15)
playTone(ctx, 784, 0.02, 0.8, 'sine', 0.1) // 泛音
playTone(ctx, 1047, 0.04, 0.6, 'sine', 0.06) // 高泛音
},
// 乒乓 — 两个交替音
pingpong: (ctx) => {
playTone(ctx, 1000, 0, 0.08, 'sine', 0.15)
playTone(ctx, 700, 0.1, 0.08, 'sine', 0.15)
playTone(ctx, 1000, 0.2, 0.08, 'sine', 0.15)
playTone(ctx, 700, 0.3, 0.15, 'sine', 0.15)
},
// 魔法 — 快速上升扫频感
magic: (ctx) => {
playTone(ctx, 400, 0, 0.05, 'sine', 0.12)
playTone(ctx, 600, 0.04, 0.05, 'sine', 0.12)
playTone(ctx, 800, 0.08, 0.05, 'sine', 0.12)
playTone(ctx, 1100, 0.12, 0.05, 'sine', 0.12)
playTone(ctx, 1400, 0.16, 0.2, 'sine', 0.15)
},
}
/** 播放通知声效 (从 localStorage 读配置) */
export function playNotificationSound() {
try {
const enabled = localStorage.getItem('alert_sound_enabled')
if (enabled === '0') return // 关闭声效
const sound = localStorage.getItem('alert_sound') || 'ding'
if (sound === 'none') return
const ctx = getCtx()
if (!ctx) return
const preset = SOUND_PRESETS[sound]
if (preset) preset(ctx)
} catch {
// 音频不可用时静默
}
}
/** 声效选项 (供设置页下拉) */
export const SOUND_OPTIONS = [
{ key: 'ding', label: '清脆叮' },
{ key: 'chime', label: '风铃' },
{ key: 'rise', label: '上升音阶' },
{ key: 'fall', label: '下降音阶' },
{ key: 'alert', label: '急促警报' },
{ key: 'electronic', label: '电子音' },
{ key: 'drop', label: '水滴' },
{ key: 'bell', label: '钟声' },
{ key: 'pingpong', label: '乒乓' },
{ key: 'magic', label: '魔法' },
{ key: 'soft', label: '柔和' },
{ key: 'none', label: '无声' },
]
/** 预览声效 (设置页点"试听"用) */
export function previewSound(sound: string) {
try {
const ctx = getCtx()
if (!ctx) return
const preset = SOUND_PRESETS[sound]
if (preset) preset(ctx)
} catch { /* ignore */ }
}
+5
View File
@@ -64,6 +64,11 @@ export const QK = {
// Custom Signals
customSignals: ['custom-signals'] as const,
customSignalsOptions: ['custom-signals-options'] as const,
// Monitor (监控规则 + 触发记录)
monitorRules: ['monitor-rules'] as const,
monitorRuleOptions: ['monitor-rule-options'] as const,
alerts: (source?: string) => ['alerts', source ?? ''] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
+24 -4
View File
@@ -22,17 +22,37 @@ export const SIGNAL_LABELS: Record<string, string> = {
signal_limit_up: '涨停',
signal_limit_down: '跌停',
signal_limit_down_recovery: '跌停翘板',
signal_broken_board_recovery: '断板反包',
signal_broken_limit_up: '炸板',
}
/** 内置信号 ID 列表 */
export const SIGNAL_OPTIONS = Object.keys(SIGNAL_LABELS)
/** 常用技术指标/字段 → 中文 (阈值条件展示用, 与后端 ENRICHED_COLUMNS 对齐) */
const FIELD_LABELS: Record<string, string> = {
close: '收盘价', open: '开盘价', high: '最高价', low: '最低价',
change_pct: '涨跌幅', change_amount: '涨跌额', amplitude: '振幅',
turnover_rate: '换手率', volume: '成交量', amount: '成交额',
ma5: 'MA5', ma10: 'MA10', ma20: 'MA20', ma30: 'MA30', ma60: 'MA60',
ema5: 'EMA5', ema10: 'EMA10', ema20: 'EMA20',
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
boll_upper: '布林上轨', boll_lower: '布林下轨',
kdj_k: 'KDJ-K', kdj_d: 'KDJ-D', kdj_j: 'KDJ-J',
rsi_6: 'RSI6', rsi_14: 'RSI14', rsi_24: 'RSI24',
vol_ratio_5d: '5日量比', vol_ratio_20d: '20日量比',
vol_ma5: '5日均量', vol_ma10: '10日均量',
high_60d: '60日最高', low_60d: '60日最低',
momentum_5d: '5日动量', momentum_20d: '20日动量', momentum_60d: '60日动量',
atr_14: 'ATR14', annual_vol_20d: '20日年化波动',
consecutive_limit_ups: '连板数', consecutive_limit_downs: '跌停连板',
}
/**
* 信号 ID → 中文显示名。
* 内置信号查 SIGNAL_LABELS; csg_ 前缀查传入的自定义信号名称映射, 找不到则原样返回。
* 信号/字段 ID → 中文显示名。
* 内置信号查 SIGNAL_LABELS; csg_ 前缀查传入的自定义信号名称映射;
* 技术指标查 FIELD_LABELS; 都找不到则原样返回。
*/
export function cnSignal(name: string, customNames?: Record<string, string>): string {
if (customNames && name in customNames) return customNames[name]
return SIGNAL_LABELS[name] ?? name
return SIGNAL_LABELS[name] ?? FIELD_LABELS[name] ?? name
}
+19 -16
View File
@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { SSE_INVALIDATE_PREFIXES } from './queryKeys'
import { getQueryConfig } from './useQueryConfig'
import { toast } from '@/components/Toast'
import { pushAlertToast } from '@/components/AlertToast'
import type { StrategyAlertEvent } from './api'
/**
@@ -34,28 +35,25 @@ export function useQuoteStream(
toast(a.message, 'success')
}
if (onAlert && strategyAlerts.length > 0) {
// 监控告警: 用专用 AlertToast (最多显示 2 条, 自动去重)
if (strategyAlerts.length > 0) {
// 有 onAlert 回调时走回调, 否则弹 AlertToast
if (onAlert) {
onAlert(strategyAlerts)
} else if (strategyAlerts.length > 0) {
// 默认: 弹 toast
for (const a of strategyAlerts.slice(0, 3)) {
const label = a.name ? `${a.symbol} ${a.name}` : a.symbol
toast(`[${a.strategy_id}] ${label}${a.message}`, 'success')
}
if (strategyAlerts.length > 3) {
toast(`...以及另外 ${strategyAlerts.length - 3} 条告警`, 'success')
// 同时弹专用通知 (不管有没有 onAlert)
for (const a of strategyAlerts.slice(0, 2)) {
pushAlertToast(a as any)
}
}
}, [onAlert])
const enabledRef = useRef(enabled)
enabledRef.current = enabled
useEffect(() => {
if (!enabled) {
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
return
}
// SSE 始终连接 — 监控告警不依赖实时行情开关
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
const connect = () => {
const es = new EventSource('/api/intraday/stream')
@@ -64,6 +62,8 @@ export function useQuoteStream(
// sse-starlette ping 心跳走 SSE comment,不会到达这里
es.addEventListener('quotes_updated', () => {
// 实时行情未开启时不处理行情刷新
if (!enabledRef.current) return
// 根据用户配置过滤 invalidation
const pages = pagesRef.current
if (pages) {
@@ -96,6 +96,9 @@ export function useQuoteStream(
const alerts: StrategyAlertEvent[] = data.alerts || []
if (alerts.length > 0) {
handleAlerts(alerts)
// 实时刷新触发记录列表 + 监控中心徽标
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
}
} catch {
// 忽略解析错误
@@ -119,5 +122,5 @@ export function useQuoteStream(
esRef.current = null
}
}
}, [enabled, qc, handleAlerts])
}, [qc, handleAlerts])
}
+89 -16
View File
@@ -1,13 +1,15 @@
import { useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Flame, Gauge, LineChart, Loader2, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket } from '@/lib/api'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum } from '@/lib/format'
import { fmtBigNum, fmtPct } from '@/lib/format'
import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { cn } from '@/lib/cn'
function n(v: number | null | undefined) {
return typeof v === 'number' && Number.isFinite(v) ? v : null
@@ -72,6 +74,81 @@ function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; titl
)
}
// 看板监控中心小组件 — 显示前 10 条触发记录 + 更多按钮
const _SOURCE_BADGE: Record<string, string> = {
strategy: 'bg-amber-400/10 text-amber-400',
signal: 'bg-accent/10 text-accent',
price: 'bg-emerald-400/10 text-emerald-400',
market: 'bg-purple-500/10 text-purple-400',
}
const _SOURCE_LABEL: Record<string, string> = {
strategy: '策略', signal: '信号', price: '价格', market: '异动',
}
const _SEVERITY_BAR: Record<string, string> = {
info: 'bg-accent/40', warn: 'bg-warning', critical: 'bg-danger',
}
function MonitorWidget() {
const alerts = useQuery({
queryKey: ['alerts', ''],
queryFn: () => api.alertsList({ days: 7, limit: 10 }),
refetchInterval: 10000,
refetchIntervalInBackground: true,
})
const events: AlertEvent[] = alerts.data?.alerts ?? []
if (events.length === 0) {
return (
<div className="mt-1 py-6 text-center text-[11px] text-muted"></div>
)
}
return (
<div className="mt-1 space-y-1.5">
{events.map((ev, i) => {
const sev = _SEVERITY_BAR[ev.severity ?? 'info'] ?? _SEVERITY_BAR.info
const pct = ev.change_pct ?? 0
return (
<motion.div
key={`${ev.ts}-${i}`}
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
className="relative overflow-hidden rounded-md border border-border/40 bg-surface/60 pl-2.5 pr-2 py-1.5 hover:border-border hover:bg-surface transition-colors"
>
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 */}
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] font-medium text-foreground/80 shrink-0">{ev.symbol?.replace(/\.(SH|SZ|BJ)$/, '')}</span>
{ev.name && <span className="text-[10px] text-secondary truncate flex-1">{ev.name}</span>}
{ev.price != null && (
<span className="text-[10px] font-mono text-foreground/60 shrink-0">{fmtPrice(ev.price)}</span>
)}
{ev.change_pct != null && (
<span className={cn('text-[10px] font-mono font-medium shrink-0 w-12 text-right', pct >= 0 ? 'text-danger' : 'text-bear')}>
{pct >= 0 ? '+' : ''}{fmtPct(pct)}
</span>
)}
</div>
{/* 第二行: 分类标签 + 触发消息 + 时间 */}
<div className="mt-0.5 flex items-center gap-1.5">
<span className={cn('shrink-0 rounded px-1 py-px text-[8px] font-medium', _SOURCE_BADGE[ev.source] ?? 'bg-elevated text-muted')}>
{_SOURCE_LABEL[ev.source] ?? ev.source}
</span>
{ev.message && (
<span className="text-[9px] text-muted truncate flex-1">{ev.message}</span>
)}
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
</motion.div>
)
})}
</div>
)
}
function KpiCell({ label, value, sub, tone = 'neutral' }: { label: ReactNode; value: ReactNode; sub?: string; tone?: 'bull' | 'bear' | 'accent' | 'neutral' }) {
const isPlain = typeof value === 'string' || typeof value === 'number'
const color = tone === 'bull' ? 'text-bull' : tone === 'bear' ? 'text-bear' : tone === 'accent' ? 'text-accent' : 'text-foreground'
@@ -499,21 +576,17 @@ export function Dashboard() {
<LadderMini limit={data.limit} />
</section>
<section className="rounded-card border border-border bg-surface/80 p-3">
<SectionTitle icon={BellRing} title="监控通知" hint="实时信号" />
<Link
to="/monitor"
className="mt-1 flex flex-col items-center justify-center gap-2 py-6 text-center rounded-btn hover:bg-elevated/60 transition-colors group"
>
<span className="relative">
<span className="absolute inset-0 blur-xl bg-accent/20 rounded-full" />
<BellRing className="relative h-7 w-7 text-accent group-hover:scale-110 transition-transform" />
</span>
<span className="text-xs text-muted leading-relaxed">
<br />
<span className="text-[11px] text-accent/80"> · </span>
</span>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<BellRing className="h-3.5 w-3.5 text-accent" />
<h2 className="text-xs font-semibold text-foreground"></h2>
<span className="font-mono text-[10px] text-muted"></span>
</div>
<Link to="/monitor" className="inline-flex items-center justify-center h-5 w-5 rounded text-muted hover:text-accent hover:bg-accent/10 transition-colors" title="进入监控中心">
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</div>
<MonitorWidget />
</section>
</aside>
</div>
+357
View File
@@ -0,0 +1,357 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, Search, AlertTriangle, CheckCircle2, XCircle, FlaskConical, Activity } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { resetBadge } from '@/lib/monitorBadge'
// ── 分钟K探测 (迁移自 MinuteDataProbe) ─────────────────
interface ProbeResult {
date: string
rows: number
source: string
ok: boolean
}
function MinuteProbePanel() {
const [symbol, setSymbol] = useState('603261.SH')
const [days, setDays] = useState(10)
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<ProbeResult[]>([])
const [error, setError] = useState<string | null>(null)
const runProbe = async () => {
const sym = symbol.trim().toUpperCase()
if (!sym) return
setLoading(true)
setError(null)
setResults([])
const dates: string[] = []
const today = new Date()
for (let i = 0; i < days; i++) {
const d = new Date(today)
d.setDate(d.getDate() - i)
dates.push(d.toISOString().slice(0, 10))
}
const out: ProbeResult[] = []
try {
for (const date of dates) {
const r = await api.klineMinute(sym, date)
const rows = r.rows?.length ?? 0
out.push({
date,
rows,
source: r.source ?? (rows > 0 ? 'local' : 'none'),
ok: rows > 0,
})
setResults([...out])
}
} catch (e: any) {
setError(e?.message ?? String(e))
} finally {
setLoading(false)
}
}
const total = results.length
const hasData = results.filter((r) => r.ok).length
const missing = results.filter((r) => !r.ok)
return (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold text-foreground">K数据探测</h2>
<p className="mt-1 text-xs text-muted">
<code className="px-1 rounded bg-elevated text-secondary">/api/kline/minute</code>
K数据是否齐全 TickFlow
</p>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-btn bg-elevated p-4">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted"></label>
<input
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
placeholder="603261.SH"
className="w-44 rounded-btn border border-border bg-base px-3 py-1.5 text-sm text-foreground outline-none focus:border-accent"
onKeyDown={(e) => e.key === 'Enter' && !loading && runProbe()}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted"></label>
<input
type="number"
min={1}
max={30}
value={days}
onChange={(e) => setDays(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
className="w-24 rounded-btn border border-border bg-base px-3 py-1.5 text-sm text-foreground outline-none focus:border-accent"
/>
</div>
<button
onClick={runProbe}
disabled={loading || !symbol.trim()}
className="flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-sm font-medium text-base hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{loading ? '探测中…' : '开始探测'}
</button>
</div>
{error && (
<div className="flex items-center gap-2 rounded-btn border border-danger/40 bg-danger/10 p-3 text-sm text-danger">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{total > 0 && (
<div className="grid grid-cols-3 gap-3">
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-foreground">{total}</div>
</div>
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-emerald-400">{hasData}</div>
</div>
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-danger">{missing.length}</div>
</div>
</div>
)}
{results.length > 0 && (
<div className="overflow-hidden rounded-btn border border-border">
<table className="w-full text-sm">
<thead className="bg-elevated text-xs text-muted">
<tr>
<th className="px-4 py-2 text-left font-medium"></th>
<th className="px-4 py-2 text-right font-medium">K条数</th>
<th className="px-4 py-2 text-left font-medium"></th>
<th className="px-4 py-2 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{results.map((r) => (
<tr key={r.date} className="border-t border-border/60">
<td className="px-4 py-2 text-foreground">{r.date}</td>
<td className="px-4 py-2 text-right tabular-nums text-foreground">{r.rows}</td>
<td className="px-4 py-2 text-secondary">
<span className="rounded bg-elevated px-1.5 py-0.5 text-xs">{r.source}</span>
</td>
<td className="px-4 py-2 text-center">
{r.ok ? (
<span className="inline-flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-danger">
<XCircle className="h-4 w-4" />
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{missing.length > 0 && (
<div className="rounded-btn border border-warning/40 bg-warning/10 p-3 text-xs text-foreground">
<div className="mb-1 flex items-center gap-1.5 font-medium text-warning">
<AlertTriangle className="h-4 w-4" />
</div>
<p className="leading-relaxed text-secondary">
<span className="text-foreground">/</span>
<span className="text-foreground"></span> 0
<span className="text-foreground"></span>K有成交量K
TickFlow
</p>
</div>
)}
</div>
)
}
// ── 演示数据生成 ──────────────────────────────────────
function SeedPanel() {
const qc = useQueryClient()
const [count, setCount] = useState(12)
const [recent, setRecent] = useState(true)
const [msg, setMsg] = useState('')
const seedMut = useMutation({
mutationFn: () => api.alertSeed(count, recent),
onSuccess: (data) => {
setMsg(`已生成 ${data.generated} 条触发记录`)
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
setTimeout(() => setMsg(''), 4000)
},
onError: () => {
setMsg('生成失败')
setTimeout(() => setMsg(''), 4000)
},
})
const clearMut = useMutation({
mutationFn: () => api.alertsClear(),
onSuccess: (data) => {
setMsg(`已清空 ${data.cleared} 条触发记录`)
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
resetBadge()
setTimeout(() => setMsg(''), 4000)
},
})
const ruleSeedMut = useMutation({
mutationFn: () => api.monitorRuleSeed(),
onSuccess: (data) => {
setMsg(`已生成 ${data.generated} 条监控规则`)
qc.invalidateQueries({ queryKey: ['monitor-rules'] })
setTimeout(() => setMsg(''), 4000)
},
onError: () => {
setMsg('规则生成失败')
setTimeout(() => setMsg(''), 4000)
},
})
return (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold text-foreground"></h2>
<p className="mt-1 text-xs text-muted">
,
</p>
</div>
<div className="space-y-3 rounded-btn bg-elevated p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted"></label>
<input
type="number"
min={1}
max={50}
value={count}
onChange={(e) => setCount(Math.max(1, Math.min(50, Number(e.target.value) || 1)))}
className="w-24 rounded-btn border border-border bg-base px-3 py-1.5 text-sm text-foreground outline-none focus:border-accent"
/>
</div>
<label className="flex items-center gap-1.5 pb-1.5">
<input
type="checkbox"
checked={recent}
onChange={(e) => setRecent(e.target.checked)}
className="h-3.5 w-3.5 accent-accent"
/>
<span className="text-xs text-secondary">"刚刚"()</span>
</label>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => seedMut.mutate()}
disabled={seedMut.isPending}
className="flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-sm font-medium text-base hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
>
{seedMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <FlaskConical className="h-4 w-4" />}
</button>
<button
onClick={() => clearMut.mutate()}
disabled={clearMut.isPending}
className="flex items-center gap-1.5 rounded-btn border border-danger/40 bg-danger/10 px-4 py-1.5 text-sm font-medium text-danger hover:bg-danger/20 disabled:opacity-50 cursor-pointer"
>
{clearMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <XCircle className="h-4 w-4" />}
</button>
</div>
</div>
{msg && (
<div className="rounded-btn border border-accent/40 bg-accent/10 p-3 text-sm text-accent">{msg}</div>
)}
{/* 监控规则生成 */}
<div className="space-y-3 rounded-btn bg-elevated p-4">
<div>
<h3 className="text-sm font-medium text-foreground"></h3>
<p className="mt-0.5 text-xs text-muted">
(//),
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => ruleSeedMut.mutate()}
disabled={ruleSeedMut.isPending}
className="flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-sm font-medium text-base hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
>
{ruleSeedMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <FlaskConical className="h-4 w-4" />}
</button>
</div>
</div>
<div className="rounded-btn border border-border/40 bg-surface/40 p-4 text-xs leading-relaxed text-muted">
<div className="mb-1 font-medium text-secondary">使</div>
<ul className="list-disc space-y-0.5 pl-4">
<li>,,</li>
<li></li>
<li>///</li>
<li></li>
</ul>
</div>
</div>
)
}
// ── Dev 主页面 ────────────────────────────────────────
export function Dev() {
const [tab, setTab] = useState<'minute' | 'seed'>('seed')
return (
<div className="flex flex-col h-full">
<PageHeader
title="开发者工具"
subtitle="调试与测试 · 不暴露在菜单"
right={
<div className="flex items-center gap-1 rounded-btn bg-elevated p-0.5">
<button
onClick={() => setTab('seed')}
className={cn(
'inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer',
tab === 'seed' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary',
)}
>
<FlaskConical className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setTab('minute')}
className={cn(
'inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer',
tab === 'minute' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary',
)}
>
<Activity className="h-3.5 w-3.5" />K探测
</button>
</div>
}
/>
<div className="flex-1 overflow-auto px-5 py-4">
<div className="mx-auto max-w-3xl space-y-4">
{tab === 'minute' ? <MinuteProbePanel /> : <SeedPanel />}
</div>
</div>
</div>
)
}
-189
View File
@@ -1,189 +0,0 @@
import { useState } from 'react'
import { Loader2, Search, AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'
import { api } from '@/lib/api'
interface ProbeResult {
date: string
rows: number
source: string
ok: boolean
}
/**
* 分钟K数据探测页(隐藏路由,不暴露在菜单)。
* 用于排查"点击日K蜡烛加载分钟K为 0 条"类问题。
* 直接访问: /minute-probe
*/
export function MinuteDataProbe() {
const [symbol, setSymbol] = useState('603261.SH')
const [days, setDays] = useState(10)
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<ProbeResult[]>([])
const [error, setError] = useState<string | null>(null)
const runProbe = async () => {
const sym = symbol.trim().toUpperCase()
if (!sym) return
setLoading(true)
setError(null)
setResults([])
// 生成最近 N 天的日期(含非交易日,由后端返回行数判定)
const dates: string[] = []
const today = new Date()
for (let i = 0; i < days; i++) {
const d = new Date(today)
d.setDate(d.getDate() - i)
dates.push(d.toISOString().slice(0, 10))
}
const out: ProbeResult[] = []
try {
for (const date of dates) {
const r = await api.klineMinute(sym, date)
const rows = r.rows?.length ?? 0
out.push({
date,
rows,
source: r.source ?? (rows > 0 ? 'local' : 'none'),
ok: rows > 0,
})
setResults([...out])
}
} catch (e: any) {
setError(e?.message ?? String(e))
} finally {
setLoading(false)
}
}
const total = results.length
const hasData = results.filter((r) => r.ok).length
const missing = results.filter((r) => !r.ok)
return (
<div className="min-h-screen bg-base p-6">
<div className="mx-auto max-w-3xl space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">K数据探测</h1>
<p className="mt-1 text-xs text-muted">
<code className="px-1 rounded bg-elevated text-secondary">/api/kline/minute</code>
K数据是否齐全 TickFlow
</p>
</div>
{/* 输入区 */}
<div className="flex flex-wrap items-end gap-3 rounded-btn bg-elevated p-4">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted"></label>
<input
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
placeholder="603261.SH"
className="w-44 rounded-btn border border-line bg-base px-3 py-1.5 text-sm text-foreground outline-none focus:border-accent"
onKeyDown={(e) => e.key === 'Enter' && !loading && runProbe()}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted"></label>
<input
type="number"
min={1}
max={30}
value={days}
onChange={(e) => setDays(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
className="w-24 rounded-btn border border-line bg-base px-3 py-1.5 text-sm text-foreground outline-none focus:border-accent"
/>
</div>
<button
onClick={runProbe}
disabled={loading || !symbol.trim()}
className="flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-sm font-medium text-base hover:bg-accent/90 disabled:opacity-50"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{loading ? '探测中…' : '开始探测'}
</button>
</div>
{error && (
<div className="flex items-center gap-2 rounded-btn border border-danger/40 bg-danger/10 p-3 text-sm text-danger">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* 汇总 */}
{total > 0 && (
<div className="grid grid-cols-3 gap-3">
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-foreground">{total}</div>
</div>
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-success">{hasData}</div>
</div>
<div className="rounded-btn bg-elevated p-3">
<div className="text-xs text-muted"></div>
<div className="mt-1 text-lg font-semibold text-danger">{missing.length}</div>
</div>
</div>
)}
{/* 明细表 */}
{results.length > 0 && (
<div className="overflow-hidden rounded-btn border border-line">
<table className="w-full text-sm">
<thead className="bg-elevated text-xs text-muted">
<tr>
<th className="px-4 py-2 text-left font-medium"></th>
<th className="px-4 py-2 text-right font-medium">K条数</th>
<th className="px-4 py-2 text-left font-medium"></th>
<th className="px-4 py-2 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{results.map((r) => (
<tr key={r.date} className="border-t border-line/60">
<td className="px-4 py-2 text-foreground">{r.date}</td>
<td className="px-4 py-2 text-right tabular-nums text-foreground">{r.rows}</td>
<td className="px-4 py-2 text-secondary">
<span className="rounded bg-elevated px-1.5 py-0.5 text-xs">
{r.source}
</span>
</td>
<td className="px-4 py-2 text-center">
{r.ok ? (
<span className="inline-flex items-center gap-1 text-success">
<CheckCircle2 className="h-4 w-4" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-danger">
<XCircle className="h-4 w-4" />
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{missing.length > 0 && (
<div className="rounded-btn border border-warning/40 bg-warning/10 p-3 text-xs text-foreground">
<div className="mb-1 flex items-center gap-1.5 font-medium text-warning">
<AlertTriangle className="h-4 w-4" />
</div>
<p className="leading-relaxed text-secondary">
<span className="text-foreground">/</span>
<span className="text-foreground"></span> 0
<span className="text-foreground"></span>K有成交量K
TickFlow
</p>
</div>
)}
</div>
</div>
)
}
+656 -63
View File
@@ -1,77 +1,670 @@
import { RadioTower } from 'lucide-react'
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 } 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'
// 后续实现计划(本轮为占位):
//
// 一、信号来源
// 1. 策略告警 —— 链路已存在(StrategyMonitorService → quote_service → SSE),
// 后续接入持久化即可直接进入本列表。
// 2. 全市场涨跌停异动 —— 涨停/跌停/炸板(signal_broken_limit_up)/翘板
// (signal_limit_down_recovery)指标已在 enriched 全量计算,补一条
// 全市场扫描链路即可产出告警。
// 3. 板块异动 —— 当前后端无独立模块,需从零开发板块聚合异动检测。
//
// 二、历史持久化
// 告警落盘到 data/user_data/alerts.jsonl(追加写,保留近 7 天 / 上限约
// 5000 条),支持按来源/类型过滤、一键清空。当前告警为 fire-and-forget,
// 刷新即丢失,持久化后将形成真正的「监控列表」。
//
// 三、通知通道扩展(预留)
// StrategyMonitorService 已预留 alert_handler 扩展点,后续可接入飞书
// webhook、邮件、短信等外部通知通道。
const PLAN: { title: string; desc: string }[] = [
{
title: '策略告警',
desc: '链路已存在(StrategyMonitorService → quote_service → SSE),后续接入持久化即可直接进入本列表。',
},
{
title: '涨跌停异动',
desc: '涨停 / 跌停 / 炸板 / 翘板指标已在 enriched 全量计算,补一条全市场扫描链路即可产出告警。',
},
{
title: '板块异动',
desc: '当前后端无独立模块,需从零开发板块聚合异动检测逻辑。',
},
{
title: '历史持久化',
desc: '告警落盘 data/user_data/alerts.jsonl(追加写,保留近 7 天 / 上限约 5000 条),支持按来源/类型过滤、清空。',
},
{
title: '通知通道',
desc: 'StrategyMonitorService 已预留 alert_handler 扩展点,后续可接入飞书 webhook、邮件、短信。',
},
]
const TYPE_LABEL: Record<string, string> = {
signal: '个股信号', price: '价格/涨跌', market: '市场异动', strategy: '策略监控',
}
/** 严重级别 → 左侧色条 + 图标 */
const SEVERITY_CONFIG: Record<string, { bar: string; icon: any; iconCls: string }> = {
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<string, string> = {
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 <span className="text-secondary">{message}</span>
}
const m = message.match(/^(.*?「)([^」]+)(」)(买入|卖出)(信号.*)$/)
if (!m) return <span className="text-foreground">{message}</span>
const [, pre, strategyName, mid, direction, post] = m
return (
<>
<span className="text-foreground/80">{pre}</span>
<span className="text-amber-400 font-medium">{strategyName}</span>
<span className="text-foreground/80">{mid}</span>
<span className={direction === '买入' ? 'text-danger font-medium' : 'text-bear font-medium'}>{direction}</span>
<span className="text-foreground/80">{post}</span>
</>
)
}
export function Monitor() {
const qc = useQueryClient()
const [editorOpen, setEditorOpen] = useState(false)
const [editingRule, setEditingRule] = useState<MonitorRule | null>(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<number>(Date.now())
useEffect(() => {
enterTsRef.current = Date.now()
markSeen()
return () => leaveMonitorPage()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="flex flex-col h-full">
<PageHeader title="监控通知" subtitle="实时信号中心 · 开发中" />
<div className="flex-1 overflow-auto px-5 py-6">
<div className="max-w-3xl mx-auto">
<EmptyState
icon={RadioTower}
title="监控通知开发中"
hint="本页面将汇聚实时监控产生的全部信号:策略触发的买卖提醒、全市场涨跌停异动、板块异动等。当前为占位页面,下方为后续实现规划。"
/>
<section className="mt-6 rounded-card border border-border bg-surface p-5">
<h3 className="text-sm font-semibold text-foreground"></h3>
<ul className="mt-3 space-y-3">
{PLAN.map((item) => (
<li key={item.title} className="flex gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
<div>
<p className="text-sm font-medium text-foreground">{item.title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-secondary">{item.desc}</p>
</div>
</li>
<PageHeader title="监控中心" subtitle="实时信号与规则管理" />
<div className="flex-1 min-h-0 px-5 py-4">
<div className="mx-auto flex h-full max-w-7xl flex-col gap-4 lg:flex-row">
{/* 左栏: 触发记录 */}
<section className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-surface/40 shadow-lg shadow-black/5">
<div className="flex items-center gap-3 border-b border-border/60 bg-surface/60 px-4 py-2.5">
<SectionHeader icon={BellRing} title="触发记录" />
{/* 过滤标签 */}
<div className="flex flex-wrap items-center gap-0.5">
{(['all', 'strategy', 'signal', 'price', 'market'] as const).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'rounded-md px-1.5 py-0.5 text-[10px] font-medium transition-all cursor-pointer',
filter === f ? 'bg-accent/15 text-accent' : 'text-muted hover:bg-elevated/60 hover:text-secondary',
)}
>
{f === 'all' ? '全部' : TYPE_LABEL[f]}
</button>
))}
</ul>
</div>
{/* 数量 + 清空 */}
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className="rounded-md bg-elevated/50 px-1.5 py-0.5 text-[10px] font-medium text-muted">{total}</span>
{total > 0 && (
<button
onClick={() => setConfirmClear(true)}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-muted transition-colors hover:bg-danger/10 hover:text-danger cursor-pointer"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3.5">
<AlertsList alertsQuery={alertsQuery} confirmClear={confirmClear} setConfirmClear={setConfirmClear} total={total} enterTs={enterTsRef.current} />
</div>
</section>
{/* 右栏: 监控规则 */}
<section className="flex min-h-0 w-full flex-col overflow-hidden rounded-xl border border-border bg-surface/40 shadow-lg shadow-black/5 lg:w-[400px] lg:shrink-0">
<div className="flex items-center gap-3 border-b border-border/60 bg-surface/60 px-4 py-2.5">
<SectionHeader icon={ListChecks} title="监控规则" />
<span className="rounded-md bg-elevated/50 px-1.5 py-0.5 text-[10px] font-medium text-muted">{rulesCount}</span>
<div className="ml-auto flex items-center gap-1">
<button
onClick={() => { setEditingRule(null); setEditorOpen(true) }}
title="新建规则"
className="inline-flex h-6 w-6 items-center justify-center rounded-lg border border-border/60 bg-surface text-muted transition-all hover:border-accent/40 hover:text-accent hover:shadow-sm cursor-pointer"
>
<Plus className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setConfirmClearRules(true)}
disabled={rulesCount === 0}
title="清除全部规则"
className="inline-flex h-6 w-6 items-center justify-center rounded-lg border border-border/60 bg-surface text-muted transition-all hover:border-danger/40 hover:text-danger disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3.5">
<RulesList
rulesQuery={rulesQuery}
onEdit={(r) => { setEditingRule(r); setEditorOpen(true) }}
/>
</div>
</section>
</div>
</div>
<RuleEditorDialog
open={editorOpen}
rule={editingRule}
onClose={() => { setEditorOpen(false); setEditingRule(null) }}
/>
<ConfirmDialog
open={confirmClearRules}
title="清除全部监控规则?"
message={`将删除全部 ${rulesCount} 条规则,此操作不可撤销。`}
confirmText="清除"
danger
onCancel={() => setConfirmClearRules(false)}
onConfirm={() => clearRulesMut.mutate()}
pending={clearRulesMut.isPending}
/>
</div>
)
}
function SectionHeader({ icon: Icon, title }: { icon: any; title: string }) {
return (
<div className="flex items-center gap-1.5 shrink-0">
<Icon className="h-4 w-4 text-accent" />
<h2 className="text-sm font-semibold text-foreground whitespace-nowrap">{title}</h2>
</div>
)
}
// ── 触发记录列表 ──────────────────────────────────────
function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs }: {
alertsQuery: ReturnType<typeof useQuery>
confirmClear: boolean
setConfirmClear: (v: boolean) => void
total: number
enterTs: number
}) {
const qc = useQueryClient()
const [confirmTs, setConfirmTs] = useState<number | null>(null)
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(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 (
<div className="space-y-3">
{events.length === 0 ? (
<EmptyState
icon={Bell}
title="暂无触发记录"
hint="监控规则命中后,触发记录会出现在这里。可在右侧配置规则,或在个股详情页加入监控。"
/>
) : (
<div className="space-y-2">
{events.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 (
<motion.div
key={`${ev.ts}-${i}`}
initial={isNew ? { opacity: 0, y: -8, scale: 0.98 } : { opacity: 0, y: 4 }}
animate={isNew ? {
opacity: [0, 1, 1, 0.85, 1],
scale: [0.98, 1, 1, 1.01, 1],
y: [-8, 0, 0, 0, 0],
} : { opacity: 1, y: 0 }}
transition={isNew ? { duration: 1.2, times: [0, 0.2, 0.5, 0.75, 1] } : { duration: 0.2, delay: Math.min(i * 0.02, 0.2) }}
className={cn(
'group relative flex items-start gap-3 overflow-hidden rounded-lg border bg-surface pl-3.5 pr-3 py-2.5 shadow-sm transition-all duration-200 hover:border-border hover:shadow-md hover:shadow-black/10 hover:-translate-y-px',
isNew ? 'border-accent/60 ring-1 ring-accent/30' : 'border-border/50',
)}
>
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev.bar)} />
<div className={cn('mt-px shrink-0', sev.iconCls)}>
<SevIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
{ev.symbol && (() => {
const board = boardTag(ev.symbol)
return (
<button
onClick={() => setPreviewEv(ev)}
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
title="点击查看日K"
>
<span className="font-mono text-xs font-medium text-foreground hover:text-accent">{ev.symbol}</span>
{board && (
<span className={`inline-flex items-center justify-center h-3.5 w-3.5 rounded text-[8px] font-bold leading-none border ${board.color}`}>
{board.label}
</span>
)}
{ev.name && <span className="text-xs text-secondary truncate max-w-[8rem] hover:text-foreground">{ev.name}</span>}
</button>
)
})()}
<span className={cn('rounded border px-1.5 py-0.5 text-[9px] font-medium', SOURCE_BADGE_STYLE[ev.source] ?? 'bg-elevated text-muted border-border')}>
{TYPE_LABEL[ev.source] ?? ev.source}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px]">{renderMessage(ev.source, ev.message)}</span>
</div>
<div className="mt-1.5 flex items-center gap-3">
{ev.price != null && (
<span className="text-[11px] font-mono text-foreground/60">{fmtPrice(ev.price)}</span>
)}
{ev.change_pct != null && (
<span className={cn('inline-flex items-center gap-0.5 text-[11px] font-mono font-medium',
ev.change_pct >= 0 ? 'text-danger' : 'text-bear')}>
{ev.change_pct >= 0 ? <TrendingUp className="h-2.5 w-2.5" /> : <TrendingDown className="h-2.5 w-2.5" />}
{fmtPct(ev.change_pct)}
</span>
)}
</div>
{ev.signals && ev.signals.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{ev.signals.map((s: string, j: number) => (
<span key={j} className="rounded bg-accent/8 px-1.5 py-0.5 text-[9px] text-accent/70">{cnSignal(s)}</span>
))}
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="text-[10px] text-muted/60 font-mono">
{new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
{confirmTs === ev.ts ? (
// 确认态: 红色实心按钮 (原删除图标位置), 再点确认删除
<button
onClick={() => handleClickDelete(ev.ts)}
title="再次点击确认删除"
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[10px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
) : (
<button
onClick={() => handleClickDelete(ev.ts)}
disabled={delMut.isPending}
title="删除"
className="rounded p-1 text-muted/0 transition-colors group-hover:text-muted/40 hover:!text-danger hover:bg-danger/10 cursor-pointer"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
</motion.div>
)
})}
</div>
)}
<ConfirmDialog
open={confirmClear}
title="清空全部触发记录?"
message={`将删除全部 ${total} 条记录,此操作不可撤销。`}
confirmText="清空"
danger
onCancel={() => setConfirmClear(false)}
onConfirm={() => clearMut.mutate()}
pending={clearMut.isPending}
/>
<StockPreviewDialog
symbol={previewEv?.symbol ?? null}
name={previewEv?.name ?? undefined}
triggerInfo={previewEv ? {
price: previewEv.price ?? null,
changePct: previewEv.change_pct ?? null,
ts: previewEv.ts,
signals: previewEv.signals,
message: previewEv.message,
} : null}
onClose={() => setPreviewEv(null)}
/>
</div>
)
}
// ── 监控规则列表 ──────────────────────────────────────
function RulesList({ rulesQuery, onEdit }: {
rulesQuery: ReturnType<typeof useQuery>
onEdit: (rule: MonitorRule) => void
}) {
const qc = useQueryClient()
const [confirmId, setConfirmId] = useState<string | null>(null)
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const rules: MonitorRule[] = (rulesQuery.data as any)?.rules ?? []
// 收集所有规则的股票代码, 批量查名称
const allSymbols = useMemo(() => {
const set = new Set<string>()
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 ?? {}
// 查策略详情 (建 strategy_id → {entry, exit} signals 映射)
const strategiesQuery = useQuery({
queryKey: QK.screenerStrategies,
queryFn: () => api.strategyList(),
staleTime: 300000,
})
const strategySignals = useMemo(() => {
const m: Record<string, { entry: string[]; exit: string[] }> = {}
for (const s of strategiesQuery.data?.strategies ?? []) {
m[s.id] = { entry: s.entry_signals ?? [], exit: s.exit_signals ?? [] }
}
return m
}, [strategiesQuery.data])
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 (
<div className="space-y-2.5">
{rules.length === 0 ? (
<EmptyState
icon={RadioTower}
title="暂无监控规则"
hint="点击标题栏「+」新建规则,或在个股详情页点「加监控」快速添加。"
/>
) : (
rules.map(r => {
// 名称截取: "策略监控 · MACD金叉" → "MACD金叉", "个股信号监控 · 300750.SZ" → "个股信号监控"
const dotIdx = r.name.indexOf(' · ')
const displayName = dotIdx >= 0 ? r.name.slice(dotIdx + 3) : r.name
return (
<motion.div
key={r.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className={cn(
'group relative overflow-hidden rounded-lg border pl-3.5 pr-2.5 py-2 shadow-sm transition-all duration-200 hover:shadow-md hover:shadow-black/10',
r.enabled
? 'border-border/50 bg-surface hover:border-accent/30'
: 'border-border/30 bg-surface/40 opacity-70 hover:opacity-100',
)}
>
{/* 左侧状态条 */}
<div className={cn('absolute left-0 top-0 h-full w-0.5', r.enabled ? 'bg-accent/50' : 'bg-border')} />
{/* 第一行: 分类标签 + 名称 + 操作按钮 */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold', SOURCE_BADGE_STYLE[r.type] ?? 'bg-elevated text-muted')}>
{TYPE_LABEL[r.type]}
</span>
{/* 个股类型: 直接显示可点击的代码+名称; 其他类型显示规则名 */}
{r.scope === 'symbols' && r.symbols.length > 0 ? (
<button
onClick={() => setPreviewSymbol(r.symbols[0])}
className="inline-flex items-center gap-1 min-w-0 hover:bg-elevated/50 rounded px-0.5 transition-colors cursor-pointer"
title={`查看 ${r.symbols[0]} 日K`}
>
<span className="font-mono text-xs font-medium text-foreground hover:text-accent">{r.symbols[0]}</span>
{symbolNames[r.symbols[0]] && <span className="text-xs text-secondary truncate">{symbolNames[r.symbols[0]]}</span>}
</button>
) : (
<h3 className={cn('text-xs font-medium truncate', r.enabled ? 'text-foreground' : 'text-muted')}>{displayName}</h3>
)}
{!r.enabled && <span className="shrink-0 text-[9px] text-secondary">· </span>}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={() => toggleEnabled(r)}
title={r.enabled ? '停用' : '启用'}
className={cn(
'p-1 rounded-md transition-all cursor-pointer',
r.enabled ? 'text-accent hover:bg-accent/10' : 'text-muted hover:bg-elevated hover:text-accent',
)}
>
<Zap className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onEdit(r)}
className="p-1 rounded-md text-secondary transition-all hover:bg-accent/10 hover:text-accent cursor-pointer"
title="编辑"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
{confirmId === r.id ? (
<button
onClick={() => handleClickDelete(r.id)}
title="再次点击确认删除"
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[9px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
) : (
<button
onClick={() => handleClickDelete(r.id)}
disabled={del.isPending}
className="p-1 rounded-md text-secondary transition-all hover:bg-danger/10 hover:text-danger cursor-pointer"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* 第二行: 触发条件 (策略类型显示买卖信号) */}
{r.type === 'strategy' && r.strategy_id ? (
<div className="mt-0.5 flex items-center gap-2 pl-0.5 flex-wrap">
{(() => {
const sigs = strategySignals[r.strategy_id]
const entrySigs = (sigs?.entry ?? []).map(s => cnSignal(s))
const exitSigs = (sigs?.exit ?? []).map(s => cnSignal(s))
return (
<>
{(r.direction === 'entry' || r.direction === 'both') && entrySigs.length > 0 && (
<span className="inline-flex items-center gap-1">
<span className="text-[9px] text-danger"></span>
<span className="text-[9px] text-accent/80">{entrySigs.join('、')}</span>
</span>
)}
{(r.direction === 'exit' || r.direction === 'both') && exitSigs.length > 0 && (
<span className="inline-flex items-center gap-1">
<span className="text-[9px] text-bear"></span>
<span className="text-[9px] text-accent/80">{exitSigs.join('、')}</span>
</span>
)}
</>
)
})()}
</div>
) : r.conditions.length > 0 && (
<div className="mt-0.5 flex items-center gap-1 pl-0.5">
<span className="text-[9px] text-secondary shrink-0"></span>
<span className="min-w-0 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[9px]">
{r.conditions.slice(0, 3).map((c, i) => (
<span key={i} className="inline-flex items-center gap-0.5">
{i > 0 && <span className="text-secondary">{r.logic === 'and' ? '且' : '或'}</span>}
{c.op === 'truth' ? (
<span className="text-accent/80">{cnSignal(c.field)}</span>
) : (
<span className="text-foreground/80 font-mono">{cnSignal(c.field)}{c.op}{c.value}</span>
)}
</span>
))}
{r.conditions.length > 3 && <span className="text-secondary">+{r.conditions.length - 3}</span>}
</span>
</div>
)}
</motion.div>
)
})
)}
<StockPreviewDialog
symbol={previewSymbol}
name={previewSymbol ? symbolNames[previewSymbol] : undefined}
onClose={() => setPreviewSymbol(null)}
/>
</div>
)
}
// ── 规则编辑对话框 ────────────────────────────────────
function RuleEditorDialog({ open, rule, onClose }: { open: boolean; rule: MonitorRule | null; onClose: () => void }) {
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-start justify-center overflow-auto bg-black/40 backdrop-blur-sm p-4"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.15 }}
className="mt-12 w-full max-w-2xl"
onClick={e => e.stopPropagation()}
>
<RuleEditor
rule={rule}
onClose={onClose}
onSaved={onClose}
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
// ── 确认对话框 ────────────────────────────────────────
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 (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={onCancel}
>
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.15 }}
className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-2xl"
onClick={e => e.stopPropagation()}
>
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<p className="mt-1.5 text-xs text-muted">{message}</p>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onCancel} className="px-3 py-1.5 rounded-btn bg-elevated text-secondary text-xs cursor-pointer"></button>
<button
onClick={onConfirm}
disabled={pending}
className={cn(
'px-3 py-1.5 rounded-btn text-xs font-medium disabled:opacity-50 cursor-pointer',
danger ? 'bg-danger text-base' : 'bg-accent text-base',
)}
>
{confirmText ?? '确定'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
+45 -3
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store } from 'lucide-react'
import { api, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
import { useDataStatus, usePreferences } from '@/lib/useSharedQueries'
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
@@ -328,7 +328,7 @@ export function Screener() {
// asOf 确定后 + 策略列表就绪 + 策略池非空 → 自动跑一次 (受系统设置开关控制)
// 缓存命中时秒加载; 未命中时, 仅当 screener_auto_run 开启才自动触发 runAll
useEffect(() => {
if (!asOf || !strategies.data?.presets.length || runAll.isPending || visiblePool.length === 0) return
if (!asOf || !strategies.data?.presets?.length || runAll.isPending || visiblePool.length === 0) return
const runKey = `${asOf}|${visiblePool.join(',')}|${extColumnsParam}`
if (runAllDateRef.current === runKey) return
// 缓存已覆盖当前策略池 → 秒加载, 不触发 runAll
@@ -433,6 +433,46 @@ export function Screener() {
},
})
// 策略监控: 查询规则, 建立 strategyId → ruleId 映射 (只看 type=strategy 且 enabled)
const monitorRules = useQuery({ queryKey: QK.monitorRules, queryFn: api.monitorRulesList })
const strategyMonitorMap = useMemo(() => {
const m = new Map<string, string>()
for (const r of monitorRules.data?.rules ?? []) {
if (r.type === 'strategy' && r.enabled && r.strategy_id) {
m.set(r.strategy_id, r.id)
}
}
return m
}, [monitorRules.data])
const toggleStrategyMonitor = (strategyId: string, strategyName: string) => {
const existingRuleId = strategyMonitorMap.get(strategyId)
if (existingRuleId) {
// 已监控 → 删除规则
api.monitorRuleDelete(existingRuleId).then(() =>
qc.invalidateQueries({ queryKey: QK.monitorRules }),
)
} else {
// 未监控 → 直接创建 type=strategy 规则
api.monitorRuleSave({
id: genRuleId(),
name: `策略监控 · ${strategyName}`,
enabled: true,
type: 'strategy',
scope: 'all',
symbols: [],
sector: null,
strategy_id: strategyId,
direction: 'entry',
conditions: [],
logic: 'or',
cooldown_seconds: 3600,
severity: 'info',
message: '',
}).then(() => qc.invalidateQueries({ queryKey: QK.monitorRules }))
}
}
const handleBatchAdd = () => {
if (!displayRows.length) return
const symbols = displayRows.map((r: any) => r.symbol)
@@ -515,7 +555,7 @@ export function Screener() {
<Layers className="h-3.5 w-3.5" />
<span className="ml-0.5 min-w-[28px] h-4 flex items-center justify-center rounded-full bg-accent/15 text-accent text-[10px] font-bold">
{visiblePool.length}/{strategies.data?.presets.length ?? 0}
{visiblePool.length}/{strategies.data?.presets?.length ?? 0}
</span>
</button>
{/* 创建策略 */}
@@ -570,6 +610,8 @@ export function Screener() {
onRun={() => handleRun(s)}
disabled={run.isPending && activeStrategy === s.id}
onSettings={() => setSettingsStrategyId(s.id)}
monitored={strategyMonitorMap.has(s.id)}
onToggleMonitor={() => toggleStrategyMonitor(s.id, s.name)}
/>
)
})}
+37 -5
View File
@@ -17,7 +17,7 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Eye, EyeOff, ExternalLink, GripVertical, Settings } from 'lucide-react'
import { Eye, EyeOff, ExternalLink, GripVertical, Settings, Bell } from 'lucide-react'
import { Link } from 'react-router-dom'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -42,16 +42,18 @@ const BUILTIN_PAGES: NavEntry[] = [
{ id: '/financials', label: '财务', type: 'builtin', visible: true },
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
{ id: '/trading', label: '交易', type: 'builtin', visible: true },
{ id: '/monitor', label: '监控通知', type: 'builtin', visible: true },
{ id: '/monitor', label: '监控中心', type: 'builtin', visible: true },
{ id: '/data', label: '数据', type: 'builtin', visible: true },
]
// ── Sortable row ──
function SortableItem({ entry, hidden, onToggleHidden }: {
function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBadge }: {
entry: NavEntry
hidden: boolean
onToggleHidden: (id: string) => void
badgeEnabled?: boolean
onToggleBadge?: (id: string) => void
}) {
const {
attributes,
@@ -73,7 +75,7 @@ function SortableItem({ entry, hidden, onToggleHidden }: {
<div
ref={setNodeRef}
style={style}
className={`grid grid-cols-[2.5rem_1fr_5rem_3rem_3rem] items-center border-b border-border/70 px-4 py-3 last:border-b-0 ${
className={`grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border/70 px-4 py-3 last:border-b-0 ${
isDragging ? 'bg-elevated rounded-lg shadow-lg' : ''
} ${hidden ? 'opacity-50' : ''}`}
>
@@ -132,6 +134,22 @@ function SortableItem({ entry, hidden, onToggleHidden }: {
</Link>
)}
</div>
{/* 第 6 列: 徽标开关 (仅监控中心) */}
<div className="flex justify-center">
{onToggleBadge && (
<button
onClick={() => onToggleBadge(entry.id)}
className={`rounded p-1 transition-colors ${
badgeEnabled
? 'text-accent hover:bg-accent/10'
: 'text-muted hover:text-accent hover:bg-accent/10'
}`}
title={badgeEnabled ? '关闭数字提示' : '开启数字提示'}
>
<Bell className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
)
}
@@ -230,6 +248,17 @@ export function SettingsMenuSettingsPanel() {
saveNavHidden.mutate([...next])
}
// 监控中心徽标开关 (localStorage)
const [badgeEnabled, setBadgeEnabled] = useState(() => {
try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true }
})
const toggleBadge = (id: string) => {
if (id !== '/monitor') return
const next = !badgeEnabled
setBadgeEnabled(next)
try { localStorage.setItem('monitor_badge_enabled', next ? '1' : '0') } catch { /* ignore */ }
}
return (
<div className="max-w-5xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.12),transparent_38%)]">
@@ -241,12 +270,13 @@ export function SettingsMenuSettingsPanel() {
</section>
<section className="rounded-card border border-border bg-surface overflow-hidden">
<div className="grid grid-cols-[2.5rem_1fr_5rem_3rem_3rem] items-center border-b border-border px-4 py-2 text-[11px] text-muted">
<div className="grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border px-4 py-2 text-[11px] text-muted">
<div />
<div></div>
<div></div>
<div className="text-center"></div>
<div className="text-center"></div>
<div className="text-center"></div>
</div>
<DndContext
@@ -264,6 +294,8 @@ export function SettingsMenuSettingsPanel() {
entry={entry}
hidden={hiddenSet.has(entry.id)}
onToggleHidden={toggleHidden}
badgeEnabled={entry.id === '/monitor' ? badgeEnabled : undefined}
onToggleBadge={entry.id === '/monitor' ? toggleBadge : undefined}
/>
))}
</SortableContext>
+12 -133
View File
@@ -7,8 +7,6 @@ import {
BarChart3,
Flame,
Zap,
Plus,
X,
} from 'lucide-react'
import {
usePreferences,
@@ -17,7 +15,7 @@ import {
useCapabilities,
} from '@/lib/useSharedQueries'
import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
import { api, type StrategyDetail } from '@/lib/api'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { toast } from '@/components/Toast'
import { DepthConfigContent } from '@/components/data/DepthConfigCard'
@@ -46,13 +44,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const { data: intervalData } = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const toggleQuote = useToggleRealtimeQuotes()
const [saving, setSaving] = useState(false)
const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free')
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
const refreshPages = prefs?.sse_refresh_pages ?? {}
const monitorEnabled = prefs?.strategy_monitor_enabled ?? false
const monitorIds = prefs?.strategy_monitor_ids ?? []
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
const hasDepth = !!caps?.capabilities?.['depth5.batch']
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
@@ -64,12 +58,11 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const maxInterval = intervalData?.max_interval ?? 60
const save = useCallback(async (cfg: Record<string, unknown>) => {
setSaving(true)
try {
await api.updateRealtimeMonitorConfig(cfg)
qc.invalidateQueries({ queryKey: QK.preferences })
} finally {
setSaving(false)
} catch (e) {
// 忽略 — Toast 已在 request 层处理
}
}, [qc])
@@ -235,28 +228,17 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
{/* ========== 右列 ========== */}
<div className="space-y-6">
{/* 策略监控 */}
{/* 策略监控已迁移至监控中心 */}
<Card icon={Shield} title="策略监控">
<p className="text-xs text-secondary mb-4">
/
<p className="text-xs text-secondary mb-3">
,
</p>
<ToggleRow
label="启用策略监控"
desc="开启后后端每次轮询自动跑策略评估"
checked={monitorEnabled}
onChange={(v) => save({ strategy_monitor_enabled: v })}
/>
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[10px] uppercase tracking-widest text-muted mb-2">
({monitorIds.length})
</div>
<StrategyPoolSelector
selectedIds={monitorIds}
disabled={!monitorEnabled || saving}
onChange={(ids) => save({ strategy_monitor_ids: ids })}
/>
</div>
<a
href="#/monitor"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/15 text-accent text-xs font-medium hover:bg-accent/25 transition-colors"
>
</a>
</Card>
{/* 连板梯队降级修正 */}
@@ -311,109 +293,6 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
}
// ===== 策略池选择器 =====
function StrategyPoolSelector({
selectedIds,
disabled,
onChange,
}: {
selectedIds: string[]
disabled: boolean
onChange: (ids: string[]) => void
}) {
const [allStrategies, setAllStrategies] = useState<StrategyDetail[] | null>(null)
const [showAdd, setShowAdd] = useState(false)
const loadStrategies = useCallback(async () => {
const res = await api.strategyList()
setAllStrategies(res.strategies)
}, [])
const addStrategy = (id: string) => {
if (!selectedIds.includes(id)) {
onChange([...selectedIds, id])
}
setShowAdd(false)
}
const removeStrategy = (id: string) => {
onChange(selectedIds.filter((s) => s !== id))
}
const selected = allStrategies?.filter((s) => selectedIds.includes(s.id)) ?? []
const available = allStrategies?.filter((s) => !selectedIds.includes(s.id)) ?? []
return (
<div className="space-y-2">
{/* 已选标签 */}
{selected.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selected.map((s) => (
<span
key={s.id}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px]
bg-accent/10 text-accent border border-accent/20"
>
<span className="font-medium">{s.name}</span>
{!disabled && (
<button onClick={() => removeStrategy(s.id)} className="hover:text-foreground">
<X className="h-3 w-3" />
</button>
)}
<span className="text-[9px] text-muted font-mono">{s.source}</span>
</span>
))}
</div>
) : (
<div className="text-[11px] text-muted py-1">
{disabled ? '请先开启策略监控' : '未选择策略'}
</div>
)}
{/* 添加按钮 */}
{!disabled && (
<div className="relative">
<button
onClick={() => {
if (!allStrategies) loadStrategies()
setShowAdd(!showAdd)
}}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px]
bg-elevated text-secondary hover:text-foreground transition-colors"
>
<Plus className="h-3 w-3" />
</button>
{showAdd && available.length > 0 && (
<div className="absolute left-0 top-full mt-1 z-20 w-64 max-h-48 overflow-y-auto
bg-surface border border-border rounded-lg shadow-xl">
{available.map((s) => (
<button
key={s.id}
onClick={() => addStrategy(s.id)}
className="w-full text-left px-3 py-2 hover:bg-elevated transition-colors
text-[11px] border-b border-border/50 last:border-0"
>
<div className="font-medium text-foreground">{s.name}</div>
<div className="text-muted truncate">{s.description}</div>
</button>
))}
</div>
)}
{showAdd && available.length === 0 && allStrategies && (
<div className="absolute left-0 top-full mt-1 z-20 px-3 py-2 text-[11px] text-muted
bg-surface border border-border rounded-lg shadow-xl">
</div>
)}
</div>
)}
</div>
)
}
// ===== ToggleRow =====
function ToggleRow({
+101 -1
View File
@@ -5,11 +5,13 @@
*/
import { useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Settings2, Trash2, RefreshCw } from 'lucide-react'
import { Settings2, Trash2, RefreshCw, Bell, Volume2 } from 'lucide-react'
import { usePreferences } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { refreshAlertToastConfig } from '@/components/AlertToast'
import { SOUND_OPTIONS, previewSound } from '@/lib/notificationSound'
export function SettingsSystemPanel() {
const qc = useQueryClient()
@@ -18,6 +20,21 @@ export function SettingsSystemPanel() {
const screenerAutoRun = prefs?.screener_auto_run ?? true
const [clearing, setClearing] = useState(false)
const [toastEnabled, setToastEnabled] = useState(() => {
try { return localStorage.getItem('alert_toast_enabled') !== '0' } catch { return true }
})
const [toastMax, setToastMax] = useState(() => {
try {
const v = parseInt(localStorage.getItem('alert_toast_max') || '', 10)
return v >= 1 && v <= 5 ? v : 3
} catch { return 3 }
})
const [soundEnabled, setSoundEnabled] = useState(() => {
try { return localStorage.getItem('alert_sound_enabled') !== '0' } catch { return true }
})
const [soundType, setSoundType] = useState(() => {
try { return localStorage.getItem('alert_sound') || 'ding' } catch { return 'ding' }
})
const save = useCallback(async (cfg: Record<string, unknown>) => {
setSaving(true)
@@ -62,6 +79,89 @@ export function SettingsSystemPanel() {
/>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Bell className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<ToggleRow
label="开启监控通知弹窗"
desc="收到监控告警时在右下角弹出通知卡片"
checked={toastEnabled}
disabled={saving}
onChange={(v) => {
localStorage.setItem('alert_toast_enabled', v ? '1' : '0')
setToastEnabled(v)
refreshAlertToastConfig()
}}
/>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"> (1-5), </div>
</div>
<select
value={toastMax}
disabled={!toastEnabled}
onChange={(e) => {
const v = Number(e.target.value)
localStorage.setItem('alert_toast_max', String(v))
setToastMax(v)
refreshAlertToastConfig()
}}
className="w-16 h-8 px-1.5 rounded-btn border border-border bg-base text-xs text-foreground disabled:opacity-50"
>
{[1, 2, 3, 4, 5].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<ToggleRow
label="通知声效"
desc="收到监控告警时播放提示音"
checked={soundEnabled}
disabled={!toastEnabled}
onChange={(v) => {
localStorage.setItem('alert_sound_enabled', v ? '1' : '0')
setSoundEnabled(v)
if (v) previewSound(soundType)
}}
/>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0 flex items-center gap-1.5">
<Volume2 className="h-3.5 w-3.5 text-muted" />
<div>
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted truncate"></div>
</div>
</div>
<div className="flex items-center gap-1.5">
<select
value={soundType}
disabled={!toastEnabled || !soundEnabled}
onChange={(e) => {
const v = e.target.value
localStorage.setItem('alert_sound', v)
setSoundType(v)
if (v !== 'none') previewSound(v)
}}
className="w-20 h-8 px-1.5 rounded-btn border border-border bg-base text-xs text-foreground disabled:opacity-50"
>
{SOUND_OPTIONS.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
<button
onClick={() => previewSound(soundType)}
disabled={!toastEnabled || !soundEnabled || soundType === 'none'}
className="px-2 h-8 rounded-btn border border-border bg-base text-xs text-secondary hover:text-foreground hover:border-accent/30 disabled:opacity-50 transition-colors cursor-pointer"
>
</button>
</div>
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Trash2 className="h-4 w-4 text-accent" />
+3 -3
View File
@@ -17,7 +17,7 @@ import { LimitUpLadder } from './pages/LimitUpLadder'
import { Branding } from './pages/Branding'
import { Settings } from './pages/Settings'
import { Indices } from './pages/Indices'
import { MinuteDataProbe } from './pages/MinuteDataProbe'
import { Dev } from './pages/Dev'
import { useSettings } from './lib/useSharedQueries'
import { Logo } from './components/Logo'
@@ -75,8 +75,8 @@ export const router = createBrowserRouter([
{ path: 'indices', element: <Indices /> },
{ path: 'branding', element: <Branding /> },
{ path: 'settings', element: <Settings /> },
// 隐藏路由:分钟K数据探测(不暴露在菜单,仅供调试)
{ path: 'minute-probe', element: <MinuteDataProbe /> },
// 隐藏路由:开发者工具(不暴露在菜单,仅供调试)
{ path: 'dev', element: <Dev /> },
// 旧路由兼容重定向
{ path: 'settings/keys', element: <Navigate to="/settings?tab=account" replace /> },
{ path: 'settings/ai', element: <Navigate to="/settings?tab=ai" replace /> },
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
},
},
server: {
host: '0.0.0.0', // 允许局域网访问
port: 3011,
proxy: {
// dev 时 /api 转发到 FastAPI