Add WeCom markdown alerts and keep target exits while strategy is paused.

Notify open/close/start/pause/fault with SIM vs LIVE venue labels; configure webhook in Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 23:19:39 +08:00
parent 4d331da962
commit 56a4007130
12 changed files with 602 additions and 16 deletions
+4
View File
@@ -55,3 +55,7 @@ CLOSE_BID_MARK_MAX_PCT=30
REST_SECONDS=300
PERP_QTY_ETH=1
OPTION_QTY_ETH=2
# 企业微信群机器人(系统设置页可改)
WECOM_ENABLED=0
WECOM_WEBHOOK_URL=
+51
View File
@@ -329,3 +329,54 @@ async def put_runtime_settings(
pass
return _runtime_payload()
class NotifySettingsBody(BaseModel):
enabled: bool | None = None
webhook_url: str | None = None
def _notify_payload() -> dict:
from ..notify import wecom
from ..env_store import mask_secret
s = get_settings()
url = wecom.wecom_webhook_url()
return {
"enabled": wecom.wecom_enabled(),
"webhook_configured": bool(url),
"webhook_url_masked": mask_secret(url) if url else None,
"venue_label": wecom.venue_label(),
}
@router.get("/notify")
async def get_notify_settings(_user: Annotated[str, Depends(require_user)]) -> dict:
return _notify_payload()
@router.put("/notify")
async def put_notify_settings(
body: NotifySettingsBody,
_user: Annotated[str, Depends(require_user)],
) -> dict:
updates: dict[str, str] = {}
if body.enabled is not None:
updates["WECOM_ENABLED"] = "1" if body.enabled else "0"
get_db().set_setting("wecom_enabled", "1" if body.enabled else "0")
if body.webhook_url is not None and body.webhook_url.strip():
updates["WECOM_WEBHOOK_URL"] = body.webhook_url.strip()
get_db().set_setting("wecom_webhook_url", body.webhook_url.strip())
if updates:
upsert_env_keys(updates)
return _notify_payload()
@router.post("/notify/test")
async def test_notify(_user: Annotated[str, Depends(require_user)]) -> dict:
from ..notify import wecom
ok, msg = wecom.notify_test()
if not ok:
raise HTTPException(status_code=400, detail=f"推送失败: {msg}")
return {"ok": True, "detail": "测试消息已发送", **_notify_payload()}
+26
View File
@@ -115,6 +115,22 @@ async def sim_open_group(
)
if not r.ok:
raise HTTPException(status_code=400, detail=r.detail)
try:
from ..notify import wecom
wecom.notify_open(
group_id=gid,
detail=r.detail,
extra={
"bias": bias,
"option_side": option_side,
"option_inst_id": option_inst,
"strike": pick.pair.strike,
"expiry_ymd": pick.pair.expiry_ymd,
},
)
except Exception:
pass
return {
"ok": True,
**(r.data or {}),
@@ -141,6 +157,16 @@ async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict:
if r.ok:
# 与自动/紧急全平一致:成功全平后进入组间休息
get_engine().enter_rest_after_close()
try:
from ..notify import wecom
wecom.notify_close(
reason="manual",
detail=r.detail,
data=r.data or {},
)
except Exception:
pass
return {
"ok": r.ok,
"liquidity_wait": r.liquidity_wait,
+4
View File
@@ -77,6 +77,10 @@ class Settings(BaseSettings):
option_qty_eth: float = 2.0
db_path: str = "" # empty -> backend/data/hedge.db
# 企业微信群机器人
wecom_enabled: bool = False
wecom_webhook_url: str = ""
@property
def is_sim(self) -> bool:
return self.mode.strip().upper() != "LIVE"
+5
View File
@@ -0,0 +1,5 @@
"""通知子系统。"""
from . import wecom
__all__ = ["wecom"]
+219
View File
@@ -0,0 +1,219 @@
"""企业微信群机器人通知(独立模块)。"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
import httpx
from ..config import get_settings
from ..exchange.runtime import load_runtime_settings
from ..models.db import get_db
logger = logging.getLogger(__name__)
# 消息标识(Markdown 内展示,便于检索)
TAG_OPEN = "OPEN"
TAG_CLOSE = "CLOSE"
TAG_START = "START"
TAG_PAUSE = "PAUSE"
TAG_FAULT = "FAULT"
TAG_TEST = "TEST"
_last_fault_key: str | None = None
_last_fault_ms: float = 0.0
_FAULT_DEDUP_SEC = 300.0
def _as_bool(raw: str | None, default: bool = False) -> bool:
if raw is None or raw == "":
return default
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def wecom_enabled() -> bool:
s = get_settings()
if getattr(s, "wecom_enabled", False):
return True
try:
return _as_bool(get_db().get_setting("wecom_enabled", "0"), False)
except Exception:
return False
def wecom_webhook_url() -> str:
s = get_settings()
url = (getattr(s, "wecom_webhook_url", None) or "").strip()
if url:
return url
try:
return (get_db().get_setting("wecom_webhook_url", "") or "").strip()
except Exception:
return ""
def venue_label() -> str:
"""模拟盘 / 实盘·OKX / 实盘·BINANCE。"""
s = get_settings()
if s.is_sim:
return "模拟盘"
try:
ex = load_runtime_settings().exchange
except Exception:
ex = s.exchange or "okx"
ex_u = str(ex).strip().lower()
if ex_u in ("binance", "bn"):
return "实盘·币安"
return "实盘·OKX"
def build_markdown(*, tag: str, title: str, lines: list[str] | None = None) -> str:
body = "\n".join(f"> {ln}" if not ln.startswith(">") else ln for ln in (lines or []))
parts = [
f"## 【{venue_label()}{title}",
f"> **标识**: `{tag}`",
f"> **时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}",
]
if body:
parts.append("")
parts.append(body)
return "\n".join(parts)
def _post_markdown_sync(content: str) -> tuple[bool, str]:
if not wecom_enabled():
return False, "未开启企业微信通知"
url = wecom_webhook_url()
if not url:
return False, "未配置 Webhook"
# 企业微信 markdown 上限约 4096 字节
raw = content.encode("utf-8")
if len(raw) > 4000:
content = raw[:3900].decode("utf-8", errors="ignore") + "\n"
payload = {"msgtype": "markdown", "markdown": {"content": content}}
try:
with httpx.Client(timeout=8.0) as client:
r = client.post(url, json=payload)
data = r.json() if r.content else {}
if r.status_code != 200 or int(data.get("errcode") or 0) != 0:
return False, str(data.get("errmsg") or r.text or r.status_code)
return True, "ok"
except Exception as e:
return False, str(e)
async def send_markdown(content: str) -> tuple[bool, str]:
return await asyncio.to_thread(_post_markdown_sync, content)
def notify_async(content: str) -> None:
"""火忘:不阻塞策略循环。"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
ok, msg = _post_markdown_sync(content)
if not ok:
logger.warning("wecom sync send failed: %s", msg)
return
async def _run() -> None:
ok, msg = await send_markdown(content)
if not ok:
logger.warning("wecom send failed: %s", msg)
loop.create_task(_run())
def notify_start() -> None:
notify_async(
build_markdown(
tag=TAG_START,
title="策略启动",
lines=["策略已启动,可新开仓并盯目标平仓。"],
)
)
def notify_pause() -> None:
notify_async(
build_markdown(
tag=TAG_PAUSE,
title="策略暂停",
lines=[
"策略已暂停:**不再新开仓**。",
"仍会盯盘:**目标平仓 + 到期平仓**。",
],
)
)
def notify_open(*, group_id: str, detail: str = "", extra: dict[str, Any] | None = None) -> None:
extra = extra or {}
lines = [
f"**组**: `{group_id}`",
f"**方向**: {extra.get('bias') or extra.get('option_side') or ''}",
f"**期权**: `{extra.get('option_inst_id') or ''}`",
f"**行权/到期**: {extra.get('strike') or ''} / {extra.get('expiry_ymd') or ''}",
]
if detail:
lines.append(f"**说明**: {detail}")
notify_async(build_markdown(tag=TAG_OPEN, title="开仓成功", lines=lines))
def notify_close(
*,
reason: str,
detail: str = "",
group_id: str | None = None,
data: dict[str, Any] | None = None,
) -> None:
data = data or {}
reason_zh = {
"expiry": "到期平仓",
"target_perp_only": "目标平仓·只平永续",
"fixed_usdt": "目标平仓·双腿",
"premium_multiple": "目标平仓·双腿",
"emergency": "紧急全平",
"emergency_perp": "紧急·只平永续",
"manual": "手动全平",
"perp_pending_retry": "续平永续",
}.get(reason, reason)
lines = [
f"**原因**: {reason_zh} (`{reason}`)",
f"**组**: `{group_id or data.get('group_id') or ''}`",
]
if detail:
lines.append(f"**说明**: {detail}")
net = data.get("net_pnl")
if net is not None:
lines.append(f"**净盈亏**: {net}")
notify_async(build_markdown(tag=TAG_CLOSE, title=f"平仓 · {reason_zh}", lines=lines))
def notify_fault(*, title: str, detail: str, dedupe_key: str | None = None) -> None:
global _last_fault_key, _last_fault_ms
key = dedupe_key or f"{title}:{detail[:120]}"
now = time.time()
if key == _last_fault_key and now - _last_fault_ms < _FAULT_DEDUP_SEC:
return
_last_fault_key = key
_last_fault_ms = now
notify_async(
build_markdown(
tag=TAG_FAULT,
title=f"故障 · {title}",
lines=[f"**详情**: {detail[:500]}"],
)
)
def notify_test() -> tuple[bool, str]:
content = build_markdown(
tag=TAG_TEST,
title="测试推送",
lines=["企业微信通知已连通。", f"当前盘口标签: **{venue_label()}**"],
)
return _post_markdown_sync(content)
+139 -15
View File
@@ -146,6 +146,12 @@ class StrategyEngine:
async def pause(self) -> dict[str, Any]:
self._set_state(running=0, phase="paused", last_error=None)
try:
from ..notify import wecom
wecom.notify_pause()
except Exception:
logger.exception("wecom notify_pause failed")
return self.state()
async def start(self) -> dict[str, Any]:
@@ -155,13 +161,25 @@ class StrategyEngine:
ok, reason = live_ready()
if not ok:
self._set_state(running=0, phase="paused", last_error=reason)
try:
from ..notify import wecom
wecom.notify_fault(title="启动失败", detail=reason, dedupe_key="start_fail")
except Exception:
pass
return self.state()
self._set_state(running=1, last_error=None, phase="idle")
self.ensure_loop()
try:
from ..notify import wecom
wecom.notify_start()
except Exception:
logger.exception("wecom notify_start failed")
return self.state()
def ensure_loop(self) -> None:
"""保证后台循环在跑(即使策略暂停,也要盯到期全平)。"""
"""保证后台循环在跑(暂停时仍盯目标/到期,不新开)。"""
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._loop(), name="strategy-engine")
@@ -204,6 +222,17 @@ class StrategyEngine:
self.enter_rest_after_close()
residuals = self.matcher.settle_all_residuals_now()
try:
if ok:
from ..notify import wecom
wecom.notify_close(
reason="emergency",
detail=detail,
data=close_data or {},
)
except Exception:
logger.exception("wecom emergency notify failed")
return {
"close": {
"ok": ok,
@@ -298,6 +327,16 @@ class StrategyEngine:
last_error=None,
phase="resting",
)
try:
from ..notify import wecom
wecom.notify_close(
reason="target_perp_only",
detail=r.detail,
data=r.data or {},
)
except Exception:
logger.exception("wecom notify_close failed")
else:
self._note_retry_result(kind, ok=False, detail=r.detail)
self._set_state(phase="closing", last_error=r.detail)
@@ -311,6 +350,16 @@ class StrategyEngine:
if r.ok:
self._note_retry_result(kind, ok=True)
self._after_close()
try:
from ..notify import wecom
wecom.notify_close(
reason=reason,
detail=r.detail,
data=r.data or {},
)
except Exception:
logger.exception("wecom notify_close failed")
elif r.liquidity_wait and not bypass_liquidity:
# 等待期间若已变成远虚,下一 tick 走归档
if self.matcher.option_is_deep_otm():
@@ -321,6 +370,16 @@ class StrategyEngine:
if r2.ok:
self._note_retry_result(kind, ok=True)
self._after_close()
try:
from ..notify import wecom
wecom.notify_close(
reason="target_perp_only",
detail=r2.detail,
data=r2.data or {},
)
except Exception:
logger.exception("wecom notify_close failed")
return
self._note_retry_result("liquidity", ok=False, detail=r.detail)
self._set_state(phase="liquidity_wait", last_error=r.detail)
@@ -360,9 +419,9 @@ class StrategyEngine:
row = self.db.fetchone("SELECT running FROM strategy_state WHERE id=1")
running = bool(row and int(row["running"]))
if not running:
# 暂停时仍执行到期全平,避免拖过期
# 暂停:不新开仓;仍盯目标平仓 + 到期平仓
async with self._lock:
await self._maybe_expiry_close()
await self._tick_manage_positions()
await asyncio.sleep(1)
continue
async with self._lock:
@@ -382,21 +441,25 @@ class StrategyEngine:
continue
logger.exception("strategy tick failed")
self._set_state(last_error=err)
try:
from ..notify import wecom
wecom.notify_fault(
title="策略循环异常",
detail=err,
dedupe_key=f"tick:{err[:80]}",
)
except Exception:
pass
sleep_for = 1.0 + max(0.0, self._extra_sleep_sec)
self._extra_sleep_sec = 0.0
await asyncio.sleep(min(sleep_for, 60.0))
async def _tick_async(self) -> None:
# 残留期权到期结算(与活跃组隔离,不挡开仓)
async def _tick_manage_positions(self) -> None:
"""有仓时的盯盘:残留结算 / 半仓修复 / 目标平 / 到期平。不新开仓。"""
await self._settle_residuals()
s = get_settings()
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
wkey = window_key()
if st["window_key"] != wkey:
self._set_state(window_key=wkey, phase="idle")
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
exit_mode = self.ledger.get_setting_str("exit_mode", s.exit_mode)
@@ -409,7 +472,6 @@ class StrategyEngine:
pos = self.matcher.current_position()
st_pos = str(pos.get("status") or "flat")
# 实盘半仓修复:禁止新开;失败指数退避,避免每秒砸期权
if st_pos == "half_open":
allowed, left = self._retry_allowed("half_open")
if not allowed:
@@ -425,12 +487,21 @@ class StrategyEngine:
self._note_retry_result("half_open", ok=True)
self._after_close()
self._set_state(phase="resting", last_error=None)
try:
from ..notify import wecom
wecom.notify_close(
reason="emergency",
detail=r.detail or "half_open repaired",
data=r.data or {},
)
except Exception:
pass
else:
self._note_retry_result("half_open", ok=False, detail=r.detail)
self._set_state(phase="closing", last_error=r.detail)
return
# 期权已平、永续待平:只续平永续(带退避)
if st_pos == "option_closed_perp_pending":
await self._close_open_position(
reason="perp_pending_retry",
@@ -441,7 +512,6 @@ class StrategyEngine:
)
return
# 有活跃持仓:只盯当前组平仓;残留期权不在此扫描
if st_pos == "open":
upl = self.matcher.unrealized()
expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl))
@@ -462,7 +532,6 @@ class StrategyEngine:
else:
reason = decision.reason or "liquidity_retry"
bypass = False
# 目标达标(或流动性等待重试)时:远虚走只平永续
abandon = bool(decision.should_close or pending_close)
rkind = "liquidity" if pending_close else "close"
await self._close_open_position(
@@ -474,6 +543,24 @@ class StrategyEngine:
)
else:
self._set_state(phase="open", last_error=None)
async def _tick_async(self) -> None:
# 先盯仓(平仓路径)
await self._tick_manage_positions()
s = get_settings()
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
wkey = window_key()
if st["window_key"] != wkey:
self._set_state(window_key=wkey, phase="idle")
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
pos = self.matcher.current_position()
st_pos = str(pos.get("status") or "flat")
# 仍有活跃仓或半仓:本 tick 不再开新仓
if st_pos in ("open", "half_open", "option_closed_perp_pending", "opening"):
return
if st["phase"] == "resting" and st["rest_until_ms"]:
@@ -514,6 +601,7 @@ class StrategyEngine:
return
self._set_state(phase="opening", last_error=None)
wkey = window_key()
count = self._count_groups_for_day(wkey)
gid = next_group_id(count)
option_inst = (
@@ -526,6 +614,16 @@ class StrategyEngine:
safe, safe_msg = assert_safe_to_open_live(self.matcher)
if not safe:
self._set_state(phase="idle", last_error=safe_msg)
try:
from ..notify import wecom
wecom.notify_fault(
title="开仓前对账拒绝",
detail=safe_msg,
dedupe_key=f"recon:{safe_msg[:80]}",
)
except Exception:
pass
return
r = await asyncio.to_thread(
self.matcher.open_group,
@@ -540,8 +638,34 @@ class StrategyEngine:
)
if r.ok:
self._set_state(phase="open", last_error=None)
try:
from ..notify import wecom
wecom.notify_open(
group_id=gid,
detail=r.detail,
extra={
"bias": pick.bias,
"option_side": pick.option_side,
"option_inst_id": option_inst,
"strike": pick.pair.strike,
"expiry_ymd": pick.pair.expiry_ymd,
},
)
except Exception:
logger.exception("wecom notify_open failed")
else:
self._set_state(phase="idle", last_error=r.detail)
try:
from ..notify import wecom
wecom.notify_fault(
title="开仓失败",
detail=r.detail,
dedupe_key=f"open_fail:{r.detail[:80]}",
)
except Exception:
pass
_engine: StrategyEngine | None = None
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from app.notify.wecom import TAG_OPEN, build_markdown, venue_label
def test_build_markdown_has_tag_and_title(monkeypatch):
monkeypatch.setattr("app.notify.wecom.venue_label", lambda: "模拟盘")
md = build_markdown(tag=TAG_OPEN, title="开仓成功", lines=["组: G-1"])
assert "【模拟盘】开仓成功" in md
assert "`OPEN`" in md
assert "组: G-1" in md
def test_venue_label_sim(monkeypatch):
class S:
is_sim = True
exchange = "okx"
monkeypatch.setattr("app.notify.wecom.get_settings", lambda: S())
assert venue_label() == "模拟盘"
+11
View File
@@ -5,6 +5,17 @@
---
## 2026-07-26 — 企业微信通知 + 暂停盯目标
### 变更
1. **企业微信**:独立模块 `backend/app/notify/wecom.py`;系统设置「运行模式」可配 Webhook / 开关 / 测试推送。
2. **推送事件**:开仓、平仓、策略启动、暂停、故障(同类 5 分钟去重)。Markdown + 标识 `OPEN/CLOSE/START/PAUSE/FAULT`
3. **标题盘口**:模拟盘 →「模拟盘」;实盘 →「实盘·OKX」或「实盘·币安」。
4. **暂停策略**:仍执行目标平仓 + 到期平仓,**不新开仓**(策略说明已同步)。
---
## 2026-07-26 — 一键部署环境检测 / 卸载删目录
### 变更
+2 -1
View File
@@ -92,6 +92,7 @@
| 周六/周日 | 默认 **跳过新开仓**(上海时区,`skip_weekends`);持仓仍可平 |
| 每日轮次上限 | **不限制**;波动大可能多轮,波动小可能一轮都难 |
| 组间休息 | 全平后默认休息 **300 秒**`rest_seconds`)再开下一组;**自动达标/到期、手动全平、紧急全平**成功后均进入休息 |
| 暂停时 | **不新开仓**;仍执行 **目标平仓 + 到期平仓**(防重启/暂停漏平) |
| 同时持仓 | 最多 1 组 |
### 3.4 费用(SIM
@@ -160,7 +161,7 @@
- 到达期权到期时刻(OKXUTC 08:00 = **上海 16:00**)→ 原因 `expiry`
- **活跃组**:期权按标的结算价的 **内在价值** 入账;若永续仍在则市价平掉。
- **残留组**(曾走 4.1.B):永续早已平掉,**只结算归档期权**,不碰当前活跃仓、不改下方 ATM。
- 策略 **暂停时仍执行到期结算**避免拖过期
- 策略 **暂停时仍执行目标平仓与到期结算**不新开仓;避免重启/暂停后漏平
- 波动小、拖到到期:权利金亏损视为 **预算内成本**,可接受。
### 4.3 平仓流动性闸门(仅 4.1.A)
+7
View File
@@ -299,6 +299,13 @@ export type RuntimeSettings = {
sim: boolean;
};
export type NotifySettings = {
enabled: boolean;
webhook_configured: boolean;
webhook_url_masked: string | null;
venue_label: string;
};
export async function downloadBackup(name: string): Promise<void> {
await ensureFreshToken();
const token = getToken();
+114
View File
@@ -6,6 +6,7 @@ import {
apiFetch,
StrategySettings,
RuntimeSettings,
NotifySettings,
BackupStatus,
downloadBackup,
uploadRestoreBackup,
@@ -89,6 +90,10 @@ export default function SettingsPage() {
const [bnKey, setBnKey] = useState("");
const [bnSecret, setBnSecret] = useState("");
const [runtimeOk, setRuntimeOk] = useState("");
const [wecomEnabled, setWecomEnabled] = useState(false);
const [wecomWebhook, setWecomWebhook] = useState("");
const [wecomMeta, setWecomMeta] = useState<NotifySettings | null>(null);
const [wecomOk, setWecomOk] = useState("");
const [backup, setBackup] = useState<BackupStatus | null>(null);
const [bakAuto, setBakAuto] = useState(true);
@@ -106,6 +111,12 @@ export default function SettingsPage() {
setMode(r.mode === "LIVE" ? "LIVE" : "SIM");
})
.catch(() => undefined);
apiFetch<NotifySettings>("/api/settings/notify")
.then((n) => {
setWecomMeta(n);
setWecomEnabled(n.enabled === true);
})
.catch(() => undefined);
}
function loadBackup() {
@@ -351,6 +362,46 @@ export default function SettingsPage() {
}
}
async function onSaveWecom() {
setErr("");
setWecomOk("");
try {
const body: Record<string, unknown> = { enabled: wecomEnabled };
if (wecomWebhook.trim()) body.webhook_url = wecomWebhook.trim();
const n = await apiFetch<NotifySettings>("/api/settings/notify", {
method: "PUT",
body: JSON.stringify(body),
});
setWecomMeta(n);
setWecomEnabled(n.enabled === true);
setWecomWebhook("");
setWecomOk(
n.enabled
? n.webhook_configured
? "企业微信通知已保存并开启"
: "已开启,但尚未配置 Webhook"
: "企业微信通知已关闭",
);
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
async function onTestWecom() {
setErr("");
setWecomOk("");
try {
const n = await apiFetch<NotifySettings & { detail?: string }>(
"/api/settings/notify/test",
{ method: "POST", body: "{}" },
);
setWecomMeta(n);
setWecomOk(n.detail || "测试消息已发送,请查看企业微信群");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
return (
<div className="settings-page">
<h2 style={{ marginTop: 0 }}></h2>
@@ -880,6 +931,65 @@ export default function SettingsPage() {
</div>
</section>
<section className="settings-section">
<h3></h3>
<p style={{ color: "var(--muted)", marginTop: 0 }}>
Webhook MarkdownOPEN/CLOSE/START/PAUSE/FAULT
<span className="mono">
{" "}
{wecomMeta?.venue_label ||
(runtime?.mode === "LIVE"
? `实盘·${(runtime.exchange || "okx").toUpperCase()}`
: "模拟盘")}
</span>
////·
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="wecomEn"></label>
<select
id="wecomEn"
className="mono"
value={wecomEnabled ? "1" : "0"}
onChange={(e) => setWecomEnabled(e.target.value === "1")}
>
<option value="0"></option>
<option value="1"></option>
</select>
</div>
<div className="field">
<label htmlFor="wecomUrl">Webhook URL</label>
<input
id="wecomUrl"
className="mono"
type="password"
autoComplete="off"
placeholder={
wecomMeta?.webhook_configured
? `已配置 ${wecomMeta.webhook_url_masked || "********"}`
: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
}
value={wecomWebhook}
onChange={(e) => setWecomWebhook(e.target.value)}
/>
</div>
</div>
{wecomOk ? <div className="settings-ok">{wecomOk}</div> : null}
<div className="settings-actions" style={{ gap: 8 }}>
<button className="btn ghost" type="button" onClick={() => void onSaveWecom()}>
</button>
<button
className="btn ghost"
type="button"
onClick={() => void onTestWecom()}
>
</button>
</div>
</section>
<RulesFold
open={runtimeRulesOpen}
onToggle={() => setRuntimeRulesOpen((v) => !v)}
@@ -905,6 +1015,10 @@ export default function SettingsPage() {
= LIVE
Passphrase
</li>
<li>
Webhook
URL 5
</li>
</ul>
</RulesFold>