mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
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:
@@ -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,3 +1,3 @@
|
||||
"""TickFlow Stock Panel backend."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.28"
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "监控触发")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>`
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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 前缀列表 =====
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0', // 允许局域网访问
|
||||
port: 3011,
|
||||
proxy: {
|
||||
// dev 时 /api 转发到 FastAPI
|
||||
|
||||
Reference in New Issue
Block a user