diff --git a/backend/mailer.js b/backend/mailer.js index 962f221..7f59d64 100644 --- a/backend/mailer.js +++ b/backend/mailer.js @@ -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); + } +} diff --git a/backend/routes/notifications.js b/backend/routes/notifications.js index 1d1dc69..49cee27 100644 --- a/backend/routes/notifications.js +++ b/backend/routes/notifications.js @@ -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 '无效的触发事件'; diff --git a/backend/routes/sources.js b/backend/routes/sources.js index 21af5f9..6d7f368 100644 --- a/backend/routes/sources.js +++ b/backend/routes/sources.js @@ -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 }); } diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js index f1b52a5..d888dbb 100644 --- a/backend/routes/tickets.js +++ b/backend/routes/tickets.js @@ -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(()=>{}); } diff --git a/public/css/style.css b/public/css/style.css index cf9a8e0..75bde5e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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)} diff --git a/public/js/app.js b/public/js/app.js index ca4431e..0075ae2 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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(); }; diff --git a/public/js/pages/notifications.js b/public/js/pages/notifications.js index 26ce050..963db41 100644 --- a/public/js/pages/notifications.js +++ b/public/js/pages/notifications.js @@ -55,7 +55,7 @@ const NotificationsPage = { -
+暂无来源
| code | 名称 | 排序 | 状态 | 操作 |
|---|