From 1979cab5ece5b7dbf9b790bcf04097ba0dba2720 Mon Sep 17 00:00:00 2001 From: canglan Date: Sun, 23 Aug 2026 17:50:42 +0800 Subject: [PATCH] feat: anonymous ticket tracking as standalone public page - tracking: promoted to public route (before auth gate) with full single-ticket view - backend: GET /tickets/tracking/:id validates cookie tracking_token, returns responses/attachments/transfers - tracking.js: rewrite - token list + chat-flow detail (player/staff bubbles) + processing log + anon reply - ticket-create: anon submit now lands on #/tracking instead of home - home: tracking tab navigates to standalone page --- backend/routes/tickets.js | 14 ++++ public/js/app.js | 3 +- public/js/pages/home.js | 4 +- public/js/pages/ticket-create.js | 2 +- public/js/pages/tracking.js | 126 ++++++++++++++++++++++++++----- 5 files changed, 125 insertions(+), 24 deletions(-) diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js index d888dbb..8d0e044 100644 --- a/backend/routes/tickets.js +++ b/backend/routes/tickets.js @@ -109,6 +109,20 @@ router.get('/tracking', async (req, res) => { res.json(rows); }); +// ---- 匿名追踪: 单工单完整详情(cookie 追踪标识校验, 含对话/附件/处理日志) ---- +router.get('/tracking/:id', async (req, res) => { + const cookieToken = req.headers.cookie?.split('; ').find(c => c.startsWith('tracking_token='))?.split('=')[1]; + if (!cookieToken) return res.status(403).json({ error: '无权操作,请先提交工单获取追踪标识' }); + const t = await getRow(`SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name, a.username as assignee_username + 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.id = ? AND t.tracking_token = ?`, [req.params.id, cookieToken]); + if (!t) return res.status(404).json({ error: '工单不存在或无权查看' }); + t.responses = await query(`SELECT r.*, u.username, u.role FROM responses r LEFT JOIN users u ON r.user_id = u.id WHERE r.ticket_id = ? ORDER BY r.created_at ASC`, [req.params.id]); + 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]); + res.json(t); +}); + router.get('/:id', authenticate, async (req, res) => { await autoCloseExpired(); const t = await getRow(`SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name, a.username as assignee_username diff --git a/public/js/app.js b/public/js/app.js index 0075ae2..2b582f2 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -206,6 +206,7 @@ const App = { if (page === 'install') { this.showPublic('install'); return; } if (page === 'home') { this.showPublic('home', param); return; } + if (page === 'tracking') { this.showPublic('tracking', param); return; } if (page === 'login' || page === 'register' || page === 'forgot' || page === 'reset') { this.showPublic(page); return; } // 受保护页面: Auth.logged() 内部解码 JWT exp,过期自动清除登录态(同步,无异步循环) @@ -258,7 +259,7 @@ const App = { document.getElementById('top-bar').classList.remove('hidden'); this.updateTopAuth(); this.loadFooter(); - const comp = page === 'login' ? LoginPage : page === 'register' ? RegisterPage : page === 'install' ? InstallPage : page === 'forgot' ? ForgotPage : page === 'reset' ? ResetPage : page === 'verify' ? VerifyPage : HomePage; + const comp = page === 'login' ? LoginPage : page === 'register' ? RegisterPage : page === 'install' ? InstallPage : page === 'forgot' ? ForgotPage : page === 'reset' ? ResetPage : page === 'verify' ? VerifyPage : page === 'tracking' ? TrackingPage : HomePage; comp.render(param).then(html => { pub.innerHTML = html; if (comp.mount) comp.mount(param); }).catch(() => { pub.innerHTML = '
'; }); diff --git a/public/js/pages/home.js b/public/js/pages/home.js index 2532f2d..7e12b05 100644 --- a/public/js/pages/home.js +++ b/public/js/pages/home.js @@ -34,10 +34,10 @@ const HomePage = { mount(tab) { const t = tab || 'report'; + if (t === 'tracking') { App.navigate('tracking'); return; } // 追踪独立公开页 document.querySelectorAll('#home-content .tab-nav button').forEach(b => { b.onclick = () => App.navigate('home', b.dataset.tab); }); const ct = document.getElementById('home-tab'); - if (t === 'tracking') this.renderTracking(ct); - else TicketCreate.render(ct, { type: t, prefill: {}, backLink: null, hideTabs: true }); + TicketCreate.render(ct, { type: t, prefill: {}, backLink: null, hideTabs: true }); }, renderTracking(ct) { diff --git a/public/js/pages/ticket-create.js b/public/js/pages/ticket-create.js index a65721c..ee859bc 100644 --- a/public/js/pages/ticket-create.js +++ b/public/js/pages/ticket-create.js @@ -233,7 +233,7 @@ const TicketCreate = { } alert('提交成功!工单编号: #' + res.id); if (Auth.logged()) App.navigate('tickets'); - else App.navigate('home'); + else App.navigate('tracking'); // 匿名: 直达追踪页查看工单与回复 } catch (ex) { alert(ex.message); btn.disabled = false; diff --git a/public/js/pages/tracking.js b/public/js/pages/tracking.js index 412e8dd..201d474 100644 --- a/public/js/pages/tracking.js +++ b/public/js/pages/tracking.js @@ -17,41 +17,127 @@ * along with this program. If not, see . */ +// 匿名工单追踪: 公开页(无需登录) +// 通过 cookie 中的 tracking_token 识别提交者, 可查看工单列表与单工单完整对话/处理日志, 并匿名补充回复 const TrackingPage = { - async render() { return '
'; }, + token: '', - mount() { - const ct = document.getElementById('page-content'); - document.getElementById('page-title').textContent = '追踪工单'; - document.getElementById('page-actions').innerHTML = ''; - - const token = document.cookie.split('; ').find(r => r.startsWith('tracking_token='))?.split('=')[1] || ''; - - ct.innerHTML = ` -
-
- -
-
- `; + async render(param) { + this.token = document.cookie.split('; ').find(r => r.startsWith('tracking_token='))?.split('=')[1] || ''; + const directId = param && !param.startsWith('?') ? param : ''; + return ` +
+ +

使用提交工单时下发的追踪标识, 查看工单进展与双方回复。如未找到工单, 请确认使用同一浏览器提交。

+
+ + +
+
+
`; }, + mount(param) { + const directId = param && !param.startsWith('?') ? param : ''; + if (directId) { this.showDetail(directId); } + else if (this.token) { this.search(); } + }, + + // ---- 列表 ---- async search() { - const token = document.getElementById('track-token').value.trim(); + const input = document.getElementById('track-token'); + const token = (input ? input.value.trim() : this.token) || this.token; if (!token) return alert('请输入追踪标识'); + this.token = token; const ct = document.getElementById('track-result'); U.loading(ct); try { - const tickets = await API.get(`/tickets/tracking?token=${token}`); + const tickets = await API.get(`/tickets/tracking?token=${encodeURIComponent(token)}`); ct.innerHTML = tickets.length ? ` -
${tickets.map(t => ` +
我的工单 (${tickets.length})点击查看完整对话与处理进度
+
#类型标题状态时间
${tickets.map(t => ` - `).join('')}
#类型标题状态提交时间操作
${t.id}${U.badge(t.type,'type')} ${U.esc(t.title)} ${U.badge(t.status,'status')}${U.date(t.created_at)}
-

如需获取更详细的跟踪和回复通知,请注册账号

+ + `).join('')} +

如需邮件通知与完整功能, 请注册账号

` : '

未找到工单

'; } catch (e) { ct.innerHTML = `
${e.message}
`; } + }, + + // ---- 单工单详情(对话流 + 处理日志 + 匿名回复) ---- + async showDetail(id) { + const ct = document.getElementById('track-result'); + U.loading(ct); + try { + const t = await API.get(`/tickets/tracking/${id}`); + this.renderDetail(ct, t); + } catch (e) { ct.innerHTML = `
${e.message}
`; } + }, + + renderDetail(ct, t) { + const isRep = t.type === 'report'; + const isAppeal = t.type === 'appeal' || t.type === 'result_appeal'; + 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, text: r.content, who: r.username||'系统' })), + ...(t.transfers||[]).map(tf => ({ time: tf.created_at, text: `转交: ${tf.from_game_name||tf.from_username} → ${tf.to_game_name||tf.to_username}${tf.reason?' ('+tf.reason+')':''}`, who: tf.from_username||'' })), + ]; + if (t.claimed_at) logItems.push({ time: t.claimed_at, text: `工单被认领 (${t.assignee_name||''})`, who: '' }); + logItems.sort((a, b) => new Date(a.time) - new Date(b.time)); + + ct.innerHTML = ` +
+
对话记录 (${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('')}
` : '

暂无对话记录

'} +
+ +
补充信息
+
+
+
+ +
+
基本信息
+
标题${U.esc(t.title)}
+
提交者${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)}
+
+ + ${t.attachments?.length?`
附件 (${t.attachments.length})
+ ${t.attachments.map(a=>a.mime_type.startsWith('image')?``:` ${U.esc(a.original_name)}`).join('')} +
`:''} + +
处理日志 (${logItems.length})
+ ${logItems.length ? `
${logItems.map(li => `
${U.esc(li.who||'系统')}${U.date(li.time)}
${U.esc(li.text)}
`).join('')}
` : '

暂无处理记录

'} +
+ +
处理信息
+
负责人${t.assignee_name?`${U.esc(t.assignee_name)}`:'待认领'}
+
更新时间${U.date(t.updated_at)}
+
+ +
+
`; + + document.getElementById('trk-reply-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const c = document.getElementById('trk-reply-content').value.trim(); + if (!c) return; + try { await API.post(`/tickets/${t.id}/anon-response`, { content: c }); alert('已提交'); this.showDetail(t.id); } + catch (ex) { alert(ex.message); } + }); } };