feat: multi-channel notifications (Discord/email/generic Webhook)

This commit is contained in:
2026-07-16 23:51:09 +08:00
parent ab08b1a2a9
commit 18bb8152ef
2 changed files with 46 additions and 26 deletions

View File

@@ -24,7 +24,7 @@ router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res)
const fields = {}; const fields = {};
if (req.body.name) fields.name = req.body.name; if (req.body.name) fields.name = req.body.name;
if (req.body.webhook_url !== undefined) fields.webhook_url = req.body.webhook_url; if (req.body.webhook_url !== undefined) fields.webhook_url = req.body.webhook_url;
if (req.body.events) fields.events = req.body.events; if (req.body.type) fields.type = req.body.type;
if (req.body.active !== undefined) fields.active = req.body.active?1:0; if (req.body.active !== undefined) fields.active = req.body.active?1:0;
if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' }); if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' });
const sets = Object.keys(fields).map(k=>`${k}=?`).join(','); const sets = Object.keys(fields).map(k=>`${k}=?`).join(',');

View File

@@ -3,7 +3,7 @@ const NotificationsPage = {
async mount() { async mount() {
document.getElementById('page-title').textContent = '通知配置'; document.getElementById('page-title').textContent = '通知配置';
document.getElementById('page-actions').innerHTML = '<button class="btn btn-p btn-sm" onclick="NotificationsPage.showCreate()"><i class="fas fa-plus"></i> 添加Webhook</button>'; document.getElementById('page-actions').innerHTML = '<button class="btn btn-p btn-sm" data-action="NotificationsPage:showCreate"><i class="fas fa-plus"></i> 添加通知</button>';
await this.load(); await this.load();
}, },
@@ -11,60 +11,80 @@ const NotificationsPage = {
const ct = document.getElementById('page-content'); const ct = document.getElementById('page-content');
try { try {
const cfgs = await API.get('/notifications'); const cfgs = await API.get('/notifications');
const typeLabel = { webhook:'Discord', email:'邮件通知', generic:'通用Webhook' };
ct.innerHTML = cfgs.length ? cfgs.map(c => ` ct.innerHTML = cfgs.length ? cfgs.map(c => `
<div class="card"><div class="card-h"><span><i class="fas fa-${c.type==='webhook'?'link':'envelope'}"></i> ${U.esc(c.name)}</span><span>${c.active?`<span class="badge bg-resolved">启用</span>`:`<span class="badge bg-rejected">禁用</span>`}</span></div><div class="card-b"> <div class="card"><div class="card-h"><span><i class="fas fa-${c.type==='email'?'envelope':c.type==='generic'?'plug':'link'}"></i> ${U.esc(c.name)}</span><span>${c.active?`<span class="badge bg-resolved">启用</span>`:`<span class="badge bg-rejected">禁用</span>`}</span></div><div class="card-b">
<div class="detail-row"><span class="lbl">类型</span><span class="val">${c.type==='webhook'?'Webhook':'邮件'}</span></div> <div class="detail-row"><span class="lbl">类型</span><span class="val">${typeLabel[c.type]||c.type}</span></div>
${c.webhook_url?`<div class="detail-row"><span class="lbl">地址</span><span class="val ts" style="word-break:break-all">${U.esc(c.webhook_url)}</span></div>`:''} ${c.type!=='email' && c.webhook_url?`<div class="detail-row"><span class="lbl">地址</span><span class="val ts" style="word-break:break-all">${U.esc(c.webhook_url)}</span></div>`:''}
<div class="detail-row"><span class="lbl">事件</span><span class="val">${EventsLabel(c.events)}</span></div> <div class="detail-row"><span class="lbl">事件</span><span class="val">${EventsLabel(c.events)}</span></div>
<div class="mt-2 flex g2"> <div class="mt-2 flex g2">
<button class="btn btn-o btn-sm" onclick="NotificationsPage.showEdit(${c.id},'${U.escJs(c.name)}','${c.type}','${U.escJs(c.webhook_url)}','${U.escJs(c.events)}',${c.active})"><i class="fas fa-edit"></i> 编辑</button> <button class="btn btn-o btn-sm" onclick="NotificationsPage.showEdit(${c.id},'${U.escJs(c.name)}','${c.type}','${U.escJs(c.webhook_url||'')}','${c.events}',${c.active})"><i class="fas fa-edit"></i> 编辑</button>
<button class="btn btn-i btn-sm" onclick="NotificationsPage.test(${c.id})"><i class="fas fa-paper-plane"></i> 测试</button> ${c.type!=='email'?`<button class="btn btn-i btn-sm" onclick="NotificationsPage.test(${c.id})"><i class="fas fa-paper-plane"></i> 测试</button>`:''}
<button class="btn btn-d btn-sm" onclick="NotificationsPage.del(${c.id})"><i class="fas fa-trash"></i> 删除</button> <button class="btn btn-d btn-sm" onclick="NotificationsPage.del(${c.id})"><i class="fas fa-trash"></i></button>
</div> </div>
</div></div> </div></div>
`).join('') : '<div class="empty"><i class="fas fa-bell-slash"></i><p>暂无通知配置</p><p class="ts tm">添加Webhook后可自动推送工单状态变更通知</p></div>'; `).join('') : '<div class="empty"><i class="fas fa-bell-slash"></i><p>暂无通知配置</p></div>';
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; } } catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
}, },
showCreate() { showCreate() {
U.modal('添加Webhook', ` U.modal('添加通知', `
<form id="nc-form"><div class="form-group"><label>名称 *</label><input id="nc-name" placeholder="如: Discord通知" required></div> <form id="nc-form">
<div class="form-group"><label>类型 *</label><select id="nc-type"><option value="webhook">Webhook</option></select></div> <div class="form-group"><label>名称 *</label><input id="nc-name" placeholder="如Discord通知" required></div>
<div class="form-group"><label>Webhook URL *</label><input id="nc-url" placeholder="https://discord.com/api/webhooks/..." required></div> <div class="form-group"><label>类型 *</label><select id="nc-type" onchange="NotificationsPage.toggleFields()">
<option value="webhook">Discord Webhook</option>
<option value="email">邮件通知</option>
<option value="generic">通用Webhook(企微/QQ/飞书)</option>
</select></div>
<div class="form-group" id="nc-url-group"><label>Webhook URL</label><input id="nc-url" placeholder="https://..."></div>
<div class="form-group"><label>触发事件</label> <div class="form-group"><label>触发事件</label>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">${EventOptions('all')}</div> <div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">${EventOptions('all')}</div>
<input type="hidden" id="nc-events" value="all"> <input type="hidden" id="nc-events" value="all">
</div> </div>
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">创建</button></div></form> <div class="modal-f"><button type="button" class="btn btn-o" data-action="App:closeModal">取消</button><button type="submit" class="btn btn-p">创建</button></div>
</form>
`); `);
bindEventToggles(); bindEventToggles();
document.getElementById('nc-form').onsubmit = async e => { e.preventDefault(); document.getElementById('nc-form').onsubmit = async e => { e.preventDefault();
try { await API.post('/notifications',{name:document.getElementById('nc-name').value,type:'webhook',webhook_url:document.getElementById('nc-url').value,events:document.getElementById('nc-events').value}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);} const type = document.getElementById('nc-type').value;
try { await API.post('/notifications',{name:document.getElementById('nc-name').value, type, webhook_url:document.getElementById('nc-url').value, events:document.getElementById('nc-events').value}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
}; };
}, },
showEdit(id,name,type,url,events,active) { toggleFields() {
const type = document.getElementById('nc-type')?.value || document.getElementById('ne-type')?.value;
const urlGroup = document.querySelector('#nc-url-group') || document.querySelector('#ne-url-group');
if (urlGroup) urlGroup.style.display = type === 'email' ? 'none' : '';
},
showEdit(id, name, type, url, events, active) {
U.modal('编辑通知', ` U.modal('编辑通知', `
<form id="ne-form"><div class="form-group"><label>名称</label><input id="ne-name" value="${U.esc(name).replace(/"/g,'&quot;')}"></div> <form id="ne-form"><div class="form-group"><label>名称</label><input id="ne-name" value="${name}"></div>
<div class="form-group"><label>Webhook URL</label><input id="ne-url" value="${U.esc(url).replace(/"/g,'&quot;')}"></div> <div class="form-group"><label>类型</label><select id="ne-type" onchange="NotificationsPage.toggleFields()">
<option value="webhook" ${type==='webhook'?'selected':''}>Discord Webhook</option>
<option value="email" ${type==='email'?'selected':''}>邮件通知</option>
<option value="generic" ${type==='generic'?'selected':''}>通用Webhook</option>
</select></div>
<div class="form-group" id="ne-url-group" style="${type==='email'?'display:none':''}"><label>Webhook URL</label><input id="ne-url" value="${url}"></div>
<div class="form-group"><label>触发事件</label> <div class="form-group"><label>触发事件</label>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">${EventOptions(events)}</div> <div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">${EventOptions(events)}</div>
<input type="hidden" id="ne-events" value="${events}"> <input type="hidden" id="ne-events" value="${events}">
</div> </div>
<div class="form-group"><label>状态</label><select id="ne-active"><option value="1" ${active?'selected':''}>启用</option><option value="0" ${!active?'selected':''}>禁用</option></select></div> <div class="form-group"><label>状态</label><select id="ne-active"><option value="1" ${active?'selected':''}>启用</option><option value="0" ${!active?'selected':''}>禁用</option></select></div>
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">保存</button></div></form> <div class="modal-f"><button type="button" class="btn btn-o" data-action="App:closeModal">取消</button><button type="submit" class="btn btn-p">保存</button></div></form>
`); `);
bindEventToggles(); bindEventToggles();
document.getElementById('ne-form').onsubmit = async e => { e.preventDefault(); document.getElementById('ne-form').onsubmit = async e => { e.preventDefault();
try { await API.put(`/notifications/${id}`,{name:document.getElementById('ne-name').value,webhook_url:document.getElementById('ne-url').value,events:document.getElementById('ne-events').value,active:document.getElementById('ne-active').value==='1'}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);} const newType = document.getElementById('ne-type').value;
try { await API.put(`/notifications/${id}`,{name:document.getElementById('ne-name').value, webhook_url:document.getElementById('ne-url')?.value, events:document.getElementById('ne-events').value, active:document.getElementById('ne-active').value==='1', type:newType}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
}; };
}, },
async test(id) { try { alert((await API.post(`/notifications/${id}/test`)).message); } catch(ex){alert(ex.message);} }, async test(id) { try { alert((await API.post(`/notifications/${id}/test`)).message); } catch(ex){alert(ex.message);} },
async del(id) { if(await U.confirm('确认删除此通知配置')){ try { await API.req('DELETE',`/notifications/${id}`); this.load(); } catch(ex){alert(ex.message);} } } async del(id) { if(await U.confirm('确认删除?')){ try { await API.req('DELETE',`/notifications/${id}`); this.load(); } catch(ex){alert(ex.message);} } }
}; };
function EventOptions(current) { return ['all','ticket_created','ticket_claimed','ticket_transferred','ticket_updated'].map(e=>`<label style="cursor:pointer;font-size:12px"><input type="checkbox" value="${e}" class="ev-check" ${current.split(',').map(s=>s.trim()).includes(e)?'checked':''}> ${EventsLabelSingle(e)}</label>`).join(''); } function EventOptions(current) { return ['all','ticket_created','ticket_claimed','ticket_transferred','ticket_updated'].map(e=>`<label style="cursor:pointer;font-size:12px"><input type="checkbox" value="${e}" class="ev-check" ${Current.split(',').map(s=>s.trim()).includes(e)?'checked':''}> ${EventsLabelSingle(e)}</label>`).join(''); }
function EventsLabel(v) { return v==='all'?'全部事件':v.split(',').map(s=>s.trim()).map(EventsLabelSingle).join(', '); } function EventsLabel(v) { return v==='all'?'全部事件':v.split(',').map(s=>s.trim()).map(EventsLabelSingle).join(', '); }
function EventsLabelSingle(e) { return {all:'全部',ticket_created:'工单创建',ticket_claimed:'工单认领',ticket_transferred:'工单转交',ticket_updated:'工单更新'}[e]||e; } function EventsLabelSingle(e) { return {all:'全部',ticket_created:'工单创建',ticket_claimed:'工单认领',ticket_transferred:'工单转交',ticket_updated:'工单更新'}[e]||e; }
function bindEventToggles() { function bindEventToggles() {