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 = { -
+
${EventOptions('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 = {
-
+
${EventOptions(events)}
@@ -93,6 +106,7 @@ const NotificationsPage = { `); 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);} diff --git a/public/js/pages/sources.js b/public/js/pages/sources.js index 24c2ad1..9cba5b7 100644 --- a/public/js/pages/sources.js +++ b/public/js/pages/sources.js @@ -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 ? '

暂无来源

' : `
${rows.map(s => ` diff --git a/public/js/pages/ticket-detail.js b/public/js/pages/ticket-detail.js index a49835d..bce10b1 100644 --- a/public/js/pages/ticket-detail.js +++ b/public/js/pages/ticket-detail.js @@ -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 = `
-
基本信息
${U.badge(t.status,'status')} ${U.badge(t.type,'type')} ${U.badge(t.priority,'priority')}
+
对话记录 (${chat.length})${U.badge(t.status,'status')} ${U.badge(t.type,'type')} ${U.badge(t.priority,'priority')}
+ ${chat.length ? `
${chat.map(r => ` +
+
+
${U.esc(r.username||(r.is_staff?'客服':'提交者'))}${U.date(r.created_at)}
+
${U.esc(r.content)}
+
+
`).join('')}
` : '

暂无对话记录

'} +
+ +
${isStaff?'回复 (公开)':'补充信息'}
+
+
+
+ +
+
基本信息
标题${U.esc(t.title)}
-
提交者${U.esc(t.reporter_game_name)}
- ${isRep?`
被举报人${U.esc(t.target_game_name||'?')} (UID: ${U.esc(t.target_game_uid||'?')})
-
原因${U.esc(t.reason)}
`:''} - ${isAppeal?`
申诉理由${U.esc(t.reason)}
`:''} +
提交者${U.esc(t.reporter_game_name)}${t.reporter_game_uid?' (UID: '+U.esc(t.reporter_game_uid)+')':''}
+ ${isRep?`
被举报人${U.esc(t.target_game_name||'?')} (UID: ${U.esc(t.target_game_uid||'?')})
`:''} + ${t.reason?`
${isRep?'原因':isAppeal?'申诉理由':'说明'}${U.esc(t.reason)}
`:''} ${t.description?`
${isRep?'详情':isAppeal?'详细说明':'建议内容'}${U.esc(t.description)}
`:''}
提交时间${U.date(t.created_at)}
更新时间${U.date(t.updated_at)}
- ${t.claim_note?`
处理备注
${U.esc(t.claim_note)}
`:''} - ${t.parent_ticket?`
关联工单
原工单#${t.parent_ticket.id} ${U.esc(t.parent_ticket.title)} — ${U.badge(t.parent_ticket.status,'status')}
`:''} @@ -61,16 +84,12 @@ const TicketDetail = { ${t.attachments.map(a=>a.mime_type.startsWith('image')?``:` ${U.esc(a.original_name)}`).join('')}
`:''} -
沟通记录
- ${t.responses?.length?`
${t.responses.map(r=>`
${U.esc(r.username||'系统')}${r.role?`${U.ROLE[r.role]}`:''}${U.date(r.created_at)}
${U.esc(r.content)}
`).join('')}
`:'

暂无记录

'} +
处理日志 (${logItems.length})
+ ${logItems.length ? `
${logItems.map(li => `
${U.esc(li.who||'系统')}${U.date(li.time)}
${U.esc(li.text)}
`).join('')}
` : '

暂无处理记录

'}
-
${isStaff?'回复 (公开)':'补充信息'}
-
-
-
+ ${t.claim_note?`
处理备注
${U.esc(t.claim_note)}
`:''} -
处理信息
提交账号${t.submitter?`${U.esc(t.submitter)} (${U.esc(t.user_game_name||'')})`:'匿名'}
负责人${t.assignee_name?`${U.esc(t.assignee_name)}`:'待认领'}
@@ -91,11 +110,6 @@ const TicketDetail = {

对处理结果有异议?可以提交一次申诉,由服主复核。

`:''} - - ${t.transfers?.length?` -
转交历史
-
${t.transfers.map(tf=>`
${U.esc(tf.from_game_name||tf.from_username)}${U.esc(tf.to_game_name||tf.to_username)}${U.date(tf.created_at)}
${tf.reason?`
${U.esc(tf.reason)}
`:''}
`).join('')}
-
`:''}
`; document.getElementById('reply-form')?.addEventListener('submit', async e => {
code名称排序状态操作