mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat: 新增企业微信推送、个股分析页重构、价位算法优化及多处交互改进 (v0.1.82)
- 推送: 新增企业微信群机器人通道(与飞书并列), 监控告警/复盘报告多渠道分发 - 个股分析: 历史报告改为右侧常驻栏(展示全部股票报告), 进页面自动恢复上次选股 - 价位算法: 成交密集区改用换手率衰减模型(国内主流筹码分布); 缺口回补判定修正(必须完全穿越缺口) - 回测: 回撤止盈改纯峰值口径(与 trailing_stop 一致); 佣金/印花税/滑点合并一行; 建仓口径加问号气泡说明 - 分时图: 自选列表迷你分时图加渐变填充(对齐个股对话框风格) - 图表: 个股分析日K图右侧标签适配浅色主题; 价位标签与下方文字行双向 hover 高亮联动
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import sys
|
||||
|
||||
__version__ = "0.1.81"
|
||||
__version__ = "0.1.82"
|
||||
|
||||
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
|
||||
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
|
||||
|
||||
@@ -398,6 +398,7 @@ def get_preferences() -> dict:
|
||||
"system_notify_enabled": preferences.get_system_notify_enabled(),
|
||||
"feishu_webhook_url": preferences.get_feishu_webhook_url(),
|
||||
"feishu_webhook_secret": preferences.get_feishu_webhook_secret(),
|
||||
"wecom_webhook_url": preferences.get_wecom_webhook_url(),
|
||||
"webhook_enabled_default": preferences.get_webhook_enabled_default(),
|
||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||
"nav_order": preferences.get_nav_order(),
|
||||
@@ -821,6 +822,31 @@ def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict:
|
||||
return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret}
|
||||
|
||||
|
||||
class WecomWebhookPrefsIn(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
@router.put("/preferences/wecom-webhook")
|
||||
def update_wecom_webhook(req: WecomWebhookPrefsIn) -> dict:
|
||||
"""企业微信群机器人 Webhook 地址 — 与飞书并列的第二推送通道。
|
||||
|
||||
- url: 传入空串表示清空配置; 非空需为合法企业微信群机器人地址, 或纯 key。
|
||||
- 用户可只填 key (webhook/send?key=xxx 的 xxx 部分), 后端自动补全为完整 URL。
|
||||
"""
|
||||
from app.services import preferences
|
||||
from app.services import webhook_adapter
|
||||
|
||||
url = (req.url or "").strip()
|
||||
if url and not webhook_adapter.is_valid_wecom_url(url):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Webhook 地址非法, 需为企业微信群机器人地址 "
|
||||
"(https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=... 或纯 key)",
|
||||
)
|
||||
saved_url = preferences.set_wecom_webhook_url(url)
|
||||
return {"wecom_webhook_url": saved_url}
|
||||
|
||||
|
||||
class WebhookEnabledDefaultIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@@ -576,7 +576,8 @@ class BacktestEngine:
|
||||
if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price:
|
||||
peak_profit = peak_price / entry_price - 1
|
||||
if peak_profit >= abs(float(activate_pct)):
|
||||
risk_lines.append((entry_price * (1 + peak_profit - abs(float(drawdown_pct))), "trailing_take_profit"))
|
||||
# 回撤止盈触发线: 相对峰值价回撤 drawdown 个点 (纯峰值口径)
|
||||
risk_lines.append((peak_price * (1 - abs(float(drawdown_pct))), "trailing_take_profit"))
|
||||
|
||||
risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
|
||||
# 止损/移损/回撤止盈: 价格跌破风控线触发 (取最高优先级线)
|
||||
@@ -1039,7 +1040,9 @@ class BacktestEngine:
|
||||
if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price:
|
||||
peak_profit = peak_price / entry_price - 1
|
||||
if peak_profit >= abs(float(activate_pct)):
|
||||
take_profit_line = entry_price * (1 + peak_profit - abs(float(drawdown_pct)))
|
||||
# 回撤止盈触发线: 相对峰值价回撤 drawdown 个点 (纯峰值口径)
|
||||
# 启动门槛用成本基准的浮盈率, 触发线用峰值基准, 与 trailing_stop 同口径
|
||||
take_profit_line = peak_price * (1 - abs(float(drawdown_pct)))
|
||||
risk_lines.append((take_profit_line, "trailing_take_profit"))
|
||||
|
||||
# 止损/移损/回撤止盈: 价格跌破风控线触发
|
||||
|
||||
@@ -60,15 +60,31 @@ LEVEL_TYPES = {
|
||||
# ================================================================
|
||||
|
||||
def _support_resistance(df: pl.DataFrame, bins: int = 40) -> list[dict]:
|
||||
"""成交量分布 (Volume Profile) —— 真正基于价+量的支撑/压力位。
|
||||
"""筹码分布(换手率衰减模型) —— 国内主流 A 股支撑/压力位算法。
|
||||
|
||||
把每个价位层按价格分桶,统计落在该桶的累计成交量,取高成交密集区作为关键
|
||||
价位带。与 BOLL/Keltner 等"波动通道"不同,成交密集区反映的是真实换手堆积,
|
||||
是经典意义的支撑/压力。
|
||||
算法依据:大智慧/同花顺/通达信"筹码分布"专利模型(CN109711994A),物理含义是
|
||||
"当前各价位还剩多少筹码(持仓成本分布)",而非海外 Volume Profile 的"历史成交
|
||||
堆积"。两者关键区别在于衰减方式:
|
||||
- 海外 VP: 无衰减, 历史成交永远累加(反映"曾经在哪换过手")
|
||||
- 国内筹码分布: 按换手率衰减, 反映"现在谁还拿着"(物理正确的持仓成本)
|
||||
|
||||
密集区 = 成交量高于均值的桶,按成交量降序取前 3 个作为关键价位带:
|
||||
- POC(控制点):成交量最大的桶,标记为 strong
|
||||
- 其他高成交区:高于均值,标记为 medium
|
||||
核心迭代公式(逐日, 从老到新):
|
||||
当日收盘后各价位筹码 = 前一日各价位筹码 × (1 - 当日换手率)
|
||||
+ 当日新增成交(按 high~low 区间分摊到对应价位桶)
|
||||
|
||||
物理解释: 今天市场换了 turnover_rate 比例的手, 意味着昨天所有价位的筹码中有
|
||||
这么比例被换走了(被卖掉), 同时今天新成交的筹码按当天价格区间分布在各价位。
|
||||
这样:
|
||||
- 低换手股票的老筹码衰减慢(长期横盘低换手 → 历史套牢盘长期保留, 符合真实)
|
||||
- 高换手股票的老筹码快速消失(近期高换手 → 老筹码被消化, 近期成本占主导)
|
||||
比固定时间衰减更聪明: 停牌期间换手率为 0, 筹码不动; 固定时间衰减会错误地衰减。
|
||||
|
||||
成交量按 high~low 价格区间分摊到桶(而非全归 (high+low)/2 中点), 振幅大的 K 线
|
||||
不再污染中点价, 更接近真实成交分布。
|
||||
|
||||
密集区输出:
|
||||
- POC(控制点):当前筹码量最大的桶(=最多人持仓的成本区), strong
|
||||
- 其他高筹码区:高于均值, medium, 最多 2 个
|
||||
"""
|
||||
if df.is_empty() or "volume" not in df.columns or df.height < 20:
|
||||
return []
|
||||
@@ -78,33 +94,54 @@ def _support_resistance(df: pl.DataFrame, bins: int = 40) -> list[dict]:
|
||||
if not (hi > lo > 0):
|
||||
return []
|
||||
|
||||
# 每根 K 的价格区间中点 × 成交量 ≈ 该价位层贡献的成交量(简化模型)
|
||||
df2 = df.select([
|
||||
((pl.col("high") + pl.col("low")) / 2).alias("mid"),
|
||||
pl.col("volume").alias("vol"),
|
||||
]).drop_nulls()
|
||||
n = df.height
|
||||
|
||||
# 桶边界:bins 个桶需要 bins-1 个内部 break,cut 据此切成 bins 段
|
||||
# 桶边界:bins 个桶需要 bins-1 个内部 break
|
||||
step = (hi - lo) / bins
|
||||
edges = [lo + i * step for i in range(bins + 1)] # 含首尾,共 bins+1 个边界值
|
||||
breaks = edges[1:-1] # 内部 break,bins-1 个
|
||||
bin_labels = [f"{i}" for i in range(bins)] # 桶序号 0..bins-1
|
||||
# 至少要有 1 个不同的内部 break
|
||||
if len(set(f"{b:.6f}" for b in breaks)) < 1:
|
||||
return []
|
||||
edges = [lo + i * step for i in range(bins + 1)]
|
||||
|
||||
df2 = df2.with_columns(
|
||||
pl.col("mid").cut(breaks, labels=bin_labels).alias("bin")
|
||||
)
|
||||
prof = df2.group_by("bin").agg(pl.col("vol").sum())
|
||||
if prof.is_empty():
|
||||
return []
|
||||
# 取换手率序列(百分数, 如 1.23 表示 1.23%)。无该字段则退化为纯累加(无衰减)。
|
||||
has_turnover = "turnover_rate" in df.columns
|
||||
turnovers = df["turnover_rate"].to_list() if has_turnover else [0.0] * n
|
||||
|
||||
# 把桶序号字符串还原为 int,以便回查 edges;并按序号排序保证可索引
|
||||
prof = prof.with_columns(pl.col("bin").cast(pl.Int64).alias("bi")).sort("bi")
|
||||
bin_ids = prof["bi"].to_list()
|
||||
vols = prof["vol"].to_list()
|
||||
mean_vol = sum(vols) / len(vols) if vols else 0
|
||||
highs = df["high"].to_list()
|
||||
lows = df["low"].to_list()
|
||||
vols = df["volume"].to_list()
|
||||
|
||||
# 逐日迭代: 保留各桶的"当前筹码量"。
|
||||
# 顺序: 从老(i=0)到新(i=n-1)。每日先把存量按 (1 - turnover) 衰减, 再叠加当日新增。
|
||||
chips = [0.0] * bins
|
||||
for i in range(n):
|
||||
t = float(turnovers[i] or 0)
|
||||
# 换手率正常 0~100, 异常值(负/超大)钳制到 [0, 1] 区间比例
|
||||
decay_ratio = 1.0 - max(0.0, min(t, 100.0)) / 100.0
|
||||
|
||||
# 1) 存量筹码按今日换手率衰减(被卖掉的部分移除)
|
||||
if decay_ratio < 1.0:
|
||||
for k in range(bins):
|
||||
chips[k] *= decay_ratio
|
||||
|
||||
# 2) 当日新增成交按 high~low 区间分摊到对应价位桶
|
||||
v = vols[i] or 0
|
||||
if v > 0:
|
||||
k_low = min(int((lows[i] - lo) / step), bins - 1)
|
||||
k_high = min(int((highs[i] - lo) / step), bins - 1)
|
||||
if k_low > k_high:
|
||||
k_low, k_high = k_high, k_low
|
||||
if k_high < 0 or k_low >= bins:
|
||||
continue
|
||||
k_low = max(k_low, 0)
|
||||
k_high = min(k_high, bins - 1)
|
||||
share = v / (k_high - k_low + 1)
|
||||
for k in range(k_low, k_high + 1):
|
||||
chips[k] += share
|
||||
|
||||
# 只保留有筹码的桶
|
||||
bin_ids = [k for k in range(bins) if chips[k] > 0]
|
||||
if not bin_ids:
|
||||
return []
|
||||
vals = [chips[k] for k in bin_ids]
|
||||
mean_val = sum(vals) / len(vals) if vals else 0
|
||||
|
||||
def bin_mid(bin_id: int) -> float:
|
||||
return (edges[bin_id] + edges[bin_id + 1]) / 2
|
||||
@@ -112,14 +149,14 @@ def _support_resistance(df: pl.DataFrame, bins: int = 40) -> list[dict]:
|
||||
close = float(df.tail(1)["close"][0])
|
||||
|
||||
out: list[dict] = []
|
||||
# POC:成交量最大的桶
|
||||
poc_pos = max(range(len(vols)), key=lambda i: vols[i])
|
||||
# POC: 当前筹码量最大的桶(最多人持仓的成本区)
|
||||
poc_pos = max(range(len(vals)), key=lambda i: vals[i])
|
||||
poc_mid = bin_mid(bin_ids[poc_pos])
|
||||
out.append({"value": round(poc_mid, 2), "label": "成交密集区(POC)",
|
||||
"type": "sr", "side": _side(poc_mid, close), "strength": "strong"})
|
||||
|
||||
# 其他高成交区(高于均值,排除 POC),按成交量降序取 2 个
|
||||
candidates = [(i, v) for i, v in enumerate(vols) if v > mean_vol and i != poc_pos]
|
||||
# 其他高筹码区(高于均值, 排除 POC), 按筹码量降序取 2 个
|
||||
candidates = [(i, v) for i, v in enumerate(vals) if v > mean_val and i != poc_pos]
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
for i, _v in candidates[:2]:
|
||||
mid = bin_mid(bin_ids[i])
|
||||
@@ -373,8 +410,10 @@ def _gap_levels(df: pl.DataFrame, lookback: int = 120) -> list[dict]:
|
||||
向上缺口:当日 low > 前日 high(开盘跳空高开,全天未回补)
|
||||
向下缺口:当日 high < 前日 low(开盘跳空低开,全天未回补)
|
||||
|
||||
缺口是天然的支撑/阻力位。只保留"未回补"的(后续价格未回到缺口区间内),
|
||||
并按价格聚合相近缺口(±0.5%),每方向只取距当前价最近的 2~3 个。
|
||||
缺口是天然的支撑/阻力位。只保留"未回补"的:缺口形成后,后续任何一根 K 线的
|
||||
价格(low/high)只要回到缺口区间内,就算已回补(支撑/阻力已被测试消化),过滤掉。
|
||||
这与"当前价是否在缺口内"无关 —— 即使价格后来远离,只要曾经回补过就不算有效缺口。
|
||||
最后按价格聚合相近缺口(±0.5%),每方向只取距当前价最近的 2~3 个。
|
||||
"""
|
||||
if df.is_empty() or df.height < 5:
|
||||
return []
|
||||
@@ -383,34 +422,44 @@ def _gap_levels(df: pl.DataFrame, lookback: int = 120) -> list[dict]:
|
||||
highs = sub["high"].to_list()
|
||||
lows = sub["low"].to_list()
|
||||
|
||||
up_gaps: list[tuple[float, float]] = [] # (缺口低点, 缺口高点)
|
||||
dn_gaps: list[tuple[float, float]] = []
|
||||
# 收集缺口: (形成位置 i, 缺口下沿, 缺口上沿)
|
||||
# 向上缺口: 第 i 日 low > 第 i-1 日 high, 缺口区间 = (highs[i-1], lows[i])
|
||||
# 向下缺口: 第 i 日 high < 第 i-1 日 low, 缺口区间 = (highs[i], lows[i-1])
|
||||
up_gaps: list[tuple[int, float, float]] = []
|
||||
dn_gaps: list[tuple[int, float, float]] = []
|
||||
for i in range(1, len(highs)):
|
||||
if _ok(highs[i]) and _ok(lows[i]) and _ok(highs[i - 1]) and _ok(lows[i - 1]):
|
||||
if lows[i] > highs[i - 1]: # 向上缺口
|
||||
up_gaps.append((highs[i - 1], lows[i]))
|
||||
elif highs[i] < lows[i - 1]: # 向下缺口
|
||||
dn_gaps.append((highs[i], lows[i - 1]))
|
||||
if lows[i] > highs[i - 1]:
|
||||
up_gaps.append((i, float(highs[i - 1]), float(lows[i])))
|
||||
elif highs[i] < lows[i - 1]:
|
||||
dn_gaps.append((i, float(highs[i]), float(lows[i - 1])))
|
||||
|
||||
def _filter_unfilled(gaps: list[tuple[float, float]], is_up: bool) -> list[float]:
|
||||
"""过滤掉已被后续价格回补的缺口,取缺口价位中点。"""
|
||||
def _filter_unfilled(gaps: list[tuple[int, float, float]]) -> list[float]:
|
||||
"""过滤掉已被回补的缺口。
|
||||
|
||||
回补判定: 缺口在位置 i 形成, 向后扫描 i+1..end, 只要任意一根 K 线的价格区间
|
||||
完全覆盖缺口真空带(low <= g_hi 且 high >= g_lo), 即价格真正穿越了缺口 → 已回补。
|
||||
仅"触及缺口边缘"(如 low 跌到上沿)不算回补, 因为缺口是价格真空带, 必须整个
|
||||
被某根 K 线的高低区间覆盖才算填补了真空。
|
||||
"""
|
||||
mids: list[float] = []
|
||||
for g_lo, g_hi in gaps:
|
||||
# 未回补判定:当前价不在缺口区间内
|
||||
if is_up and close >= g_hi: # 向上缺口:价格已超过缺口上沿 = 未回补(站在缺口上方)
|
||||
for i, g_lo, g_hi in gaps:
|
||||
filled = False
|
||||
for j in range(i + 1, len(highs)):
|
||||
if lows[j] <= g_hi and highs[j] >= g_lo:
|
||||
filled = True
|
||||
break
|
||||
if not filled:
|
||||
mids.append((g_lo + g_hi) / 2)
|
||||
elif not is_up and close <= g_lo: # 向下缺口:价格已低于缺口下沿 = 未回补
|
||||
mids.append((g_lo + g_hi) / 2)
|
||||
# 聚合相近缺口 + 按距当前价排序取最近 3 个
|
||||
agg = _aggregate_levels(mids, 0.005)
|
||||
agg.sort(key=lambda v: abs(v - close))
|
||||
return agg[:3]
|
||||
|
||||
out: list[dict] = []
|
||||
for mid in _filter_unfilled(up_gaps, True):
|
||||
for mid in _filter_unfilled(up_gaps):
|
||||
out.append({"value": round(mid, 2), "label": "向上缺口",
|
||||
"type": "gap", "side": _side(mid, close), "strength": "medium"})
|
||||
for mid in _filter_unfilled(dn_gaps, False):
|
||||
for mid in _filter_unfilled(dn_gaps):
|
||||
out.append({"value": round(mid, 2), "label": "向下缺口",
|
||||
"type": "gap", "side": _side(mid, close), "strength": "medium"})
|
||||
return out
|
||||
|
||||
@@ -726,6 +726,17 @@ def _maybe_push_review(content: str, meta: dict) -> None:
|
||||
url, "TickFlow · 每日复盘", subtitle, content, secret
|
||||
)
|
||||
logger.info("review push(feishu) %s", "sent" if ok else "failed")
|
||||
elif ch == "wecom":
|
||||
url = preferences.get_wecom_webhook_url()
|
||||
if not url:
|
||||
logger.info("review push(wecom) skipped: webhook not configured")
|
||||
continue
|
||||
# 企业微信 markdown 标题已含一级标题, subtitle 拼到正文首行
|
||||
full_body = (f"**{subtitle}**\n\n{content}" if subtitle else content)
|
||||
ok = webhook_adapter.send_wecom_markdown(
|
||||
url, "TickFlow · 每日复盘", full_body
|
||||
)
|
||||
logger.info("review push(wecom) %s", "sent" if ok else "failed")
|
||||
# 未来更多渠道在此追加分支
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("review push error: %s", e)
|
||||
|
||||
@@ -283,9 +283,9 @@ def set_depth_finalize_time(hour: int, minute: int) -> dict:
|
||||
return {"hour": h, "minute": m}
|
||||
|
||||
|
||||
# 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位)
|
||||
# 复盘推送可选渠道白名单 (企业微信已实现, 与飞书并列)
|
||||
# 多选: 不推送 = 空数组, 而非 'none'
|
||||
REVIEW_PUSH_CHANNELS = {"feishu"}
|
||||
REVIEW_PUSH_CHANNELS = {"feishu", "wecom"}
|
||||
|
||||
|
||||
def get_review_schedule() -> dict:
|
||||
@@ -476,6 +476,25 @@ def set_feishu_webhook_secret(secret: str) -> str:
|
||||
return get_feishu_webhook_secret()
|
||||
|
||||
|
||||
def get_wecom_webhook_url() -> str:
|
||||
"""企业微信群机器人 Webhook 地址 — 与飞书并列的第二推送通道。
|
||||
|
||||
存储完整 URL (https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx);
|
||||
用户也可只填 key, 由 webhook_adapter.normalize_wecom_url 自动补全。
|
||||
"""
|
||||
return load().get("wecom_webhook_url", "")
|
||||
|
||||
|
||||
def set_wecom_webhook_url(url: str) -> str:
|
||||
"""保存企业微信 Webhook 地址。传入空串表示清空配置。
|
||||
|
||||
存储时统一补全为完整 URL, 避免后续每次推送都要再判一次。
|
||||
"""
|
||||
from app.services.webhook_adapter import normalize_wecom_url
|
||||
save({"wecom_webhook_url": normalize_wecom_url(url)})
|
||||
return get_wecom_webhook_url()
|
||||
|
||||
|
||||
def get_webhook_enabled_default() -> bool:
|
||||
"""新建监控规则时是否默认勾选「飞书推送」。
|
||||
|
||||
|
||||
@@ -886,7 +886,7 @@ class QuoteService:
|
||||
def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None:
|
||||
"""把告警通过 Webhook 推送到外部 IM (由规则 webhook_enabled 开关控制)。
|
||||
|
||||
- 全局飞书 URL 未配置: 直接返回
|
||||
- 飞书 / 企业微信任一已配置即生效 (两个都没配才跳过)
|
||||
- 仅推送 webhook_enabled=True 的规则触发的告警
|
||||
- 失败静默, 不阻断主流程
|
||||
- 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重
|
||||
@@ -898,10 +898,12 @@ class QuoteService:
|
||||
from app.services import preferences
|
||||
from app.services import webhook_adapter
|
||||
|
||||
url = preferences.get_feishu_webhook_url()
|
||||
if not url:
|
||||
feishu_url = preferences.get_feishu_webhook_url()
|
||||
feishu_secret = preferences.get_feishu_webhook_secret()
|
||||
wecom_url = preferences.get_wecom_webhook_url()
|
||||
# 两个通道都没配置才跳过
|
||||
if not feishu_url and not wecom_url:
|
||||
return
|
||||
secret = preferences.get_feishu_webhook_secret()
|
||||
|
||||
# 反查规则, 过滤出启用推送的事件
|
||||
source_labels = {
|
||||
@@ -909,7 +911,8 @@ class QuoteService:
|
||||
"price": "价格", "market": "异动",
|
||||
}
|
||||
rules = engine.rules if engine is not None else {}
|
||||
pushed = 0
|
||||
pushed_feishu = 0
|
||||
pushed_wecom = 0
|
||||
for ev in rule_events:
|
||||
rule = rules.get(ev.get("rule_id"))
|
||||
if not rule or not rule.get("webhook_enabled"):
|
||||
@@ -921,10 +924,14 @@ class QuoteService:
|
||||
message = ev.get("message") or ""
|
||||
title = f"TickFlow · {source_label}"
|
||||
body = f"{symbol} {name} {message}".strip() if symbol else (message or name)
|
||||
if webhook_adapter.send_feishu(url, title, body, secret):
|
||||
pushed += 1
|
||||
if pushed:
|
||||
logger.info("飞书 Webhook 推送: %d 条", pushed)
|
||||
if feishu_url and webhook_adapter.send_feishu(feishu_url, title, body, feishu_secret):
|
||||
pushed_feishu += 1
|
||||
if wecom_url and webhook_adapter.send_wecom(wecom_url, title, body):
|
||||
pushed_wecom += 1
|
||||
if pushed_feishu:
|
||||
logger.info("飞书 Webhook 推送: %d 条", pushed_feishu)
|
||||
if pushed_wecom:
|
||||
logger.info("企业微信 Webhook 推送: %d 条", pushed_wecom)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("Webhook 推送异常 (不影响告警主流程): %s", e)
|
||||
|
||||
|
||||
@@ -168,3 +168,128 @@ def send_feishu_card(webhook_url: str, title: str, subtitle: str, body_md: str,
|
||||
},
|
||||
}
|
||||
return _post_feishu(webhook_url, payload, secret)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 企业微信群机器人
|
||||
# ================================================================
|
||||
#
|
||||
# 与飞书自定义机器人几乎同构: 同样是"群机器人 Webhook + POST JSON"。
|
||||
# 关键差异:
|
||||
# 1. Webhook 形态: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
|
||||
# 2. 无需签名校验 (key 本身即凭证; 企业微信群机器人可选"签名校验"但极少用)
|
||||
# 3. Markdown 原生支持 (msg_type=markdown), 不必像飞书那样包进 interactive 卡片
|
||||
# 4. 成功响应: {"errcode":0,"errmsg":"ok"}
|
||||
#
|
||||
# 限制: 每个机器人每分钟最多 20 条消息 (超出会被限流 460min 内不可用),
|
||||
# 依赖 MonitorRuleEngine 的 cooldown 去重即可应对告警场景。
|
||||
# 企业微信群的消息可在绑定的个人微信接收, 实现"微信推送"体验。
|
||||
|
||||
WECOM_HOOK_PREFIX = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send"
|
||||
|
||||
|
||||
def is_valid_wecom_url(url: str) -> bool:
|
||||
"""校验是否为合法的企业微信群机器人 Webhook 地址。
|
||||
|
||||
允许两种写法:
|
||||
- 完整: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
|
||||
- 仅 key: xxx (企业微信群机器人 key 为 36 位 UUID 样式, 保存时自动补全)
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
if url.startswith(WECOM_HOOK_PREFIX):
|
||||
return True
|
||||
# 纯 key: 企业微信 key 形如 12345678-1234-1234-1234-1234567890ab (36 位),
|
||||
# 但用户可能截断, 放宽到 >= 20 位的无空格无斜杠字符串。
|
||||
url = url.strip()
|
||||
if " " in url or "/" in url or "?" in url:
|
||||
return False
|
||||
return len(url) >= 20
|
||||
|
||||
|
||||
def normalize_wecom_url(url: str) -> str:
|
||||
"""把纯 key 补全为完整 Webhook URL。已是完整 URL 则原样返回。"""
|
||||
url = (url or "").strip()
|
||||
if not url:
|
||||
return ""
|
||||
if url.startswith(WECOM_HOOK_PREFIX):
|
||||
return url
|
||||
return f"{WECOM_HOOK_PREFIX}?key={url}"
|
||||
|
||||
|
||||
def _post_wecom(webhook_url: str, payload: dict) -> bool:
|
||||
"""发送一次企业微信 webhook 请求并判定成败。
|
||||
|
||||
成功响应: HTTP 200 且 errcode=0。失败静默返回 False。
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.post(webhook_url, json=payload, timeout=5.0)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
if isinstance(data, dict):
|
||||
# errcode=0 表示成功; 45009=频率限制, 其它非零=业务失败
|
||||
if data.get("errcode") == 0:
|
||||
return True
|
||||
logger.debug("企业微信推送业务失败: %s", data)
|
||||
return False
|
||||
except ValueError:
|
||||
return True
|
||||
logger.debug("企业微信推送 HTTP %s: %s", resp.status_code, resp.text[:200])
|
||||
return False
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("企业微信 Webhook 推送失败: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def send_wecom(webhook_url: str, title: str, body: str) -> bool:
|
||||
"""推送一条文本消息到企业微信群机器人。
|
||||
|
||||
Args:
|
||||
webhook_url: 企业微信群机器人 Webhook 地址 (或纯 key, 会自动补全)
|
||||
title: 消息标题 (与正文拼接为一条文本)
|
||||
body: 消息正文
|
||||
|
||||
Returns:
|
||||
True=成功送达, False=失败或 URL 非法。
|
||||
失败静默, 不抛异常 (与 send_feishu 一致)。
|
||||
"""
|
||||
webhook_url = normalize_wecom_url(webhook_url)
|
||||
if not is_valid_wecom_url(webhook_url):
|
||||
return False
|
||||
|
||||
text = _truncate(f"{title}\n{body}".strip())
|
||||
if not text:
|
||||
return False
|
||||
|
||||
payload: dict = {"msg_type": "text", "text": {"content": text}}
|
||||
return _post_wecom(webhook_url, payload)
|
||||
|
||||
|
||||
def send_wecom_markdown(webhook_url: str, title: str, body_md: str) -> bool:
|
||||
"""推送一条 Markdown 消息到企业微信群机器人 —— 承载完整复盘报告。
|
||||
|
||||
企业微信群机器人原生支持 markdown 类型 (比飞书 interactive 卡片简单),
|
||||
支持 # ## **粗体** >引用 - 列表 等基础语法, 单条上限 4096 字节。
|
||||
|
||||
Args:
|
||||
webhook_url: 企业微信群机器人 Webhook 地址 (或纯 key)
|
||||
title: 标题 (作为一级标题 ## 拼到正文前)
|
||||
body_md: markdown 正文
|
||||
|
||||
Returns:
|
||||
True=成功送达, False=失败或 URL 非法。
|
||||
"""
|
||||
webhook_url = normalize_wecom_url(webhook_url)
|
||||
if not is_valid_wecom_url(webhook_url):
|
||||
return False
|
||||
|
||||
content = f"## {title}\n\n{_truncate_card(body_md)}"
|
||||
if not content.strip():
|
||||
return False
|
||||
|
||||
payload: dict = {"msg_type": "markdown", "markdown": {"content": content}}
|
||||
return _post_wecom(webhook_url, payload)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.81"
|
||||
version = "0.1.82"
|
||||
description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
|
||||
readme = "../README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tickflow-stock-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.81",
|
||||
"version": "0.1.82",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -125,11 +125,15 @@ export function AnalysisKChart({
|
||||
}: Props) {
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const chartInstRef = useRef<ECharts | null>(null)
|
||||
/** seriesIndex → levelKey 映射, buildOption 填充, ECharts hover 事件反查 */
|
||||
const seriesKeyMapRef = useRef<Map<number, string>>(new Map())
|
||||
// 主题: buildOption 内部用 CT() 动态取色, 这里只负责切换时触发重建
|
||||
const theme = useTheme()
|
||||
const [activeTypes, setActiveTypes] = useState<Set<LevelType>>(new Set(defaultLevelTypes))
|
||||
/** 枢轴点显示到第几档:1=只P+R1/S1, 2=到R2/S2, 3=全档(R3/S3) */
|
||||
const [pivotRank, setPivotRank] = useState<1 | 2 | 3>(1)
|
||||
/** 双向联动高亮: hover 价位标签 ↔ hover 下方文字行。值为 levelKey, null=无高亮 */
|
||||
const [hoveredKey, setHoveredKey] = useState<string | null>(null)
|
||||
|
||||
// 数据预处理 + 带状曲线序列对齐(后端 series 的日期范围可能与 rows 不同,需映射)
|
||||
const { dates, candle, vols, dateIndex, zoomStart, alignedSeries } = useMemo(() => {
|
||||
@@ -221,6 +225,8 @@ export function AnalysisKChart({
|
||||
const series: any[] = [
|
||||
{
|
||||
name: 'K', type: 'candlestick', data: candle, animation: false,
|
||||
// z=2 让蜡烛始终在价位线(z=1)之上, hover 高亮价位线时不会被遮挡/变淡
|
||||
z: 2,
|
||||
itemStyle: {
|
||||
color: THEME.bull, color0: THEME.bear,
|
||||
borderColor: THEME.bull, borderColor0: THEME.bear,
|
||||
@@ -236,18 +242,31 @@ export function AnalysisKChart({
|
||||
|
||||
// 价位水平线 —— 用 line series(恒定值)画水平线,endLabel 显示标签文字;
|
||||
// 与通道曲线一致,标签落在右侧 grid.right 预留带(外侧),不压蜡烛。
|
||||
// hoveredKey 非空时:命中线加粗高亮,其它线淡化(opacity 0.15),形成聚焦效果。
|
||||
const dimming = hoveredKey != null
|
||||
for (const p of priceLines) {
|
||||
const k = levelKey(p.type, p.value)
|
||||
const hit = hoveredKey === k
|
||||
const opacity = dimming ? (hit ? 1 : 0.12) : 0.7
|
||||
const width = hit ? 2 : 1
|
||||
series.push({
|
||||
name: p.label, type: 'line', silent: true, animation: false,
|
||||
name: p.label, type: 'line', silent: false, animation: false,
|
||||
symbol: 'none',
|
||||
data: dates.map(() => p.value),
|
||||
lineStyle: { width: 1, color: p.color, type: 'dashed', opacity: 0.7 },
|
||||
// 默认 z=1 在蜡烛(z=2)之下; 命中时 zlevel=10 提到独立顶层, 标签不再被遮挡
|
||||
z: 1,
|
||||
zlevel: hit ? 10 : 0,
|
||||
lineStyle: { width, color: p.color, type: 'dashed', opacity },
|
||||
itemStyle: { color: p.color },
|
||||
endLabel: {
|
||||
show: true,
|
||||
formatter: () => `${p.label} ${p.value.toFixed(2)}`,
|
||||
color: p.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
|
||||
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
|
||||
color: p.color, fontSize: hit ? 10 : 9, fontFamily: 'JetBrains Mono, monospace',
|
||||
fontWeight: hit ? 'bold' : 'normal',
|
||||
backgroundColor: hit ? CT().tooltipBg : CT().infoBarBg,
|
||||
borderColor: hit ? p.color : 'transparent',
|
||||
borderWidth: hit ? 1 : 0,
|
||||
padding: [2, 5], borderRadius: 2,
|
||||
distance: 6,
|
||||
},
|
||||
})
|
||||
@@ -264,22 +283,47 @@ export function AnalysisKChart({
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
if (data[i] != null) { lastVal = data[i]; break }
|
||||
}
|
||||
// 曲线 key 用 group(同组上下轨联动),hover 命中时高亮
|
||||
const hit = hoveredKey === def.group
|
||||
const opacity = dimming ? (hit ? 1 : 0.12) : 0.8
|
||||
const width = hit ? 1.8 : 1
|
||||
series.push({
|
||||
name: def.endLabel, type: 'line', data: data.map(v => v ?? '-'),
|
||||
smooth: true, symbol: 'none', silent: true, animation: false,
|
||||
lineStyle: { width: 1, color: def.color, type: def.dashed === false ? 'solid' : 'dashed', opacity: 0.8 },
|
||||
smooth: true, symbol: 'none', silent: false, animation: false,
|
||||
z: 1,
|
||||
zlevel: hit ? 10 : 0,
|
||||
lineStyle: { width, color: def.color, type: def.dashed === false ? 'solid' : 'dashed', opacity },
|
||||
itemStyle: { color: def.color },
|
||||
// 右侧端点标签:显示该通道的最新数值,距绘图区右缘留 6px 间距
|
||||
endLabel: lastVal != null ? {
|
||||
show: true,
|
||||
formatter: () => `${lastVal!.toFixed(2)}`,
|
||||
color: def.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
|
||||
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
|
||||
color: def.color, fontSize: hit ? 10 : 9, fontFamily: 'JetBrains Mono, monospace',
|
||||
fontWeight: hit ? 'bold' : 'normal',
|
||||
backgroundColor: hit ? CT().tooltipBg : CT().infoBarBg,
|
||||
borderColor: hit ? def.color : 'transparent',
|
||||
borderWidth: hit ? 1 : 0,
|
||||
padding: [2, 5], borderRadius: 2,
|
||||
distance: 6,
|
||||
} : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 填充 seriesIndex → levelKey 映射(K/成交量索引 0/1 不参与联动)
|
||||
const keyMap = new Map<number, string>()
|
||||
// series[0]=K线, series[1]=成交量, 之后是按 priceLines + CURVE_DEFS 顺序 push 的
|
||||
let si = 2
|
||||
for (const p of priceLines) {
|
||||
keyMap.set(si++, levelKey(p.type, p.value))
|
||||
}
|
||||
for (const def of CURVE_DEFS) {
|
||||
if (!activeTypes.has(def.group)) continue
|
||||
const data = alignedSeries[def.alignedKey]
|
||||
if (!data || !data.some(v => v != null)) continue
|
||||
keyMap.set(si++, def.group)
|
||||
}
|
||||
seriesKeyMapRef.current = keyMap
|
||||
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
@@ -336,10 +380,18 @@ export function AnalysisKChart({
|
||||
onDateClick(dates[params.dataIndex])
|
||||
}
|
||||
})
|
||||
// hover 价位线/曲线 endLabel → 联动高亮(与下方文字行双向联动)
|
||||
chartInstRef.current.on('mouseover', (params: any) => {
|
||||
if (params.componentType === 'series') {
|
||||
const k = seriesKeyMapRef.current.get(params.seriesIndex as number)
|
||||
if (k) setHoveredKey(k)
|
||||
}
|
||||
})
|
||||
chartInstRef.current.on('globalout', () => setHoveredKey(null))
|
||||
}
|
||||
chartInstRef.current.setOption(buildOption(), true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, levels, series, seriesDates, activeTypes, pivotRank, markers, ranges, height, theme])
|
||||
}, [rows, levels, series, seriesDates, activeTypes, pivotRank, markers, ranges, height, theme, hoveredKey])
|
||||
|
||||
// resize
|
||||
useEffect(() => {
|
||||
@@ -424,6 +476,8 @@ export function AnalysisKChart({
|
||||
activeTypes={activeTypes}
|
||||
pivotRank={pivotRank}
|
||||
close={rows.length ? rows[rows.length - 1].close : undefined}
|
||||
hoveredKey={hoveredKey}
|
||||
onHover={setHoveredKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -432,12 +486,14 @@ export function AnalysisKChart({
|
||||
|
||||
// ===== 价位统计面板(图表下方,结构化文本展示) =====
|
||||
function LevelOverview({
|
||||
levels, activeTypes, pivotRank, close,
|
||||
levels, activeTypes, pivotRank, close, hoveredKey, onHover,
|
||||
}: {
|
||||
levels: Record<LevelType, PriceLevel[]>
|
||||
activeTypes: Set<LevelType>
|
||||
pivotRank: 1 | 2 | 3
|
||||
close?: number
|
||||
hoveredKey: string | null
|
||||
onHover: (k: string | null) => void
|
||||
}) {
|
||||
// 收集当前显示的点位(同 collectPriceLines 的过滤逻辑)
|
||||
const visible: PriceLevel[] = []
|
||||
@@ -469,11 +525,21 @@ function LevelOverview({
|
||||
|
||||
const Row = ({ p }: { p: PriceLevel }) => {
|
||||
const color = LEVEL_GROUPS.find(g => g.key === p.type)?.color ?? CT().text
|
||||
const k = levelKey(p.type, p.value)
|
||||
const hit = hoveredKey === k
|
||||
const dim = hoveredKey != null && !hit
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||
<span className="text-[11px] text-secondary w-24 shrink-0 truncate">{p.label}</span>
|
||||
<span className="text-[11px] font-mono text-foreground">{p.value.toFixed(2)}</span>
|
||||
<div
|
||||
onMouseEnter={() => onHover(k)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
className={`flex items-center gap-2 py-0.5 px-1.5 -mx-1.5 rounded transition-colors cursor-default ${
|
||||
hit ? 'bg-elevated/60' : ''
|
||||
}`}
|
||||
style={dim ? { opacity: 0.35 } : undefined}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full shrink-0 transition-transform" style={{ backgroundColor: color, transform: hit ? 'scale(1.5)' : 'scale(1)' }} />
|
||||
<span className={`text-[11px] w-24 shrink-0 truncate ${hit ? 'text-foreground font-medium' : 'text-secondary'}`}>{p.label}</span>
|
||||
<span className={`text-[11px] font-mono ${hit ? 'text-foreground font-bold' : 'text-foreground'}`}>{p.value.toFixed(2)}</span>
|
||||
<span className="text-[9px] font-mono text-muted">{fmtPct(p.value)}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -518,9 +584,9 @@ function collectPriceLines(
|
||||
levels: Record<LevelType, PriceLevel[]> | undefined,
|
||||
active: Set<LevelType>,
|
||||
pivotRank: 1 | 2 | 3,
|
||||
): { value: number; label: string; color: string }[] {
|
||||
): { value: number; label: string; color: string; type: string }[] {
|
||||
if (!levels) return []
|
||||
const out: { value: number; label: string; color: string }[] = []
|
||||
const out: { value: number; label: string; color: string; type: string }[] = []
|
||||
for (const g of LEVEL_GROUPS) {
|
||||
if (!active.has(g.key)) continue
|
||||
for (const p of levels[g.key] ?? []) {
|
||||
@@ -530,7 +596,7 @@ function collectPriceLines(
|
||||
// sr 组现为成交密集区水平点,直接画线即可,无需特判。
|
||||
if (p.type === 'boll' || p.type === 'keltner_s' || p.type === 'keltner_m'
|
||||
|| p.type === 'keltner_l' || p.type === 'atr_stop') continue
|
||||
out.push({ value: p.value, label: p.label, color: strengthColor(p.strength, g.color) })
|
||||
out.push({ value: p.value, label: p.label, color: strengthColor(p.strength, g.color), type: p.type })
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -543,6 +609,11 @@ function strengthColor(strength: string | undefined, base: string): string {
|
||||
return base
|
||||
}
|
||||
|
||||
/** 价位唯一标识: 同类型同价格视为同一点位(用于联动高亮)。 */
|
||||
function levelKey(type: string, value: number): string {
|
||||
return `${type}-${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
function fmtVol(v: number): string {
|
||||
if (!v) return '0'
|
||||
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
- 价格折线:涨(收盘 ≥ 昨收)红色,跌绿色 — 以昨收价(prevClose)为基准, 不是当日开盘价
|
||||
- 昨收基准线:浅灰实线(从开到右),比虚线更明显
|
||||
- 分时均线:黄色细线(成交均价,这里用 close 的累计均值近似)
|
||||
- 价格线下方渐变填充: 顶部半透明实色 → 底部全透明(与个股对话框 EChartsIntraday 一致)
|
||||
空数据返回等尺寸占位 SVG,保证加载前后尺寸一致(同 MiniCandlestick 模式)。
|
||||
*/
|
||||
import { useId } from 'react'
|
||||
import type { MinuteKlineRow } from '@/lib/api'
|
||||
|
||||
export function MiniIntraday({ rows, prevClose, changePct, width = 100, height = 56 }: {
|
||||
@@ -80,16 +82,31 @@ export function MiniIntraday({ rows, prevClose, changePct, width = 100, height =
|
||||
// 均线 points
|
||||
const avgPoints = avgLine.map((v, i) => `${xScale(i).toFixed(1)},${yScale(v).toFixed(1)}`).join(' ')
|
||||
|
||||
// 渐变填充多边形 points: 价格折线 + 底部右下角 + 左下角, 闭合到画布底边
|
||||
const bottomY = H - padY
|
||||
const areaPoints = `${pricePoints} ${xScale(n - 1).toFixed(1)},${bottomY.toFixed(1)} ${xScale(0).toFixed(1)},${bottomY.toFixed(1)}`
|
||||
|
||||
// 昨收参考线 y 坐标
|
||||
const prevCloseY = yScale(baseline)
|
||||
|
||||
// 渐变 id 唯一化(自选列表同屏多张图, 避免互相覆盖)
|
||||
const gradId = useId().replace(/:/g, '')
|
||||
|
||||
return (
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="block">
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={color} stopOpacity={0.4} />
|
||||
<stop offset="1" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* 昨收基准线 (深灰实线, 比虚线更明显) */}
|
||||
<line
|
||||
x1={0} y1={prevCloseY} x2={W} y2={prevCloseY}
|
||||
stroke={LINE_PREV_CLOSE} strokeWidth={0.6} opacity={0.7}
|
||||
/>
|
||||
{/* 价格折线下方渐变填充 */}
|
||||
<polygon points={areaPoints} fill={`url(#${gradId})`} stroke="none" />
|
||||
{/* 分时均线 (暖黄细线) */}
|
||||
<polyline
|
||||
points={avgPoints}
|
||||
|
||||
@@ -789,6 +789,7 @@ export interface Preferences {
|
||||
system_notify_enabled: boolean
|
||||
feishu_webhook_url?: string
|
||||
feishu_webhook_secret?: string
|
||||
wecom_webhook_url?: string
|
||||
webhook_enabled_default?: boolean
|
||||
sidebar_index_symbols: string[]
|
||||
nav_order: string[]
|
||||
@@ -986,6 +987,11 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ url, secret }),
|
||||
}),
|
||||
updateWecomWebhook: (url: string) =>
|
||||
request<{ wecom_webhook_url: string }>('/api/settings/preferences/wecom-webhook', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
updateWebhookDefault: (enabled: boolean) =>
|
||||
request<{ webhook_enabled_default: boolean }>('/api/settings/preferences/webhook-enabled-default', {
|
||||
method: 'PUT',
|
||||
@@ -1456,6 +1462,12 @@ export const api = {
|
||||
)
|
||||
},
|
||||
|
||||
extDataDetectUrl: (body: ExtDataDetectUrlRequest) =>
|
||||
request<ExtDataDetectUrlResult>('/api/ext-data/detect-url', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
extDataFixSymbol: (id: string) =>
|
||||
request<{ status: string; fixed_files: number }>(
|
||||
`/api/ext-data/${id}/fix-symbol`,
|
||||
@@ -2007,6 +2019,26 @@ export interface PullConfig {
|
||||
next_run?: string | null
|
||||
}
|
||||
|
||||
export interface ExtDataDetectUrlRequest {
|
||||
url: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
body?: string
|
||||
response_path?: string
|
||||
field_map?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ExtDataDetectUrlResult {
|
||||
status: string
|
||||
total_rows: number
|
||||
response_path: string
|
||||
response_path_candidates: string[]
|
||||
fields: ExtDataField[]
|
||||
symbol_candidates: string[]
|
||||
code_candidates: string[]
|
||||
preview: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
export interface ExtDataConfig {
|
||||
id: string
|
||||
label: string
|
||||
|
||||
@@ -104,8 +104,9 @@ export function Review() {
|
||||
const prefs = usePreferences()
|
||||
const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 }
|
||||
const feishuConfigured = !!(prefs.data?.feishu_webhook_url)
|
||||
const wecomConfigured = !!(prefs.data?.wecom_webhook_url)
|
||||
// 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置
|
||||
// []=不推送, ['feishu']=飞书(微信开发中, 仅占位)
|
||||
// []=不推送, ['feishu']=飞书, ['wecom']=企业微信
|
||||
const reviewPushChannels = prefs.data?.review_push_channels ?? []
|
||||
// 弹窗内的本地草稿: 开关和时间都在本地改, 点「保存」才真正提交(避免开关一拨就关弹窗)
|
||||
const [draft, setDraft] = useState(reviewSched)
|
||||
@@ -443,17 +444,31 @@ export function Review() {
|
||||
{feishuConfigured ? '已配置' : '未配置'}
|
||||
</span>
|
||||
</button>
|
||||
{/* 微信(开发中, 占位不可选) */}
|
||||
<div className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-1.5 opacity-60">
|
||||
<span className="flex h-3 w-3 shrink-0 items-center justify-center rounded border border-border" />
|
||||
<span className="text-[11px] text-secondary">微信</span>
|
||||
<span className="text-[9px] text-muted">公众号/企业微信</span>
|
||||
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">开发中</span>
|
||||
</div>
|
||||
{/* 企业微信(可用, 多选) */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={pushMut.isPending}
|
||||
onClick={() => togglePushChannel('wecom')}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-btn border px-2.5 py-1.5 text-left transition-colors disabled:opacity-50',
|
||||
reviewPushChannels.includes('wecom')
|
||||
? 'border-accent/40 bg-accent/10'
|
||||
: 'border-border/60 bg-base/40 hover:bg-base/60',
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex h-3 w-3 shrink-0 items-center justify-center rounded border', reviewPushChannels.includes('wecom') ? 'border-accent bg-accent text-white' : 'border-border')}>
|
||||
{reviewPushChannels.includes('wecom') && <Check className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className="text-[11px] text-foreground">企业微信</span>
|
||||
<span className="text-[9px] text-muted">群机器人</span>
|
||||
<span className={cn('ml-auto text-[9px]', wecomConfigured ? 'text-emerald-500' : 'text-warning')}>
|
||||
{wecomConfigured ? '已配置' : '未配置'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
|
||||
手动或定时生成的复盘都会以卡片消息推送完整报告。复用「设置 → 实时监控」的飞书 Webhook。
|
||||
{reviewPushChannels.includes('feishu') && !feishuConfigured && (
|
||||
手动或定时生成的复盘都会推送完整报告。复用「设置 → 实时监控」的 Webhook 配置。
|
||||
{((reviewPushChannels.includes('feishu') && !feishuConfigured) || (reviewPushChannels.includes('wecom') && !wecomConfigured)) && (
|
||||
<Link to="/settings?tab=monitoring" className="ml-1 text-accent hover:underline" onClick={() => setShowSchedule(false)}>
|
||||
前往配置 →
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Sparkles, LineChart, History as HistoryIcon, Loader2, ExternalLink, Bell } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
@@ -13,7 +13,7 @@ import { QK } from '@/lib/queryKeys'
|
||||
import { toast } from '@/components/Toast'
|
||||
import {
|
||||
startAnalysis, findTodayReport, useHistoryReports,
|
||||
deleteReport, openHistoryReport,
|
||||
deleteReport, openHistoryReport, loadHistory,
|
||||
} from '@/lib/stockAnalysisStore'
|
||||
|
||||
/**
|
||||
@@ -29,14 +29,24 @@ export function StockAnalysis() {
|
||||
const [name, setName] = useState<string>('')
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const { last: lastStock, remember: rememberStock } = useLastStock('stock-analysis')
|
||||
|
||||
// 进入页面立即加载历史报告(供右侧常驻列表)。store 内部有 historyLoaded 去重, 重复调用安全。
|
||||
useEffect(() => { loadHistory() }, [])
|
||||
|
||||
// 自动恢复上次选中的股票(切走再回来不丢)。useLastStock 的 last 来自 localStorage, 同步可用。
|
||||
useEffect(() => {
|
||||
if (!symbol && lastStock) {
|
||||
setSymbol(lastStock.symbol)
|
||||
setName(lastStock.name)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onSelect = (sym: string, nm: string) => {
|
||||
setSymbol(sym)
|
||||
setName(nm)
|
||||
setShowHistory(false)
|
||||
setConfirmReport(null)
|
||||
rememberStock(sym, nm)
|
||||
}
|
||||
@@ -77,15 +87,6 @@ export function StockAnalysis() {
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<LastStockChip stock={lastStock} onSelect={onSelect} />
|
||||
{symbol && (
|
||||
<button
|
||||
onClick={() => setShowHistory(v => !v)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-border text-secondary text-xs hover:text-foreground hover:bg-elevated transition-colors"
|
||||
>
|
||||
<HistoryIcon className="h-3.5 w-3.5" />
|
||||
历史报告
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -130,18 +131,21 @@ export function StockAnalysis() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 主体 */}
|
||||
{!symbol ? (
|
||||
<EmptyState
|
||||
icon={LineChart}
|
||||
title="选择一只股票开始分析"
|
||||
hint="搜索代码或名称,查看日 K 与关键价位,并可让 AI 进行技术面 / 基本面 / 财务面 / 消息面四维综合分析。"
|
||||
/>
|
||||
) : showHistory ? (
|
||||
<HistoryList symbol={symbol} />
|
||||
) : (
|
||||
<StockAnalysisBoard symbol={symbol} />
|
||||
)}
|
||||
{/* 主体:左侧当前个股看板 + 右侧常驻历史报告 */}
|
||||
<div className="grid grid-cols-[1fr_288px] gap-6 items-start">
|
||||
<div className="min-w-0">
|
||||
{!symbol ? (
|
||||
<EmptyState
|
||||
icon={LineChart}
|
||||
title="选择一只股票开始分析"
|
||||
hint="搜索代码或名称,查看日 K 与关键价位,并可让 AI 进行技术面 / 基本面 / 财务面 / 消息面四维综合分析。"
|
||||
/>
|
||||
) : (
|
||||
<StockAnalysisBoard symbol={symbol} />
|
||||
)}
|
||||
</div>
|
||||
<HistorySidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二次确认:已有历史报告 */}
|
||||
@@ -230,41 +234,69 @@ function StockAnalysisBoard({ symbol }: { symbol: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 历史报告列表 =====
|
||||
function HistoryList({ symbol }: { symbol: string }) {
|
||||
// ===== 左侧常驻:历史报告侧栏(所有股票,按时间倒序平铺) =====
|
||||
function HistorySidebar() {
|
||||
const { reports, loaded } = useHistoryReports()
|
||||
const mine = reports.filter(r => r.symbol === symbol)
|
||||
|
||||
if (!loaded) {
|
||||
return <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
|
||||
}
|
||||
if (mine.length === 0) {
|
||||
return <EmptyState icon={HistoryIcon} title="暂无历史报告" hint={`还没有 ${symbol} 的个股分析报告,点击「AI 个股分析」生成第一份。`} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{mine.map(r => (
|
||||
<div key={r.id} className="rounded-card border border-border/60 bg-surface/40 p-3 hover:border-border transition-colors">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button onClick={() => openHistoryReport(r.id)} className="flex-1 text-left min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary">{fmtRelative(r.created_at)}</span>
|
||||
{r.close && <span className="text-[10px] font-mono text-muted">价 {r.close.toFixed(2)}</span>}
|
||||
{r.focus && <span className="text-[10px] text-sky-300/70 truncate">关注: {r.focus}</span>}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted truncate">{r.summary || '点击查看完整报告'}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { deleteReport(r.id); toast('已删除', 'success') }}
|
||||
className="shrink-0 text-[10px] text-muted hover:text-danger transition-colors px-2 py-1"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<aside className="self-start sticky top-0">
|
||||
<div className="rounded-card border border-border/60 bg-surface/40 overflow-hidden">
|
||||
<div className="px-3 py-2.5 border-b border-border/40 flex items-center gap-2">
|
||||
<HistoryIcon className="h-3.5 w-3.5 text-sky-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground">历史报告</span>
|
||||
{loaded && reports.length > 0 && (
|
||||
<span className="ml-auto text-[10px] text-muted">{reports.length}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loaded ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted" />
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center">
|
||||
<p className="text-xs text-muted">还没有任何个股分析报告</p>
|
||||
<p className="text-[10px] text-muted/60 mt-1">选一只股票,点「AI 个股分析」生成</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[calc(100vh-220px)] overflow-y-auto p-2 space-y-1.5">
|
||||
{reports.map(r => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="group rounded-lg border border-border/40 bg-elevated/20 p-2.5 hover:border-border hover:bg-elevated/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={() => openHistoryReport(r.id)}
|
||||
className="flex-1 text-left min-w-0"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-xs font-medium text-foreground truncate">{r.name || r.symbol}</span>
|
||||
<span className="text-[10px] font-mono text-muted shrink-0">{r.symbol}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted">
|
||||
<span>{fmtRelative(r.created_at)}</span>
|
||||
{r.close != null && <span className="font-mono">价 {r.close.toFixed(2)}</span>}
|
||||
{r.focus && <span className="text-sky-300/70 truncate">关注: {r.focus}</span>}
|
||||
</div>
|
||||
{r.summary && (
|
||||
<div className="mt-1 text-[11px] text-muted truncate">{r.summary}</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { deleteReport(r.id); toast('已删除', 'success') }}
|
||||
className="shrink-0 text-[10px] text-muted/60 hover:text-danger transition-colors px-1 py-0.5 opacity-0 group-hover:opacity-100"
|
||||
title="删除"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle } from 'lucide-react'
|
||||
import {
|
||||
api,
|
||||
type StrategyBacktestResult,
|
||||
@@ -89,6 +89,57 @@ const quickRangeTitle = (range: QuickRangeConfig) => range.unit === 'all'
|
||||
const INPUT_CLS = `w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs
|
||||
focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`
|
||||
|
||||
/** 建仓/清仓口径说明 — 黄色问号图标, 点击弹出气泡。
|
||||
* 用 fixed 定位脱离父容器 overflow 裁剪(左侧表单是 overflow-y-auto, absolute 气泡会被裁)。 */
|
||||
function FillRuleHint() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
const iconRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!open && iconRef.current) {
|
||||
const r = iconRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, left: r.left })
|
||||
}
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
// 气泡宽度 256px(w-64), 若右侧超出视口则向左对齐
|
||||
const bubbleLeft = pos ? Math.min(pos.left, window.innerWidth - 256 - 8) : 0
|
||||
|
||||
return (
|
||||
<div ref={iconRef} className="relative inline-flex items-center">
|
||||
<HelpCircle
|
||||
className="h-3.5 w-3.5 text-yellow-500/80 hover:text-yellow-500 cursor-help transition-colors"
|
||||
onClick={handleOpen}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{open && pos && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -4, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ top: pos.top, left: bubbleLeft }}
|
||||
className="fixed z-50 w-64 bg-surface border border-border rounded-md shadow-xl p-3 text-[11px] text-secondary leading-relaxed"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="font-medium text-foreground mb-1.5">成交口径说明</div>
|
||||
<div className="space-y-1">
|
||||
<div><b className="text-foreground">建仓</b>默认<b className="text-foreground">次日开盘</b>(避免未来函数)</div>
|
||||
<div><b className="text-foreground">清仓</b>默认<b className="text-foreground">当日收盘</b>(持仓中可盘中/收盘卖)</div>
|
||||
<div>买卖点由<b className="text-foreground">策略触发器</b>决定,这里只决定成交价。</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
|
||||
const TRADE_PAGE_SIZE_OPTIONS = [10, 20, 30, 50, 100]
|
||||
const BADGE_CLS_MAP: Record<string, string> = {
|
||||
@@ -1263,7 +1314,10 @@ export function StrategyBacktest() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-secondary block mb-1.5">建仓口径</label>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<label className="text-xs font-medium text-secondary">建仓口径</label>
|
||||
<FillRuleHint />
|
||||
</div>
|
||||
<select value={entryFill} onChange={e => setEntryFill(e.target.value as any)} className={INPUT_CLS}>
|
||||
<option value="open_t+1">次日开盘成交(推荐)</option>
|
||||
<option value="close_t">信号日收盘成交</option>
|
||||
@@ -1277,7 +1331,6 @@ export function StrategyBacktest() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] leading-4 text-muted">建仓默认次日开盘(避免未来函数),清仓默认当日收盘(持仓中可盘中/收盘卖);买卖点由策略触发器决定,这里只决定成交价。</div>
|
||||
|
||||
{simMode === 'position' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -1303,16 +1356,20 @@ export function StrategyBacktest() {
|
||||
<input type="number" min={0} max={100} value={maxExposure} onChange={e => setMaxExposure(e.target.value)}
|
||||
className={INPUT_CLS} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{simMode === 'position' && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-secondary block mb-1.5">佣金(万分之)</label>
|
||||
<label className="text-[10px] font-medium text-secondary block mb-1">佣金 ‱</label>
|
||||
<input type="number" min={0} value={fees} onChange={e => setFees(e.target.value)} className={INPUT_CLS} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-secondary block mb-1.5">印花税(千分之)</label>
|
||||
<label className="text-[10px] font-medium text-secondary block mb-1">印花税 ‰</label>
|
||||
<input type="number" min={0} value={stampTax} onChange={e => setStampTax(e.target.value)} className={INPUT_CLS} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-secondary block mb-1.5">滑点(万分之)</label>
|
||||
<label className="text-[10px] font-medium text-secondary block mb-1">滑点 ‱</label>
|
||||
<input type="number" min={0} value={slippage} onChange={e => setSlippage(e.target.value)} className={INPUT_CLS} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,12 +69,21 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
const [feishuDraft, setFeishuDraft] = useState(feishuWebhookUrl)
|
||||
const [feishuSecretDraft, setFeishuSecretDraft] = useState(feishuWebhookSecret)
|
||||
const [feishuError, setFeishuError] = useState('')
|
||||
// 企业微信 webhook
|
||||
const wecomWebhookUrl = prefs?.wecom_webhook_url ?? ''
|
||||
const [wecomDraft, setWecomDraft] = useState(wecomWebhookUrl)
|
||||
const [wecomError, setWecomError] = useState('')
|
||||
// 飞书渠道配置区展开态 (推送通知卡片内)
|
||||
const [channelOpen, setChannelOpen] = useState(false)
|
||||
// 企业微信渠道配置区展开态
|
||||
const [wecomOpen, setWecomOpen] = useState(false)
|
||||
useEffect(() => {
|
||||
setFeishuDraft(feishuWebhookUrl)
|
||||
setFeishuSecretDraft(feishuWebhookSecret)
|
||||
}, [feishuWebhookUrl, feishuWebhookSecret])
|
||||
useEffect(() => {
|
||||
setWecomDraft(wecomWebhookUrl)
|
||||
}, [wecomWebhookUrl])
|
||||
const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? []
|
||||
const watchlist = useQuery({
|
||||
queryKey: QK.watchlist,
|
||||
@@ -144,6 +153,26 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
saveFeishuWebhook.mutate({ url, secret })
|
||||
}, [feishuDraft, feishuSecretDraft, saveFeishuWebhook])
|
||||
|
||||
const saveWecomWebhook = useMutation({
|
||||
mutationFn: (url: string) => api.updateWecomWebhook(url),
|
||||
onSuccess: () => {
|
||||
setWecomError('')
|
||||
toast('企业微信 Webhook 已保存', 'success')
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
},
|
||||
onError: (err: any) => setWecomError(String(err?.message ?? '保存失败')),
|
||||
})
|
||||
const WECOM_PREFIX = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send'
|
||||
const submitWecom = useCallback(() => {
|
||||
const url = wecomDraft.trim()
|
||||
// 允许完整 URL 或纯 key (36位UUID样式)
|
||||
if (url && !url.startsWith(WECOM_PREFIX) && url.length < 20) {
|
||||
setWecomError('请输入完整 Webhook 地址或纯 key (至少 20 位)')
|
||||
return
|
||||
}
|
||||
saveWecomWebhook.mutate(url)
|
||||
}, [wecomDraft, saveWecomWebhook])
|
||||
|
||||
const runFix = useMutation({
|
||||
mutationFn: () => api.runLimitLadderFix(),
|
||||
onSuccess: (data) => {
|
||||
@@ -390,7 +419,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
</div>
|
||||
|
||||
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。
|
||||
飞书已实现; 微信开发中, QMT/ptrade 待定。
|
||||
飞书 / 企业微信已实现; QMT/ptrade 待定。
|
||||
每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}
|
||||
<Card icon={Webhook} title="推送通知">
|
||||
<p className="text-xs text-secondary mb-3">
|
||||
@@ -486,9 +515,82 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 企业微信群机器人 (可用): 与飞书并列, 勾选默认 + 展开地址配置 */}
|
||||
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
|
||||
<div
|
||||
onClick={() => setWecomOpen(o => !o)}
|
||||
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={webhookDefault}
|
||||
onChange={e => { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
title="作为新建规则的默认推送渠道"
|
||||
className="h-3 w-3 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-foreground">企业微信</span>
|
||||
<span className="text-[9px] text-muted">群机器人</span>
|
||||
{webhookDefault && (
|
||||
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent">默认</span>
|
||||
)}
|
||||
<span className={`ml-auto text-[9px] ${wecomWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
|
||||
{wecomWebhookUrl ? '已配置' : '未配置'}
|
||||
</span>
|
||||
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${wecomOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
{wecomOpen && (
|
||||
<div className="border-t border-border/60 bg-base/30 p-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[11px] text-muted">Webhook 地址 或 Key</span>
|
||||
<input
|
||||
value={wecomDraft}
|
||||
onChange={e => setWecomDraft(e.target.value)}
|
||||
placeholder={WECOM_PREFIX + '?key=xxxxxxxx' + ' 或直接填 key'}
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{wecomError && (
|
||||
<div className="mt-2 text-[11px] text-danger">{wecomError}</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={submitWecom}
|
||||
disabled={saveWecomWebhook.isPending || wecomDraft.trim() === wecomWebhookUrl}
|
||||
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
{saveWecomWebhook.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
{wecomWebhookUrl && (
|
||||
<span className="text-[10px] text-emerald-500">● 已配置</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details className="mt-3 text-[10px] text-muted">
|
||||
<summary className="cursor-pointer hover:text-secondary">如何获取企业微信 Webhook 地址?</summary>
|
||||
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
|
||||
<li>打开企业微信,进入目标群聊 → 右上角「...」→ <b>群机器人</b></li>
|
||||
<li>点击「添加」→ 选择「<b>自定义机器人</b>」→ 填写名字</li>
|
||||
<li>复制生成的 <b>Webhook 地址</b>(含 key 参数),粘贴到上方输入框</li>
|
||||
<li>也可只复制 key 参数部分(= 后面的内容)填入</li>
|
||||
<li>企业微信群的消息可同步到绑定的个人微信,实现"微信推送"</li>
|
||||
</ol>
|
||||
<p className="mt-1.5 pl-4 text-muted/70">
|
||||
📖 官方文档:
|
||||
<a href="https://developer.work.weixin.qq.com/document/path/91770" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
|
||||
群机器人使用指南 ↗
|
||||
</a>
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 占位渠道 — 不可点 */}
|
||||
{[
|
||||
{ name: '微信', hint: '公众号/企业微信', status: '开发中' },
|
||||
{ name: 'QMT', hint: '量化交易终端', status: '待定' },
|
||||
{ name: 'ptrade', hint: '量化交易终端', status: '待定' },
|
||||
].map(ch => (
|
||||
|
||||
Reference in New Issue
Block a user