1
This commit is contained in:
parent
6721cfddf1
commit
377c6ee930
@ -62,9 +62,13 @@ X-Webhook-Token: your-shared-secret
|
||||
|
||||
`docker-compose.yml` 默认已经设置了占位 token,生产使用前请替换;本地临时调试如果不想校验 token,可以把 `WEBHOOK_TOKEN` 置为空。
|
||||
|
||||
## Feishu Webhooks
|
||||
|
||||
「飞书 Webhook」页面只维护目标名称和机器人地址。目标一旦被路由规则选中就会发送,不再提供启用/停用状态。
|
||||
|
||||
## Feishu Message Templates
|
||||
|
||||
路由规则统一发送飞书 interactive card。
|
||||
路由规则统一发送飞书 interactive card。可以先在「飞书模板」页面维护常用卡片模板,再在新增或编辑路由规则时从下拉框一键套用。
|
||||
|
||||
标题和正文模板支持 `{{field}}` 占位符,字段来自 TradingView alert JSON。嵌套字段可以写成 `{{order.id}}`。
|
||||
|
||||
|
||||
48
app/db.py
48
app/db.py
@ -81,6 +81,16 @@ class Database:
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
card_title_template TEXT NOT NULL,
|
||||
card_body_template TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rules_match
|
||||
ON routing_rules(enabled, timeframe, symbol, strategy, priority);
|
||||
|
||||
@ -138,6 +148,44 @@ class Database:
|
||||
"ALTER TABLE routing_rules ADD COLUMN card_body_template TEXT NOT NULL DEFAULT '{{symbol}} {{timeframe}} {{strategy}} {{action}} @ {{price}}'"
|
||||
)
|
||||
now = now_iso()
|
||||
conn.execute("UPDATE webhook_targets SET enabled = 1 WHERE enabled <> 1")
|
||||
template_count = conn.execute("SELECT COUNT(*) AS c FROM message_templates").fetchone()["c"]
|
||||
if template_count == 0:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO message_templates (
|
||||
name, description, card_title_template, card_body_template,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"供需系统通用卡片",
|
||||
"适合支撑/阻力区域、确认、成交、浮盈保护等供需系统信号。",
|
||||
"{{title}}",
|
||||
"**{{symbol}} · {{timeframe}} · {{direction}}**\n\n"
|
||||
"> {{signal_type}}\n\n"
|
||||
"**价格区**\n"
|
||||
"- 试仓位:{{probe_entry}}\n"
|
||||
"- 优先位:{{priority_entry}}\n"
|
||||
"- 防守位:{{defense_price}}\n"
|
||||
"- 止损位:{{stop_loss}}\n"
|
||||
"- 当前价:{{current_price}}\n\n"
|
||||
"**确认状态**\n"
|
||||
"- 区域评分:{{zone_score}}\n"
|
||||
"- 触碰次数:{{touch_count}}\n"
|
||||
"- 确认状态:{{confirmation_label}}\n"
|
||||
"- 交易准备:{{trade_ready}}\n\n"
|
||||
"**说明**\n"
|
||||
"{{description}}\n\n"
|
||||
"**风险提示**\n"
|
||||
"{{risk_tip}}\n\n"
|
||||
"---\n"
|
||||
"{{source}} · {{strategy}} · {{time}}",
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO admin_settings (id, password_hash, created_at, updated_at)
|
||||
|
||||
@ -166,7 +166,7 @@ class Dispatcher:
|
||||
if target_ids:
|
||||
placeholders = ",".join("?" for _ in target_ids)
|
||||
targets = conn.execute(
|
||||
f"SELECT * FROM webhook_targets WHERE enabled = 1 AND id IN ({placeholders})",
|
||||
f"SELECT * FROM webhook_targets WHERE id IN ({placeholders})",
|
||||
target_ids,
|
||||
).fetchall()
|
||||
for target in targets:
|
||||
@ -194,7 +194,7 @@ class Dispatcher:
|
||||
if rule and not delivery_ids:
|
||||
conn.execute(
|
||||
"UPDATE alerts SET status = ?, error = ? WHERE id = ?",
|
||||
("unmatched", "Matched rule has no enabled webhook targets.", alert_id),
|
||||
("unmatched", "Matched rule has no webhook targets.", alert_id),
|
||||
)
|
||||
|
||||
self.process_due_deliveries()
|
||||
|
||||
194
app/server.py
194
app/server.py
@ -75,14 +75,35 @@ def target_checkbox_options(
|
||||
options = []
|
||||
for target in targets:
|
||||
checked = "checked" if target["id"] in selected_ids else ""
|
||||
disabled = "" if target["enabled"] else "disabled"
|
||||
suffix = "" if target["enabled"] else " (停用)"
|
||||
options.append(
|
||||
f'<label class="target-choice"><input type="checkbox" name="target_ids" value="{target["id"]}" {checked} {disabled}> <span>{html.escape(target["name"])}{suffix}</span></label>'
|
||||
f'<label class="target-choice"><input type="checkbox" name="target_ids" value="{target["id"]}" {checked}> <span>{html.escape(target["name"])}</span></label>'
|
||||
)
|
||||
return "".join(options)
|
||||
|
||||
|
||||
def template_picker(templates: list[dict[str, Any]]) -> str:
|
||||
if not templates:
|
||||
return '<p class="muted-note">还没有飞书内容模板,可以直接填写标题和正文。</p>'
|
||||
options = ['<option value="">不套用模板</option>']
|
||||
for template in templates:
|
||||
options.append(
|
||||
"<option "
|
||||
f'value="{template["id"]}" '
|
||||
f'data-title="{html.escape(template["card_title_template"], quote=True)}" '
|
||||
f'data-body="{html.escape(template["card_body_template"], quote=True)}">'
|
||||
f'{html.escape(template["name"])}'
|
||||
"</option>"
|
||||
)
|
||||
return (
|
||||
'<div class="template-picker">'
|
||||
'<label>套用飞书内容模板'
|
||||
f'<select data-template-picker>{"".join(options)}</select>'
|
||||
'</label>'
|
||||
'<a class="button-link secondary compact" href="/templates/new">新增模板</a>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
context: AppContext
|
||||
|
||||
@ -108,6 +129,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.render_targets()
|
||||
elif parsed.path == "/targets/delete":
|
||||
self.render_target_delete(parsed)
|
||||
elif parsed.path == "/templates":
|
||||
self.render_templates()
|
||||
elif parsed.path == "/templates/new":
|
||||
self.render_template_new()
|
||||
elif parsed.path == "/templates/edit":
|
||||
self.render_template_edit(parsed)
|
||||
elif parsed.path == "/templates/delete":
|
||||
self.render_template_delete(parsed)
|
||||
elif parsed.path == "/rules":
|
||||
self.render_rules()
|
||||
elif parsed.path == "/rules/new":
|
||||
@ -146,6 +175,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"/targets/update": self.update_target,
|
||||
"/targets/delete": self.delete_target,
|
||||
"/targets/test": self.test_target,
|
||||
"/templates/create": self.create_template,
|
||||
"/templates/update": self.update_template,
|
||||
"/templates/delete": self.delete_template,
|
||||
"/rules/create": self.create_rule,
|
||||
"/rules/update": self.update_rule,
|
||||
"/rules/delete": self.delete_rule,
|
||||
@ -170,6 +202,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
nav = [
|
||||
("/dashboard", "概览"),
|
||||
("/rules", "路由规则"),
|
||||
("/templates", "飞书模板"),
|
||||
("/targets", "飞书 Webhook"),
|
||||
("/logs", "日志"),
|
||||
("/test", "测试发送"),
|
||||
@ -301,6 +334,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
rules.append(item)
|
||||
return rules
|
||||
|
||||
def list_templates(self) -> list[dict[str, Any]]:
|
||||
with self.context.db.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM message_templates ORDER BY id DESC").fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def list_logs(self) -> dict[str, list[dict[str, Any]]]:
|
||||
with self.context.db.connect() as conn:
|
||||
alerts = conn.execute("SELECT * FROM alerts ORDER BY id DESC LIMIT 100").fetchall()
|
||||
@ -353,7 +391,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
<td>{target['id']}<input form="target-update-{target['id']}" type="hidden" name="id" value="{target['id']}"></td>
|
||||
<td><input form="target-update-{target['id']}" name="name" value="{html.escape(target['name'])}" required></td>
|
||||
<td class="url"><input form="target-update-{target['id']}" name="webhook_url" value="{html.escape(target['webhook_url'])}" type="url" required></td>
|
||||
<td><label class="check"><input form="target-update-{target['id']}" name="enabled" type="checkbox" {'checked' if target['enabled'] else ''}> 启用</label></td>
|
||||
<td><form id="target-update-{target['id']}" class="inline" method="post" action="/targets/update"></form><button form="target-update-{target['id']}" type="submit">更新</button>
|
||||
<form class="inline" method="post" action="/targets/test"><input type="hidden" name="id" value="{target['id']}"><button type="submit">测试</button></form>
|
||||
<a class="button-link danger-link" href="/targets/delete?id={target['id']}">删除</a>
|
||||
@ -364,11 +401,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
<h2>新增飞书 Webhook</h2>
|
||||
<label>名称<input name="name" required></label>
|
||||
<label>Webhook URL<input name="webhook_url" type="url" required></label>
|
||||
<label class="check"><input name="enabled" type="checkbox" checked> 启用</label>
|
||||
<button type="submit">保存目标</button>
|
||||
</form>"""
|
||||
notice = getattr(self, "_target_notice", "")
|
||||
self.send_html("飞书 Webhook", f"<header><h1>飞书 Webhook</h1><p>维护所有可分发的飞书机器人地址。</p></header>{notice}{form}<table><thead><tr><th>ID</th><th>名称</th><th>URL</th><th>状态</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
|
||||
self.send_html("飞书 Webhook", f"<header><h1>飞书 Webhook</h1><p>维护所有可分发的飞书机器人地址。</p></header>{notice}{form}<table><thead><tr><th>ID</th><th>名称</th><th>URL</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
|
||||
|
||||
def render_target_delete(self, parsed: Any) -> None:
|
||||
target_id = parse_qs(parsed.query).get("id", [""])[-1]
|
||||
@ -389,6 +425,78 @@ class Handler(BaseHTTPRequestHandler):
|
||||
</section>"""
|
||||
self.send_html("删除飞书 Webhook", body)
|
||||
|
||||
def render_templates(self) -> None:
|
||||
templates = self.list_templates()
|
||||
rows = "".join(
|
||||
f"""<tr>
|
||||
<td>{template['id']}</td>
|
||||
<td>{html.escape(template['name'])}</td>
|
||||
<td>{html.escape(template['description'] or '-')}</td>
|
||||
<td><span class="template-sample">{html.escape(template['card_title_template'])}</span></td>
|
||||
<td><a class="button-link" href="/templates/edit?id={template['id']}">编辑</a><a class="button-link danger-link" href="/templates/delete?id={template['id']}">删除</a></td>
|
||||
</tr>"""
|
||||
for template in templates
|
||||
)
|
||||
header = """<header class="page-header"><div><h1>飞书内容模板</h1><p>把常用卡片标题和正文保存成模板,新建或编辑路由规则时可以一键套用。</p></div><a class="button-link" href="/templates/new">新增模板</a></header>"""
|
||||
self.send_html("飞书内容模板", f"{header}<table><thead><tr><th>ID</th><th>名称</th><th>说明</th><th>标题模板</th><th>操作</th></tr></thead><tbody>{rows}</tbody></table>")
|
||||
|
||||
def render_template_form(
|
||||
self,
|
||||
title: str,
|
||||
action: str,
|
||||
template: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
template = template or {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"card_title_template": "{{title}}",
|
||||
"card_body_template": "**{{symbol}} · {{timeframe}} · {{direction}}**\n\n> {{signal_type}}\n\n**价格区**\n- 试仓位:{{probe_entry}}\n- 优先位:{{priority_entry}}\n- 防守位:{{defense_price}}\n- 止损位:{{stop_loss}}\n- 当前价:{{current_price}}\n\n**说明**\n{{description}}\n\n**风险提示**\n{{risk_tip}}",
|
||||
}
|
||||
hidden_id = f'<input type="hidden" name="id" value="{template["id"]}">' if template.get("id") else ""
|
||||
button_text = "保存修改" if template.get("id") else "创建模板"
|
||||
body = f"""<header><h1>{html.escape(title)}</h1><p>模板支持 <code>{{{{field}}}}</code> 或 <code>{{field}}</code> 占位符,字段来自 TradingView JSON。</p></header>
|
||||
<form class="panel template-form" method="post" action="{action}">
|
||||
{hidden_id}
|
||||
<label>模板名称<input name="name" value="{html.escape(str(template['name']))}" required></label>
|
||||
<label>说明<input name="description" value="{html.escape(str(template['description']))}" placeholder="例如:供需系统阻力/支撑区域通用"></label>
|
||||
<label>卡片标题模板<input name="card_title_template" value="{html.escape(str(template['card_title_template']))}" required></label>
|
||||
<label>卡片正文模板<textarea name="card_body_template" rows="16" required>{html.escape(str(template['card_body_template']))}</textarea></label>
|
||||
<div class="actions"><button type="submit">{button_text}</button><a class="button-link secondary" href="/templates">返回列表</a></div>
|
||||
</form>"""
|
||||
self.send_html(title, body)
|
||||
|
||||
def render_template_new(self) -> None:
|
||||
self.render_template_form("新增飞书内容模板", "/templates/create")
|
||||
|
||||
def render_template_edit(self, parsed: Any) -> None:
|
||||
template_id = parse_qs(parsed.query).get("id", [""])[-1]
|
||||
with self.context.db.connect() as conn:
|
||||
template = conn.execute("SELECT * FROM message_templates WHERE id = ?", (template_id,)).fetchone()
|
||||
if not template:
|
||||
self.send_error(404)
|
||||
return
|
||||
self.render_template_form("编辑飞书内容模板", "/templates/update", dict(template))
|
||||
|
||||
def render_template_delete(self, parsed: Any) -> None:
|
||||
template_id = parse_qs(parsed.query).get("id", [""])[-1]
|
||||
with self.context.db.connect() as conn:
|
||||
template = conn.execute("SELECT * FROM message_templates WHERE id = ?", (template_id,)).fetchone()
|
||||
if not template:
|
||||
self.send_error(404)
|
||||
return
|
||||
body = f"""<header><h1>删除飞书内容模板</h1><p>请确认是否删除这个模板。</p></header>
|
||||
<section class="panel narrow">
|
||||
<h2>{html.escape(template['name'])}</h2>
|
||||
<p>删除模板不会影响已经创建的路由规则,只是后续不能再套用它。</p>
|
||||
<form method="post" action="/templates/delete" class="actions">
|
||||
<input type="hidden" name="id" value="{template['id']}">
|
||||
<button class="danger" type="submit">确认删除</button>
|
||||
<a class="button-link secondary" href="/templates">取消</a>
|
||||
</form>
|
||||
</section>"""
|
||||
self.send_html("删除飞书内容模板", body)
|
||||
|
||||
def render_rules(self) -> None:
|
||||
targets = self.list_targets()
|
||||
rules = self.list_rules()
|
||||
@ -400,15 +508,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
f"品种={html.escape(rule['symbol'])}" if rule["symbol"] else "",
|
||||
f"策略={html.escape(rule['strategy'])}" if rule["strategy"] else "",
|
||||
]
|
||||
target_name = "<br>".join(
|
||||
html.escape(target_names.get(target_id, f"#{target_id}")) for target_id in rule["target_ids"]
|
||||
target_badges = "".join(
|
||||
f'<span class="tag">{html.escape(target_names.get(target_id, f"#{target_id}"))}</span>'
|
||||
for target_id in rule["target_ids"]
|
||||
) or "-"
|
||||
rows += f"""<tr>
|
||||
<td>{rule['id']}</td>
|
||||
<td>{html.escape(rule['name'])}</td>
|
||||
<td>{'<br>'.join(item for item in conditions if item) or '-'}</td>
|
||||
<td>{rule['priority']}</td>
|
||||
<td>{html.escape(target_name)}</td>
|
||||
<td><div class="tag-list">{target_badges}</div></td>
|
||||
<td><span class="status">{'启用' if rule['enabled'] else '停用'}</span></td>
|
||||
<td><a class="button-link" href="/rules/edit?id={rule['id']}">编辑</a><a class="button-link danger-link" href="/rules/delete?id={rule['id']}">删除</a></td>
|
||||
</tr>"""
|
||||
@ -422,6 +531,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
rule: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
targets = self.list_targets()
|
||||
templates = self.list_templates()
|
||||
rule = rule or {
|
||||
"id": "",
|
||||
"name": "",
|
||||
@ -435,6 +545,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"enabled": 1,
|
||||
}
|
||||
selected_targets = target_checkbox_options(targets, rule.get("target_ids", []))
|
||||
picker = template_picker(templates)
|
||||
hidden_id = f'<input type="hidden" name="id" value="{rule["id"]}">' if rule.get("id") else ""
|
||||
button_text = "保存修改" if rule.get("id") else "创建规则"
|
||||
body = f"""<header><h1>{html.escape(title)}</h1><p>消息统一使用飞书卡片。周期、品种、策略至少填写一个,空字段表示不限。</p></header>
|
||||
@ -447,8 +558,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
<label>策略<input name="strategy" value="{html.escape(str(rule['strategy']))}" placeholder="breakout,空=不限"></label>
|
||||
<label>优先级<input name="priority" type="number" value="{rule['priority']}" required></label>
|
||||
</div>
|
||||
<label>卡片标题模板<input name="card_title_template" value="{html.escape(str(rule['card_title_template']))}" required></label>
|
||||
<label>卡片正文模板<textarea name="card_body_template" rows="6">{html.escape(str(rule['card_body_template']))}</textarea></label>
|
||||
{picker}
|
||||
<label>卡片标题模板<input data-title-template-input name="card_title_template" value="{html.escape(str(rule['card_title_template']))}" required></label>
|
||||
<label>卡片正文模板<textarea data-body-template-input name="card_body_template" rows="10">{html.escape(str(rule['card_body_template']))}</textarea></label>
|
||||
<div class="field-target"><span class="field-label">发送到</span><div class="target-choices">{selected_targets}</div></div>
|
||||
<label class="switch"><input name="enabled" type="checkbox" {'checked' if rule.get('enabled') else ''}><span></span><strong>启用规则</strong></label>
|
||||
<div class="actions"><button type="submit">{button_text}</button><a class="button-link secondary" href="/rules">返回列表</a></div>
|
||||
@ -546,7 +658,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO webhook_targets (name, webhook_url, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(form["name"].strip(), form["webhook_url"].strip(), 1 if form.get("enabled") == "on" else 0, now, now),
|
||||
(form["name"].strip(), form["webhook_url"].strip(), 1, now, now),
|
||||
)
|
||||
redirect(self, "/targets")
|
||||
|
||||
@ -555,7 +667,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE webhook_targets SET name = ?, webhook_url = ?, enabled = ?, updated_at = ? WHERE id = ?",
|
||||
(form["name"].strip(), form["webhook_url"].strip(), 1 if form.get("enabled") == "on" else 0, now_iso(), form["id"]),
|
||||
(form["name"].strip(), form["webhook_url"].strip(), 1, now_iso(), form["id"]),
|
||||
)
|
||||
redirect(self, "/targets")
|
||||
|
||||
@ -599,6 +711,56 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._target_notice = f"""<section class="result-panel {'success' if success else 'error'}"><h2>Webhook 测试</h2><p>{message}</p></section>"""
|
||||
self.render_targets()
|
||||
|
||||
def create_template(self) -> None:
|
||||
form = parse_form(self)
|
||||
now = now_iso()
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO message_templates (
|
||||
name, description, card_title_template, card_body_template,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
form["name"].strip(),
|
||||
form.get("description", "").strip(),
|
||||
form["card_title_template"].strip(),
|
||||
form["card_body_template"].strip(),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
redirect(self, "/templates")
|
||||
|
||||
def update_template(self) -> None:
|
||||
form = parse_form(self)
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE message_templates
|
||||
SET name = ?, description = ?, card_title_template = ?,
|
||||
card_body_template = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
form["name"].strip(),
|
||||
form.get("description", "").strip(),
|
||||
form["card_title_template"].strip(),
|
||||
form["card_body_template"].strip(),
|
||||
now_iso(),
|
||||
form["id"],
|
||||
),
|
||||
)
|
||||
redirect(self, "/templates")
|
||||
|
||||
def delete_template(self) -> None:
|
||||
form = parse_form(self)
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute("DELETE FROM message_templates WHERE id = ?", (form["id"],))
|
||||
redirect(self, "/templates")
|
||||
|
||||
def create_rule(self) -> None:
|
||||
form = parse_form_multi(self)
|
||||
target_ids = [int(value) for value in form.get("target_ids", [])]
|
||||
@ -608,6 +770,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not any((timeframe, symbol, strategy)):
|
||||
self.send_error(400, "周期、品种、策略至少填写一个")
|
||||
return
|
||||
if not target_ids:
|
||||
self.send_error(400, "至少选择一个飞书 Webhook")
|
||||
return
|
||||
now = now_iso()
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute(
|
||||
@ -651,6 +816,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not any((timeframe, symbol, strategy)):
|
||||
self.send_error(400, "周期、品种、策略至少填写一个")
|
||||
return
|
||||
if not target_ids:
|
||||
self.send_error(400, "至少选择一个飞书 Webhook")
|
||||
return
|
||||
with self.context.db.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
|
||||
@ -13,3 +13,15 @@ document.addEventListener("click", async (event) => {
|
||||
window.prompt("复制下面的内容", value);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("change", (event) => {
|
||||
const picker = event.target.closest("[data-template-picker]");
|
||||
if (!picker) return;
|
||||
const option = picker.selectedOptions[0];
|
||||
if (!option || !option.value) return;
|
||||
const form = picker.closest("form");
|
||||
const titleInput = form?.querySelector("[data-title-template-input]");
|
||||
const bodyInput = form?.querySelector("[data-body-template-input]");
|
||||
if (titleInput) titleInput.value = option.dataset.title || "";
|
||||
if (bodyInput) bodyInput.value = option.dataset.body || "";
|
||||
});
|
||||
|
||||
@ -105,6 +105,10 @@ p {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@ -227,6 +231,58 @@ td textarea {
|
||||
background: #eef8f5;
|
||||
}
|
||||
|
||||
.template-picker {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 420px) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin: 6px 0 14px;
|
||||
}
|
||||
|
||||
.template-picker label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.button-link.compact {
|
||||
width: fit-content;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.muted-note {
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 180px;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid #bdd8d2;
|
||||
border-radius: 999px;
|
||||
background: #eef8f5;
|
||||
color: #0b534d;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.template-sample {
|
||||
display: block;
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -543,4 +599,8 @@ th {
|
||||
.copy-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.template-picker {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,6 +150,18 @@ class DispatcherTest(unittest.TestCase):
|
||||
count = conn.execute("SELECT COUNT(*) AS c FROM deliveries WHERE alert_id = ?", (result["alert_id"],)).fetchone()["c"]
|
||||
self.assertEqual(count, 2)
|
||||
|
||||
def test_legacy_disabled_target_is_still_dispatchable(self) -> None:
|
||||
target_id = self.add_target()
|
||||
with self.db.connect() as conn:
|
||||
conn.execute("UPDATE webhook_targets SET enabled = 0 WHERE id = ?", (target_id,))
|
||||
self.add_rule(target_id)
|
||||
|
||||
result = self.dispatcher.receive_alert(
|
||||
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy"}
|
||||
)
|
||||
|
||||
self.assertEqual(len(result["delivery_ids"]), 1)
|
||||
|
||||
def test_card_template_uses_alert_fields(self) -> None:
|
||||
message = build_feishu_message(
|
||||
{"timeframe": "5m", "symbol": "BTCUSDT", "strategy": "breakout", "action": "buy", "price": 68000},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user