fix: data-action buttons, sources enabled, email notify, ticket detail single-page

- app.js: mount page objects via inline script (CSP blocks eval → data-action buttons dead)
- app.js: add tickets list entry to sidebar nav (admin could not find claimed tickets)
- sources: GET /sources?manage=1 returns enabled field for admin view
- mailer: sendNotifyEmail consumes email-type notification_configs (webhook_url = recipients)
- tickets: trigger notify on created/claimed/transferred/updated/status-change
- notifications: email type shows recipient field, validates email list
- ticket-detail: single-page chat flow (player/staff bubbles) + processing log timeline
- css: chat bubble styles
This commit is contained in:
2026-08-22 20:00:12 +08:00
parent e5a1d693f1
commit c5ceba7cf0
9 changed files with 120 additions and 37 deletions

View File

@@ -93,4 +93,25 @@ async function sendEmail(to, templateCode, vars) {
}
}
module.exports = { sendEmail, renderTemplate, getTemplate, getSmtpConfig, getSiteName, getSiteUrl };
module.exports = { sendEmail, sendNotifyEmail, renderTemplate, getTemplate, getSmtpConfig, getSiteName, getSiteUrl };
// ---- 事件通知(email 类型配置消费) ----
// 读取 notification_configs 中 type='email' 且 active=1 的配置, 事件匹配则向收件人发信
// email 类型配置的 webhook_url 字段复用为收件人邮箱(逗号分隔)
async function sendNotifyEmail(event, data) {
try {
const configs = await query("SELECT * FROM notification_configs WHERE type = 'email' AND active = 1");
for (const cfg of configs) {
const events = cfg.events || 'all';
if (events !== 'all' && !events.split(',').map(s => s.trim()).includes(event)) continue;
const recipients = String(cfg.webhook_url || '').split(',').map(s => s.trim()).filter(Boolean);
if (!recipients.length) continue;
const templateCode = { ticket_created: 'ticket_created', ticket_claimed: 'ticket_claimed', ticket_transferred: 'ticket_transferred', ticket_updated: 'ticket_updated' }[event] || 'ticket_updated';
for (const to of recipients) {
sendEmail(to, templateCode, data).catch(() => {});
}
}
} catch (err) {
console.error('[NotifyEmail]', err.message);
}
}

View File

@@ -33,11 +33,19 @@ async function validateNotify(body) {
if (name !== undefined && (!name || String(name).length > 100)) return '名称不能为空且不超过100字符';
if (type !== undefined && !NOTIFY_TYPES.includes(type)) return '无效的通知类型';
if (webhook_url !== undefined && webhook_url !== '') {
let u;
try { u = new URL(webhook_url); } catch { return 'Webhook地址格式无效'; }
if (u.protocol !== 'https:' && u.protocol !== 'http:') return 'Webhook地址协议不支持';
if (await isPrivateUrl(webhook_url)) return 'Webhook地址不允许指向内网';
if (type === 'email') {
// email 类型: webhook_url 复用为收件人邮箱(逗号分隔)
const emails = String(webhook_url).split(',').map(s => s.trim()).filter(Boolean);
if (!emails.length) return '收件人邮箱不能为空';
if (!emails.every(e => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e))) return '收件人邮箱格式无效';
} else {
let u;
try { u = new URL(webhook_url); } catch { return 'Webhook地址格式无效'; }
if (u.protocol !== 'https:' && u.protocol !== 'http:') return 'Webhook地址协议不支持';
if (await isPrivateUrl(webhook_url)) return 'Webhook地址不允许指向内网';
}
}
if (type === 'email' && !webhook_url) return '邮件通知需填写收件人邮箱';
if (events !== undefined) {
const list = String(events).split(',').map(s => s.trim());
if (!list.every(e => NOTIFY_EVENTS.includes(e))) return '无效的触发事件';

View File

@@ -19,14 +19,19 @@
const express = require('express');
const { query, getRow } = require('../db');
const { authenticate, requireRole } = require('../middleware/auth');
const { authenticate, optionalAuth, requireRole } = require('../middleware/auth');
const { logSystem } = require('../logger');
const router = express.Router();
// ---- 来源列表(公开: 注册页/绑定身份页需要) ----
router.get('/', async (req, res) => {
// ---- 来源列表 ----
// 公开(注册页/绑定身份页): 仅启用项, 不含 enabled
// ?manage=1(authenticate owner/admin): 全量含 enabled, 供来源管理页显示/切换状态
router.get('/', optionalAuth, async (req, res) => {
try {
if (req.query.manage === '1' && req.user && ['owner','admin'].includes(req.user.role)) {
return res.json(await query('SELECT code, label, sort_order, enabled FROM sources ORDER BY sort_order, id'));
}
const rows = await query('SELECT code, label, sort_order FROM sources WHERE enabled = 1 ORDER BY sort_order, id');
res.json(rows);
} catch (e) { res.status(500).json({ error: e.message }); }

View File

@@ -22,7 +22,7 @@ const { v4: uuid } = require('uuid');
const { getPool, query, getRow } = require('../db');
const { authenticate, requireRole, optionalAuth } = require('../middleware/auth');
const { upload, finalizeUpload } = require('../middleware/upload');
const { sendEmail } = require('../mailer');
const { sendEmail, sendNotifyEmail } = require('../mailer');
const { sendWebhook } = require('../webhook');
const { validateLengths, ticketAnonLimiter } = require('../middleware/security');
@@ -221,6 +221,7 @@ router.post('/', ticketAnonLimiter, optionalAuth, upload.array('files', 5), fina
const emailVars = { reporter_game_name: rgn, reporter_game_uid: rgu, ticket_type: TL[type], ticket_id: ticketId, ticket_title: title, ticket_reason: reason||'', ticket_description: description||'', tracking_link: `${await siteUrl()}#/ticket/${ticketId}`, date: new Date().toLocaleString('zh-CN') };
if (req.user?.email) sendEmail(req.user.email, 'ticket_created', emailVars).catch(()=>{});
sendNotifyEmail('ticket_created', { ...emailVars, type });
sendWebhook('ticket_created', { ...emailVars, type }).catch(()=>{});
res.setHeader('Set-Cookie', `tracking_token=${trackingToken}; Path=/; SameSite=Lax; Max-Age=${365*24*3600}`);
@@ -276,6 +277,7 @@ router.post('/:id/claim', authenticate, requireRole('owner','admin'), async (req
const user = await getRow('SELECT username, game_name FROM users WHERE id = ?', [req.user.id]);
await sendEmailForTicket(t, 'ticket_claimed', { assigned_to: user?.game_name||req.user.username, claim_note: claimNote });
sendNotifyEmail('ticket_claimed', { ticket_id: t.id, ticket_title: t.title, assigned_to: user?.game_name||req.user.username, claim_note: claimNote });
sendWebhook('ticket_claimed', { ticket_id: t.id, ticket_title: t.title, assigned_to: user?.game_name||req.user.username, claim_note: claimNote }).catch(()=>{});
res.json({ message: '认领成功' });
});
@@ -306,6 +308,7 @@ router.post('/:id/transfer', authenticate, requireRole('owner','admin'), async (
const fromUser = await getRow('SELECT game_name FROM users WHERE id = ?', [req.user.id]);
await sendEmailForTicket(t, 'ticket_transferred', { from_user: fromUser?.game_name||req.user.username, to_user: toUser.game_name||toUser.username, transfer_reason: reason||'', claim_note: t.claim_note||'无' });
sendNotifyEmail('ticket_transferred', { ticket_id: t.id, ticket_title: t.title, from_user: fromUser?.game_name, to_user: toUser.game_name, reason: reason||'' });
sendWebhook('ticket_transferred', { ticket_id: t.id, ticket_title: t.title, from_user: fromUser?.game_name, to_user: toUser.game_name, reason: reason||'' }).catch(()=>{});
res.json({ message: '转交成功' });
});
@@ -349,6 +352,7 @@ router.post('/:id/response', authenticate, async (req, res) => {
if (isStaff && t.user_id) {
await sendEmailForTicket(t, 'ticket_updated', { update_note: content });
sendNotifyEmail('ticket_updated', { ticket_id: t.id, ticket_title: t.title, responder: req.user.username, note: content });
sendWebhook('ticket_updated', { ticket_id: t.id, ticket_title: t.title, responder: req.user.username, note: content }).catch(()=>{});
}
res.status(201).json({ message: '回复成功' });
@@ -369,6 +373,7 @@ async function notifyStatusChange(ticket, newStatus) {
if (ticket.user_id) {
await sendEmailForTicket(ticket, 'ticket_updated', { ticket_status: SL[newStatus]||newStatus, ticket_status_color: SC[newStatus]||'#6b7280' });
}
sendNotifyEmail('ticket_updated', { ticket_id: ticket.id, ticket_title: ticket.title, new_status: SL[newStatus]||newStatus, type: ticket.type });
sendWebhook('ticket_updated', { ticket_id: ticket.id, ticket_title: ticket.title, new_status: SL[newStatus]||newStatus, type: ticket.type }).catch(()=>{});
}

View File

@@ -259,6 +259,19 @@ tbody tr:last-child td{border-bottom:none}
font-size:13.5px;line-height:1.7;white-space:pre-wrap;border:1px solid var(--g2);
}
.timeline-item.staff .tc{background:var(--pl);border-color:rgba(99,102,241,.2)}
/* 对话流(工单详情单页) */
.chat-flow{display:flex;flex-direction:column;gap:12px;max-height:520px;overflow-y:auto;padding:4px 2px}
.chat-row{display:flex}
.chat-row.user{justify-content:flex-start}
.chat-row.staff{justify-content:flex-end}
.chat-bubble{max-width:78%;padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.6}
.chat-row.user .chat-bubble{background:var(--g1);border:1px solid var(--g2);border-top-left-radius:2px}
.chat-row.staff .chat-bubble{background:var(--pl);border:1px solid rgba(99,102,241,.25);border-top-right-radius:2px;color:var(--g8)}
.chat-meta{display:flex;align-items:center;gap:8px;margin-bottom:3px;font-size:11px}
.chat-row.user .chat-meta{flex-direction:row}
.chat-row.staff .chat-meta{flex-direction:row-reverse}
.chat-name{font-weight:600}
.chat-text{word-break:break-word}
/* ===== Empty / Loading / Alert ===== */
.empty{text-align:center;padding:60px 24px;color:var(--g5)}

View File

@@ -20,6 +20,7 @@
const App = {
navItems: [
{ id: 'dashboard', label: '控制台', icon: 'fa-chart-simple', roles: ['owner','admin','player'] },
{ id: 'tickets', label: '工单列表', icon: 'fa-list', roles: ['owner','admin'] },
{ id: 'tickets-create', label: '提交工单', icon: 'fa-plus-circle', roles: ['player'] },
{ id: 'bans', label: '封禁列表', icon: 'fa-ban', roles: ['owner','admin','player'] },
{ id: 'unclaimed', label: '待处理工单', icon: 'fa-inbox', roles: ['owner','admin'] },
@@ -163,11 +164,13 @@ const App = {
const s = document.createElement('script');
s.src = src;
s.onload = () => {
// 页面脚本顶层 const 在全局词法环境, 用间接 eval 挂到 window,
// 供 inline onclick / data-action 的 window[obj] 查找
// 页面脚本顶层 const 在全局词法环境, 用内联 script 挂到 window
// (不能用 eval: CSP scriptSrc 无 'unsafe-eval', (0,eval) 会被拦截导致 data-action 按钮失效)
const objs = this.PAGE_OBJS[src.split('/').pop()] || [];
for (const o of objs) {
try { if (typeof window[o] === 'undefined') window[o] = (0, eval)(o); } catch {}
if (objs.length) {
const inline = document.createElement('script');
inline.textContent = objs.map(o => `window.${o} = ${o};`).join('\n');
document.head.appendChild(inline);
}
resolve();
};

View File

@@ -55,7 +55,7 @@ const NotificationsPage = {
<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" id="nc-url-group"><label id="nc-url-label">Webhook URL</label><input id="nc-url" placeholder="https://..."></div>
<div class="form-group"><label>触发事件</label>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">${EventOptions('all')}</div>
<input type="hidden" id="nc-events" value="all">
@@ -68,12 +68,25 @@ const NotificationsPage = {
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);}
};
this.toggleFields();
},
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' : '';
const urlLabel = document.querySelector('#nc-url-label') || document.querySelector('#ne-url-label');
if (!urlGroup) return;
if (type === 'email') {
urlGroup.style.display = '';
urlLabel.textContent = '收件人邮箱 (逗号分隔)';
const inp = urlGroup.querySelector('input');
if (inp) { inp.placeholder = 'admin@example.com, staff@example.com'; inp.type = 'email'; }
} else {
urlGroup.style.display = '';
urlLabel.textContent = 'Webhook URL';
const inp = urlGroup.querySelector('input');
if (inp) { inp.placeholder = 'https://...'; inp.type = 'text'; }
}
},
showEdit(id, name, type, url, events, active) {
@@ -84,7 +97,7 @@ const NotificationsPage = {
<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" id="ne-url-group" style="${type==='email'?'display:none':''}"><label id="ne-url-label">Webhook URL</label><input id="ne-url" value="${url}"></div>
<div class="form-group"><label>触发事件</label>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">${EventOptions(events)}</div>
<input type="hidden" id="ne-events" value="${events}">
@@ -93,6 +106,7 @@ const NotificationsPage = {
<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();
this.toggleFields();
document.getElementById('ne-form').onsubmit = async e => { e.preventDefault();
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);}

View File

@@ -29,8 +29,8 @@ const SourcesPage = {
async load() {
const ct = document.getElementById('page-content');
try {
// 管理视图: 停用
const rows = await API.get('/sources');
// 管理视图: 全量含 enabled(可显示/切换启用停用)
const rows = await API.get('/sources?manage=1');
ct.innerHTML = rows.length === 0
? '<div class="empty"><i class="fas fa-tag"></i><p>暂无来源</p></div>'
: `<div class="card table-wrap"><table><thead><tr><th>code</th><th>名称</th><th>排序</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows.map(s => `

View File

@@ -38,21 +38,44 @@ const TicketDetail = {
const canTransfer = isStaff && t.assigned_to === Auth.user().id && t.status === 'processing';
const isMyTicket = t.assigned_to === Auth.user().id;
// 对话流 vs 处理日志: 普通回复进对话, 系统动作/状态变更/认领/转交进日志
const isSystemMsg = r => r.is_staff === 1 && /^(【|状态变更为)/.test(r.content);
const chat = (t.responses||[]).filter(r => !isSystemMsg(r));
const logItems = [
...(t.responses||[]).filter(isSystemMsg).map(r => ({ time: r.created_at, kind: 'system', text: r.content, who: r.username||'系统' })),
...(t.transfers||[]).map(tf => ({ time: tf.created_at, kind: 'transfer', text: `转交: ${tf.from_game_name||tf.from_username}${tf.to_game_name||tf.to_username}${tf.reason?' ('+tf.reason+')':''}`, who: tf.from_username||'' })),
].sort((a, b) => new Date(a.time) - new Date(b.time));
if (t.claimed_at) logItems.push({ time: t.claimed_at, kind: 'claim', text: `工单被认领 (${t.assignee_name||''})`, who: '' });
logItems.sort((a, b) => new Date(a.time) - new Date(b.time));
ct.innerHTML = `
<div class="detail-grid"><div>
<div class="card"><div class="card-h"><span>基本信息</span><div>${U.badge(t.status,'status')} ${U.badge(t.type,'type')} ${U.badge(t.priority,'priority')}</div></div><div class="card-b">
<div class="card"><div class="card-h"><span><i class="fas fa-comments"></i> 对话记录 (${chat.length})</span>${U.badge(t.status,'status')} ${U.badge(t.type,'type')} ${U.badge(t.priority,'priority')}</div><div class="card-b">
${chat.length ? `<div class="chat-flow">${chat.map(r => `
<div class="chat-row ${r.is_staff ? 'staff' : 'user'}">
<div class="chat-bubble">
<div class="chat-meta"><span class="chat-name">${U.esc(r.username||(r.is_staff?'客服':'提交者'))}</span><span class="ts tm">${U.date(r.created_at)}</span></div>
<div class="chat-text" style="white-space:pre-wrap">${U.esc(r.content)}</div>
</div>
</div>`).join('')}</div>` : '<p class="tm">暂无对话记录</p>'}
</div></div>
<div class="card"><div class="card-h">${isStaff?'回复 (公开)':'补充信息'}</div><div class="card-b">
<form id="reply-form"><div class="form-group"><textarea id="reply-content" rows="3" placeholder="${isStaff?'输入回复内容...':'补充说明...'}" required></textarea></div><button type="submit" class="btn btn-p btn-sm"><i class="fas fa-reply"></i> 发送</button></form>
</div></div>
</div>
<div>
<div class="card"><div class="card-h"><span>基本信息</span></div><div class="card-b">
<div class="detail-row"><span class="lbl">标题</span><span class="val fb">${U.esc(t.title)}</span></div>
<div class="detail-row"><span class="lbl">提交者</span><span class="val">${U.esc(t.reporter_game_name)}</span></div>
${isRep?`<div class="detail-row"><span class="lbl">被举报人</span><span class="val">${U.esc(t.target_game_name||'?')} (UID: ${U.esc(t.target_game_uid||'?')})</span></div>
<div class="detail-row"><span class="lbl">原因</span><span class="val">${U.esc(t.reason)}</span></div>`:''}
${isAppeal?`<div class="detail-row"><span class="lbl">申诉理由</span><span class="val">${U.esc(t.reason)}</span></div>`:''}
<div class="detail-row"><span class="lbl">提交者</span><span class="val">${U.esc(t.reporter_game_name)}${t.reporter_game_uid?' (UID: '+U.esc(t.reporter_game_uid)+')':''}</span></div>
${isRep?`<div class="detail-row"><span class="lbl">被举报人</span><span class="val">${U.esc(t.target_game_name||'?')} (UID: ${U.esc(t.target_game_uid||'?')})</span></div>`:''}
${t.reason?`<div class="detail-row"><span class="lbl">${isRep?'原因':isAppeal?'申诉理由':'说明'}</span><span class="val">${U.esc(t.reason)}</span></div>`:''}
${t.description?`<div class="detail-row"><span class="lbl">${isRep?'详情':isAppeal?'详细说明':'建议内容'}</span><span class="val" style="white-space:pre-wrap">${U.esc(t.description)}</span></div>`:''}
<div class="detail-row"><span class="lbl">提交时间</span><span class="val">${U.date(t.created_at)}</span></div>
<div class="detail-row"><span class="lbl">更新时间</span><span class="val">${U.date(t.updated_at)}</span></div>
</div></div>
${t.claim_note?`<div class="card"><div class="card-h">处理备注</div><div class="card-b" style="white-space:pre-wrap">${U.esc(t.claim_note)}</div></div>`:''}
${t.parent_ticket?`<div class="card"><div class="card-h">关联工单</div><div class="card-b">
<div class="detail-row"><span class="lbl">原工单</span><span class="val"><a href="#" onclick="App.navigate('ticket',${t.parent_ticket.id});return false" style="color:var(--p)">#${t.parent_ticket.id} ${U.esc(t.parent_ticket.title)}</a> — ${U.badge(t.parent_ticket.status,'status')}</span></div>
</div></div>`:''}
@@ -61,16 +84,12 @@ const TicketDetail = {
${t.attachments.map(a=>a.mime_type.startsWith('image')?`<a href="/api/uploads/${a.stored_name}" target="_blank"><img src="/api/uploads/${a.stored_name}" style="max-width:150px;max-height:120px;border-radius:6px;border:1px solid var(--g200)" title="${U.esc(a.original_name)}"></a>`:`<a href="/api/uploads/${a.stored_name}" target="_blank" class="btn btn-o btn-sm"><i class="fas fa-video"></i> ${U.esc(a.original_name)}</a>`).join('')}
</div></div>`:''}
<div class="card"><div class="card-h">沟通记录</div><div class="card-b">
${t.responses?.length?`<div class="timeline">${t.responses.map(r=>`<div class="timeline-item ${r.is_staff?'staff':''}"><div class="th"><span class="tn">${U.esc(r.username||'系统')}</span>${r.role?`<span class="badge bg-${r.role}">${U.ROLE[r.role]}</span>`:''}<span>${U.date(r.created_at)}</span></div><div class="tc">${U.esc(r.content)}</div></div>`).join('')}</div>`:'<p class="tm">暂无记录</p>'}
<div class="card"><div class="card-h"><i class="fas fa-history"></i> 处理日志 (${logItems.length})</div><div class="card-b">
${logItems.length ? `<div class="timeline">${logItems.map(li => `<div class="timeline-item"><div class="th"><span class="tn">${U.esc(li.who||'系统')}</span><span>${U.date(li.time)}</span></div><div class="tc">${U.esc(li.text)}</div></div>`).join('')}</div>` : '<p class="tm">暂无处理记录</p>'}
</div></div>
<div class="card"><div class="card-h">${isStaff?'回复 (公开)':'补充信息'}</div><div class="card-b">
<form id="reply-form"><div class="form-group"><textarea id="reply-content" rows="3" placeholder="${isStaff?'输入回复内容...':'补充说明...'}" required></textarea></div><button type="submit" class="btn btn-p btn-sm"><i class="fas fa-reply"></i> 发送</button></form>
</div></div>
</div>
${t.claim_note?`<div class="card"><div class="card-h">处理备注</div><div class="card-b" style="white-space:pre-wrap">${U.esc(t.claim_note)}</div></div>`:''}
<div>
<div class="card"><div class="card-h">处理信息</div><div class="card-b">
<div class="detail-row"><span class="lbl">提交账号</span><span class="val">${t.submitter?`${U.esc(t.submitter)} (${U.esc(t.user_game_name||'')})`:'匿名'}</span></div>
<div class="detail-row"><span class="lbl">负责人</span><span class="val">${t.assignee_name?`<span class="badge bg-admin">${U.esc(t.assignee_name)}</span>`:'<span class="tm">待认领</span>'}</span></div>
@@ -91,11 +110,6 @@ const TicketDetail = {
<p class="tm mb-4">对处理结果有异议?可以提交一次申诉,由服主复核。</p>
<button class="btn btn-w btn-sm w-full" onclick="TicketDetail.appealResult(${t.id})"><i class="fas fa-gavel"></i> 对结果提出申诉</button>
</div></div>`:''}
${t.transfers?.length?`
<div class="card"><div class="card-h">转交历史</div><div class="card-b">
<div class="timeline">${t.transfers.map(tf=>`<div class="timeline-item"><div class="th"><span class="tn">${U.esc(tf.from_game_name||tf.from_username)}</span><i class="fas fa-arrow-right ts tm"></i><span class="tn">${U.esc(tf.to_game_name||tf.to_username)}</span><span>${U.date(tf.created_at)}</span></div>${tf.reason?`<div class="tc">${U.esc(tf.reason)}</div>`:''}</div>`).join('')}</div>
</div></div>`:''}
</div></div>`;
document.getElementById('reply-form')?.addEventListener('submit', async e => {