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:
@@ -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 '无效的触发事件';
|
||||
|
||||
@@ -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 }); }
|
||||
|
||||
@@ -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(()=>{});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user