feat: admin complaint (owner only) + result appeal (one per ticket, owner only)

This commit is contained in:
2026-07-12 15:19:09 +08:00
parent 6657c05dec
commit 10b6ff94ed
5 changed files with 72 additions and 9 deletions

View File

@@ -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) {

View File

@@ -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 || '';

View File

@@ -46,6 +46,10 @@ const TicketCreate = {
</datalist>
</div>
<div class="form-group"><label>详细描述</label><textarea name="description" rows="4" placeholder="请详细描述事件经过..."></textarea></div>
<div class="form-group" style="display:flex;align-items:center;gap:8px;margin-top:12px">
<input type="checkbox" name="is_admin_complaint" value="1" id="cb-admin" style="width:auto">
<label for="cb-admin" style="margin:0;font-weight:500;text-transform:none;letter-spacing:0;color:var(--d)"><i class="fas fa-exclamation-triangle"></i> 投诉管理员(仅服主可见)</label>
</div>
` : isAppeal ? `
<div class="form-group"><label>申诉理由 *</label><input name="reason" required placeholder="如:误封、证据不足被封、违规行为已纠正等"></div>
<div class="form-group"><label>详细说明 *</label><textarea name="description" rows="5" placeholder="请详细说明情况,包括被封时间、可能原因、为什么应解封等..." required></textarea></div>

View File

@@ -34,6 +34,10 @@ const TicketDetail = {
${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>`:''}
${t.attachments?.length?`<div class="card"><div class="card-h">附件 (${t.attachments.length})</div><div class="card-b" style="display:flex;flex-wrap:wrap;gap:10px">
${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>`:''}
@@ -63,6 +67,12 @@ const TicketDetail = {
</div></div>
`:''}
${!isStaff && t.user_id === Auth.user().id && ['resolved','rejected','closed'].includes(t.status) && t.type !== 'result_appeal' ? `
<div class="card"><div class="card-h">结果申诉</div><div class="card-b">
<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>
@@ -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('结果申诉', `<form id="ar-form">
<div class="form-group"><label>申诉理由 *</label><input id="ar-reason" required placeholder="为什么对该处理结果有异议?"></div>
<div class="form-group"><label>详细说明 *</label><textarea id="ar-desc" rows="4" required placeholder="请详细说明情况..."></textarea></div>
<div class="alert alert-i ts">申诉后将生成新工单,仅服主可见及处理</div>
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-w"><i class="fas fa-gavel"></i> 提交申诉</button></div>
</form>`);
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); }
};
}
};

View File

@@ -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:'紧急' },