mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
feat(v0.2): 自选分组监控作用域 + 自选页交互增强 + 弱化默认数据源绑定
后端: - 监控规则新增 scope=watchlist_group: 规则只存 group_id, 引擎按版本号缓存的 分组成员动态解析, 分组增删标的自动同步监控范围, 无需改规则 - 分组删除 fail-closed (规则暂停+runtime_warning), API 层校验分组存在性 - watchlist 服务增加数据版本号 _REVISION, 读取免锁、写后立即失效引擎缓存 - stock-sdk 插件 display_name 去掉括号合规备注 (说明保留在 description) 前端: - RuleEditor 分组模式交互 (选择分组/成员预览/动态绑定提示), Monitor 列表分组摘要 - 自选搜索: Plus 快速加当前分组 + FolderPlus 展开分组菜单; 多分组圆点叠瓦显示 - 搜索下拉加宽防名称截断, 创/科/ETF 标签紧贴名称 - 自选筛选新增「排除ST」开关 (默认关, 持久化) - 个股详情新增异动信息条: 状态着色/窗口偏离/接近度/计算时间 (无数据不显示) - 引导页: 能力探测按数据源分流 (仅选 TickFlow 才显示 Key/档位), 切换 loading 收进卡片 - 看板空态/首次弹窗按数据源分流文案, 去除 Key/None 档引导注册表述 - 侧边栏实时行情无权限态改为「不可用 + 去配置数据源」 - 设置-数据源页: 新增插件化与配置文档说明块, 内置/插件标识统一改为第三方 测试: test_monitor_group_scope.py 8 例 (动态成员/删除分组/校验/API), 全量 946 passed
This commit is contained in:
@@ -79,8 +79,10 @@ class RuleModel(BaseModel):
|
||||
enabled: bool = True
|
||||
type: str # strategy | signal | price | market | sector | abnormal
|
||||
asset_type: str = "stock" # stock | etf (etf: strategy 型走 ETF 历史加载器)
|
||||
scope: str = "symbols" # symbols | all | sector
|
||||
scope: str = "symbols" # symbols | all | sector | watchlist_group
|
||||
symbols: list[str] = []
|
||||
# watchlist_group 作用域: 绑定的自选分组 id (成员动态解析, 增删自选自动生效)
|
||||
group_id: str | None = None
|
||||
sector: str | None = None
|
||||
sector_kind: str | None = None # index | concept | industry
|
||||
sector_targets: list[SectorTargetModel] = []
|
||||
@@ -161,6 +163,7 @@ def get_options(request: Request):
|
||||
],
|
||||
"scopes": [
|
||||
{"key": "symbols", "label": "指定标的"},
|
||||
{"key": "watchlist_group", "label": "自选分组"},
|
||||
{"key": "all", "label": "全市场"},
|
||||
{"key": "sector", "label": "板块"},
|
||||
],
|
||||
@@ -227,6 +230,18 @@ def list_rules(request: Request):
|
||||
rule["runtime_warning"] = "部分板块数据已不存在, 请重新选择监控对象"
|
||||
elif unavailable:
|
||||
rule["runtime_warning"] = "所选指数未加入实时指数池, 请先在实时监控设置中启用"
|
||||
# 分组作用域规则: 绑定的分组被删除 → 标注运行时警告 (引擎侧已 fail-closed 跳过)
|
||||
group_rules = [rule for rule in rules if rule.get("scope") == "watchlist_group"]
|
||||
if group_rules:
|
||||
from app.services import watchlist as watchlist_service
|
||||
|
||||
try:
|
||||
existing_ids = {g["id"] for g in watchlist_service.list_groups()}
|
||||
for rule in group_rules:
|
||||
if rule.get("group_id") not in existing_ids:
|
||||
rule["runtime_warning"] = "绑定的自选分组已删除, 规则已暂停监控, 编辑可重新选择"
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# 按 created_at 倒序
|
||||
rules.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
return {"rules": rules}
|
||||
@@ -273,6 +288,17 @@ def save_rule(req: RuleModel, request: Request):
|
||||
monitor_rules.validate(rule)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
if rule.get("scope") == "watchlist_group":
|
||||
# 绑定的分组必须存在 (strategy 层校验形状, 存在性在本层校验)
|
||||
from app.services import watchlist as watchlist_service
|
||||
|
||||
group_id = str(rule.get("group_id") or "")
|
||||
try:
|
||||
group_ids = {g["id"] for g in watchlist_service.list_groups()}
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=503, detail=f"自选分组读取失败: {e}") from e
|
||||
if group_id not in group_ids:
|
||||
raise HTTPException(status_code=400, detail="自选分组不存在或已被删除, 请重新选择")
|
||||
if rule.get("type") == "sector":
|
||||
sector_service = getattr(request.app.state, "sector_monitor_service", None)
|
||||
if sector_service is None:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# 开发模式下需手动安装依赖: cd backend/app/plugins/stocksdk && npm install
|
||||
|
||||
name: stocksdk
|
||||
display_name: "stock-sdk(第三方行情·合规风险自负)"
|
||||
display_name: "stock-sdk"
|
||||
runtime: node
|
||||
entry: app.plugins.stocksdk.provider:StockSDKProvider
|
||||
check: app.plugins.stocksdk.bridge:availability
|
||||
|
||||
@@ -31,6 +31,14 @@ from app.tickflow.rate_limits import chunked, resolve_limit
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCK = threading.RLock()
|
||||
# 数据版本号: 每次写盘 +1 (在 _LOCK 内递增, 读取免锁)。供监控引擎等进程内
|
||||
# 消费方做缓存失效判断 —— 版本没变就不必重读文件, 版本一变立即拿到新成员。
|
||||
_REVISION = 0
|
||||
|
||||
|
||||
def revision() -> int:
|
||||
"""自选/分组数据版本号, 每次写操作递增。"""
|
||||
return _REVISION
|
||||
_MAX_GROUP_NAME_LENGTH = 24
|
||||
DEFAULT_GROUP_COLOR = "sky"
|
||||
GROUP_COLORS = frozenset({
|
||||
@@ -92,6 +100,7 @@ def _read_entries() -> pl.DataFrame:
|
||||
|
||||
|
||||
def _write_entries(df: pl.DataFrame) -> None:
|
||||
global _REVISION
|
||||
p = _path()
|
||||
# 首次从旧 schema 迁移到 group_ids 前, 备份原文件(一次性)
|
||||
if p.exists():
|
||||
@@ -103,6 +112,7 @@ def _write_entries(df: pl.DataFrame) -> None:
|
||||
tmp = p.with_suffix(p.suffix + ".tmp")
|
||||
df.select(list(_ENTRY_SCHEMA)).write_parquet(tmp)
|
||||
os.replace(tmp, p)
|
||||
_REVISION += 1
|
||||
|
||||
|
||||
def _read_groups() -> list[dict]:
|
||||
@@ -129,10 +139,12 @@ def _read_groups() -> list[dict]:
|
||||
|
||||
|
||||
def _write_groups(groups: list[dict]) -> None:
|
||||
global _REVISION
|
||||
p = _groups_path()
|
||||
tmp = p.with_suffix(p.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(groups, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, p)
|
||||
_REVISION += 1
|
||||
|
||||
|
||||
def _normalize_group_name(name: str) -> str:
|
||||
|
||||
@@ -222,6 +222,58 @@ class StrategyMonitorService:
|
||||
_SIGNAL_PREFIXES = ("signal_", "csg_")
|
||||
|
||||
|
||||
# ── 自选分组作用域: group_id → 成员集合解析 (进程内缓存) ────
|
||||
# 缓存按 watchlist 数据版本号失效: 版本不变时零磁盘 IO; 自选页任何增删
|
||||
# 分组/成员的操作都会 bump 版本号, 下一轮评估立即拿到新成员 (无需等 TTL)。
|
||||
_group_cache_lock = threading.Lock()
|
||||
_group_cache: dict[str, Any] = {}
|
||||
# 已告警过的「分组已删除」(rule_id, group_id), 防止每轮评估刷日志
|
||||
_warned_missing_groups: set[tuple[str, str]] = set()
|
||||
|
||||
|
||||
def _watchlist_groups_snapshot() -> dict[str, frozenset[str]]:
|
||||
"""返回 {group_id: 成员symbol集}。读前后版本一致才写缓存, 避免缓存住写竞态下的旧数据。"""
|
||||
from app.services import watchlist
|
||||
|
||||
rev_before = watchlist.revision()
|
||||
with _group_cache_lock:
|
||||
cached = _group_cache.get("groups")
|
||||
if cached is not None and _group_cache.get("_rev") == rev_before:
|
||||
return cached
|
||||
groups: dict[str, set[str]] = {g["id"]: set() for g in watchlist.list_groups()}
|
||||
for row in watchlist.list_symbols():
|
||||
for gid in row.get("group_ids") or []:
|
||||
members = groups.get(gid)
|
||||
if members is not None:
|
||||
members.add(str(row["symbol"]))
|
||||
frozen = {gid: frozenset(syms) for gid, syms in groups.items()}
|
||||
if watchlist.revision() == rev_before:
|
||||
with _group_cache_lock:
|
||||
_group_cache["_rev"] = rev_before
|
||||
_group_cache["groups"] = frozen
|
||||
return frozen
|
||||
|
||||
|
||||
def _group_members_or_none(rule: dict) -> frozenset[str] | None:
|
||||
"""解析规则绑定的分组成员; 分组已删除返回 None, 解析异常返回 None 并记日志。"""
|
||||
group_id = str(rule.get("group_id") or "")
|
||||
try:
|
||||
groups = _watchlist_groups_snapshot()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("自选分组数据读取失败, 规则 %s 本轮跳过: %s", rule.get("id"), exc)
|
||||
return None
|
||||
members = groups.get(group_id)
|
||||
if members is None:
|
||||
key = (str(rule.get("id") or ""), group_id)
|
||||
if key not in _warned_missing_groups:
|
||||
_warned_missing_groups.add(key)
|
||||
logger.warning(
|
||||
"监控规则 %s 绑定的自选分组 %s 已删除, 本轮跳过 (fail-closed, 恢复分组后自动生效)",
|
||||
rule.get("id"), group_id,
|
||||
)
|
||||
return members
|
||||
|
||||
|
||||
def _is_signal_field(field: str) -> bool:
|
||||
return any(field.startswith(p) for p in _SIGNAL_PREFIXES)
|
||||
|
||||
@@ -821,10 +873,16 @@ class MonitorRuleEngine:
|
||||
threshold = 0.7
|
||||
direction = rule.get("direction", "both")
|
||||
window_filter = str(rule.get("abnormal_window", "any"))
|
||||
scope_symbols = (
|
||||
{str(s) for s in rule.get("symbols", []) if s}
|
||||
if rule.get("scope") == "symbols" else None
|
||||
)
|
||||
if rule.get("scope") == "symbols":
|
||||
scope_symbols = {str(s) for s in rule.get("symbols", []) if s}
|
||||
elif rule.get("scope") == "watchlist_group":
|
||||
# 异动规则同样支持动态分组; 分组已删除返回 None → 本轮整体跳过
|
||||
members = _group_members_or_none(rule)
|
||||
if members is None:
|
||||
return events
|
||||
scope_symbols = set(members)
|
||||
else:
|
||||
scope_symbols = None
|
||||
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
@@ -1000,6 +1058,13 @@ class MonitorRuleEngine:
|
||||
if not syms:
|
||||
return df.head(0)
|
||||
return df.filter(pl.col("symbol").is_in(syms))
|
||||
if scope == "watchlist_group":
|
||||
# 动态绑定自选分组: 每轮评估按分组当前成员过滤 (带版本号缓存)。
|
||||
# 分组已删除/暂时为空 → fail-closed 返回空, 绝不退化为全市场。
|
||||
members = _group_members_or_none(rule)
|
||||
if not members:
|
||||
return df.head(0)
|
||||
return df.filter(pl.col("symbol").is_in(list(members)))
|
||||
if scope == "sector":
|
||||
# sector 过滤需 df 含板块列 (后续接入 ext_data JOIN)。在 JOIN 落地前
|
||||
# fail-closed 返回空 —— 绝不退化为「全市场」误触发 (旧行为 return df 会让
|
||||
|
||||
@@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
# ── 常量 ────────────────────────────────────────────────
|
||||
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector", "abnormal"}
|
||||
SCOPES = {"symbols", "all", "sector"}
|
||||
SCOPES = {"symbols", "all", "sector", "watchlist_group"}
|
||||
LOGICS = {"and", "or"}
|
||||
DIRECTIONS = {"entry", "exit", "both"}
|
||||
STRATEGY_NOTIFY_EVENTS = {"buy_signal", "sell_signal", "pool_entry", "pool_exit"}
|
||||
@@ -224,6 +224,14 @@ def validate(rule: dict) -> None:
|
||||
syms = rule.get("symbols")
|
||||
if not isinstance(syms, list) or len(syms) == 0:
|
||||
raise ValueError("scope=symbols 时 symbols 不能为空")
|
||||
if rule.get("scope") == "watchlist_group":
|
||||
# 动态绑定自选分组: 评估时实时解析成员 (分组后续增删自动生效)。
|
||||
# 分组存在性由 API 层在保存时校验 (strategy 层不依赖 services)。
|
||||
gid = rule.get("group_id")
|
||||
if not isinstance(gid, str) or not gid.strip():
|
||||
raise ValueError("scope=watchlist_group 时必须选择自选分组")
|
||||
if rule.get("asset_type", "stock") != "stock":
|
||||
raise ValueError("自选分组作用域仅支持个股")
|
||||
if uses_intraday_signals(rule) and rule.get("scope") != "symbols":
|
||||
raise ValueError("分时穿越信号仅支持指定标的")
|
||||
# sector 作用域的板块 JOIN 尚未实现: _apply_scope 目前会退化为「全市场」,
|
||||
@@ -248,6 +256,12 @@ def normalize(rule: dict) -> dict:
|
||||
# sector/abnormal 默认全市场 (sector 随后强制 all; abnormal 支持指定标的)
|
||||
r.setdefault("scope", "all" if r.get("type") in {"sector", "abnormal"} else "symbols")
|
||||
r.setdefault("symbols", [])
|
||||
r.setdefault("group_id", None)
|
||||
# watchlist_group 作用域: 成员动态来自分组, symbols 不参与; 其他作用域清掉残留 group_id
|
||||
if r.get("scope") == "watchlist_group":
|
||||
r["symbols"] = []
|
||||
else:
|
||||
r["group_id"] = None
|
||||
r.setdefault("sector", None)
|
||||
r.setdefault("sector_kind", None)
|
||||
r.setdefault("sector_targets", [])
|
||||
@@ -279,6 +293,7 @@ def normalize(rule: dict) -> dict:
|
||||
if r.get("type") == "sector":
|
||||
r["scope"] = "all"
|
||||
r["symbols"] = []
|
||||
r["group_id"] = None
|
||||
# abnormal 专属默认字段 (异动边缘监控)
|
||||
r.setdefault("abnormal_window", "any")
|
||||
r.setdefault("logic", "and")
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""监控规则 scope=watchlist_group — 自选分组动态作用域。
|
||||
|
||||
覆盖: 规则校验/normalize、引擎按分组当前成员过滤 (分组增删自选后无需改规则,
|
||||
下一轮评估自动生效)、分组删除 fail-closed、异动规则分组过滤、API 保存时
|
||||
分组存在性校验与列表 runtime_warning。
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api import monitor_rules as monitor_rules_api
|
||||
from app.config import settings
|
||||
from app.services import watchlist
|
||||
from app.strategy import monitor_rules
|
||||
from app.strategy.monitor import MonitorRuleEngine
|
||||
|
||||
|
||||
def _group_rule(rid="r_grp", group_id="g1", **overrides):
|
||||
rule = {
|
||||
"id": rid, "name": rid, "type": "signal", "asset_type": "stock",
|
||||
"scope": "watchlist_group", "group_id": group_id, "symbols": [],
|
||||
"logic": "or",
|
||||
"conditions": [{"field": "rsi_14", "op": "<", "value": 100}],
|
||||
"cooldown_seconds": 0, "enabled": True,
|
||||
}
|
||||
rule.update(overrides)
|
||||
return rule
|
||||
|
||||
|
||||
def _stock_df():
|
||||
return pl.DataFrame({
|
||||
"symbol": ["600000.SH", "000001.SZ", "300750.SZ"],
|
||||
"name": ["浦发银行", "平安银行", "宁德时代"],
|
||||
"close": [10.0, 12.0, 200.0],
|
||||
"change_pct": [1.0, 2.0, 3.0],
|
||||
"rsi_14": [40.0, 50.0, 60.0],
|
||||
})
|
||||
|
||||
|
||||
# ── 校验与 normalize ─────────────────────────────────────
|
||||
|
||||
def test_group_scope_validation():
|
||||
with pytest.raises(ValueError, match="自选分组"):
|
||||
monitor_rules.validate(_group_rule(group_id=None))
|
||||
with pytest.raises(ValueError, match="自选分组"):
|
||||
monitor_rules.validate(_group_rule(group_id=" "))
|
||||
with pytest.raises(ValueError, match="仅支持个股"):
|
||||
monitor_rules.validate(_group_rule(asset_type="etf"))
|
||||
# 分时穿越信号仅支持指定标的 (沿用既有限制)
|
||||
with pytest.raises(ValueError, match="分时穿越"):
|
||||
monitor_rules.validate(_group_rule(
|
||||
conditions=[{"field": "signal_intraday_avg_cross_up", "op": "truth"}],
|
||||
))
|
||||
monitor_rules.validate(_group_rule()) # 合法
|
||||
|
||||
|
||||
def test_normalize_group_scope_fields():
|
||||
# 分组作用域: 保留 group_id, 清掉 symbols (成员动态来自分组)
|
||||
r = monitor_rules.normalize(_group_rule(symbols=["600000.SH"]))
|
||||
assert r["group_id"] == "g1"
|
||||
assert r["symbols"] == []
|
||||
# 非分组作用域: 清掉残留 group_id
|
||||
r = monitor_rules.normalize(_group_rule(scope="all"))
|
||||
assert r["group_id"] is None
|
||||
|
||||
|
||||
# ── 引擎: 动态成员过滤 ───────────────────────────────────
|
||||
|
||||
def test_engine_group_scope_dynamic_members(monkeypatch, tmp_path):
|
||||
"""分组内后续加入的标的, 无需修改规则即自动进入监控范围。"""
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
_, group = watchlist.create_group("核心池")
|
||||
gid = group["id"]
|
||||
watchlist.add("600000.SH", group_id=gid)
|
||||
watchlist.add("000001.SZ", group_id=gid)
|
||||
|
||||
eng = MonitorRuleEngine()
|
||||
eng.set_rules([_group_rule(group_id=gid)])
|
||||
df = _stock_df()
|
||||
|
||||
events = eng.evaluate(df)
|
||||
assert {e["symbol"] for e in events} == {"600000.SH", "000001.SZ"}
|
||||
|
||||
# 分组新增宁德时代 → 同一条规则下一轮自动覆盖 (版本号缓存立即失效)
|
||||
watchlist.add("300750.SZ", group_id=gid)
|
||||
events = eng.evaluate(df)
|
||||
assert "300750.SZ" in {e["symbol"] for e in events}
|
||||
|
||||
# 移出分组 → 自动退出监控范围
|
||||
watchlist.remove_from_group("300750.SZ", gid)
|
||||
events = eng.evaluate(df)
|
||||
assert "300750.SZ" not in {e["symbol"] for e in events}
|
||||
|
||||
|
||||
def test_engine_group_scope_missing_group_fail_closed(monkeypatch, tmp_path):
|
||||
"""分组已删除: 不崩、不触发、绝不退化为全市场。"""
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
watchlist.create_group("核心池") # 让分组文件存在, 但规则绑定的 id 不在其中
|
||||
|
||||
eng = MonitorRuleEngine()
|
||||
eng.set_rules([_group_rule(group_id="ghost")])
|
||||
assert eng.evaluate(_stock_df()) == []
|
||||
|
||||
|
||||
def test_engine_group_scope_empty_group(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
_, group = watchlist.create_group("空组")
|
||||
|
||||
eng = MonitorRuleEngine()
|
||||
eng.set_rules([_group_rule(group_id=group["id"])])
|
||||
assert eng.evaluate(_stock_df()) == []
|
||||
|
||||
|
||||
def test_abnormal_group_scope_filtering(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
_, group = watchlist.create_group("异动池")
|
||||
gid = group["id"]
|
||||
watchlist.add("600000.SH", group_id=gid)
|
||||
|
||||
def _row(symbol, dev_3d):
|
||||
return {
|
||||
"symbol": symbol, "name": symbol, "board": "主板", "st": False,
|
||||
"close": 10.0, "rt_pct": 1.0,
|
||||
"windows": {"3d": {"value": dev_3d, "threshold": 0.2, "closeness": abs(dev_3d) / 0.2}},
|
||||
}
|
||||
|
||||
eng = MonitorRuleEngine()
|
||||
eng.set_rules([_group_rule(
|
||||
rid="r_ab", group_id=gid, type="abnormal",
|
||||
scope="watchlist_group", threshold_pct=70, direction="both",
|
||||
conditions=[], symbols=[],
|
||||
)])
|
||||
high_rows = [_row("600000.SH", 0.16), _row("000001.SZ", 0.18), _row("300750.SZ", 0.19)]
|
||||
low_rows = [_row("600000.SH", 0.10), _row("000001.SZ", 0.05), _row("300750.SZ", 0.05)]
|
||||
# 边缘触发: 首轮观测不告警, 回落置 False 后再次上穿才触发
|
||||
eng.evaluate_abnormal(low_rows)
|
||||
events = eng.evaluate_abnormal(high_rows)
|
||||
# 只有分组内的 600000.SH 触发; 组外两只偏离更高也不会告警
|
||||
assert [e["symbol"] for e in events] == ["600000.SH"]
|
||||
|
||||
|
||||
# ── API: 保存校验 + 列表警告 ────────────────────────────
|
||||
|
||||
def _fake_request(tmp_path):
|
||||
repo = MagicMock()
|
||||
repo.store.data_dir = tmp_path
|
||||
repo.resolve_asset_type.return_value = "stock"
|
||||
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo)))
|
||||
|
||||
|
||||
def test_api_save_rejects_missing_group(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
watchlist.create_group("真实分组")
|
||||
|
||||
req = _fake_request(tmp_path)
|
||||
model = monitor_rules_api.RuleModel(**_group_rule(group_id="ghost"))
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
monitor_rules_api.save_rule(model, req)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_api_save_and_list_group_rule(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
_, group = watchlist.create_group("核心池")
|
||||
|
||||
req = _fake_request(tmp_path)
|
||||
model = monitor_rules_api.RuleModel(**_group_rule(group_id=group["id"]))
|
||||
resp = monitor_rules_api.save_rule(model, req)
|
||||
assert resp["ok"] is True
|
||||
assert resp["rule"]["group_id"] == group["id"]
|
||||
|
||||
listed = monitor_rules_api.list_rules(req)
|
||||
rule = next(r for r in listed["rules"] if r["id"] == "r_grp")
|
||||
assert "runtime_warning" not in rule
|
||||
|
||||
# 分组删除后: 列表标注警告 (引擎侧同轮已 fail-closed)
|
||||
watchlist.delete_group(group["id"])
|
||||
listed = monitor_rules_api.list_rules(req)
|
||||
rule = next(r for r in listed["rules"] if r["id"] == "r_grp")
|
||||
assert "已删除" in rule["runtime_warning"]
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
RadioTower,
|
||||
CheckCircle2,
|
||||
BookOpenCheck,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Sun,
|
||||
@@ -67,7 +66,6 @@ import { getFrontendExtensionNavigation } from '@/extensions/registry'
|
||||
|
||||
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
|
||||
const BRAND = '#8B5CF6'
|
||||
const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA'
|
||||
|
||||
const CORE_INDEXES = [
|
||||
{ symbol: '000001.SH', name: '上证指数' },
|
||||
@@ -746,22 +744,19 @@ export function Layout() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-secondary truncate">实时行情</span>
|
||||
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
|
||||
Free+
|
||||
<span className="text-[10px] text-muted/80 bg-elevated px-1.5 py-0.5 rounded">
|
||||
不可用
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[10px] leading-snug text-muted">
|
||||
免费注册
|
||||
<a
|
||||
href={TICKFLOW_REGISTER_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mx-1 inline-flex items-baseline gap-0.5 text-accent/80 hover:text-accent hover:underline"
|
||||
当前数据源无实时行情权限,
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings?tab=data-sources')}
|
||||
className="mx-0.5 text-accent/80 hover:text-accent hover:underline"
|
||||
>
|
||||
TickFlow
|
||||
<ExternalLink className="h-2.5 w-2.5 self-center" />
|
||||
</a>
|
||||
开启个股监控
|
||||
去配置数据源
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2 } from 'lucide-react'
|
||||
import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2, Activity } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { cnSignal } from '@/lib/signals'
|
||||
import { fmtPct } from '@/lib/format'
|
||||
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
|
||||
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
|
||||
import { StockMultiDayIntradayChart } from '@/components/StockMultiDayIntradayChart'
|
||||
@@ -63,6 +64,21 @@ function boardTag(symbol: string): { label: string; color: string } | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// ===== 异动边缘 (与异动页同口径) =====
|
||||
|
||||
const AB_STATUS_META: Record<string, { label: string; cls: string; bar: string; icon: string }> = {
|
||||
triggered: { label: '已触发', cls: 'bg-danger/20 text-danger font-semibold', bar: 'border-b border-danger/30 bg-danger/[0.08]', icon: 'text-danger' },
|
||||
edge: { label: '异动边缘', cls: 'bg-warning/20 text-warning font-semibold', bar: 'border-b border-warning/30 bg-warning/[0.07]', icon: 'text-warning' },
|
||||
watch: { label: '观察', cls: 'bg-elevated text-secondary font-semibold', bar: 'border-b border-border bg-surface', icon: 'text-secondary' },
|
||||
}
|
||||
|
||||
/** 异动引擎计算时间 (服务端 asof 秒级时间戳 → 月-日 时:分:秒) */
|
||||
function fmtAbnormalCalcTime(asofSec: number): string {
|
||||
const d = new Date(asofSec * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) {
|
||||
const [view, setView] = useState<PreviewView>('daily')
|
||||
const [intradayDays, setIntradayDays] = useState(loadIntradayDays)
|
||||
@@ -83,6 +99,22 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
queryFn: api.monitorRulesList,
|
||||
enabled: !!symbol,
|
||||
})
|
||||
// 异动边缘: 与异动页同 queryKey 共享缓存; 该股处于观察/边缘/触发状态时在图表上方显示信息条
|
||||
const abnormal = useQuery({
|
||||
queryKey: QK.abnormalOverview(0.5, 300),
|
||||
queryFn: () => api.abnormalOverview(0.5, 300),
|
||||
enabled: !!symbol,
|
||||
})
|
||||
const abRow = symbol
|
||||
? abnormal.data?.rows.find(r => r.symbol === symbol)
|
||||
: undefined
|
||||
// 接近度最高的窗口 (信息条中高亮)
|
||||
const abDominantWindow = abRow
|
||||
? Object.entries(abRow.windows).reduce(
|
||||
(best, [k, w]) => (!best || w.closeness > best[1].closeness ? [k, w] as const : best),
|
||||
undefined as undefined | readonly [string, { value: number; threshold: number; closeness: number }],
|
||||
)
|
||||
: undefined
|
||||
const monitorPriceLines = useMemo(
|
||||
() => symbol ? buildMonitorPriceLines(monitorRules.data?.rules ?? [], symbol) : [],
|
||||
[monitorRules.data?.rules, symbol],
|
||||
@@ -394,6 +426,49 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 异动边缘信息条 (与异动页同源; 该股无异动数据时不显示)。整条按状态着色提升辨识度 */}
|
||||
{abRow && (() => {
|
||||
const meta = AB_STATUS_META[abRow.status] ?? AB_STATUS_META.watch
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-x-3 gap-y-1 px-5 py-2 shrink-0 ${meta.bar}`}>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<Activity className={`h-3.5 w-3.5 ${meta.icon}`} />
|
||||
<span className={`text-[11px] font-bold ${meta.icon}`}>异动</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${meta.cls}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</span>
|
||||
{Object.entries(abRow.windows)
|
||||
.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10))
|
||||
.map(([w, info]) => {
|
||||
const dominant = abDominantWindow?.[0] === w
|
||||
return (
|
||||
<span
|
||||
key={w}
|
||||
title={`近${parseInt(w, 10)}日累计偏离(含实时) / 交易所规则阈值 · 接近度=|偏离|/阈值`}
|
||||
className={`shrink-0 rounded border px-1.5 py-0.5 font-mono text-[11px] ${
|
||||
dominant
|
||||
? 'border-border bg-elevated font-semibold text-foreground'
|
||||
: 'border-border/60 bg-base/40 text-secondary'
|
||||
}`}
|
||||
>
|
||||
{parseInt(w, 10)}日{' '}
|
||||
<span className={info.value >= 0 ? 'text-bull' : 'text-bear'}>{fmtPct(info.value, 1)}</span>
|
||||
<span className="text-muted"> / ±{(info.threshold * 100).toFixed(0)}%</span>
|
||||
<span className="text-muted"> · 接近{(info.closeness * 100).toFixed(0)}%</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<span
|
||||
className="ml-auto shrink-0 font-mono text-[10px] text-muted"
|
||||
title="异动引擎上次计算时间"
|
||||
>
|
||||
计算于 {fmtAbnormalCalcTime(abnormal.data?.asof ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 图表内容 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{view === 'daily' ? (
|
||||
|
||||
@@ -610,10 +610,17 @@ export function WatchlistGroupPicker({ groups, groupIds, symbol, disabled, onTog
|
||||
{dots.length === 0 ? (
|
||||
<FolderInput className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="flex items-center gap-0.5">
|
||||
{dots.map(g => <span key={g.id} className={`h-2 w-2 rounded-full ${resolveWatchlistGroupColor(g.color).dot}`} />)}
|
||||
// 叠瓦式圆点: 先加的分组在最上层完整显示, 后加的从其右侧露出半圆, 紧凑不撑宽
|
||||
<span className="flex items-center">
|
||||
{dots.map((g, i) => (
|
||||
<span
|
||||
key={g.id}
|
||||
style={{ zIndex: dots.length - i }}
|
||||
className={`relative h-2 w-2 rounded-full ring-1 ring-border/50 ${resolveWatchlistGroupColor(g.color).dot} ${i > 0 ? '-ml-1' : ''}`}
|
||||
/>
|
||||
))}
|
||||
{memberGroups.length > 3 && (
|
||||
<span className="font-mono text-[9px] leading-none text-muted">+{memberGroups.length - 3}</span>
|
||||
<span className="ml-0.5 font-mono text-[9px] leading-none text-muted">+{memberGroups.length - 3}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -56,6 +56,7 @@ const emptyRule = (preset?: Partial<MonitorRule>): MonitorRule => ({
|
||||
asset_type: 'stock',
|
||||
scope: 'symbols',
|
||||
symbols: [],
|
||||
group_id: null,
|
||||
sector: null,
|
||||
sector_kind: 'index',
|
||||
sector_targets: [],
|
||||
@@ -112,19 +113,34 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
})
|
||||
const [error, setError] = useState('')
|
||||
const [symbolQuery, setSymbolQuery] = useState('')
|
||||
// 「自选导入」下拉: 从自选/自选分组批量并入标的 (与自选页共用查询缓存)
|
||||
const isGroupScope = draft.scope === 'watchlist_group'
|
||||
// 「自选导入」下拉: 从自选/自选分组批量并入标的 (与自选页共用查询缓存)。
|
||||
// 分组作用域模式同样需要分组/成员数据 (选择分组 + 成员预览)。
|
||||
const [watchMenuOpen, setWatchMenuOpen] = useState(false)
|
||||
const watchMenuRef = useRef<HTMLDivElement>(null)
|
||||
const watchlistQ = useQuery({
|
||||
queryKey: QK.watchlist,
|
||||
queryFn: api.watchlistList,
|
||||
enabled: watchMenuOpen,
|
||||
enabled: watchMenuOpen || isGroupScope,
|
||||
})
|
||||
const watchGroupsQ = useQuery({
|
||||
queryKey: QK.watchlistGroups,
|
||||
queryFn: api.watchlistGroups,
|
||||
enabled: watchMenuOpen,
|
||||
enabled: watchMenuOpen || isGroupScope,
|
||||
})
|
||||
// 分组选择下拉 (scope=watchlist_group)
|
||||
const [groupMenuOpen, setGroupMenuOpen] = useState(false)
|
||||
const groupMenuRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!groupMenuOpen) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (groupMenuRef.current && !groupMenuRef.current.contains(e.target as Node)) {
|
||||
setGroupMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [groupMenuOpen])
|
||||
useEffect(() => {
|
||||
if (!watchMenuOpen) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
@@ -161,6 +177,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
? `${base} · ${d.sector_targets[0].name}${d.sector_targets.length > 1 ? ` 等${d.sector_targets.length}个` : ''}`
|
||||
: d.type === 'abnormal'
|
||||
? `${base} · 接近度≥${d.threshold_pct ?? 70}%${d.abnormal_window && d.abnormal_window !== 'any' ? ` (${d.abnormal_window.toUpperCase()})` : ''}`
|
||||
: d.scope === 'watchlist_group' && selectedGroup
|
||||
? `${base} · 分组「${selectedGroup.name}」`
|
||||
: d.scope === 'symbols' && d.symbols.length > 0
|
||||
? `${base} · ${d.symbols[0]}${d.symbols.length > 1 ? ` 等${d.symbols.length}只` : ''}`
|
||||
: base
|
||||
@@ -204,6 +222,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
}
|
||||
}
|
||||
if (d.type !== 'sector' && d.scope === 'symbols' && d.symbols.length === 0) throw new Error('请选择至少一只标的')
|
||||
if (d.type !== 'sector' && d.scope === 'watchlist_group' && !d.group_id) throw new Error('请选择一个自选分组')
|
||||
return api.monitorRuleSave(d)
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -292,6 +311,32 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
return options
|
||||
})()
|
||||
|
||||
// ── 自选分组作用域 (scope=watchlist_group): 分组选择 + 只读成员预览 ──
|
||||
const groupList = watchGroupsQ.data?.groups ?? []
|
||||
const watchEntries = watchlistQ.data?.symbols ?? []
|
||||
const groupCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const entry of watchEntries) {
|
||||
for (const gid of entry.group_ids ?? []) counts[gid] = (counts[gid] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
}, [watchEntries])
|
||||
const selectedGroup = groupList.find(g => g.id === draft.group_id)
|
||||
const selectedGroupSymbols = useMemo(
|
||||
() => selectedGroup
|
||||
? watchEntries.filter(e => e.group_ids?.includes(selectedGroup.id)).map(e => e.symbol)
|
||||
: [],
|
||||
[selectedGroup, watchEntries],
|
||||
)
|
||||
// 预览区名称补齐 (分组成员通常不在 draft.symbols 里, 单独批量查询)
|
||||
const groupNamesQ = useQuery({
|
||||
queryKey: ['instrument-names', selectedGroupSymbols.join(',')],
|
||||
queryFn: () => api.instrumentNames(selectedGroupSymbols),
|
||||
enabled: isGroupScope && selectedGroupSymbols.length > 0,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
const groupNameBySymbol = groupNamesQ.data?.names ?? {}
|
||||
|
||||
const selectSectorKind = (kind: SectorKind) => {
|
||||
setDraft(d => ({ ...d, sector_kind: kind, sector_targets: [] }))
|
||||
setSectorQuery('')
|
||||
@@ -341,13 +386,20 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
const pickerSignals = assetType === 'index'
|
||||
? monitorBuiltinSignals.filter(o => !INDEX_HIDDEN_SIGNALS(o.key))
|
||||
: monitorBuiltinSignals
|
||||
// 分时穿越信号: 数据按标的清单订阅且有上限, 自选分组是动态集合 (静默超限风险) → 禁用
|
||||
const intradayDisabledSignals =
|
||||
intradaySupport?.available === false || isGroupScope ? MONITOR_INTRADAY_SIGNAL_OPTIONS : []
|
||||
const intradayDisabledHint = isGroupScope
|
||||
? '分时穿越信号需逐股订阅, 暂不支持自选分组作用域'
|
||||
: intradaySupport?.reason
|
||||
// 指数: 监控类型仅 signal/price (无涨跌停/策略/封单语义)
|
||||
const visibleTypes = (options.data?.types ?? []).filter(
|
||||
t => assetType !== 'index' || t.key === 'signal' || t.key === 'price',
|
||||
)
|
||||
// 指数: 作用范围仅 symbols (无全市场/板块语义)
|
||||
// 指数: 作用范围仅 symbols (无全市场/板块语义); ETF: 不支持自选分组 (分组为个股)
|
||||
const visibleScopes = (options.data?.scopes ?? []).filter(
|
||||
s => assetType !== 'index' || s.key === 'symbols',
|
||||
s => (assetType !== 'index' || s.key === 'symbols')
|
||||
&& (assetType === 'stock' || s.key !== 'watchlist_group'),
|
||||
)
|
||||
const sectorKind = draft.sector_kind ?? 'index'
|
||||
const sectorTargets = options.data?.sector_targets?.[sectorKind] ?? []
|
||||
@@ -504,7 +556,10 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
strategy_id: null,
|
||||
symbols: [],
|
||||
type: t === 'index' && d.type !== 'signal' && d.type !== 'price' ? 'signal' : d.type,
|
||||
scope: t === 'index' ? 'symbols' : d.scope,
|
||||
// 指数仅指定标的; ETF 不支持分组作用域 (自选分组为个股)
|
||||
scope: t === 'index' || (t !== 'stock' && d.scope === 'watchlist_group')
|
||||
? 'symbols'
|
||||
: d.scope,
|
||||
}))
|
||||
setStrategyQuery('')
|
||||
setStrategyCategory('all')
|
||||
@@ -855,7 +910,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWatchMenuOpen(v => !v)}
|
||||
title="从自选 / 自选分组导入标的 (导入当前成员, 后续增删自选不影响本规则)"
|
||||
title="从自选 / 自选分组导入当前成员 (一次性拷贝, 后续增删自选不影响本规则); 需要动态跟随分组请把作用范围切到「自选分组」"
|
||||
className={`inline-flex h-7 shrink-0 items-center gap-1 rounded border px-2 text-[11px] transition-colors cursor-pointer ${
|
||||
watchMenuOpen
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
@@ -964,6 +1019,87 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{draft.scope === 'watchlist_group' && (
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="relative" ref={groupMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGroupMenuOpen(v => !v)}
|
||||
title="选择要监控的自选分组 (动态绑定, 分组内增删标的自动生效)"
|
||||
className={`inline-flex h-7 max-w-full items-center gap-1.5 rounded border px-2 text-[11px] transition-colors cursor-pointer ${
|
||||
groupMenuOpen
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: 'border-border bg-base text-secondary hover:border-accent/30 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{selectedGroup ? (
|
||||
<>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${resolveWatchlistGroupColor(selectedGroup.color).dot}`} />
|
||||
<span className="max-w-32 truncate">{selectedGroup.name}</span>
|
||||
<span className="shrink-0 font-mono text-[9px] tabular-nums text-muted">{groupCounts[selectedGroup.id] ?? 0}只</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted">{watchGroupsQ.isLoading ? '加载分组中...' : '选择自选分组...'}</span>
|
||||
)}
|
||||
{groupMenuOpen ? <ChevronUp className="h-3 w-3 shrink-0" /> : <ChevronDown className="h-3 w-3 shrink-0" />}
|
||||
</button>
|
||||
{groupMenuOpen && (
|
||||
<div className="absolute z-10 mt-1 max-h-56 w-56 overflow-y-auto rounded border border-border bg-surface py-1 shadow-lg">
|
||||
{watchGroupsQ.isLoading ? (
|
||||
<div className="px-2.5 py-2 text-[11px] text-muted">正在加载分组...</div>
|
||||
) : groupList.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-[11px] text-muted">
|
||||
还没有自选分组,<Link to="/watchlist" className="text-accent hover:text-accent/80">去自选页创建 →</Link>
|
||||
</div>
|
||||
) : groupList.map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft(d => ({ ...d, group_id: g.id }))
|
||||
setGroupMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left text-[11px] text-secondary transition-colors hover:bg-elevated hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${resolveWatchlistGroupColor(g.color).dot}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{g.name}</span>
|
||||
<span className="shrink-0 font-mono text-[9px] tabular-nums text-muted">{groupCounts[g.id] ?? 0}</span>
|
||||
{draft.group_id === g.id && <Check className="h-3 w-3 shrink-0 text-accent" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 成员预览 (只读): 让用户明确当前监控哪些标的; 与手动选标的的可编辑标签区分 */}
|
||||
{selectedGroup && (
|
||||
<div className="space-y-1">
|
||||
{selectedGroupSymbols.length > 0 ? (
|
||||
<div className="flex max-h-24 flex-wrap gap-1 overflow-y-auto rounded border border-border/60 bg-base/40 p-1.5">
|
||||
{selectedGroupSymbols.map(sym => {
|
||||
const b = boardTag(sym)
|
||||
return (
|
||||
<span key={sym} className="inline-flex items-center gap-1 rounded border border-border bg-elevated px-1.5 py-0.5 text-[10px] text-secondary">
|
||||
<span className="max-w-24 truncate text-foreground/90" title={groupNameBySymbol[sym] ? `${groupNameBySymbol[sym]} ${sym}` : sym}>
|
||||
{groupNameBySymbol[sym] ?? sym}
|
||||
</span>
|
||||
{b && <span className={`inline-flex items-center justify-center rounded px-0.5 text-[9px] font-bold leading-tight border ${b.color}`}>{b.label}</span>}
|
||||
<span className="font-mono text-[9px] tabular-nums text-muted">{sym}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed border-border px-2 py-1.5 text-[10px] text-muted">
|
||||
该分组当前没有标的, 后续在分组内添加自选会自动纳入监控
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-muted/70">
|
||||
动态绑定: 分组内增删标的自动同步监控范围, 无需修改本规则
|
||||
</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>
|
||||
@@ -995,8 +1131,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
||||
onChange={onSignalPickerChange}
|
||||
kind="entry"
|
||||
builtinSignals={pickerSignals}
|
||||
disabledSignals={intradaySupport?.available === false ? MONITOR_INTRADAY_SIGNAL_OPTIONS : []}
|
||||
disabledSignalHint={intradaySupport?.reason}
|
||||
disabledSignals={intradayDisabledSignals}
|
||||
disabledSignalHint={intradayDisabledHint}
|
||||
/>
|
||||
{hasIntradaySignal && (
|
||||
<div className={`mt-2 text-[10px] ${intradaySupport?.available === false ? 'text-danger' : 'text-muted'}`}>
|
||||
|
||||
@@ -780,8 +780,10 @@ export interface MonitorRule {
|
||||
enabled: boolean
|
||||
type: 'strategy' | 'signal' | 'price' | 'market' | 'ladder' | 'sector' | 'abnormal'
|
||||
asset_type?: 'stock' | 'etf' | 'index'
|
||||
scope: 'symbols' | 'all' | 'sector'
|
||||
scope: 'symbols' | 'all' | 'sector' | 'watchlist_group'
|
||||
symbols: string[]
|
||||
/** scope=watchlist_group 时绑定的自选分组 id (成员动态解析, 增删自选自动生效) */
|
||||
group_id?: string | null
|
||||
sector?: string | null
|
||||
sector_kind?: SectorKind | null
|
||||
sector_targets?: SectorMonitorTarget[]
|
||||
|
||||
@@ -60,6 +60,9 @@ export const storage = {
|
||||
/** 自选列表板块筛选 */
|
||||
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
|
||||
|
||||
/** 自选列表排除 ST 标的 (默认不排除) */
|
||||
watchlistExcludeST: kv<boolean>('watchlist_excludeST'),
|
||||
|
||||
/** 自选分组统计条配置 (metric: 统计指标, sort: 排序方式, card*: 分组卡片显示项) */
|
||||
watchlistGroupStats: kv<{ metric: string; sort: string; cardTopN?: number; cardColorBar?: boolean; cardRank?: boolean }>('watchlist_groupStats'),
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DatePicker } from '@/components/DatePicker'
|
||||
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { fmtBigNum, fmtPct } from '@/lib/format'
|
||||
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
|
||||
import { useDataStatus, useCapabilities, useSettings, usePreferences } from '@/lib/useSharedQueries'
|
||||
import { SealedBadge } from '@/components/SealedBadge'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { SettingsModal } from '@/components/data/SettingsModal'
|
||||
@@ -555,8 +555,22 @@ export function Dashboard() {
|
||||
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
|
||||
const sealedReady = !!data?.limit?.sealed_ready
|
||||
const isSealedDegrade = !hasDepth || !sealedReady
|
||||
// none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限
|
||||
const isNoKey = settings.data?.mode === 'none'
|
||||
// 空态引导文案按当前数据源分流: TickFlow 源提"免费服务器", 其他源提"当前数据源",
|
||||
// 弱化与默认 TickFlow 的隐式绑定 (None 档/免费 Key 等 TickFlow 概念仅在其被选中时出现)
|
||||
const prefs = usePreferences()
|
||||
const dataSourceList = useQuery({
|
||||
queryKey: QK.dataSources,
|
||||
queryFn: api.dataSources,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const activeProvider = prefs.data?.daily_data_provider || 'tickflow'
|
||||
const isTickflowProvider = activeProvider === 'tickflow'
|
||||
const providerLabel = [
|
||||
...(dataSourceList.data?.builtin ?? []),
|
||||
...(dataSourceList.data?.plugins ?? []),
|
||||
...(dataSourceList.data?.custom ?? []),
|
||||
].find(s => s.name === activeProvider)?.display_name
|
||||
?.replace(/(.*?)|\(.*?\)/g, '').trim() || activeProvider
|
||||
// 无本地数据(enriched/daily 都没有)→ 常驻引导卡片
|
||||
// 注: 后端 status 的 rows 为性能刻意返回 0, 用 trading_days 判断是否有数据
|
||||
const ds = dataStatus.data
|
||||
@@ -668,14 +682,16 @@ export function Dashboard() {
|
||||
stage={fetchStatus.data?.stage}
|
||||
fetchPct={fetchStatus.data?.progress}
|
||||
onStart={() => startFetch.mutate()}
|
||||
isNoKey={isNoKey}
|
||||
isTickflowProvider={isTickflowProvider}
|
||||
providerLabel={providerLabel}
|
||||
/>
|
||||
)}
|
||||
{/* 首次使用自动弹窗(同会话仅一次) */}
|
||||
<AnimatePresence>
|
||||
{showWelcomeModal && (
|
||||
<WelcomeFetchModal
|
||||
isNoKey={isNoKey}
|
||||
isTickflowProvider={isTickflowProvider}
|
||||
providerLabel={providerLabel}
|
||||
onClose={() => setShowWelcomeModal(false)}
|
||||
onStart={() => {
|
||||
startFetch.mutate()
|
||||
@@ -857,7 +873,8 @@ export function Dashboard() {
|
||||
|
||||
// ===== 无数据常驻引导卡片: 一键触发盘后管道获取行情数据(无 Key 也可) =====
|
||||
function FetchDataCard({
|
||||
isFetching, isStarting, fetchFailed, stage, fetchPct, onStart, isNoKey,
|
||||
isFetching, isStarting, fetchFailed, stage, fetchPct, onStart,
|
||||
isTickflowProvider, providerLabel,
|
||||
}: {
|
||||
isFetching: boolean
|
||||
isStarting: boolean
|
||||
@@ -865,7 +882,8 @@ function FetchDataCard({
|
||||
stage?: string
|
||||
fetchPct?: number
|
||||
onStart: () => void
|
||||
isNoKey: boolean
|
||||
isTickflowProvider: boolean
|
||||
providerLabel: string
|
||||
}) {
|
||||
const stageText = stage ? (STAGE_LABELS[stage] ?? stage) : '正在同步行情数据…'
|
||||
return (
|
||||
@@ -877,11 +895,13 @@ function FetchDataCard({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">当前暂无数据</div>
|
||||
<p className="mt-1 text-xs text-secondary leading-relaxed">
|
||||
首次使用需获取行情数据后才能查看看板。系统将从免费数据源拉取近 1 年全 A 股日K(约 5500 只),预计 1-3 分钟,期间可继续浏览其他页面。
|
||||
首次使用需获取行情数据后才能查看看板。{isTickflowProvider
|
||||
? '可通过 TickFlow 免费服务器拉取近 1 年全 A 股日K'
|
||||
: `将从当前数据源「${providerLabel}」拉取近 1 年全 A 股日K`}(约 5500 只),预计 1-3 分钟,期间可继续浏览其他页面。
|
||||
</p>
|
||||
{isNoKey && (
|
||||
{isTickflowProvider && (
|
||||
<p className="mt-1 text-[11px] text-warning/80 leading-relaxed">
|
||||
ⓘ 无需 API Key,当前为 None 档即可获取历史日K,可制定策略+回测。配置免费 Key 可解锁实时行情监控能力。
|
||||
ⓘ 获取数据后即可进行策略定制、回测验证、选股扫描等本地分析功能。
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -940,9 +960,10 @@ function FetchDataCard({
|
||||
|
||||
// ===== 首次使用自动弹窗: 询问用户后触发盘后管道 =====
|
||||
function WelcomeFetchModal({
|
||||
isNoKey, onClose, onStart,
|
||||
onClose, onStart, isTickflowProvider, providerLabel,
|
||||
}: {
|
||||
isNoKey: boolean
|
||||
isTickflowProvider: boolean
|
||||
providerLabel: string
|
||||
onClose: () => void
|
||||
onStart: () => void
|
||||
}) {
|
||||
@@ -959,12 +980,14 @@ function WelcomeFetchModal({
|
||||
</motion.div>
|
||||
<h3 className="mt-4 text-base font-semibold text-foreground">首次使用,需先获取行情数据</h3>
|
||||
<p className="mt-2 text-xs text-secondary leading-relaxed">
|
||||
系统将从免费数据源拉取近 1 年全 A 股日K(约 5500 只),预计 1-3 分钟。
|
||||
{isTickflowProvider
|
||||
? '可通过 TickFlow 免费服务器拉取近 1 年全 A 股日K'
|
||||
: `将从当前数据源「${providerLabel}」拉取近 1 年全 A 股日K`}(约 5500 只),预计 1-3 分钟。
|
||||
同步期间可继续浏览其他页面,完成后看板自动刷新。
|
||||
</p>
|
||||
{isNoKey && (
|
||||
{isTickflowProvider && (
|
||||
<div className="mt-3 rounded-btn bg-elevated/60 px-3 py-2 text-[11px] text-muted leading-relaxed">
|
||||
ⓘ 当前无需 API Key,None 档即可获取历史日K数据。
|
||||
ⓘ 获取数据后即可进行策略定制、回测验证等本地分析功能。
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex items-center justify-center gap-2.5">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { AlertTriangle, RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame, Tags } from 'lucide-react'
|
||||
@@ -14,6 +14,7 @@ import { cn } from '@/lib/cn'
|
||||
import { cnSignal } from '@/lib/signals'
|
||||
import { LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS, strategyEventMeta, strategyName } from '@/lib/strategyMonitorEvents'
|
||||
import { boardTag } from '@/components/stock-table/primitives'
|
||||
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
|
||||
import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge'
|
||||
import { RuleEditor } from '@/components/monitor/RuleEditor'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
@@ -632,6 +633,31 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
|
||||
const rules: MonitorRule[] = (rulesQuery.data as any)?.rules ?? []
|
||||
|
||||
// 分组作用域规则: 拉取分组定义与成员, 展示分组名/成员数 chip (点击跳转自选页对应分组)
|
||||
const hasGroupRules = rules.some(r => r.scope === 'watchlist_group')
|
||||
const groupsQ = useQuery({
|
||||
queryKey: QK.watchlistGroups,
|
||||
queryFn: api.watchlistGroups,
|
||||
enabled: hasGroupRules,
|
||||
})
|
||||
const watchlistQ = useQuery({
|
||||
queryKey: QK.watchlist,
|
||||
queryFn: api.watchlistList,
|
||||
enabled: hasGroupRules,
|
||||
})
|
||||
const groupMeta = useMemo(() => {
|
||||
const meta: Record<string, { name: string; color: string; count: number }> = {}
|
||||
for (const g of groupsQ.data?.groups ?? []) {
|
||||
meta[g.id] = { name: g.name, color: g.color, count: 0 }
|
||||
}
|
||||
for (const entry of watchlistQ.data?.symbols ?? []) {
|
||||
for (const gid of entry.group_ids ?? []) {
|
||||
if (meta[gid]) meta[gid].count += 1
|
||||
}
|
||||
}
|
||||
return meta
|
||||
}, [groupsQ.data, watchlistQ.data])
|
||||
|
||||
// 收集所有规则的股票代码, 批量查名称
|
||||
const allSymbols = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
@@ -716,7 +742,7 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
{r.asset_type === 'index' && (
|
||||
<span className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold bg-sky-500/10 text-sky-400">指数</span>
|
||||
)}
|
||||
{/* 个股类型: 直接显示可点击的代码+名称; 其他类型显示规则名 */}
|
||||
{/* 个股类型: 直接显示可点击的代码+名称; 分组类型: 分组chip跳自选页; 其他类型显示规则名 */}
|
||||
{r.scope === 'symbols' && r.symbols.length > 0 ? (
|
||||
<button
|
||||
onClick={() => setPreviewSymbol(r.symbols[0])}
|
||||
@@ -726,6 +752,25 @@ function RulesList({ rulesQuery, onEdit }: {
|
||||
<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>
|
||||
) : r.scope === 'watchlist_group' && r.group_id ? (
|
||||
(() => {
|
||||
const meta = groupMeta[r.group_id]
|
||||
if (!meta) {
|
||||
return <span className="text-xs text-warning truncate" title={r.name}>分组已删除</span>
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
to={`/watchlist?group=${r.group_id}`}
|
||||
className="inline-flex min-w-0 items-center gap-1.5 rounded px-0.5 transition-colors hover:bg-elevated/50 cursor-pointer"
|
||||
title={`「${meta.name}」分组 · 当前 ${meta.count} 只 · 点击查看分组`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${resolveWatchlistGroupColor(meta.color).dot}`} />
|
||||
<span className="truncate text-xs font-medium text-foreground hover:text-accent">{meta.name}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted">{meta.count}只</span>
|
||||
<span className="shrink-0 text-[9px] text-muted/60">· 分组作用域</span>
|
||||
</Link>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<h3 className={cn('text-xs font-medium truncate', r.enabled ? 'text-foreground' : 'text-muted')}>{displayName}</h3>
|
||||
)}
|
||||
|
||||
@@ -371,6 +371,8 @@ function DataSourceStep({ onNext, onBack }: { onNext: () => void; onBack: () =>
|
||||
const isSelected = selected === item.name
|
||||
const unavailable = item.kind === 'plugin' && !item.available
|
||||
const plugin = item.kind === 'plugin' ? plugins.find(p => p.name === item.name) : undefined
|
||||
// 切换中的目标卡片: 圆点位置显示转圈, 其余卡片压暗
|
||||
const switchingToThis = switchProvider.isPending && switchProvider.variables === item.name
|
||||
return (
|
||||
<button
|
||||
key={item.name}
|
||||
@@ -384,12 +386,16 @@ function DataSourceStep({ onNext, onBack }: { onNext: () => void; onBack: () =>
|
||||
: isSelected
|
||||
? 'border-accent/50 bg-accent/[0.06] ring-1 ring-accent/20 cursor-pointer'
|
||||
: 'border-border/60 bg-elevated/20 hover:bg-elevated/40 cursor-pointer'
|
||||
}`}
|
||||
} ${switchProvider.isPending && !switchingToThis ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${
|
||||
isSelected ? 'bg-accent' : 'bg-transparent border border-muted/40'
|
||||
}`} />
|
||||
{switchingToThis ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
|
||||
) : (
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${
|
||||
isSelected ? 'bg-accent' : 'bg-transparent border border-muted/40'
|
||||
}`} />
|
||||
)}
|
||||
<span className={`text-sm truncate flex-1 ${isSelected ? 'font-medium text-foreground' : 'text-secondary'}`}>
|
||||
{/* 卡片展示去掉声明里的括号备注 (如合规提示), 保持名称干净 */}
|
||||
{item.display_name.replace(/(.*?)|\(.*?\)/g, '').trim() || item.display_name}
|
||||
@@ -450,13 +456,6 @@ function DataSourceStep({ onNext, onBack }: { onNext: () => void; onBack: () =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 切换反馈 */}
|
||||
{switchProvider.isPending && (
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-muted">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
正在切换数据源…
|
||||
</div>
|
||||
)}
|
||||
{switchProvider.isError && (
|
||||
<div className="mt-3 flex items-start gap-1.5 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-[11px] leading-snug text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
@@ -511,10 +510,27 @@ function DataSourceStep({ onNext, onBack }: { onNext: () => void; onBack: () =>
|
||||
}
|
||||
|
||||
// ===== Step 3: 能力探测结果 =====
|
||||
// 按当前实际选中的数据源分流:
|
||||
// - TickFlow: 提示第三方源性质 + 可配 Key、按 Key 匹配档位, 展示档位与能力探测
|
||||
// - 其他源: 弱化 TickFlow (不展示其档位/能力), 只汇总所选源的数据集覆盖与回落规则
|
||||
|
||||
function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void }) {
|
||||
const settings = useSettings()
|
||||
const caps = useCapabilities()
|
||||
const prefs = usePreferences()
|
||||
const sources = useQuery({
|
||||
queryKey: QK.dataSources,
|
||||
queryFn: api.dataSources,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const activeName = prefs.data?.daily_data_provider || 'tickflow'
|
||||
const isTickflow = activeName === 'tickflow'
|
||||
const sourceItem = [
|
||||
...(sources.data?.builtin ?? []),
|
||||
...(sources.data?.plugins ?? []),
|
||||
...(sources.data?.custom ?? []),
|
||||
].find(s => s.name === activeName)
|
||||
|
||||
// 是否配置成功 —— 免费档(free)或付费档(api_key)都算;None 档算未配置
|
||||
const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key'
|
||||
@@ -529,57 +545,112 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
|
||||
<h2 className="text-xl font-bold text-foreground">能力探测结果</h2>
|
||||
</div>
|
||||
|
||||
{hasKey ? (
|
||||
{prefs.isLoading ? (
|
||||
<div className="mt-5 flex items-center gap-2 text-xs text-muted">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
正在读取当前数据源…
|
||||
</div>
|
||||
) : isTickflow ? (
|
||||
<>
|
||||
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
|
||||
Key 已生效,以下是你当前可用的全部能力。后续可在
|
||||
<span className="text-foreground font-medium"> 设置 → 账户 </span>
|
||||
中重新检测或更换 Key。
|
||||
</p>
|
||||
|
||||
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-[10px] uppercase tracking-widest text-muted">订阅档位</span>
|
||||
<span className="font-mono text-2xl font-bold tracking-tight text-foreground">
|
||||
{caps.data?.label ?? settings.data?.tier_label ?? '—'}
|
||||
</span>
|
||||
{/* TickFlow 源说明: 点明第三方性质 + Key/档位关系 */}
|
||||
<div className="mt-4 flex items-start gap-2.5 rounded-card border border-border/60 bg-surface/60 px-3.5 py-3">
|
||||
<Database className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent/70" />
|
||||
<div className="text-[11px] leading-relaxed text-muted">
|
||||
<span className="text-secondary">当前选择了 TickFlow 第三方数据源</span>
|
||||
。实时行情、监控等能力与订阅档位由 TickFlow Key 决定:可在
|
||||
<span className="text-foreground font-medium"> 设置 → 账户 </span>
|
||||
配置 Key,系统会根据 Key 自动匹配档位;未配置 Key 时按 None 档运行,仅保留内置历史数据能力。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{caps.isLoading ? (
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-muted">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
正在探测能力…
|
||||
</div>
|
||||
) : capList.length > 0 ? (
|
||||
<div className="mt-4 grid grid-cols-1 gap-1.5">
|
||||
{capList.slice(0, 8).map(([cap]) => {
|
||||
const meta = CAP_LABELS[cap]
|
||||
return (
|
||||
<div key={cap} className="flex items-center gap-2 text-xs">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-bear shrink-0" />
|
||||
<span className="text-foreground">{meta?.name ?? cap}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{capList.length > 8 && (
|
||||
<div className="text-[11px] text-muted pl-5">…等共 {capList.length} 项</div>
|
||||
{hasKey ? (
|
||||
<>
|
||||
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
|
||||
Key 已生效,以下是你当前可用的全部能力。后续可在
|
||||
<span className="text-foreground font-medium"> 设置 → 账户 </span>
|
||||
中重新检测或更换 Key。
|
||||
</p>
|
||||
|
||||
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-[10px] uppercase tracking-widest text-muted">订阅档位</span>
|
||||
<span className="font-mono text-2xl font-bold tracking-tight text-foreground">
|
||||
{caps.data?.label ?? settings.data?.tier_label ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{caps.isLoading ? (
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-muted">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
正在探测能力…
|
||||
</div>
|
||||
) : capList.length > 0 ? (
|
||||
<div className="mt-4 grid grid-cols-1 gap-1.5">
|
||||
{capList.slice(0, 8).map(([cap]) => {
|
||||
const meta = CAP_LABELS[cap]
|
||||
return (
|
||||
<div key={cap} className="flex items-center gap-2 text-xs">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-bear shrink-0" />
|
||||
<span className="text-foreground">{meta?.name ?? cap}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{capList.length > 8 && (
|
||||
<div className="text-[11px] text-muted pl-5">…等共 {capList.length} 项</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 text-xs text-muted">暂未探测到能力</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 text-xs text-muted">暂未探测到能力</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6 text-center">
|
||||
<div className="mx-auto w-fit rounded-xl bg-elevated p-3">
|
||||
<Zap className="h-6 w-6 text-warning" />
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-medium text-foreground">将以 None 档继续</div>
|
||||
<p className="mt-2 text-xs text-muted leading-relaxed max-w-sm mx-auto">
|
||||
当前未配置有效 Key,仍可使用看板、选股、回测等功能 —— 进入看板后可直接获取近 1 年历史日K数据。配置 Key 后可解锁实时行情监控等能力,随时在
|
||||
<span className="text-foreground font-medium"> 设置 → 账户 </span>填写。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6 text-center">
|
||||
<div className="mx-auto w-fit rounded-xl bg-elevated p-3">
|
||||
<Zap className="h-6 w-6 text-warning" />
|
||||
/* 其他数据源: 不展示 TickFlow 档位/能力探测, 汇总所选源的数据集覆盖 */
|
||||
<div className="mt-5 rounded-card border border-border bg-surface/80 backdrop-blur-sm p-5">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-[10px] uppercase tracking-widest text-muted">当前数据源</span>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span className="truncate text-lg font-bold text-foreground">
|
||||
{(sourceItem?.display_name ?? activeName).replace(/(.*?)|\(.*?\)/g, '').trim() || sourceItem?.display_name || activeName}
|
||||
</span>
|
||||
<span className="shrink-0 rounded bg-warning/15 px-1 py-0.5 text-[9px] font-medium leading-none text-warning">
|
||||
{sources.data?.custom?.some(s => s.name === activeName) ? '自有' : '第三方'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-medium text-foreground">将以 None 档继续</div>
|
||||
<p className="mt-2 text-xs text-muted leading-relaxed max-w-sm mx-auto">
|
||||
当前未配置有效 Key,仍可使用看板、选股、回测等功能 —— 进入看板后可直接获取近 1 年历史日K数据。配置 Key 后可解锁实时行情监控等能力,随时在
|
||||
<span className="text-foreground font-medium"> 设置 → 账户 </span>填写。
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1">
|
||||
{(sourceItem?.datasets?.length ?? 0) > 0 ? (
|
||||
sourceItem!.datasets.map(ds => (
|
||||
<span key={ds} className="rounded bg-elevated/60 px-1.5 py-0.5 text-[10px] text-secondary">
|
||||
{DATASET_LABELS[ds] ?? ds}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-muted">未声明数据集</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2.5 text-xs text-muted leading-relaxed">
|
||||
行情能力由所选数据源决定:以上数据集由该源提供,未覆盖的数据集自动回落内置源,无需额外配置。
|
||||
</p>
|
||||
|
||||
<div className="mt-3 border-t border-border/60 pt-2.5 text-[11px] leading-relaxed text-muted">
|
||||
TickFlow 的 Key 与档位探测仅在选择 TickFlow 作为数据源时展示;如需切换,前往
|
||||
<span className="text-foreground font-medium"> 设置 → 数据源 </span>。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Rows3, BarChart3, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen, FolderMinus } from 'lucide-react'
|
||||
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Rows3, BarChart3, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen, FolderMinus, FolderPlus } from 'lucide-react'
|
||||
import { api, type KlineRow, type MinuteKlineRow, type WatchlistGroup, type WatchlistGroupColor } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { storage } from '@/lib/storage'
|
||||
@@ -314,7 +314,7 @@ function StockSearchBox({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute right-0 top-full mt-1 z-50 w-64 max-h-[320px] overflow-y-auto rounded-card border border-border bg-base shadow-xl"
|
||||
className="absolute right-0 top-full mt-1 z-50 w-72 max-h-[320px] overflow-y-auto rounded-card border border-border bg-base shadow-xl"
|
||||
>
|
||||
{results.map((r, i) => {
|
||||
const entryGids = existingBySymbol.get(r.symbol)
|
||||
@@ -332,19 +332,22 @@ function StockSearchBox({
|
||||
className="flex items-center gap-2.5 flex-1 min-w-0 text-left"
|
||||
>
|
||||
<span className="font-mono shrink-0 w-[80px]">{r.symbol}</span>
|
||||
<span className="truncate text-secondary flex-1">{r.name}</span>
|
||||
{r.asset_type === 'etf' && (
|
||||
<span className="shrink-0 px-1 py-0.5 rounded text-[10px] leading-none bg-accent/10 text-accent">ETF</span>
|
||||
)}
|
||||
{r.asset_type === 'index' && (
|
||||
<span className="shrink-0 px-1 py-0.5 rounded text-[10px] leading-none bg-sky-500/10 text-sky-400">指数</span>
|
||||
)}
|
||||
{(() => {
|
||||
const b = boardTag(r.symbol)
|
||||
return b && (
|
||||
<span className={`shrink-0 px-1 py-0.5 rounded text-[10px] leading-none border ${b.color}`}>{b.label}</span>
|
||||
)
|
||||
})()}
|
||||
{/* 名称+标签组: 标签紧贴名称文字, 而不是被 flex-1 推到行尾 */}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span className="truncate text-secondary">{r.name}</span>
|
||||
{r.asset_type === 'etf' && (
|
||||
<span className="shrink-0 px-1 py-0.5 rounded text-[10px] leading-none bg-accent/10 text-accent">ETF</span>
|
||||
)}
|
||||
{r.asset_type === 'index' && (
|
||||
<span className="shrink-0 px-1 py-0.5 rounded text-[10px] leading-none bg-sky-500/10 text-sky-400">指数</span>
|
||||
)}
|
||||
{(() => {
|
||||
const b = boardTag(r.symbol)
|
||||
return b && (
|
||||
<span className={`shrink-0 px-1 py-0.5 rounded text-[10px] leading-none border ${b.color}`}>{b.label}</span>
|
||||
)
|
||||
})()}
|
||||
</span>
|
||||
</button>
|
||||
{inWatchlist ? (
|
||||
// 已加自选: 对勾标识 + 分组勾选面板, 可继续加入/移出其他分组
|
||||
@@ -366,14 +369,33 @@ function StockSearchBox({
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<WatchlistAddMenu
|
||||
onSelect={groupId => onAdd(r.symbol, groupId)}
|
||||
preferredGroupId={preferredGroupId}
|
||||
disabled={addPending}
|
||||
triggerClassName="shrink-0 rounded p-1 text-muted transition-colors hover:bg-accent/10 hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</WatchlistAddMenu>
|
||||
// 未加自选: + 一键加入当前分组页签 (全部/未分组页签下加为未分组);
|
||||
// 文件夹图标展开分组菜单, 显式选择目标分组
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={event => { event.stopPropagation(); onAdd(r.symbol, preferredGroupId ?? null) }}
|
||||
disabled={addPending}
|
||||
className="shrink-0 rounded p-1 text-muted transition-colors hover:bg-accent/10 hover:text-accent disabled:opacity-50 cursor-pointer"
|
||||
title={
|
||||
preferredGroupId
|
||||
? `加入自选 · 当前分组「${groups.find(g => g.id === preferredGroupId)?.name ?? ''}」`
|
||||
: '加入自选 (未分组)'
|
||||
}
|
||||
aria-label={`快速加入自选 ${r.symbol}`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<WatchlistAddMenu
|
||||
onSelect={groupId => onAdd(r.symbol, groupId)}
|
||||
preferredGroupId={preferredGroupId}
|
||||
disabled={addPending}
|
||||
triggerClassName="shrink-0 rounded p-1 text-muted transition-colors hover:bg-accent/10 hover:text-accent disabled:opacity-50"
|
||||
title="展开分组, 选择要加入的自选分组"
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
</WatchlistAddMenu>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -1066,6 +1088,15 @@ export function Watchlist() {
|
||||
})
|
||||
}, [persistBoardFilter])
|
||||
|
||||
// 排除 ST (含 *ST/S*ST 等变体, 按简称含 "ST" 判定), 默认关闭并持久化
|
||||
const [excludeST, setExcludeST] = useState(() => storage.watchlistExcludeST.get(false))
|
||||
const toggleExcludeST = useCallback(() => {
|
||||
setExcludeST(prev => {
|
||||
storage.watchlistExcludeST.set(!prev)
|
||||
return !prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateFilter = useCallback((colId: string, patch: { min?: string; max?: string; text?: string }) => {
|
||||
setFilters(prev => {
|
||||
const next = { ...prev }
|
||||
@@ -1083,6 +1114,8 @@ export function Watchlist() {
|
||||
const resetAllFilters = useCallback(() => {
|
||||
setFilters({})
|
||||
persistBoardFilter(new Set(BOARDS))
|
||||
setExcludeST(false)
|
||||
storage.watchlistExcludeST.set(false)
|
||||
}, [persistBoardFilter])
|
||||
|
||||
// 可筛选的内置列
|
||||
@@ -1116,6 +1149,10 @@ export function Watchlist() {
|
||||
return board != null && boardFilter.has(board)
|
||||
})
|
||||
}
|
||||
// 排除 ST: 按简称判定 (ST/*ST/S*ST 均含 "ST"); 非股票名称不含该标记, 天然不受影响
|
||||
if (excludeST) {
|
||||
result = result.filter(r => !((r.rt_name ?? r.name ?? '').toUpperCase().includes('ST')))
|
||||
}
|
||||
// 数值/文本筛选
|
||||
const activeFilters = Object.entries(filters).filter(([, v]) => v.min || v.max || v.text)
|
||||
if (activeFilters.length > 0) {
|
||||
@@ -1136,11 +1173,11 @@ export function Watchlist() {
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [rowsInSelectedGroup, filters, columns, boardFilter])
|
||||
}, [rowsInSelectedGroup, filters, columns, boardFilter, excludeST])
|
||||
|
||||
const activeFilterCount = Object.values(filters).filter(v => v.min || v.max || v.text).length
|
||||
const hasBoardFilter = boardFilter.size > 0 && boardFilter.size < BOARDS.length
|
||||
const hasActiveFilters = activeFilterCount > 0 || hasBoardFilter
|
||||
const hasActiveFilters = activeFilterCount > 0 || hasBoardFilter || excludeST
|
||||
|
||||
// 排序(复用共享三态排序 hook)。分时列按「最新分钟收盘 vs 昨收」排序(分时图最后一点同口径),
|
||||
// 其余列走共享取值;眼睛关闭时不拉分钟数据,取值为 null → 保持原序。
|
||||
@@ -1430,6 +1467,23 @@ export function Watchlist() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 排除 ST */}
|
||||
<div className="mb-2">
|
||||
<div className="text-[10px] text-muted uppercase tracking-wider mb-0.5">风险警示</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
onClick={toggleExcludeST}
|
||||
className={`px-2 py-0.5 rounded text-[11px] transition-colors ${
|
||||
excludeST
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'bg-elevated text-secondary hover:text-foreground hover:bg-elevated/80'
|
||||
}`}
|
||||
title="勾选后隐藏简称含 ST 标记的标的 (ST/*ST/S*ST)"
|
||||
>
|
||||
排除ST
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{COLUMN_GROUPS.map(cat => {
|
||||
const items = colsByCategory[cat.label]?.filter(i => i.col)
|
||||
if (!items?.length) return null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Check, Database, Plus, RefreshCw, Zap, FileWarning } from 'lucide-react'
|
||||
import { Check, Database, Plus, RefreshCw, Zap, FileWarning, Puzzle } from 'lucide-react'
|
||||
import { api, type DataSourceItem, type PluginDataSourceItem } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
@@ -199,10 +199,10 @@ export function SettingsDataSourcesPanel() {
|
||||
{item.display_name}
|
||||
</span>
|
||||
{item.name === 'tickflow' && (
|
||||
<span className="text-[9px] text-muted/50 uppercase tracking-wider shrink-0">内置</span>
|
||||
<span className="text-[9px] text-muted/50 uppercase tracking-wider shrink-0">第三方</span>
|
||||
)}
|
||||
{pluginNames.has(item.name) && (
|
||||
<span className="text-[9px] text-muted/50 uppercase tracking-wider shrink-0">插件</span>
|
||||
<span className="text-[9px] text-muted/50 uppercase tracking-wider shrink-0">第三方</span>
|
||||
)}
|
||||
{/* 右侧操作区: 插件未安装→安装按钮; 已激活→使用中; 否则→使用/卸载 */}
|
||||
{pluginUnavailable ? (
|
||||
@@ -311,6 +311,23 @@ export function SettingsDataSourcesPanel() {
|
||||
<span className="text-muted/30">·</span>
|
||||
<span>未启用的数据集自动回退 TickFlow</span>
|
||||
</div>
|
||||
|
||||
{/* 插件化说明 + 第三方数据源配置文档指引 (与引导页同口径) */}
|
||||
<div className="mt-3 flex items-start gap-2 rounded-lg border border-border/60 bg-elevated/20 px-3 py-2.5">
|
||||
<Puzzle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent/70" />
|
||||
<div className="text-[11px] leading-relaxed text-muted">
|
||||
<span className="text-secondary">数据源已插件化</span>
|
||||
,接入自有行情有两条路径:用 YAML 描述自有 HTTP 接口,放入
|
||||
<span className="mx-0.5 rounded bg-elevated/70 px-1 py-px font-mono text-[10px] text-secondary">data/data_sources/*.yaml</span>
|
||||
(也可用「新增数据源」表单配置);或开发插件源,放入
|
||||
<span className="mx-0.5 rounded bg-elevated/70 px-1 py-px font-mono text-[10px] text-secondary">backend/app/plugins/</span>
|
||||
目录。接入方法与字段映射详见
|
||||
<span className="mx-0.5 rounded bg-elevated/70 px-1 py-px font-mono text-[10px] text-secondary">docs/custom-data-source.md</span>
|
||||
与
|
||||
<span className="mx-0.5 rounded bg-elevated/70 px-1 py-px font-mono text-[10px] text-secondary">docs/plugin-development.md</span>
|
||||
。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== 下方: 编辑区 ===== */}
|
||||
@@ -441,7 +458,7 @@ function TickFlowDetail({ active, onSwitch, switching }: { active: boolean; onSw
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-base font-semibold text-foreground">TickFlow</h2>
|
||||
<span className="text-[10px] text-muted/60 uppercase tracking-wider border border-border rounded px-1.5 py-0.5">内置默认</span>
|
||||
<span className="text-[10px] text-muted/60 uppercase tracking-wider border border-border rounded px-1.5 py-0.5">第三方</span>
|
||||
{active && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-accent bg-accent/10 px-1.5 py-0.5 rounded">
|
||||
<Check className="h-2.5 w-2.5" /> 当前使用
|
||||
|
||||
Reference in New Issue
Block a user