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 => `
| ${t.id} | ${U.badge(t.type,'type')} |
${U.esc(t.title)} |
${U.badge(t.status,'status')} | ${U.date(t.created_at)} |
-
`).join('')}
-
如需获取更详细的跟踪和回复通知,请注册账号
+
|
+ `).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})
`:''}
+
+
处理日志 (${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); }
+ });
}
};