From 10b6ff94edcfe21bcaeb1b88a88422a6b31898a7 Mon Sep 17 00:00:00 2001 From: canglan Date: Sun, 12 Jul 2026 15:19:09 +0800 Subject: [PATCH] feat: admin complaint (owner only) + result appeal (one per ticket, owner only) --- backend/db.js | 11 ++++++++++- backend/routes/tickets.js | 31 +++++++++++++++++++++++------- public/js/pages/ticket-create.js | 4 ++++ public/js/pages/ticket-detail.js | 33 ++++++++++++++++++++++++++++++++ public/js/utils.js | 2 +- 5 files changed, 72 insertions(+), 9 deletions(-) diff --git a/backend/db.js b/backend/db.js index 6c3e6e1..c148123 100644 --- a/backend/db.js +++ b/backend/db.js @@ -84,7 +84,7 @@ async function initSchema(connection) { await createIfNotExists('tickets', `CREATE TABLE tickets ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, - type ENUM('report','suggestion','appeal') NOT NULL, + type ENUM('report','suggestion','appeal','result_appeal') NOT NULL, title VARCHAR(100) NOT NULL, status ENUM('pending','processing','awaiting_info','resolved','rejected','closed') NOT NULL DEFAULT 'pending', priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium', @@ -98,6 +98,8 @@ async function initSchema(connection) { claim_note TEXT, claimed_at DATETIME, tracking_token VARCHAR(255) UNIQUE, + is_admin_complaint TINYINT(1) NOT NULL DEFAULT 0, + parent_ticket_id INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_type (type), @@ -185,6 +187,13 @@ async function initSchema(connection) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await seedTemplates(db); + await migrateAdditions(db); +} + +async function migrateAdditions(db) { + try { await db.execute("ALTER TABLE tickets ADD COLUMN is_admin_complaint TINYINT(1) NOT NULL DEFAULT 0"); } catch {} + try { await db.execute("ALTER TABLE tickets ADD COLUMN parent_ticket_id INT"); } catch {} + try { await db.execute("ALTER TABLE tickets MODIFY type ENUM('report','suggestion','appeal','result_appeal') NOT NULL"); } catch {} } async function seedTemplates(db) { diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js index e569aea..3952834 100644 --- a/backend/routes/tickets.js +++ b/backend/routes/tickets.js @@ -10,7 +10,7 @@ const { validateLengths } = require('../middleware/security'); const router = express.Router(); const SL = { pending:'待处理', processing:'处理中', awaiting_info:'待补充', resolved:'已解决', rejected:'已驳回', closed:'已关闭' }; -const TL = { report:'举报', suggestion:'建议', appeal:'申诉' }; +const TL = { report:'举报', suggestion:'建议', appeal:'申诉', result_appeal:'结果申诉' }; const SC = { pending:'#f59e0b', processing:'#7c3aed', awaiting_info:'#d97706', resolved:'#059669', rejected:'#dc2626', closed:'#6b7280' }; const SO = { processing:1, awaiting_info:2, pending:3, resolved:4, rejected:5, closed:6 }; @@ -22,7 +22,7 @@ router.get('/', authenticate, async (req, res) => { const conds = [], params = []; if (req.user.role === 'player') { conds.push('t.user_id = ?'); params.push(req.user.id); } - else if (req.user.role === 'admin') { conds.push("(t.assigned_to IS NULL OR t.assigned_to = ?) AND t.type != 'suggestion'"); params.push(req.user.id); } + else if (req.user.role === 'admin') { conds.push("(t.assigned_to IS NULL OR t.assigned_to = ?) AND t.type != 'suggestion' AND t.is_admin_complaint = 0 AND t.type != 'result_appeal'"); params.push(req.user.id); } if (req.query.type) { conds.push('t.type = ?'); params.push(req.query.type); } if (req.query.status) { conds.push('t.status = ?'); params.push(req.query.status); } @@ -50,7 +50,7 @@ router.get('/unclaimed', authenticate, requireRole('owner','admin'), async (req, let q = `SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name FROM tickets t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users a ON t.assigned_to = a.id WHERE t.assigned_to IS NULL`; const params = []; - if (req.user.role === 'admin') { q += " AND t.type != 'suggestion'"; } + if (req.user.role === 'admin') { q += " AND t.type != 'suggestion' AND t.is_admin_complaint = 0 AND t.type != 'result_appeal'"; } if (req.query.type) { q += ' AND t.type = ?'; params.push(req.query.type); } q += ` ORDER BY CASE t.status ${Object.entries(SO).map(([k,v])=>`WHEN '${k}' THEN ${v}`).join(' ')} ELSE 9 END, t.created_at ASC LIMIT 200`; res.json(await query(q, params)); @@ -86,14 +86,17 @@ router.get('/:id', authenticate, async (req, res) => { t.attachments = await query('SELECT * FROM attachments WHERE ticket_id = ?', [req.params.id]); t.transfers = await query(`SELECT tf.*, fu.username as from_username, fu.game_name as from_game_name, tu.username as to_username, tu.game_name as to_game_name FROM ticket_transfers tf LEFT JOIN users fu ON tf.from_user_id = fu.id LEFT JOIN users tu ON tf.to_user_id = tu.id WHERE tf.ticket_id = ? ORDER BY tf.created_at ASC`, [req.params.id]); + if (t.parent_ticket_id) { + t.parent_ticket = await getRow('SELECT id, title, status, reporter_game_name, reporter_game_uid FROM tickets WHERE id = ?', [t.parent_ticket_id]); + } res.json(t); }); router.post('/', optionalAuth, upload.array('files', 5), finalizeUpload, validateLengths({ title:100, reporter_game_name:50, reporter_game_uid:50, target_game_name:50, target_game_uid:50, reason:300, description:5000, }), async (req, res) => { - const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description } = req.body; - if (!type || !['report','suggestion','appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' }); + const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, is_admin_complaint, parent_ticket_id } = req.body; + if (!type || !['report','suggestion','appeal','result_appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' }); if (!title) return res.status(400).json({ error: '标题不能为空' }); const rgn = req.user?.game_name || reporter_game_name; const rgu = req.user?.game_uid || reporter_game_uid; @@ -101,6 +104,15 @@ router.post('/', optionalAuth, upload.array('files', 5), finalizeUpload, validat if (type === 'report') { if (!target_game_name && !target_game_uid) return res.status(400).json({ error: '举报需至少填写对方游戏名或UID之一' }); if (!reason) return res.status(400).json({ error: '请填写举报原因' }); } if (type === 'suggestion' && !description) return res.status(400).json({ error: '建议内容不能为空' }); if (type === 'appeal') { if (!reason) return res.status(400).json({ error: '请填写申诉理由' }); if (!description) return res.status(400).json({ error: '请填写详细申诉内容' }); } + if (type === 'result_appeal') { + if (!parent_ticket_id) return res.status(400).json({ error: '缺少原工单信息' }); + const orig = await getRow("SELECT * FROM tickets WHERE id = ? AND user_id = ? AND status IN ('resolved','rejected','closed')", [parent_ticket_id, req.user?.id||0]); + if (!orig) return res.status(400).json({ error: '原工单不存在或状态不允许申诉' }); + const existing = await getRow("SELECT id FROM tickets WHERE type = 'result_appeal' AND parent_ticket_id = ?", [parent_ticket_id]); + if (existing) return res.status(400).json({ error: '该工单已有结果申诉在处理中' }); + if (!reason) return res.status(400).json({ error: '请填写申诉理由' }); + if (!description) return res.status(400).json({ error: '请填写详细申诉内容' }); + } const countRow = await getRow("SELECT COUNT(*) as c FROM tickets WHERE (reporter_game_name = ? OR reporter_game_uid = ?) AND status IN ('pending','processing','awaiting_info')", [rgn, rgu]); if (countRow.c >= 5) return res.status(400).json({ error: '您的待处理/处理中工单已达上限(5个),请等待已有工单处理完毕后再提交' }); @@ -112,10 +124,14 @@ router.post('/', optionalAuth, upload.array('files', 5), finalizeUpload, validat try { await conn.beginTransaction(); const [r] = await conn.execute(`INSERT INTO tickets(type,title,user_id,reporter_game_name,reporter_game_uid, - target_game_name,target_game_uid,reason,description,tracking_token) VALUES (?,?,?,?,?,?,?,?,?,?)`, + target_game_name,target_game_uid,reason,description,tracking_token,is_admin_complaint,parent_ticket_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, [type, title, req.user?.id||null, rgn, rgu, type==='report'?(target_game_name||''):null, type==='report'?(target_game_uid||''):null, - (type==='report'||type==='appeal')?reason:null, (type==='suggestion'||type==='appeal')?description:'', trackingToken]); + (type==='report'||type==='appeal'||type==='result_appeal')?reason:null, + (type==='suggestion'||type==='appeal'||type==='result_appeal')?description:'', + trackingToken, + is_admin_complaint ? 1 : 0, + parent_ticket_id || null]); ticketId = r.insertId; if (req.files?.length) { @@ -169,6 +185,7 @@ router.post('/:id/claim', authenticate, requireRole('owner','admin'), async (req if (!t) return res.status(404).json({ error: '工单不存在' }); if (t.assigned_to && t.assigned_to !== req.user.id && t.status !== 'pending') return res.status(400).json({ error: '该工单已被他人认领' }); if (t.type === 'suggestion' && req.user.role !== 'owner') return res.status(403).json({ error: '建议工单仅服主可处理' }); + if ((t.type === 'result_appeal' || t.is_admin_complaint) && req.user.role !== 'owner') return res.status(403).json({ error: '该工单仅服主可处理' }); const claimNote = req.body.claim_note || ''; const initialReply = req.body.initial_reply || ''; diff --git a/public/js/pages/ticket-create.js b/public/js/pages/ticket-create.js index a738681..5118ecb 100644 --- a/public/js/pages/ticket-create.js +++ b/public/js/pages/ticket-create.js @@ -46,6 +46,10 @@ const TicketCreate = {
+
+ + +
` : isAppeal ? `
diff --git a/public/js/pages/ticket-detail.js b/public/js/pages/ticket-detail.js index 3b9532d..74050f4 100644 --- a/public/js/pages/ticket-detail.js +++ b/public/js/pages/ticket-detail.js @@ -34,6 +34,10 @@ const TicketDetail = { ${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')}
+
`:''} + ${t.attachments?.length?`
附件 (${t.attachments.length})
${t.attachments.map(a=>a.mime_type.startsWith('image')?``:` ${U.esc(a.original_name)}`).join('')}
`:''} @@ -63,6 +67,12 @@ const TicketDetail = { `:''} + ${!isStaff && t.user_id === Auth.user().id && ['resolved','rejected','closed'].includes(t.status) && t.type !== 'result_appeal' ? ` +
结果申诉
+

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

+ +
`:''} + ${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('')}
@@ -104,5 +114,28 @@ const TicketDetail = { document.getElementById('cs-form').onsubmit = async e => { e.preventDefault(); try { await API.put(`/tickets/${id}`, {status:document.getElementById('cs-status').value}); App.closeModal(); this.mount(); } catch(ex){alert(ex.message);} }; + }, + + appealResult(parentId) { + U.modal('结果申诉', `
+
+
+
申诉后将生成新工单,仅服主可见及处理
+ +
`); + document.getElementById('ar-form').onsubmit = async e => { e.preventDefault(); + try { + await API.post('/tickets', { + type: 'result_appeal', + title: `结果申诉: #${parentId}`, + reason: document.getElementById('ar-reason').value, + description: document.getElementById('ar-desc').value, + parent_ticket_id: parentId, + }); + App.closeModal(); + alert('申诉已提交,请等待服主处理'); + this.mount(); + } catch(ex) { alert(ex.message); } + }; } }; diff --git a/public/js/utils.js b/public/js/utils.js index c03f18c..25181c3 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -1,6 +1,6 @@ const U = { STATUS: { pending:'待处理', processing:'处理中', awaiting_info:'待补充', resolved:'已解决', rejected:'已驳回', closed:'已关闭' }, - TYPE: { report:'举报', suggestion:'建议', appeal:'申诉' }, + TYPE: { report:'举报', suggestion:'建议', appeal:'申诉', result_appeal:'结果申诉' }, ROLE: { owner:'服主', admin:'管理员', player:'玩家' }, PRIORITY: { low:'低', medium:'中', high:'高', urgent:'紧急' },