feat(notifications): add custom webhook and email channels

This commit is contained in:
yushenglin
2026-09-04 16:37:06 +08:00
parent 6803b050b5
commit 51e2bfc8be
23 changed files with 1238 additions and 68 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ class RuleModel(BaseModel):
severity: str = "info" # info | warn | critical
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定)
webhook_enabled: bool = False # 兼容老规则 (已由 webhook_channels 取代, 仅做向后兼容读)
webhook_channels: list[str] = [] # 命中时推送的外部渠道 (合法值 'feishu' | 'wecom')
webhook_channels: list[str] = [] # 合法值: feishu | wecom | custom | email
message: str = ""
# abnormal 专属 (异动边缘监控): any | 3d | 10d | 30d
abnormal_window: str = "any"
+116 -5
View File
@@ -537,6 +537,10 @@ def get_preferences() -> dict:
"feishu_webhook_url": preferences.get_feishu_webhook_url(),
"feishu_webhook_secret": preferences.get_feishu_webhook_secret(),
"wecom_webhook_url": preferences.get_wecom_webhook_url(),
"custom_webhook_url": preferences.get_custom_webhook_url(),
"custom_webhook_secret_set": bool(secrets_store.get_custom_webhook_secret()),
"email_smtp_config": preferences.get_email_smtp_config(),
"email_smtp_password_set": bool(secrets_store.get_email_smtp_password()),
"wecom_bot_id": preferences.get_wecom_bot_id(),
"wecom_bot_secret": preferences.get_wecom_bot_secret(),
"wecom_bot_enabled": preferences.get_wecom_bot_enabled(),
@@ -1232,8 +1236,88 @@ def update_wecom_webhook(req: WecomWebhookPrefsIn) -> dict:
return {"wecom_webhook_url": saved_url}
class CustomWebhookPrefsIn(BaseModel):
url: str
# None preserves the stored secret; an explicit empty string clears it.
secret: str | None = None
@router.put("/preferences/custom-webhook")
def update_custom_webhook(req: CustomWebhookPrefsIn) -> dict:
"""Configure the generic third-party JSON webhook and optional HMAC secret."""
from app.services import preferences, webhook_adapter
url = (req.url or "").strip()
if url and not webhook_adapter.is_valid_custom_url(url):
raise HTTPException(status_code=400, detail="Webhook 地址必须是完整的 HTTP(S) URL")
saved_url = preferences.set_custom_webhook_url(url)
if not saved_url:
secrets_store.set_custom_webhook_secret("")
elif req.secret is not None:
secrets_store.set_custom_webhook_secret(req.secret)
return {
"custom_webhook_url": saved_url,
"custom_webhook_secret_set": bool(secrets_store.get_custom_webhook_secret()),
}
class EmailSmtpPrefsIn(BaseModel):
host: str
port: int = Field(default=465, ge=1, le=65535)
security: Literal["ssl", "starttls", "none"] = "ssl"
username: str = ""
# None preserves the stored password; an explicit empty string clears it.
password: str | None = None
from_address: str = ""
to_addresses: list[str] = Field(default_factory=list)
@router.put("/preferences/email-smtp")
def update_email_smtp(req: EmailSmtpPrefsIn) -> dict:
"""Configure the SMTP transport shared by monitor alerts and review reports."""
from app.services import email_adapter, preferences
host = (req.host or "").strip()
username = (req.username or "").strip()
from_address = (req.from_address or username).strip()
recipients = list(dict.fromkeys(item.strip() for item in req.to_addresses if item.strip()))
if host:
if not from_address or not email_adapter.is_valid_email(from_address):
raise HTTPException(status_code=400, detail="请填写有效的发件人邮箱")
if not recipients or any(not email_adapter.is_valid_email(item) for item in recipients):
raise HTTPException(status_code=400, detail="请至少填写一个有效的收件人邮箱")
effective_password = (
secrets_store.get_email_smtp_password()
if req.password is None
else req.password
)
if username and not effective_password:
raise HTTPException(status_code=400, detail="已填写 SMTP 登录用户名,请同时填写密码或授权码")
else:
username = ""
from_address = ""
recipients = []
config = preferences.set_email_smtp_config({
"host": host,
"port": req.port,
"security": req.security,
"username": username,
"from_address": from_address,
"to_addresses": recipients,
})
if not host or not username:
secrets_store.set_email_smtp_password("")
elif req.password is not None:
secrets_store.set_email_smtp_password(req.password)
return {
"email_smtp_config": config,
"email_smtp_password_set": bool(secrets_store.get_email_smtp_password()),
}
class WebhookTestIn(BaseModel):
channel: Literal["feishu", "wecom"]
channel: Literal["feishu", "wecom", "custom", "email"]
@router.post("/preferences/webhook-test")
@@ -1259,16 +1343,43 @@ def test_webhook(req: WebhookTestIn) -> dict:
secret = preferences.get_feishu_webhook_secret()
# 诊断用途单次尝试: 失败即返回, 不等生产退避重试 (~17s)
ok = webhook_adapter.send_feishu(url, title, body, secret, max_attempts=1)
else: # wecom
elif req.channel == "wecom":
url = preferences.get_wecom_webhook_url()
if not url:
return {"ok": False, "detail": "尚未配置企业微信 Webhook,请先保存"}
if not webhook_adapter.is_valid_wecom_url(url):
return {"ok": False, "detail": "已保存的企业微信 Webhook 地址非法,请重新保存"}
ok = webhook_adapter.send_wecom(url, title, body)
elif req.channel == "custom":
url = preferences.get_custom_webhook_url()
if not url:
return {"ok": False, "detail": "尚未配置第三方 Webhook,请先保存"}
if not webhook_adapter.is_valid_custom_url(url):
return {"ok": False, "detail": "已保存的第三方 Webhook 地址非法,请重新保存"}
ok = webhook_adapter.send_custom(
url,
title,
body,
event_type="test",
secret=secrets_store.get_custom_webhook_secret(),
max_attempts=1,
)
else: # email
from app.services import email_adapter
config = preferences.get_email_smtp_config()
if not email_adapter.is_configured(config):
return {"ok": False, "detail": "尚未完整配置邮件 SMTP,请先保存"}
ok = email_adapter.send_email(
config,
secrets_store.get_email_smtp_password(),
title,
body,
max_attempts=1,
)
if ok:
return {"ok": True, "detail": "测试消息已发送,请到群内查收"}
return {"ok": True, "detail": "测试消息已发送,请检查对应接收端"}
return {"ok": False, "detail": "推送失败:网络不可达或地址/密钥不正确,详情见后端日志"}
@@ -1353,7 +1464,7 @@ def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict:
class WebhookDefaultChannelsIn(BaseModel):
channels: list[str] # 多选: ['feishu','wecom'] 等; 空数组=默认不推送
channels: list[str] # 多选: feishu / wecom / custom / email; 空数组=不推送
@router.put("/preferences/webhook-default-channels")
@@ -1796,7 +1907,7 @@ def update_review_schedule(req: ReviewScheduleIn, request: Request) -> dict:
class ReviewPushIn(BaseModel):
channels: list[str] # 多选: ['feishu'] 等; 空数组=不推送。微信等开发中
channels: list[str] # 多选: feishu / wecom / custom / email; 空数组=不推送
@router.put("/preferences/review-push")
+30 -2
View File
@@ -979,11 +979,12 @@ def _maybe_push_review(content: str, meta: dict) -> None:
"""复盘报告归档后, 按 review_push_channels 选定的外部工具逐个推送完整报告。
定时生成与手动生成共用本函数 (手动归档端点 POST /api/market-recap/reports 也会调用)。
channels 为空则不推送; 'feishu' 复用监控中心的全局飞书 Webhook 通道。
channels 为空则不推送; 复用监控中心的全局外部渠道配置。
推送失败静默降级 (Webhook 是辅助通道), 不影响已归档的报告。
"""
try:
from app.services import preferences, webhook_adapter
from app import secrets_store
from app.services import email_adapter, preferences, webhook_adapter
channels = preferences.get_review_push_channels()
if not channels:
@@ -1015,6 +1016,33 @@ def _maybe_push_review(content: str, meta: dict) -> None:
url, "每日复盘", full_body
)
logger.info("review push(wecom) %s", "sent" if ok else "failed")
elif ch == "custom":
url = preferences.get_custom_webhook_url()
if not url:
logger.info("review push(custom) skipped: webhook not configured")
continue
ok = webhook_adapter.send_custom(
url,
"每日复盘",
content,
"market_review",
meta,
secrets_store.get_custom_webhook_secret(),
)
logger.info("review push(custom) %s", "sent" if ok else "failed")
elif ch == "email":
config = preferences.get_email_smtp_config()
if not email_adapter.is_configured(config):
logger.info("review push(email) skipped: SMTP not configured")
continue
email_body = (f"{subtitle}\n\n{content}" if subtitle else content)
ok = email_adapter.send_email(
config,
secrets_store.get_email_smtp_password(),
"每日复盘",
email_body,
)
logger.info("review push(email) %s", "sent" if ok else "failed")
# 未来更多渠道在此追加分支
except Exception as e: # noqa: BLE001
logger.warning("review push error: %s", e)
+30
View File
@@ -99,6 +99,36 @@ def get_ai_config_int(key: str, default: int) -> int:
return int(getattr(settings, key, default) or default)
def get_custom_webhook_secret() -> str:
"""Return the optional HMAC secret for the generic outbound webhook."""
return str(load().get("custom_webhook_secret") or "")
def set_custom_webhook_secret(secret: str) -> str:
"""Persist or clear the generic outbound webhook HMAC secret."""
value = (secret or "").strip()
if value:
save({"custom_webhook_secret": value})
else:
clear("custom_webhook_secret")
return value
def get_email_smtp_password() -> str:
"""Return the SMTP password used by the email notification channel."""
return str(load().get("email_smtp_password") or "")
def set_email_smtp_password(password: str) -> str:
"""Persist or clear the SMTP password used by email notifications."""
value = password or ""
if value:
save({"email_smtp_password": value})
else:
clear("email_smtp_password")
return value
def get_env_backed_secret(field: str, env_name: str) -> str:
"""取环境变量后备的密钥(插件 API Key 等):secrets.json 优先,否则环境变量。
+104
View File
@@ -0,0 +1,104 @@
"""SMTP email notification adapter.
Transport failures are isolated from alert persistence and SSE delivery. SMTP credentials
are supplied by the caller from ``secrets_store`` and never logged here.
"""
from __future__ import annotations
import logging
import smtplib
import time
from contextlib import suppress
from email.message import EmailMessage
from email.utils import parseaddr
logger = logging.getLogger(__name__)
SECURITY_MODES = {"ssl", "starttls", "none"}
_MAX_ATTEMPTS = 2
def is_valid_email(address: str) -> bool:
"""Small dependency-free mailbox validation suitable for configuration checks."""
parsed = parseaddr((address or "").strip())[1]
if parsed != (address or "").strip() or parsed.count("@") != 1:
return False
local, domain = parsed.rsplit("@", 1)
return bool(local and domain and "." in domain and " " not in parsed)
def is_configured(config: dict) -> bool:
"""Return whether the non-secret fields are sufficient to attempt delivery."""
sender = str(config.get("from_address") or config.get("username") or "").strip()
recipients = config.get("to_addresses") or []
return bool(config.get("host") and sender and recipients)
def send_email(
config: dict,
password: str,
subject: str,
body: str,
*,
max_attempts: int = _MAX_ATTEMPTS,
) -> bool:
"""Send one UTF-8 plain-text email through SSL, STARTTLS, or plain SMTP."""
if not is_configured(config):
return False
host = str(config.get("host") or "").strip()
try:
port = int(config.get("port", 465))
except (TypeError, ValueError):
return False
security = str(config.get("security") or "ssl")
username = str(config.get("username") or "").strip()
sender = str(config.get("from_address") or username).strip()
recipients = [str(item).strip() for item in config.get("to_addresses", [])]
if (
not 1 <= port <= 65535
or security not in SECURITY_MODES
or not is_valid_email(sender)
or not recipients
or any(not is_valid_email(item) for item in recipients)
):
return False
message = EmailMessage()
message["Subject"] = str(subject or "TickFlow 通知")
message["From"] = sender
message["To"] = ", ".join(recipients)
message.set_content(str(body or ""))
last_err = ""
for attempt in range(1, max_attempts + 1):
smtp = None
try:
if security == "ssl":
smtp = smtplib.SMTP_SSL(host, port, timeout=10)
else:
smtp = smtplib.SMTP(host, port, timeout=10)
if security == "starttls":
smtp.ehlo()
smtp.starttls()
smtp.ehlo()
if username:
smtp.login(username, password)
smtp.send_message(message)
# Delivery already succeeded; a failed QUIT must not retry and duplicate the email.
try:
smtp.quit()
except Exception:
with suppress(Exception):
smtp.close()
return True
except Exception as exc: # SMTP/network errors must not escape
last_err = str(exc)
if smtp is not None:
with suppress(Exception):
smtp.close()
if attempt < max_attempts:
time.sleep(1)
logger.warning("邮件推送最终失败(已尝试 %d 次): %s", max_attempts, last_err)
return False
+71 -6
View File
@@ -580,9 +580,9 @@ def set_depth_finalize_time(hour: int, minute: int) -> dict:
return {"hour": h, "minute": m}
# 复盘推送可选渠道白名单 (企业微信已实现, 与飞书并列)
# 监控与复盘共用的外部推送渠道白名单。
# 多选: 不推送 = 空数组, 而非 'none'
REVIEW_PUSH_CHANNELS = {"feishu", "wecom"}
PUSH_CHANNELS = {"feishu", "wecom", "custom", "email"}
def get_review_schedule() -> dict:
@@ -662,7 +662,7 @@ def get_review_push_channels() -> list[str]:
d = load()
raw = d.get("review_push_channels")
if isinstance(raw, list):
return [c for c in raw if c in REVIEW_PUSH_CHANNELS]
return [c for c in raw if c in PUSH_CHANNELS]
# 兼容老单选字符串
if d.get("review_push_channel") == "feishu":
return ["feishu"]
@@ -677,7 +677,7 @@ def set_review_push_channels(channels: list[str]) -> list[str]:
seen: set[str] = set()
cleaned: list[str] = []
for c in channels or []:
if c in REVIEW_PUSH_CHANNELS and c not in seen:
if c in PUSH_CHANNELS and c not in seen:
seen.add(c)
cleaned.append(c)
save({"review_push_channels": cleaned})
@@ -797,6 +797,71 @@ def set_wecom_webhook_url(url: str) -> str:
return get_wecom_webhook_url()
def get_custom_webhook_url() -> str:
"""Generic third-party JSON Webhook URL shared by enabled rules and reviews."""
return str(load().get("custom_webhook_url") or "")
def set_custom_webhook_url(url: str) -> str:
"""Persist or clear the generic third-party JSON Webhook URL."""
value = str(url or "").strip()
save({"custom_webhook_url": value})
return value
_EMAIL_SMTP_DEFAULTS = {
"host": "",
"port": 465,
"security": "ssl",
"username": "",
"from_address": "",
"to_addresses": [],
}
def get_email_smtp_config() -> dict:
"""Return non-secret SMTP settings for the email notification channel."""
raw = load().get("email_smtp_config")
if not isinstance(raw, dict):
raw = {}
security = raw.get("security", _EMAIL_SMTP_DEFAULTS["security"])
if security not in {"ssl", "starttls", "none"}:
security = _EMAIL_SMTP_DEFAULTS["security"]
try:
port = int(raw.get("port", _EMAIL_SMTP_DEFAULTS["port"]))
except (TypeError, ValueError):
port = _EMAIL_SMTP_DEFAULTS["port"]
if not 1 <= port <= 65535:
port = _EMAIL_SMTP_DEFAULTS["port"]
recipients = raw.get("to_addresses")
if not isinstance(recipients, list):
recipients = []
return {
"host": str(raw.get("host") or "").strip(),
"port": port,
"security": security,
"username": str(raw.get("username") or "").strip(),
"from_address": str(raw.get("from_address") or "").strip(),
"to_addresses": [str(item).strip() for item in recipients if str(item).strip()],
}
def set_email_smtp_config(config: dict) -> dict:
"""Atomically persist the non-secret SMTP configuration group."""
normalized = {
"host": str(config.get("host") or "").strip(),
"port": int(config.get("port", 465)),
"security": str(config.get("security") or "ssl"),
"username": str(config.get("username") or "").strip(),
"from_address": str(config.get("from_address") or "").strip(),
"to_addresses": [
str(item).strip() for item in config.get("to_addresses", []) if str(item).strip()
],
}
save({"email_smtp_config": normalized})
return get_email_smtp_config()
# ===== 企业微信智能机器人 (API 模式 / 长连接) =====
@@ -863,7 +928,7 @@ def get_webhook_default_channels() -> list[str]:
d = load()
raw = d.get("webhook_default_channels")
if isinstance(raw, list):
return [c for c in raw if c in REVIEW_PUSH_CHANNELS]
return [c for c in raw if c in PUSH_CHANNELS]
# 兼容老布尔开关 (勾选即双推)
if d.get("webhook_enabled_default") is True:
return ["feishu", "wecom"]
@@ -875,7 +940,7 @@ def set_webhook_default_channels(channels: list[str]) -> list[str]:
seen: set[str] = set()
cleaned: list[str] = []
for c in channels or []:
if c in REVIEW_PUSH_CHANNELS and c not in seen:
if c in PUSH_CHANNELS and c not in seen:
seen.add(c)
cleaned.append(c)
save({"webhook_default_channels": cleaned})
+30 -7
View File
@@ -1454,7 +1454,7 @@ class QuoteService:
def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None:
"""把告警通过 Webhook 推送到外部 IM (由规则 webhook_channels 指定渠道)。
- 飞书 / 企业微信任一已配置即生效 (两个都没配才跳过)
- 飞书 / 企业微信 / 第三方 Webhook / 邮件均按规则独立选择
- 仅推送 webhook_channels 非空的规则触发的告警, 且只投递被勾选的渠道
- 失败静默, 不阻断主流程
- 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重
@@ -1463,14 +1463,17 @@ class QuoteService:
以便反查引擎规则判断是否启用推送。
"""
try:
from app.services import preferences
from app.services import webhook_adapter
from app import secrets_store
from app.services import email_adapter, preferences, webhook_adapter
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:
custom_url = preferences.get_custom_webhook_url()
custom_secret = secrets_store.get_custom_webhook_secret()
email_config = preferences.get_email_smtp_config()
email_password = secrets_store.get_email_smtp_password()
if not any((feishu_url, wecom_url, custom_url, email_adapter.is_configured(email_config))):
return
# 反查规则, 过滤出启用推送的事件
@@ -1478,7 +1481,7 @@ class QuoteService:
enqueued = 0
for ev in rule_events:
rule = rules.get(ev.get("rule_id"))
# webhook_channels 指定命中的渠道 (['feishu'] / ['wecom'] / ['feishu','wecom'] / []).
# webhook_channels 指定本规则需要投递的外部渠道。
# 空列表 = 该规则不推送。仅推送「渠道已选 + 对应地址已配置」的组合。
channels = rule.get("webhook_channels") if rule else None
if not channels:
@@ -1493,7 +1496,7 @@ class QuoteService:
# 补上触发时的现价/涨跌幅, 让推送可执行 (止损到底触发在哪个价位)
body = _body_with_quote(body, ev)
# 提交到独立线程池, 不阻塞行情轮询线程 (webhook 慢/重试不拖累实时行情+告警)。
# 按渠道独立投递: 飞书 / 企业微信谁被勾选且已配置就推谁。
# 按渠道独立投递: 只投递同时“已勾选 + 已配置”的渠道。
# 应用内 alerts.jsonl 记录与 SSE 已在前面完成, 不依赖 webhook 成败,
# 失败由 webhook_adapter 记 WARNING(可见)。
if feishu_url and "feishu" in channels:
@@ -1502,6 +1505,26 @@ class QuoteService:
if wecom_url and "wecom" in channels:
_WEBHOOK_EXECUTOR.submit(webhook_adapter.send_wecom, wecom_url, title, body)
enqueued += 1
if custom_url and "custom" in channels:
_WEBHOOK_EXECUTOR.submit(
webhook_adapter.send_custom,
custom_url,
title,
body,
"monitor_alert",
ev,
custom_secret,
)
enqueued += 1
if email_adapter.is_configured(email_config) and "email" in channels:
_WEBHOOK_EXECUTOR.submit(
email_adapter.send_email,
email_config,
email_password,
title,
body,
)
enqueued += 1
if enqueued:
logger.info("Webhook 已提交 %d 条 (异步投递, 按渠道独立投递, 失败记 WARNING)", enqueued)
except Exception as e: # noqa: BLE001
+76 -1
View File
@@ -1,7 +1,7 @@
"""Webhook 推送适配器 — 把告警事件推送到外部 IM / 量化软件。
职责: 把后端产生的告警事件, 通过用户配置的 Webhook 地址推送到外部。
目前支持飞书群推送 Webhook; QMT / ptrade 等量化通道为待定。
目前支持飞书、企业微信和通用第三方 JSON Webhook。
飞书自定义机器人接入:
1. 飞书群 → 群设置 → 群推送 Webhook → 添加「自定义机器人」
@@ -17,8 +17,10 @@ from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import time
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
@@ -344,3 +346,76 @@ def send_wecom_markdown(webhook_url: str, title: str, body_md: str) -> bool:
payload: dict = {"msgtype": "markdown", "markdown": {"content": content}}
return _post_wecom(webhook_url, payload)
# ================================================================
# 通用第三方 JSON Webhook
# ================================================================
_CUSTOM_MAX_ATTEMPTS = 3
def is_valid_custom_url(url: str) -> bool:
"""Accept absolute HTTP(S) URLs, including LAN endpoints used by local deployments."""
try:
parsed = urlparse((url or "").strip())
except ValueError:
return False
return parsed.scheme in {"http", "https"} and bool(parsed.netloc) and not parsed.username
def send_custom(
webhook_url: str,
title: str,
body: str,
event_type: str,
data: dict | None = None,
secret: str = "",
max_attempts: int = _CUSTOM_MAX_ATTEMPTS,
) -> bool:
"""POST a stable JSON envelope to a user-configured third-party system.
When ``secret`` is configured the raw request body is signed with HMAC-SHA256.
The receiver can validate ``X-TickFlow-Timestamp`` and
``X-TickFlow-Signature: sha256=<hex>`` before accepting the event.
"""
if not is_valid_custom_url(webhook_url):
return False
timestamp = str(int(time.time()))
payload = {
"event": str(event_type or "notification"),
"timestamp": int(timestamp),
"title": str(title or ""),
"body": str(body or ""),
"data": data or {},
}
encoded = json.dumps(
payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True, default=str,
).encode("utf-8")
headers = {"Content-Type": "application/json", "User-Agent": "TickFlow-Webhook/1.0"}
if secret:
digest = hmac.new(secret.encode("utf-8"), encoded, hashlib.sha256).hexdigest()
headers["X-TickFlow-Timestamp"] = timestamp
headers["X-TickFlow-Signature"] = f"sha256={digest}"
import httpx
last_err = ""
for attempt in range(1, max_attempts + 1):
try:
response = httpx.post(
webhook_url, content=encoded, headers=headers, timeout=5.0,
)
if 200 <= response.status_code < 300:
return True
last_err = f"HTTP {response.status_code}: {response.text[:200]}"
if response.status_code < 500:
logger.warning("第三方 Webhook 推送失败(不重试): %s", last_err)
return False
except Exception as exc: # Network failures are retryable and must not escape.
last_err = str(exc)
if attempt < max_attempts:
time.sleep(min(2 ** (attempt - 1), 3))
logger.warning("第三方 Webhook 推送最终失败(已重试 %d 次): %s", max_attempts, last_err)
return False
+4 -2
View File
@@ -394,7 +394,7 @@ def normalize(rule: dict) -> dict:
r.setdefault("message", "")
r.setdefault("webhook_url", "")
r.setdefault("webhook_enabled", False)
# webhook_channels: 命中时推送的外部渠道 (合法值 'feishu' | 'wecom')。
# webhook_channels: 命中时推送的外部渠道。
# 向后兼容: 老规则只有 webhook_enabled 布尔 (当时勾选即飞书+企业微信双推),
# 这里把 webhook_enabled=True 但未带 webhook_channels 的老规则迁移为 ['feishu','wecom'],
# 还原其当时的实际行为, 用户无感知。
@@ -402,7 +402,9 @@ def normalize(rule: dict) -> dict:
r["webhook_channels"] = ["feishu", "wecom"] if r.get("webhook_enabled") else []
else:
# 防御性过滤, 只保留合法渠道
r["webhook_channels"] = [c for c in r["webhook_channels"] if c in ("feishu", "wecom")]
r["webhook_channels"] = [
c for c in r["webhook_channels"] if c in ("feishu", "wecom", "custom", "email")
]
r.setdefault("created_at", datetime.now(timezone.utc).isoformat())
return r
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import hashlib
import hmac
import json
from app.services import email_adapter, webhook_adapter
def test_custom_webhook_posts_stable_signed_json(monkeypatch):
captured = {}
class Response:
status_code = 204
text = ""
def fake_post(url, *, content, headers, timeout):
captured.update(url=url, content=content, headers=headers, timeout=timeout)
return Response()
monkeypatch.setattr("httpx.post", fake_post)
monkeypatch.setattr(webhook_adapter.time, "time", lambda: 1_700_000_000)
assert webhook_adapter.send_custom(
"https://example.com/tickflow",
"价格预警",
"600000.SH 触发",
"monitor_alert",
{"symbol": "600000.SH"},
"shared-secret",
)
payload = json.loads(captured["content"])
assert payload == {
"event": "monitor_alert",
"timestamp": 1_700_000_000,
"title": "价格预警",
"body": "600000.SH 触发",
"data": {"symbol": "600000.SH"},
}
expected = hmac.new(b"shared-secret", captured["content"], hashlib.sha256).hexdigest()
assert captured["headers"]["X-TickFlow-Signature"] == f"sha256={expected}"
assert captured["headers"]["X-TickFlow-Timestamp"] == "1700000000"
def test_custom_webhook_rejects_non_http_urls(monkeypatch):
monkeypatch.setattr("httpx.post", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError))
assert not webhook_adapter.send_custom("file:///tmp/hook", "title", "body", "test")
def test_email_adapter_uses_starttls_login_and_multiple_recipients(monkeypatch):
instances = []
class FakeSmtp:
def __init__(self, host, port, timeout):
self.host = host
self.port = port
self.timeout = timeout
self.calls = []
instances.append(self)
def ehlo(self):
self.calls.append(("ehlo",))
def starttls(self):
self.calls.append(("starttls",))
def login(self, username, password):
self.calls.append(("login", username, password))
def send_message(self, message):
self.calls.append(("send", message))
def quit(self):
self.calls.append(("quit",))
def close(self):
self.calls.append(("close",))
monkeypatch.setattr(email_adapter.smtplib, "SMTP", FakeSmtp)
config = {
"host": "smtp.example.com",
"port": 587,
"security": "starttls",
"username": "bot@example.com",
"from_address": "bot@example.com",
"to_addresses": ["one@example.com", "two@example.com"],
}
assert email_adapter.send_email(config, "smtp-password", "监控告警", "正文")
smtp = instances[0]
assert smtp.calls[:4] == [
("ehlo",),
("starttls",),
("ehlo",),
("login", "bot@example.com", "smtp-password"),
]
message = next(call[1] for call in smtp.calls if call[0] == "send")
assert message["To"] == "one@example.com, two@example.com"
assert message["Subject"] == "监控告警"
assert smtp.calls[-1] == ("quit",)
@@ -0,0 +1,87 @@
from __future__ import annotations
import pytest
from fastapi import HTTPException
from app.api.settings import (
CustomWebhookPrefsIn,
EmailSmtpPrefsIn,
update_custom_webhook,
update_email_smtp,
)
def test_custom_webhook_settings_validate_url_and_store_secret(monkeypatch):
saved = {}
monkeypatch.setattr(
"app.services.preferences.set_custom_webhook_url",
lambda url: saved.update(url=url) or url,
)
monkeypatch.setattr(
"app.secrets_store.set_custom_webhook_secret",
lambda secret: saved.update(secret=secret) or secret,
)
monkeypatch.setattr(
"app.secrets_store.get_custom_webhook_secret",
lambda: saved.get("secret", ""),
)
result = update_custom_webhook(CustomWebhookPrefsIn(
url="https://hooks.example.com/tickflow",
secret="shared-secret",
))
assert saved == {
"url": "https://hooks.example.com/tickflow",
"secret": "shared-secret",
}
assert result["custom_webhook_secret_set"] is True
with pytest.raises(HTTPException, match=r"HTTP\(S\)"):
update_custom_webhook(CustomWebhookPrefsIn(url="file:///tmp/hook"))
def test_email_settings_validate_and_store_password_separately(monkeypatch):
saved = {}
monkeypatch.setattr(
"app.services.preferences.set_email_smtp_config",
lambda config: saved.update(config=config) or config,
)
monkeypatch.setattr(
"app.secrets_store.set_email_smtp_password",
lambda password: saved.update(password=password) or password,
)
monkeypatch.setattr(
"app.secrets_store.get_email_smtp_password",
lambda: saved.get("password", ""),
)
result = update_email_smtp(EmailSmtpPrefsIn(
host="smtp.example.com",
port=587,
security="starttls",
username="bot@example.com",
password="smtp-password",
from_address="bot@example.com",
to_addresses=["alerts@example.com", "alerts@example.com"],
))
assert saved["password"] == "smtp-password"
assert saved["config"]["to_addresses"] == ["alerts@example.com"]
assert result["email_smtp_password_set"] is True
with pytest.raises(HTTPException, match="收件人"):
update_email_smtp(EmailSmtpPrefsIn(
host="smtp.example.com",
from_address="bot@example.com",
to_addresses=["not-an-email"],
))
saved.clear()
with pytest.raises(HTTPException, match="密码或授权码"):
update_email_smtp(EmailSmtpPrefsIn(
host="smtp.example.com",
username="bot@example.com",
from_address="bot@example.com",
to_addresses=["alerts@example.com"],
))
@@ -115,6 +115,10 @@ def test_ladder_webhook_uses_chinese_title_without_brand(monkeypatch):
monkeypatch.setattr("app.services.preferences.get_feishu_webhook_url", lambda: "https://open.feishu.cn/open-apis/bot/v2/hook/test")
monkeypatch.setattr("app.services.preferences.get_feishu_webhook_secret", lambda: "secret")
monkeypatch.setattr("app.services.preferences.get_wecom_webhook_url", lambda: "wecom-key")
monkeypatch.setattr("app.services.preferences.get_custom_webhook_url", lambda: "")
monkeypatch.setattr("app.services.preferences.get_email_smtp_config", lambda: {})
monkeypatch.setattr("app.secrets_store.get_custom_webhook_secret", lambda: "")
monkeypatch.setattr("app.secrets_store.get_email_smtp_password", lambda: "")
engine = type("Engine", (), {
"rules": {"r_ladder": {"webhook_channels": ["feishu", "wecom"]}},
@@ -135,6 +139,45 @@ def test_ladder_webhook_uses_chinese_title_without_brand(monkeypatch):
assert all("TickFlow" not in args[1] for _, args in calls)
def test_ladder_dispatches_custom_webhook_and_email(monkeypatch):
calls = []
class CaptureExecutor:
def submit(self, fn, *args):
calls.append((fn.__name__, args))
email_config = {
"host": "smtp.example.com",
"username": "bot@example.com",
"from_address": "bot@example.com",
"to_addresses": ["alerts@example.com"],
}
monkeypatch.setattr(quote_service, "_WEBHOOK_EXECUTOR", CaptureExecutor())
monkeypatch.setattr("app.services.preferences.get_feishu_webhook_url", lambda: "")
monkeypatch.setattr("app.services.preferences.get_feishu_webhook_secret", lambda: "")
monkeypatch.setattr("app.services.preferences.get_wecom_webhook_url", lambda: "")
monkeypatch.setattr("app.services.preferences.get_custom_webhook_url", lambda: "https://example.com/hook")
monkeypatch.setattr("app.services.preferences.get_email_smtp_config", lambda: email_config)
monkeypatch.setattr("app.secrets_store.get_custom_webhook_secret", lambda: "hook-secret")
monkeypatch.setattr("app.secrets_store.get_email_smtp_password", lambda: "smtp-password")
engine = type("Engine", (), {
"rules": {"r_ladder": {"webhook_channels": ["custom", "email"]}},
})()
event = {
"rule_id": "r_ladder",
"source": "ladder",
"symbol": "600000.SH",
"name": "浦发银行",
"message": "炸板预警",
}
QuoteService._maybe_send_webhook(object.__new__(QuoteService), [event], engine)
assert [name for name, _ in calls] == ["send_custom", "send_email"]
assert calls[0][1][3:] == ("monitor_alert", event, "hook-secret")
assert calls[1][1][:2] == (email_config, "smtp-password")
def test_review_webhooks_use_title_without_brand(monkeypatch):
calls = []
monkeypatch.setattr("app.services.preferences.get_review_push_channels", lambda: ["feishu", "wecom"])
@@ -154,3 +197,32 @@ def test_review_webhooks_use_title_without_brand(monkeypatch):
assert [args[1] for _, args in calls] == ["每日复盘", "每日复盘"]
assert all("TickFlow" not in args[1] for _, args in calls)
def test_review_pushes_custom_webhook_and_email(monkeypatch):
calls = []
email_config = {
"host": "smtp.example.com",
"username": "bot@example.com",
"from_address": "bot@example.com",
"to_addresses": ["alerts@example.com"],
}
monkeypatch.setattr("app.services.preferences.get_review_push_channels", lambda: ["custom", "email"])
monkeypatch.setattr("app.services.preferences.get_custom_webhook_url", lambda: "https://example.com/hook")
monkeypatch.setattr("app.services.preferences.get_email_smtp_config", lambda: email_config)
monkeypatch.setattr("app.secrets_store.get_custom_webhook_secret", lambda: "hook-secret")
monkeypatch.setattr("app.secrets_store.get_email_smtp_password", lambda: "smtp-password")
monkeypatch.setattr(
"app.services.webhook_adapter.send_custom",
lambda *args: calls.append(("custom", args)) or True,
)
monkeypatch.setattr(
"app.services.email_adapter.send_email",
lambda *args: calls.append(("email", args)) or True,
)
daily_pipeline._maybe_push_review("复盘正文", {"as_of": "2026-07-18"})
assert [channel for channel, _ in calls] == ["custom", "email"]
assert calls[0][1][3] == "market_review"
assert calls[1][1][2] == "每日复盘"
+29
View File
@@ -121,3 +121,32 @@ def test_mining_schedule_setter_rejects_invalid_weekday(weekday):
def test_mining_schedule_setter_rejects_invalid_profile():
with pytest.raises(ValueError, match="profile"):
preferences.set_mining_schedule(True, 4, "exploratory")
def test_external_push_channel_whitelists_include_custom_and_email(_isolated):
assert preferences.set_webhook_default_channels([
"custom", "email", "custom", "unsupported",
]) == ["custom", "email"]
assert preferences.set_review_push_channels([
"email", "wecom", "unsupported",
]) == ["email", "wecom"]
def test_email_smtp_config_falls_back_from_malformed_stored_values(_isolated):
preferences.save({
"email_smtp_config": {
"host": " smtp.example.com ",
"port": "invalid",
"security": "invalid",
"to_addresses": "alerts@example.com",
},
})
assert preferences.get_email_smtp_config() == {
"host": "smtp.example.com",
"port": 465,
"security": "ssl",
"username": "",
"from_address": "",
"to_addresses": [],
}
@@ -109,6 +109,12 @@ def test_strategy_rule_compatibility_and_validation(tmp_path):
monitor_rules.validate(_rule("pool_entry", score_min=90, score_max=70))
monitor_rules.validate(_rule("buy_signal", "pool_exit"))
normalized = monitor_rules.normalize(_rule(
"pool_entry",
webhook_channels=["feishu", "custom", "email", "unsupported"],
))
assert normalized["webhook_channels"] == ["feishu", "custom", "email"]
def test_strategy_score_range_filters_pool_and_buy_signals_but_not_sell_signals():
day = date(2026, 7, 24)
+56 -1
View File
@@ -89,6 +89,61 @@ def test_wecom_sends_saved_url(monkeypatch):
assert "推送配置正确" in calls["body"]
def test_custom_webhook_sends_saved_url_and_secret(monkeypatch):
calls = {}
monkeypatch.setattr(
"app.services.preferences.get_custom_webhook_url",
lambda: "https://example.com/tickflow",
)
monkeypatch.setattr("app.secrets_store.get_custom_webhook_secret", lambda: "secret")
monkeypatch.setattr(
"app.services.webhook_adapter.send_custom",
lambda url, title, body, event_type, data=None, secret="", max_attempts=3:
calls.update(
url=url,
event_type=event_type,
secret=secret,
max_attempts=max_attempts,
) or True,
)
result = run_webhook_test(WebhookTestIn(channel="custom"))
assert result["ok"] is True
assert calls == {
"url": "https://example.com/tickflow",
"event_type": "test",
"secret": "secret",
"max_attempts": 1,
}
def test_email_sends_with_saved_smtp_config(monkeypatch):
calls = {}
config = {
"host": "smtp.example.com",
"port": 465,
"security": "ssl",
"username": "bot@example.com",
"from_address": "bot@example.com",
"to_addresses": ["alerts@example.com"],
}
monkeypatch.setattr("app.services.preferences.get_email_smtp_config", lambda: config)
monkeypatch.setattr("app.secrets_store.get_email_smtp_password", lambda: "password")
monkeypatch.setattr(
"app.services.email_adapter.send_email",
lambda cfg, password, subject, body, max_attempts=2:
calls.update(config=cfg, password=password, subject=subject, max_attempts=max_attempts) or True,
)
result = run_webhook_test(WebhookTestIn(channel="email"))
assert result["ok"] is True
assert calls["config"] == config
assert calls["password"] == "password"
assert calls["max_attempts"] == 1
def test_unknown_channel_rejected_by_pydantic():
with pytest.raises(ValidationError):
WebhookTestIn(channel="wecom-bot")
WebhookTestIn(channel="sms")
+2 -2
View File
@@ -86,9 +86,9 @@
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
### 飞书 Webhook 推送
### 外部推送渠道
全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验)。可在设置页设「默认推送渠道」,新建规则自动预填。
全局配置飞书群 Webhook、企业微信群 Webhook、通用第三方 JSON Webhook 或 SMTP 邮件。每条监控规则可独立多选渠道,每日复盘也共用同一套渠道配置。第三方 Webhook 使用稳定 JSON 信封,并支持可选 HMAC-SHA256 签名;邮件支持 SSL、STARTTLS 和无加密 SMTP。设置页可选择新建规则的默认渠道并对各渠道发送测试消息。
---
+46 -3
View File
@@ -86,6 +86,13 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const quoteInterval = quoteStatus?.interval_s
const feishuConfigured = !!(prefs?.feishu_webhook_url)
const wecomConfigured = !!(prefs?.wecom_webhook_url)
const customConfigured = !!(prefs?.custom_webhook_url)
const emailConfigured = !!(
prefs?.email_smtp_config?.host
&& prefs.email_smtp_config.from_address
&& prefs.email_smtp_config.to_addresses.length
&& (!prefs.email_smtp_config.username || prefs.email_smtp_password_set)
)
const [editing] = useState(!!rule)
// 新建规则: 预填全局「默认推送渠道」(多选数组), preset 显式指定时以 preset 为准。
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
@@ -369,7 +376,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
})
}
// 勾选/取消勾选某个推送渠道 (飞书 / 企业微信 各自独立)
// 勾选/取消勾选某个外部推送渠道。
const toggleChannel = (ch: string) =>
setDraft(d => {
const cur = d.webhook_channels ?? []
@@ -1495,10 +1502,10 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
</label>
</div>
{/* Webhook 推送 — 飞书 / 企业微信 */}
{/* 外部推送 */}
<div className="rounded-btn border border-border/40 bg-base/40 p-3 space-y-2">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-foreground">Webhook 推送</span>
<span className="text-[11px] font-medium text-foreground">外部推送</span>
<span className="text-[9px] text-muted">触发时推送告警到外部</span>
</div>
@@ -1521,6 +1528,38 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
)}
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={(draft.webhook_channels ?? []).includes('custom')}
onChange={() => toggleChannel('custom')}
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-foreground">第三方系统</span>
<span className="text-[9px] text-muted">JSON Webhook</span>
{(draft.webhook_channels ?? []).includes('custom') && (
<span className={`ml-auto text-[9px] ${customConfigured ? 'text-emerald-500' : 'text-warning'}`}>
{customConfigured ? '已配置' : '未配置'}
</span>
)}
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={(draft.webhook_channels ?? []).includes('email')}
onChange={() => toggleChannel('email')}
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-foreground">邮件</span>
<span className="text-[9px] text-muted">SMTP</span>
{(draft.webhook_channels ?? []).includes('email') && (
<span className={`ml-auto text-[9px] ${emailConfigured ? 'text-emerald-500' : 'text-warning'}`}>
{emailConfigured ? '已配置' : '未配置'}
</span>
)}
</label>
{/* 企业微信 (可用) */}
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -1546,6 +1585,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const unconfigured: string[] = []
if (selected.includes('feishu') && !feishuConfigured) unconfigured.push('飞书')
if (selected.includes('wecom') && !wecomConfigured) unconfigured.push('企业微信')
if (selected.includes('custom') && !customConfigured) unconfigured.push('第三方系统')
if (selected.includes('email') && !emailConfigured) unconfigured.push('邮件')
if (unconfigured.length === 0) return null
return (
<p className="text-[10px] leading-relaxed text-warning/80">
@@ -1559,6 +1600,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const ready: string[] = []
if (selected.includes('feishu') && feishuConfigured) ready.push('飞书')
if (selected.includes('wecom') && wecomConfigured) ready.push('企业微信')
if (selected.includes('custom') && customConfigured) ready.push('第三方系统')
if (selected.includes('email') && emailConfigured) ready.push('邮件')
if (ready.length === 0) return null
return (
<p className="text-[10px] leading-relaxed text-muted">
@@ -106,6 +106,8 @@ export function PriceAlertDialog({
const configured = new Set<string>()
if (prefs.feishu_webhook_url) configured.add('feishu')
if (prefs.wecom_webhook_url) configured.add('wecom')
if (prefs.custom_webhook_url) configured.add('custom')
if (prefs.email_smtp_config?.host && prefs.email_smtp_config.from_address && prefs.email_smtp_config.to_addresses.length && (!prefs.email_smtp_config.username || prefs.email_smtp_password_set)) configured.add('email')
setChannels((prefs.webhook_default_channels ?? []).filter(channel => configured.has(channel)))
}, [prefs])
@@ -330,6 +332,8 @@ export function PriceAlertDialog({
{([
{ key: 'feishu', label: '飞书', configured: !!prefs?.feishu_webhook_url },
{ key: 'wecom', label: '企业微信', configured: !!prefs?.wecom_webhook_url },
{ key: 'custom', label: '第三方系统', configured: !!prefs?.custom_webhook_url },
{ key: 'email', label: '邮件', configured: !!(prefs?.email_smtp_config?.host && prefs.email_smtp_config.from_address && prefs.email_smtp_config.to_addresses.length && (!prefs.email_smtp_config.username || prefs.email_smtp_password_set)) },
]).map(channel => (
<label key={channel.key} className={`inline-flex items-center gap-2 text-xs ${channel.configured ? 'text-foreground' : 'text-muted/60'}`}>
<input type="checkbox" checked={channels.includes(channel.key)} disabled={!channel.configured} onChange={() => toggleChannel(channel.key)} className="h-3.5 w-3.5 accent-sky-500" />
+50 -3
View File
@@ -12,6 +12,16 @@ type RequestOptions = RequestInit & {
quiet?: boolean
}
export class ApiError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function request<T>(path: string, init?: RequestOptions): Promise<T> {
const { quiet, ...fetchInit } = init ?? {}
const isFormData = fetchInit.body instanceof FormData
@@ -37,7 +47,7 @@ async function request<T>(path: string, init?: RequestOptions): Promise<T> {
const msg = detail || `${res.status} ${res.statusText}`
// 401 (未登录/会话过期) 不弹 toast — 由全局认证拦截器统一跳登录页, 避免刷屏
if (res.status !== 401 && !quiet) toast(msg, 'error')
throw new Error(msg)
throw new ApiError(msg, res.status)
}
return res.json() as Promise<T>
}
@@ -167,6 +177,18 @@ export interface LevelSeries {
atr?: { stop_loss: (number | null)[]; take_profit: (number | null)[] }
}
export interface MainCostEstimate {
estimated_main_cost: number | null
main_cost_peak: number | null
main_core_cost_low: number | null
main_core_cost_high: number | null
/** 小数制:0.10 表示估算浮盈 10% */
main_profit_rate: number | null
main_net_amount: number
/** 0~1,表示输入数据完整度,不是结果真实准确率 */
confidence: number
}
export interface StockLevels {
levels: Record<LevelType, PriceLevel[]>
close: number | null
@@ -175,6 +197,8 @@ export interface StockLevels {
/** dates 与 series 对齐;前端按自身 rows 的日期映射,缺失填 null */
dates?: string[]
series?: LevelSeries
/** 疑似主力成本统计估算;旧后端响应可能不存在该字段 */
main_cost?: MainCostEstimate
}
export interface AiStockReport {
@@ -908,7 +932,7 @@ export interface MonitorRule {
message: string
webhook_url?: string
webhook_enabled?: boolean // 兼容老规则, 已由 webhook_channels 取代
webhook_channels?: string[] // 命中时推送的外部渠道 (合法值 'feishu' | 'wecom')
webhook_channels?: string[] // 合法值: feishu | wecom | custom | email
created_at?: string
runtime_warning?: string
// ladder 专属: 封单监控; volume_delta 复用 metric 表示阈值口径 (volume=手数, amount=金额)
@@ -1673,6 +1697,10 @@ export interface Preferences {
feishu_webhook_url?: string
feishu_webhook_secret?: string
wecom_webhook_url?: string
custom_webhook_url?: string
custom_webhook_secret_set?: boolean
email_smtp_config?: EmailSmtpConfig
email_smtp_password_set?: boolean
wecom_bot_id?: string
wecom_bot_secret?: string
wecom_bot_enabled?: boolean
@@ -1686,6 +1714,15 @@ export interface Preferences {
monitor_ext_fields: { concept: MonitorExtFieldItem | null; industry: MonitorExtFieldItem | null }
}
export interface EmailSmtpConfig {
host: string
port: number
security: 'ssl' | 'starttls' | 'none'
username: string
from_address: string
to_addresses: string[]
}
/** 监控中心 ext 字段单项配置 (行业/概念标签的来源 + 显示裁剪) */
export interface MonitorExtFieldItem {
/** "configId.fieldName" */
@@ -1982,7 +2019,17 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ url }),
}),
sendTestWebhook: (channel: 'feishu' | 'wecom') =>
updateCustomWebhook: (url: string, secret?: string) =>
request<{ custom_webhook_url: string; custom_webhook_secret_set: boolean }>('/api/settings/preferences/custom-webhook', {
method: 'PUT',
body: JSON.stringify({ url, ...(secret !== undefined ? { secret } : {}) }),
}),
updateEmailSmtp: (config: EmailSmtpConfig, password?: string) =>
request<{ email_smtp_config: EmailSmtpConfig; email_smtp_password_set: boolean }>('/api/settings/preferences/email-smtp', {
method: 'PUT',
body: JSON.stringify({ ...config, ...(password !== undefined ? { password } : {}) }),
}),
sendTestWebhook: (channel: 'feishu' | 'wecom' | 'custom' | 'email') =>
request<{ ok: boolean; detail: string }>('/api/settings/preferences/webhook-test', {
method: 'POST',
body: JSON.stringify({ channel }),
+3 -3
View File
@@ -28,7 +28,7 @@ import { StockPreviewDialog } from '@/components/StockPreviewDialog'
* 偏移异动计算量可控: 主开关默认关闭, 开启后才发起轮询 (每 60s 一次); 关闭后
* 保留展示上次计算结果 (含计算时间, 取自 localStorage)。规则口径通过工具栏「?」
* 展开查看。告警走系统监控体系: 在「监控中心」创建异动监控规则后由后端持续评估,
* 统一触发记录/站内通知/飞书·企微推送。
* 统一触发记录/站内通知/外部渠道推送。
*/
const WINDOW_KEYS = ['3d', '10d', '30d'] as const
@@ -86,7 +86,7 @@ export function AbnormalMoves() {
<Link
to="/monitor"
className="inline-flex h-7 items-center gap-1 rounded border border-border bg-base px-2 text-[11px] text-secondary transition-colors hover:text-foreground"
title="在监控中心创建「异动监控」规则: 后台持续评估, 触发时统一走触发记录/站内通知/飞书·企微推送, 无需保持本页打开"
title="在监控中心创建「异动监控」规则: 后台持续评估, 触发时统一走触发记录/站内通知/外部渠道推送, 无需保持本页打开"
>
<Settings2 className="h-3 w-3" />
告警规则
@@ -682,7 +682,7 @@ function DeviationView({ onPreview }: {
</p>
<p className="mx-auto mt-2 max-w-lg text-[11px] leading-relaxed text-muted/80">
需要告警推送时, 在<Link to="/monitor?new=abnormal" className="text-accent hover:underline">监控中心</Link>
新建「异动监控」规则 —— 后台持续评估, 触发时统一走触发记录 / 站内通知 / 飞书·企微推送,
新建「异动监控」规则 —— 后台持续评估, 触发时统一走触发记录 / 站内通知 / 外部渠道推送,
与本页开关互不影响。
</p>
<button
+29 -25
View File
@@ -109,7 +109,7 @@ function getExtTags(stock: LimitLadderStock, item?: ExtFieldItem): string[] {
const sep = cfg?.separator?.trim() || null
const tags = sep
? str.split(sep).map(s => s.trim()).filter(Boolean)
: str.split(/[、,,;;\-]/).map(s => s.trim()).filter(Boolean)
: str.split(/[、,,;;-]/).map(s => s.trim()).filter(Boolean)
const maxTags = cfg?.maxTags ?? 0
const sliced = maxTags > 0 ? tags.slice(0, maxTags) : tags
@@ -501,7 +501,7 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD
// 基于齿轮按钮位置算菜单坐标 (fixed 定位, 脱离父级 overflow-hidden 裁剪)
const MENU_W = 240 // w-60 = 15rem = 240px
const MENU_H = 340 // 预估高度 (含标题栏 + 4 行设置 + 权限提示 + 按钮区)
const MENU_H = 370 // 预估高度 (推送渠道可换行)
const anchorRight = anchorRect.right
const anchorBottom = anchorRect.bottom
// 水平: 默认右对齐齿轮; 超出右边则左移
@@ -572,30 +572,34 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD
</select>
</div>
{/* 推送渠道: 胶囊标签 (飞书 / 企业微信 各自独立勾选), 选中带强调色 */}
<div className="flex items-center gap-2">
{/* 推送渠道: 多选胶囊标签 */}
<div className="flex items-start gap-2">
<span className="text-[10px] text-muted shrink-0 w-8">推送</span>
{([
{ key: 'feishu', label: '飞书' },
{ key: 'wecom', label: '企业微信' },
] as const).map(ch => {
const on = pushChannels.includes(ch.key)
return (
<button
key={ch.key}
type="button"
onClick={() => togglePushChannel(ch.key)}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-medium transition-colors border cursor-pointer ${
on
? 'bg-accent/15 text-accent border-accent/40'
: 'bg-elevated/40 text-muted border-border hover:text-secondary'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${on ? 'bg-accent' : 'bg-muted/50'}`} />
{ch.label}
</button>
)
})}
<div className="flex flex-wrap gap-1">
{([
{ key: 'feishu', label: '飞书' },
{ key: 'wecom', label: '企微' },
{ key: 'custom', label: '第三方' },
{ key: 'email', label: '邮件' },
] as const).map(ch => {
const on = pushChannels.includes(ch.key)
return (
<button
key={ch.key}
type="button"
onClick={() => togglePushChannel(ch.key)}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-medium transition-colors border cursor-pointer ${
on
? 'bg-accent/15 text-accent border-accent/40'
: 'bg-elevated/40 text-muted border-border hover:text-secondary'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${on ? 'bg-accent' : 'bg-muted/50'}`} />
{ch.label}
</button>
)
})}
</div>
</div>
{/* 权限提示 (免费用户) */}
+55 -3
View File
@@ -108,8 +108,15 @@ export function Review() {
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)
const customConfigured = !!(prefs.data?.custom_webhook_url)
const emailConfigured = !!(
prefs.data?.email_smtp_config?.host
&& prefs.data.email_smtp_config.from_address
&& prefs.data.email_smtp_config.to_addresses.length
&& (!prefs.data.email_smtp_config.username || prefs.data.email_smtp_password_set)
)
// 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置
// []=不推送, ['feishu']=飞书, ['wecom']=企业微信
// []=不推送; 可多选飞书、企微、第三方 Webhook 和邮件。
const reviewPushChannels = prefs.data?.review_push_channels ?? []
// 弹窗内的本地草稿: 开关和时间都在本地改, 点「保存」才真正提交(避免开关一拨就关弹窗)
const [draft, setDraft] = useState(reviewSched)
@@ -475,10 +482,55 @@ export function Review() {
{wecomConfigured ? '已配置' : '未配置'}
</span>
</button>
<button
type="button"
disabled={pushMut.isPending}
onClick={() => togglePushChannel('custom')}
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('custom')
? '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('custom') ? 'border-accent bg-accent text-white' : 'border-border')}>
{reviewPushChannels.includes('custom') && <Check className="h-2.5 w-2.5" />}
</span>
<span className="text-[11px] text-foreground">第三方系统</span>
<span className="text-[9px] text-muted">JSON Webhook</span>
<span className={cn('ml-auto text-[9px]', customConfigured ? 'text-emerald-500' : 'text-warning')}>
{customConfigured ? '已配置' : '未配置'}
</span>
</button>
<button
type="button"
disabled={pushMut.isPending}
onClick={() => togglePushChannel('email')}
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('email')
? '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('email') ? 'border-accent bg-accent text-white' : 'border-border')}>
{reviewPushChannels.includes('email') && <Check className="h-2.5 w-2.5" />}
</span>
<span className="text-[11px] text-foreground">邮件</span>
<span className="text-[9px] text-muted">SMTP</span>
<span className={cn('ml-auto text-[9px]', emailConfigured ? 'text-emerald-500' : 'text-warning')}>
{emailConfigured ? '已配置' : '未配置'}
</span>
</button>
</div>
<p className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
手动或定时生成的复盘都会推送完整报告。复用「设置 → 实时监控」的 Webhook 配置。
{((reviewPushChannels.includes('feishu') && !feishuConfigured) || (reviewPushChannels.includes('wecom') && !wecomConfigured)) && (
手动或定时生成的复盘都会推送完整报告。复用「设置 → 实时监控」的渠道配置。
{(
(reviewPushChannels.includes('feishu') && !feishuConfigured)
|| (reviewPushChannels.includes('wecom') && !wecomConfigured)
|| (reviewPushChannels.includes('custom') && !customConfigured)
|| (reviewPushChannels.includes('email') && !emailConfigured)
) && (
<Link to="/settings?tab=monitoring&highlight=webhooks" className="ml-1 text-accent hover:underline" onClick={() => setShowSchedule(false)}>
前往配置 →
</Link>
+237 -4
View File
@@ -15,7 +15,7 @@ import {
useCapabilities,
} from '@/lib/useSharedQueries'
import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
import { api } from '@/lib/api'
import { api, type EmailSmtpConfig } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useCardFlash, cardFlashCls } from '@/lib/useCardFlash'
import { toast } from '@/components/Toast'
@@ -32,6 +32,15 @@ const PAGE_LABELS: Record<string, string> = {
'limit-ladder': '连板梯队',
}
const EMPTY_EMAIL_SMTP: EmailSmtpConfig = {
host: '',
port: 465,
security: 'ssl',
username: '',
from_address: '',
to_addresses: [],
}
// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {}) {
@@ -83,6 +92,24 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const wecomWebhookUrl = prefs?.wecom_webhook_url ?? ''
const [wecomDraft, setWecomDraft] = useState(wecomWebhookUrl)
const [wecomError, setWecomError] = useState('')
// 通用第三方 JSON webhook
const customWebhookUrl = prefs?.custom_webhook_url ?? ''
const customWebhookSecretSet = prefs?.custom_webhook_secret_set ?? false
const [customDraft, setCustomDraft] = useState(customWebhookUrl)
const [customSecretDraft, setCustomSecretDraft] = useState('')
const [customError, setCustomError] = useState('')
// SMTP 邮件通道
const emailSmtpConfig = prefs?.email_smtp_config ?? EMPTY_EMAIL_SMTP
const emailSmtpPasswordSet = prefs?.email_smtp_password_set ?? false
const emailConfigured = !!(
emailSmtpConfig.host
&& emailSmtpConfig.from_address
&& emailSmtpConfig.to_addresses.length
&& (!emailSmtpConfig.username || emailSmtpPasswordSet)
)
const [emailDraft, setEmailDraft] = useState<EmailSmtpConfig>(emailSmtpConfig)
const [emailPasswordDraft, setEmailPasswordDraft] = useState('')
const [emailError, setEmailError] = useState('')
// 企业微信智能机器人 (BotID + Secret, 长连接通道)
const wecomBotId = prefs?.wecom_bot_id ?? ''
const wecomBotSecret = prefs?.wecom_bot_secret ?? ''
@@ -95,6 +122,8 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const [channelOpen, setChannelOpen] = useState(false)
// 企业微信渠道配置区展开态
const [wecomOpen, setWecomOpen] = useState(false)
const [customOpen, setCustomOpen] = useState(false)
const [emailOpen, setEmailOpen] = useState(false)
// 智能机器人配置区展开态
const [botOpen, setBotOpen] = useState(false)
useEffect(() => {
@@ -104,6 +133,14 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
useEffect(() => {
setWecomDraft(wecomWebhookUrl)
}, [wecomWebhookUrl])
useEffect(() => {
setCustomDraft(customWebhookUrl)
setCustomSecretDraft('')
}, [customWebhookUrl, customWebhookSecretSet])
useEffect(() => {
setEmailDraft(emailSmtpConfig)
setEmailPasswordDraft('')
}, [emailSmtpConfig, emailSmtpPasswordSet])
useEffect(() => {
setBotIdDraft(wecomBotId)
setBotSecretDraft(wecomBotSecret)
@@ -129,7 +166,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
// 勾选/取消勾选某个默认推送渠道 (飞书 / 企业微信 各自独立)
// 勾选/取消勾选某个默认推送渠道。
const toggleDefaultChannel = useCallback(async (ch: string, enabled: boolean) => {
const cur = prefs?.webhook_default_channels ?? []
const next = enabled ? [...cur, ch] : cur.filter(c => c !== ch)
@@ -184,6 +221,58 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
mutationFn: () => api.sendTestWebhook('wecom'),
})
const saveCustomWebhook = useMutation({
mutationFn: ({ url, secret }: { url: string; secret?: string }) => api.updateCustomWebhook(url, secret),
onSuccess: () => {
setCustomError('')
setCustomSecretDraft('')
toast('第三方 Webhook 已保存', 'success')
qc.invalidateQueries({ queryKey: QK.preferences })
},
onError: (err: any) => setCustomError(String(err?.message ?? '保存失败')),
})
const submitCustom = useCallback(() => {
const url = customDraft.trim()
if (url && !/^https?:\/\//i.test(url)) {
setCustomError('请输入完整的 HTTP(S) URL')
return
}
saveCustomWebhook.mutate({
url,
...(customSecretDraft ? { secret: customSecretDraft } : {}),
})
}, [customDraft, customSecretDraft, saveCustomWebhook])
const testCustom = useMutation({
mutationFn: () => api.sendTestWebhook('custom'),
})
const saveEmailSmtp = useMutation({
mutationFn: ({ config, password }: { config: EmailSmtpConfig; password?: string }) =>
api.updateEmailSmtp(config, password),
onSuccess: () => {
setEmailError('')
setEmailPasswordDraft('')
toast('邮件推送配置已保存', 'success')
qc.invalidateQueries({ queryKey: QK.preferences })
},
onError: (err: any) => setEmailError(String(err?.message ?? '保存失败')),
})
const submitEmail = useCallback(() => {
saveEmailSmtp.mutate({
config: {
...emailDraft,
host: emailDraft.host.trim(),
username: emailDraft.username.trim(),
from_address: emailDraft.from_address.trim(),
to_addresses: emailDraft.to_addresses.map(item => item.trim()).filter(Boolean),
},
...(emailPasswordDraft ? { password: emailPasswordDraft } : {}),
})
}, [emailDraft, emailPasswordDraft, saveEmailSmtp])
const testEmail = useMutation({
mutationFn: () => api.sendTestWebhook('email'),
})
// 智能机器人 (BotID + Secret) 保存 → 后端立即重建连接
const saveWecomBot = useMutation({
mutationFn: ({ botId, secret }: { botId: string; secret: string }) =>
@@ -468,7 +557,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
</Card>
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。
飞书 / 企业微信。
飞书 / 企业微信 / 第三方 Webhook / 邮件。
每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}
<Card icon={Webhook} title="推送通知" anchor="webhooks">
<p className="text-xs text-secondary mb-3">
@@ -642,6 +731,150 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
)}
</div>
{/* 通用第三方 JSON Webhook */}
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
<div
onClick={() => setCustomOpen(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={webhookDefaultChannels.includes('custom')}
onChange={e => { e.stopPropagation(); toggleDefaultChannel('custom', 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">JSON Webhook</span>
{webhookDefaultChannels.includes('custom') && (
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent">默认</span>
)}
<span className={`ml-auto text-[9px] ${customWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
{customWebhookUrl ? '已配置' : '未配置'}
</span>
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${customOpen ? 'rotate-180' : ''}`} />
</div>
{customOpen && (
<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">接收端 URL</span>
<input
value={customDraft}
onChange={e => { setCustomDraft(e.target.value); if (!testCustom.isPending) testCustom.reset() }}
placeholder="https://example.com/webhooks/tickflow"
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>
<label className="block mt-2 space-y-1.5">
<span className="text-[11px] text-muted">签名密钥 (可选 · HMAC-SHA256)</span>
<input
type="password"
value={customSecretDraft}
onChange={e => setCustomSecretDraft(e.target.value)}
placeholder={customWebhookSecretSet ? '已保存;留空保持不变' : '留空则不签名'}
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>
{customError && <div className="mt-2 text-[11px] text-danger">{customError}</div>}
<div className="mt-2 flex items-center gap-2">
<button
onClick={submitCustom}
disabled={saveCustomWebhook.isPending || (customDraft.trim() === customWebhookUrl && !customSecretDraft)}
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"
>
{saveCustomWebhook.isPending ? '保存中…' : '保存'}
</button>
<TestSendButton test={testCustom} configured={!!customWebhookUrl} />
<TestResult test={testCustom} />
</div>
<details className="mt-3 text-[10px] text-muted">
<summary className="cursor-pointer hover:text-secondary">请求格式</summary>
<p className="mt-1.5 leading-relaxed">
POST JSON 包含 event、timestamp、title、body、data。配置密钥后会附带
X-TickFlow-Timestamp 和 X-TickFlow-Signature 请求头。
</p>
</details>
</div>
)}
</div>
{/* SMTP 邮件推送 */}
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
<div
onClick={() => setEmailOpen(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={webhookDefaultChannels.includes('email')}
onChange={e => { e.stopPropagation(); toggleDefaultChannel('email', 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">SMTP</span>
{webhookDefaultChannels.includes('email') && (
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent">默认</span>
)}
<span className={`ml-auto text-[9px] ${emailConfigured ? 'text-emerald-500' : 'text-warning'}`}>
{emailConfigured ? '已配置' : '未配置'}
</span>
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${emailOpen ? 'rotate-180' : ''}`} />
</div>
{emailOpen && (
<div className="border-t border-border/60 bg-base/30 p-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_6rem_8rem]">
<label className="space-y-1.5">
<span className="text-[11px] text-muted">SMTP 主机</span>
<input value={emailDraft.host} onChange={e => setEmailDraft(d => ({ ...d, host: e.target.value }))} placeholder="smtp.example.com" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">端口</span>
<input type="number" min={1} max={65535} value={emailDraft.port} onChange={e => setEmailDraft(d => ({ ...d, port: Number(e.target.value) }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">加密</span>
<select value={emailDraft.security} onChange={e => setEmailDraft(d => ({ ...d, security: e.target.value as EmailSmtpConfig['security'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-2 text-xs text-foreground">
<option value="ssl">SSL</option>
<option value="starttls">STARTTLS</option>
<option value="none">无</option>
</select>
</label>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="space-y-1.5">
<span className="text-[11px] text-muted">登录用户名 (可选)</span>
<input value={emailDraft.username} onChange={e => setEmailDraft(d => ({ ...d, username: e.target.value }))} placeholder="bot@example.com" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">密码 / 授权码 (可选)</span>
<input type="password" value={emailPasswordDraft} onChange={e => setEmailPasswordDraft(e.target.value)} placeholder={emailSmtpPasswordSet ? '已保存;留空保持不变' : '无认证服务器可留空'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">发件人</span>
<input value={emailDraft.from_address} onChange={e => setEmailDraft(d => ({ ...d, from_address: e.target.value }))} placeholder="bot@example.com" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">收件人 (多个用逗号分隔)</span>
<input value={emailDraft.to_addresses.join(', ')} onChange={e => setEmailDraft(d => ({ ...d, to_addresses: e.target.value.split(/[,;,;\n]/) }))} placeholder="alerts@example.com" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground" />
</label>
</div>
{emailError && <div className="mt-2 text-[11px] text-danger">{emailError}</div>}
<div className="mt-2 flex items-center gap-2">
<button onClick={submitEmail} disabled={saveEmailSmtp.isPending} 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">
{saveEmailSmtp.isPending ? '保存中…' : '保存'}
</button>
<TestSendButton test={testEmail} configured={emailConfigured} />
<TestResult test={testEmail} />
</div>
</div>
)}
</div>
{/* 企业微信智能机器人 (BotID + Secret): 长连接通道, 与群推送 Webhook 并列 */}
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
<div
@@ -755,7 +988,7 @@ function TestSendButton({ test, configured }: {
<button
onClick={() => test.mutate()}
disabled={test.isPending || !configured}
title={!configured ? '请先保存 Webhook 地址' : '向已保存的地址发送测试消息'}
title={!configured ? '请先完成该渠道配置' : '发送测试消息'}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:text-foreground text-xs disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{test.isPending ? '测试中…' : '测试'}