## 用户报的第二件事:格式改了,提示没跟着改
弹窗里的 URL 示例/说明、密钥标签、限流说明都写死成企业微信了——选「通用 JSON」时
占位还是 `qyapi.weixin.qq.com`,限流还写着「企微硬限 20」。现在这些文案挂在**适配器**上
(`url_hint/url_help/secret_label/secret_help/limit_help`),由接口随格式下发,切格式即时更新;
限流值在用户没手动改过时也跟着格式的推荐值走(企微 20 / 通用 JSON 60 / Bark 60)。
## 新格式:Bark(iOS 推送)
- `POST {url}`,body `{title, body, markdown, group, level[, device_key]}`
—— `markdown` 传富文本、`body` 传纯文本兜底(老版本 App 不认 markdown 字段时也能看清)
- URL 两种填法都支持:直接粘 Bark 复制的那串(`https://api.day.app/<key>`,key 在路径里),
或填 `https://api.day.app/push` + 把 key 填到「设备 Key」(作为 device_key 发送)
- **成功判定按格式**:Bark 是 `code==200`(企业微信是 `errcode==0`)→ 新增
`BaseAdapter.ok_codes`,`_post_once` 用它判定。少了这一步 Bark 的每次成功都会被误判成失败
- 正文按 2048 字节截断(走 APNs,体量有限)
## 顺带
- 通用 JSON 的 `secret` 现在会作为 `X-Webhook-Secret` 请求头发出(原先填了没用)
- `_formats()` 改为序列化适配器元信息,新增格式只改一处
## 验证
- 格式切换 14 项断言全绿:三种格式的 URL 示例/密钥标签/密钥说明/限流说明/模板区/限流默认值
全部跟着切换;下拉里 Bark 出现在已实现区
- Bark 端到端 7 项:`code=200` 判成功、`code=400` 判失败(含重试)、请求体带
device_key/title/body/markdown/group、URL 里的 key 在接口回显里被打码
- 文档:NOTIFY.md §5 的格式表补 Bark 列(URL 怎么填 / 成功码 / 约束),并说明
"提示文案挂在适配器上,别写死在页面里"
385 lines
20 KiB
JavaScript
385 lines
20 KiB
JavaScript
// 系统 → 通知:多 webhook 配置(每条自己订阅事件)+ 测试发送 + 发送记录
|
||
// 后端:web/notify_api.py(全部 admin_required);配置落 app_meta.notify_webhooks 单键。
|
||
// 安全口径:URL 里有凭据(企微 ?key=)→ 列表/记录里一律打码;编辑时**留空即不修改**。
|
||
let _notifyHooks = [];
|
||
let _notifyEvents = [];
|
||
let _notifyFormats = [];
|
||
let _notifySettings = {};
|
||
let _notifyEditingId = null; // null=新建
|
||
let _notifyPatterns = []; // 编辑中的通配订阅(如 task.*)
|
||
let _notifyPrevFormat = ''; // 上一个选中格式(切换时判断限流值要不要跟着走)
|
||
let _notifyRateTouched = false; // 用户是否手动改过限流值(没改过就跟随格式的推荐值)
|
||
|
||
// ================== 加载与渲染 ==================
|
||
function loadNotifyPanel(){
|
||
Promise.all([apiGet('/api/notify/webhooks'),
|
||
apiGet('/api/notify/events'),
|
||
apiGet('/api/notify/logs?limit=50')]).then(function(res){
|
||
const [hooks, events, logs] = res;
|
||
if(hooks && hooks.ok){
|
||
_notifyHooks = hooks.webhooks || [];
|
||
_notifyFormats = hooks.formats || [];
|
||
_notifySettings = hooks.settings || {};
|
||
}
|
||
if(events && events.ok) _notifyEvents = events.events || [];
|
||
renderNotifySettings();
|
||
renderNotifyHooks();
|
||
renderNotifyLogs(logs && logs.ok ? (logs.logs || []) : []);
|
||
});
|
||
}
|
||
|
||
function renderNotifySettings(){
|
||
const on = document.getElementById('notify-global');
|
||
if(on) on.checked = _notifySettings.global_enabled !== false;
|
||
const agg = document.getElementById('notify-agg');
|
||
if(agg) agg.value = _notifySettings.default_agg_window != null ? _notifySettings.default_agg_window : 30;
|
||
const rate = document.getElementById('notify-rate');
|
||
if(rate) rate.value = _notifySettings.default_rate_limit != null ? _notifySettings.default_rate_limit : 18;
|
||
}
|
||
|
||
function renderNotifyHooks(){
|
||
const tb = document.getElementById('tb-notify-hooks');
|
||
if(!tb) return;
|
||
if(!_notifyHooks.length){
|
||
tb.innerHTML = '<tr><td colspan="6" class="empty">还没有配置 webhook。点「+ 新建 Webhook」加一条——'
|
||
+ '比如把「任务失败」推到企业微信群。</td></tr>';
|
||
return;
|
||
}
|
||
tb.innerHTML = _notifyHooks.map(function(h){
|
||
const fmt = (_notifyFormats.find(f => f.name === h.format) || {}).label || h.format;
|
||
const evs = (h.events || []).length;
|
||
return '<tr>'
|
||
+ '<td>' + esc(h.name) + (h.secret_set ? ' <span class="label label-info" title="已配置密钥">🔑</span>' : '') + '</td>'
|
||
+ '<td>' + esc(fmt) + '</td>'
|
||
+ '<td style="max-width:280px;word-break:break-all;font-family:var(--mono);font-size:11px">'
|
||
+ esc(h.url || '') + '</td>'
|
||
+ '<td>' + evs + ' 个事件</td>'
|
||
+ '<td>' + (h.enabled ? '<span class="label label-success">启用</span>'
|
||
: '<span class="label">停用</span>') + '</td>'
|
||
+ '<td style="white-space:nowrap">'
|
||
+ '<button class="btn btn-xs" onclick="testNotifyHook(\'' + h.id + '\')" title="立刻发一条测试消息,看群里收不收得到">发送测试</button> '
|
||
+ '<button class="btn btn-xs" onclick="toggleNotifyHook(\'' + h.id + '\',' + (h.enabled ? 'false' : 'true') + ')">'
|
||
+ (h.enabled ? '停用' : '启用') + '</button> '
|
||
+ '<button class="btn btn-xs" onclick="openNotifyModal(\'' + h.id + '\')">编辑</button> '
|
||
+ '<button class="btn btn-xs btn-danger" onclick="deleteNotifyHook(\'' + h.id + '\')">删除</button>'
|
||
+ '</td></tr>';
|
||
}).join('');
|
||
}
|
||
|
||
function renderNotifyLogs(logs){
|
||
const tb = document.getElementById('tb-notify-logs');
|
||
if(!tb) return;
|
||
if(!logs.length){
|
||
tb.innerHTML = '<tr><td colspan="5" class="empty">本次运行还没有发送记录(重启后清空,历史见 logs/notify.log)</td></tr>';
|
||
return;
|
||
}
|
||
tb.innerHTML = logs.map(function(r){
|
||
const t = r.ts ? new Date(r.ts * 1000).toLocaleString('zh-CN', {hour12:false}) : '';
|
||
const st = r.ok ? '<span class="label label-success">成功</span>'
|
||
: '<span class="label label-warning">失败</span>';
|
||
const detail = r.ok ? ('HTTP ' + (r.http_status == null ? '' : r.http_status)
|
||
+ (r.errcode ? ' errcode ' + r.errcode : ''))
|
||
: esc(r.error || '');
|
||
return '<tr><td style="white-space:nowrap">' + esc(t) + '</td>'
|
||
+ '<td>' + esc(r.hook_name || '') + '</td>'
|
||
+ '<td style="font-family:var(--mono);font-size:11px">' + esc(r.event || '') + '</td>'
|
||
+ '<td>' + st + '</td>'
|
||
+ '<td style="font-size:11px">' + detail + (r.elapsed_ms != null ? ' · ' + r.elapsed_ms + 'ms' : '') + '</td></tr>';
|
||
}).join('');
|
||
}
|
||
|
||
// ================== 编辑弹窗 ==================
|
||
function notifyEventMatches(pattern, key){
|
||
if(!pattern) return false;
|
||
if(pattern === '*' || pattern === key) return true;
|
||
if(pattern.slice(-2) === '.*') return key.indexOf(pattern.slice(0, -1)) === 0;
|
||
return false;
|
||
}
|
||
|
||
function openNotifyModal(id){
|
||
_notifyEditingId = id || null;
|
||
const h = id ? (_notifyHooks.find(x => x.id === id) || {}) : {};
|
||
_notifyPatterns = (h.events || []).filter(p => p.indexOf('*') >= 0);
|
||
const checked = (h.events || []).filter(p => p.indexOf('*') < 0);
|
||
const fmtOpts = _notifyFormats.map(function(f){
|
||
const sel = (h.format || 'wecom') === f.name ? ' selected' : '';
|
||
return '<option value="' + f.name + '"' + sel + (f.implemented ? '' : ' disabled') + '>'
|
||
+ esc(f.label) + '</option>';
|
||
}).join('');
|
||
const box = document.getElementById('notify-modal-box');
|
||
if(!box) return;
|
||
box.innerHTML =
|
||
'<div style="display:flex;align-items:center;margin-bottom:12px">'
|
||
+ '<div style="font-size:15px;font-weight:700;flex:1">'
|
||
+ (id ? '编辑 Webhook' : '新建 Webhook') + '</div>'
|
||
+ '<button class="btn btn-xs" title="关闭(不保存)" onclick="closeNotifyModal()">✕ 关闭</button>'
|
||
+ '</div>'
|
||
+ '<div class="form-row">'
|
||
+ '<div class="form-group"><label>名称</label>'
|
||
+ '<input id="nf-name" class="form-control" value="' + esc(h.name || '') + '" placeholder="如:运维群"></div>'
|
||
+ '<div class="form-group"><label>格式</label>'
|
||
+ '<select id="nf-format" class="form-control" onchange="_notifyFormatChanged()">' + fmtOpts + '</select></div>'
|
||
+ '</div>'
|
||
+ '<div class="form-group"><label>Webhook URL</label>'
|
||
// 占位与说明都由**当前格式**决定(见 _notifyFormatChanged),切换格式会跟着变
|
||
+ '<input id="nf-url" class="form-control" value="">'
|
||
+ '<div class="help" id="nf-url-help"></div></div>'
|
||
+ '<div class="form-row">'
|
||
+ '<div class="form-group"><label id="nf-secret-label">签名密钥(可选)</label>'
|
||
+ '<input id="nf-secret" type="password" class="form-control" placeholder="'
|
||
+ (h.secret_set ? '已配置,留空不修改' : '留空即可') + '">'
|
||
+ '<div class="help" id="nf-secret-help"></div></div>'
|
||
+ '<div class="form-group" style="max-width:150px"><label>聚合窗口(秒)</label>'
|
||
+ '<input id="nf-agg" type="number" class="form-control" min="0" max="600" value="'
|
||
+ (h.agg_window != null ? h.agg_window : (_notifySettings.default_agg_window || 30)) + '">'
|
||
+ '<div class="help">0=不聚合</div></div>'
|
||
+ '<div class="form-group" style="max-width:150px"><label>限流(条/分)</label>'
|
||
+ '<input id="nf-rate" type="number" class="form-control" min="0" max="60" '
|
||
+ 'oninput="_notifyRateTouched=true" value="'
|
||
+ (h.rate_limit_per_min != null ? h.rate_limit_per_min
|
||
: ((_notifyFormats.find(x => x.name === (h.format || 'wecom')) || {}).limit_default
|
||
|| _notifySettings.default_rate_limit || 18)) + '">'
|
||
+ '<div class="help" id="nf-limit-help"></div></div>'
|
||
+ '</div>'
|
||
+ '<div class="form-group"><label>订阅事件(不勾就不推)</label>'
|
||
+ '<div class="help" style="margin-bottom:6px">按类别分的完整事件目录;'
|
||
+ '带 <b>★</b> 的是建议开启的。也可以直接写通配(如 <code>task.*</code>)——在下面的芯片框里加。</div>'
|
||
+ '<div id="nf-events" style="max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:10px"></div>'
|
||
+ '</div>'
|
||
+ '<div class="form-group" id="nf-pattern-wrap"><label>通配订阅(可选,任何格式都适用)</label>'
|
||
+ '<div id="nf-patterns" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px"></div>'
|
||
+ '<div style="display:flex;gap:6px"><input id="nf-pattern-input" class="form-control" placeholder="task.* 或 device.* 或 *">'
|
||
+ '<button class="btn" onclick="addNotifyPattern()">添加</button></div></div>'
|
||
+ '<div class="form-group" id="nf-body-wrap" style="display:none"><label>请求体模板(JSON)</label>'
|
||
+ '<textarea id="nf-body" class="form-control" rows="5" placeholder=\'{"text":"{{title}}","desc":"{{summary}}"}\'></textarea>'
|
||
+ '<div class="help">占位符:{{event}} {{title}} {{summary}} {{ts}} {{level}} {{markdown}} {{fields}} {{fields_json}} {{hook_name}} {{field.名称}}。'
|
||
+ '保存前会做干跑校验(渲染后必须是合法 JSON)。Slack 可直接用:'
|
||
+ '<code>{"text":"{{markdown}}"}</code></div></div>'
|
||
+ '<div class="form-group">'
|
||
+ '<label>预览</label><div id="nf-preview" class="help">点「预览请求体」看真实会发出去的内容与字节数。</div>'
|
||
+ '</div>'
|
||
// 底部操作栏(缺了它就只能靠刷新页面逃出去)
|
||
+ '<div style="display:flex;gap:8px;align-items:center;margin-top:16px;padding-top:12px;border-top:1px solid var(--border)">'
|
||
+ '<button class="btn btn-primary" onclick="saveNotifyHook()">' + (id ? '保存' : '创建') + '</button>'
|
||
+ '<button class="btn" onclick="previewNotifyHook()">预览请求体</button>'
|
||
+ '<button class="btn" onclick="closeNotifyModal()">取消</button>'
|
||
+ '<span class="help" style="margin-left:auto">Esc 或点空白处也能关</span>'
|
||
+ '</div>';
|
||
box.querySelector('#nf-body').value = h.body_template || '';
|
||
renderEventTree(checked);
|
||
renderNotifyPatterns();
|
||
_notifyPrevFormat = (h.format || 'wecom');
|
||
_notifyRateTouched = (h.rate_limit_per_min != null); // 编辑已有配置时视为"已定"
|
||
_notifyFormatChanged();
|
||
document.getElementById('notify-modal-overlay').style.display = 'flex';
|
||
}
|
||
|
||
function _notifyFormatChanged(){
|
||
const name = (document.getElementById('nf-format') || {}).value;
|
||
const meta = _notifyFormats.find(x => x.name === name) || {};
|
||
const h = _notifyEditingId
|
||
? (_notifyHooks.find(x => x.id === _notifyEditingId) || {}) : {};
|
||
|
||
// URL:编辑时占位显示现有(打码)地址,新建时显示该格式的示例
|
||
const url = document.getElementById('nf-url');
|
||
if(url){
|
||
url.placeholder = (h.url && h.url !== undefined) ? h.url : (meta.url_hint || 'https://…');
|
||
}
|
||
const urlHelp = document.getElementById('nf-url-help');
|
||
if(urlHelp){
|
||
urlHelp.innerHTML = (h.url ? ('已配置:' + esc(h.url) + '(留空=不修改)<br>') : '')
|
||
+ esc(meta.url_help || '');
|
||
}
|
||
|
||
// 密钥:不同格式叫法/用途不同(企业微信不需要、Bark 是设备 Key、通用 JSON 是自定义头)
|
||
const sl = document.getElementById('nf-secret-label');
|
||
if(sl) sl.textContent = (h.secret_set ? '🔑 ' : '') + (meta.secret_label || '密钥(可选)');
|
||
const sh = document.getElementById('nf-secret-help');
|
||
if(sh) sh.innerHTML = esc(meta.secret_help || '')
|
||
+ (h.secret_set ? '<br>已配置过:留空=不修改,想清除请点上面的 🔑 后填写新值' : '');
|
||
|
||
// 限流:上限因格式而异(企微 20/分、Bark 无硬限)
|
||
const lh = document.getElementById('nf-limit-help');
|
||
if(lh) lh.textContent = meta.limit_help || '';
|
||
// 用户没手动改过限流值时,跟着格式的推荐值走(企微 20 / 通用 JSON 60 / Bark 60)
|
||
const rate = document.getElementById('nf-rate');
|
||
if(rate && !_notifyRateTouched && meta.limit_default){
|
||
rate.value = meta.limit_default;
|
||
}
|
||
// 请求体模板:只有需要模板的格式(通用 JSON)才显示
|
||
const bodyWrap = document.getElementById('nf-body-wrap');
|
||
if(bodyWrap) bodyWrap.style.display = meta.needs_template ? 'block' : 'none';
|
||
|
||
_notifyPrevFormat = name;
|
||
}
|
||
|
||
// Esc 关闭弹窗(点空白处关闭在 overlay 的 onclick 上)
|
||
document.addEventListener('keydown', function(ev){
|
||
if(ev.key !== 'Escape') return;
|
||
const ov = document.getElementById('notify-modal-overlay');
|
||
if(ov && ov.style.display !== 'none') closeNotifyModal();
|
||
});
|
||
|
||
function renderEventTree(checked){
|
||
const box = document.getElementById('nf-events');
|
||
if(!box) return;
|
||
const cats = [];
|
||
_notifyEvents.forEach(function(e){ if(cats.indexOf(e.category) < 0) cats.push(e.category); });
|
||
box.innerHTML = cats.map(function(cat){
|
||
const rows = _notifyEvents.filter(e => e.category === cat);
|
||
const body = rows.map(function(e){
|
||
const id = 'nfe-' + e.key.replace(/\./g, '-');
|
||
return '<label style="display:flex;gap:6px;align-items:flex-start;padding:3px 0;cursor:pointer" title="'
|
||
+ esc(e.desc || '') + '">'
|
||
+ '<input type="checkbox" class="nf-ev" value="' + esc(e.key) + '" id="' + id + '"'
|
||
+ (checked.indexOf(e.key) >= 0 ? ' checked' : '') + '>'
|
||
+ '<span style="flex:1"><b>' + esc(e.label) + '</b>'
|
||
+ (e.recommend ? ' <span class="label label-success">★建议</span>' : '')
|
||
+ ' <code style="font-size:10.5px;opacity:.7">' + esc(e.key) + '</code></span></label>';
|
||
}).join('');
|
||
return '<div style="margin-bottom:8px"><div style="font-weight:600;font-size:12px;margin-bottom:2px">'
|
||
+ esc(cat) + ' <button class="btn btn-xs" onclick="_notifyToggleCat(\'' + esc(cat) + '\')">全选/取消</button></div>'
|
||
+ body + '</div>';
|
||
}).join('');
|
||
}
|
||
|
||
function _notifyToggleCat(cat){
|
||
const keys = _notifyEvents.filter(e => e.category === cat).map(e => e.key);
|
||
const boxes = [...document.querySelectorAll('#nf-events .nf-ev')].filter(b => keys.indexOf(b.value) >= 0);
|
||
const allOn = boxes.every(b => b.checked);
|
||
boxes.forEach(b => { b.checked = !allOn; });
|
||
}
|
||
|
||
function addNotifyPattern(){
|
||
const inp = document.getElementById('nf-pattern-input');
|
||
const v = (inp.value || '').trim();
|
||
if(!v) return;
|
||
if(v !== '*' && v.slice(-2) !== '.*'){ showToast('通配请写成 task.* 或 device.* 或 *', 'error'); return; }
|
||
if(_notifyPatterns.indexOf(v) < 0) _notifyPatterns.push(v);
|
||
inp.value = '';
|
||
renderNotifyPatterns();
|
||
}
|
||
|
||
function renderNotifyPatterns(){
|
||
const box = document.getElementById('nf-patterns');
|
||
if(!box) return;
|
||
box.innerHTML = _notifyPatterns.map(function(p, i){
|
||
return '<span class="label label-info" style="cursor:pointer" title="点一下移除" onclick="removeNotifyPattern(' + i + ')">'
|
||
+ esc(p) + ' ×</span>';
|
||
}).join('') || '<span class="help">(无)</span>';
|
||
}
|
||
|
||
function removeNotifyPattern(i){
|
||
_notifyPatterns.splice(i, 1);
|
||
renderNotifyPatterns();
|
||
}
|
||
|
||
function closeNotifyModal(){
|
||
const ov = document.getElementById('notify-modal-overlay');
|
||
if(ov) ov.style.display = 'none';
|
||
}
|
||
|
||
// ================== 保存 / 删除 / 启停 / 测试 ==================
|
||
function collectNotifyForm(){
|
||
const events = [...document.querySelectorAll('#nf-events .nf-ev')]
|
||
.filter(b => b.checked).map(b => b.value);
|
||
_notifyPatterns.forEach(function(p){ if(events.indexOf(p) < 0) events.push(p); });
|
||
const body = {name: (document.getElementById('nf-name').value || '').trim(),
|
||
format: document.getElementById('nf-format').value,
|
||
events: events,
|
||
agg_window: parseInt(document.getElementById('nf-agg').value, 10) || 0,
|
||
rate_limit_per_min: parseInt(document.getElementById('nf-rate').value, 10) || 0,
|
||
body_template: (document.getElementById('nf-body').value || '')};
|
||
const url = (document.getElementById('nf-url').value || '').trim();
|
||
if(url) body.url = url; // 留空 = 不修改(后端保留原值)
|
||
const sec = (document.getElementById('nf-secret').value || '').trim();
|
||
if(sec) body.secret = sec;
|
||
return body;
|
||
}
|
||
|
||
function saveNotifyHook(){
|
||
const body = collectNotifyForm();
|
||
if(!body.name){ showToast('请填名称', 'error'); return; }
|
||
if(!body.events.length){ showToast('至少要勾一个事件', 'error'); return; }
|
||
const id = _notifyEditingId;
|
||
const req = id ? apiPut('/api/notify/webhooks/' + id, body)
|
||
: apiPost('/api/notify/webhooks', body);
|
||
req.then(function(r){
|
||
if(!r || !r.ok){
|
||
let msg = (r && r.error) || '保存失败';
|
||
if(r && r.errors && r.errors.length) msg = r.errors.join(';');
|
||
showToast(msg, 'error');
|
||
return;
|
||
}
|
||
showToast('已保存', 'success');
|
||
closeNotifyModal();
|
||
loadNotifyPanel();
|
||
});
|
||
}
|
||
|
||
function deleteNotifyHook(id){
|
||
const h = _notifyHooks.find(x => x.id === id) || {};
|
||
if(!confirm('删除 webhook「' + (h.name || '') + '」?\n(只是不再推送,不影响任何任务/设备数据)')) return;
|
||
apiDelete('/api/notify/webhooks/' + id).then(function(r){
|
||
if(!r || !r.ok){ showToast((r && r.error) || '删除失败', 'error'); return; }
|
||
showToast('已删除', 'success');
|
||
loadNotifyPanel();
|
||
});
|
||
}
|
||
|
||
function toggleNotifyHook(id, on){
|
||
apiPut('/api/notify/webhooks/' + id, {enabled: on}).then(function(r){
|
||
if(!r || !r.ok){ showToast((r && r.error) || '操作失败', 'error'); return; }
|
||
loadNotifyPanel();
|
||
});
|
||
}
|
||
|
||
function testNotifyHook(id){
|
||
showToast('正在发送测试消息…', 'success');
|
||
apiPost('/api/notify/webhooks/' + id + '/test', {}).then(function(r){
|
||
if(!r){ showToast('请求失败', 'error'); return; }
|
||
const detail = 'HTTP ' + (r.http_status == null ? '-' : r.http_status)
|
||
+ (r.errcode ? ' · errcode ' + r.errcode : '')
|
||
+ (r.elapsed_ms != null ? ' · ' + r.elapsed_ms + 'ms' : '')
|
||
+ (r.attempts > 1 ? ' · 重试 ' + r.attempts + ' 次' : '')
|
||
+ (r.error ? '\n' + r.error : '');
|
||
showToast(r.ok ? ('测试消息已发出(' + detail + ')') : ('发送失败(' + detail + ')'),
|
||
r.ok ? 'success' : 'error');
|
||
loadNotifyPanel();
|
||
});
|
||
}
|
||
|
||
function previewNotifyHook(){
|
||
const body = collectNotifyForm();
|
||
apiPost('/api/notify/preview', {hook: body, event: 'notify.test'}).then(function(r){
|
||
const box = document.getElementById('nf-preview');
|
||
if(!box) return;
|
||
if(!r || !r.ok){ box.innerHTML = '<span style="color:#dc2626">预览失败:' + esc((r && r.error) || '') + '</span>'; return; }
|
||
let html = '<div class="help">UTF-8 字节数:' + (r.bytes == null ? '-' : r.bytes)
|
||
+ (r.byte_limit ? ' / 上限 ' + r.byte_limit : '')
|
||
+ (r.truncated ? ' <b style="color:#b45309">(会截断)</b>' : '') + '</div>';
|
||
html += '<pre style="max-height:220px;overflow:auto;font-size:11px;background:#f6f7f9;padding:8px;border-radius:6px">'
|
||
+ esc(r.request_body || '') + '</pre>';
|
||
box.innerHTML = html;
|
||
});
|
||
}
|
||
|
||
function saveNotifyGlobal(){
|
||
const on = document.getElementById('notify-global').checked;
|
||
const body = {global_enabled: on,
|
||
default_agg_window: parseInt(document.getElementById('notify-agg').value, 10) || 0,
|
||
default_rate_limit: parseInt(document.getElementById('notify-rate').value, 10) || 18};
|
||
apiPost('/api/notify/settings', body).then(function(r){
|
||
if(!r || !r.ok){ showToast((r && r.error) || '保存失败', 'error'); return; }
|
||
showToast('已保存(新配置对之后的事件生效)', 'success');
|
||
loadNotifyPanel();
|
||
});
|
||
}
|
||
|
||
function toggleNotifyGlobal(el){
|
||
apiPost('/api/notify/settings', {global_enabled: el.checked}).then(function(r){
|
||
if(!r || !r.ok){ el.checked = !el.checked; showToast((r && r.error) || '操作失败', 'error'); return; }
|
||
showToast(el.checked ? '通知总开关:已开启' : '通知总开关:已关闭(所有 webhook 暂停推送)', 'success');
|
||
});
|
||
}
|