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
This commit is contained in:
@@ -109,6 +109,20 @@ router.get('/tracking', async (req, res) => {
|
|||||||
res.json(rows);
|
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) => {
|
router.get('/:id', authenticate, async (req, res) => {
|
||||||
await autoCloseExpired();
|
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
|
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
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ const App = {
|
|||||||
|
|
||||||
if (page === 'install') { this.showPublic('install'); return; }
|
if (page === 'install') { this.showPublic('install'); return; }
|
||||||
if (page === 'home') { this.showPublic('home', param); 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; }
|
if (page === 'login' || page === 'register' || page === 'forgot' || page === 'reset') { this.showPublic(page); return; }
|
||||||
|
|
||||||
// 受保护页面: Auth.logged() 内部解码 JWT exp,过期自动清除登录态(同步,无异步循环)
|
// 受保护页面: Auth.logged() 内部解码 JWT exp,过期自动清除登录态(同步,无异步循环)
|
||||||
@@ -258,7 +259,7 @@ const App = {
|
|||||||
document.getElementById('top-bar').classList.remove('hidden');
|
document.getElementById('top-bar').classList.remove('hidden');
|
||||||
this.updateTopAuth();
|
this.updateTopAuth();
|
||||||
this.loadFooter();
|
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(() => {
|
comp.render(param).then(html => { pub.innerHTML = html; if (comp.mount) comp.mount(param); }).catch(() => {
|
||||||
pub.innerHTML = '<div class="pub-card"><div class="logo"><i class="fas fa-exclamation-triangle" style="color:var(--d)"></i><h2>加载失败</h2><p>请刷新页面重试</p></div></div>';
|
pub.innerHTML = '<div class="pub-card"><div class="logo"><i class="fas fa-exclamation-triangle" style="color:var(--d)"></i><h2>加载失败</h2><p>请刷新页面重试</p></div></div>';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ const HomePage = {
|
|||||||
|
|
||||||
mount(tab) {
|
mount(tab) {
|
||||||
const t = tab || 'report';
|
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); });
|
document.querySelectorAll('#home-content .tab-nav button').forEach(b => { b.onclick = () => App.navigate('home', b.dataset.tab); });
|
||||||
const ct = document.getElementById('home-tab');
|
const ct = document.getElementById('home-tab');
|
||||||
if (t === 'tracking') this.renderTracking(ct);
|
TicketCreate.render(ct, { type: t, prefill: {}, backLink: null, hideTabs: true });
|
||||||
else TicketCreate.render(ct, { type: t, prefill: {}, backLink: null, hideTabs: true });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
renderTracking(ct) {
|
renderTracking(ct) {
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ const TicketCreate = {
|
|||||||
}
|
}
|
||||||
alert('提交成功!工单编号: #' + res.id);
|
alert('提交成功!工单编号: #' + res.id);
|
||||||
if (Auth.logged()) App.navigate('tickets');
|
if (Auth.logged()) App.navigate('tickets');
|
||||||
else App.navigate('home');
|
else App.navigate('tracking'); // 匿名: 直达追踪页查看工单与回复
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
alert(ex.message);
|
alert(ex.message);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|||||||
@@ -17,41 +17,127 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// 匿名工单追踪: 公开页(无需登录)
|
||||||
|
// 通过 cookie 中的 tracking_token 识别提交者, 可查看工单列表与单工单完整对话/处理日志, 并匿名补充回复
|
||||||
const TrackingPage = {
|
const TrackingPage = {
|
||||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
token: '',
|
||||||
|
|
||||||
mount() {
|
async render(param) {
|
||||||
const ct = document.getElementById('page-content');
|
this.token = document.cookie.split('; ').find(r => r.startsWith('tracking_token='))?.split('=')[1] || '';
|
||||||
document.getElementById('page-title').textContent = '追踪工单';
|
const directId = param && !param.startsWith('?') ? param : '';
|
||||||
document.getElementById('page-actions').innerHTML = '';
|
return `
|
||||||
|
<div class="pub-card" style="max-width:1000px">
|
||||||
const token = document.cookie.split('; ').find(r => r.startsWith('tracking_token='))?.split('=')[1] || '';
|
<div class="logo"><i class="fas fa-shield-halved"></i><h2>工单追踪</h2></div>
|
||||||
|
<p class="ts tm" style="text-align:center;margin-bottom:16px">使用提交工单时下发的追踪标识, 查看工单进展与双方回复。如未找到工单, 请确认使用同一浏览器提交。</p>
|
||||||
ct.innerHTML = `
|
<div class="form-group" style="display:flex;gap:8px">
|
||||||
<div class="card"><div class="card-b">
|
<input id="track-token" value="${U.esc(this.token)}" placeholder="追踪标识(留空自动使用Cookie)" style="flex:1">
|
||||||
<div class="form-group"><label>追踪标识 (从Cookie自动读取)</label><input id="track-token" value="${token}"></div>
|
|
||||||
<button class="btn btn-p" onclick="TrackingPage.search()"><i class="fas fa-search"></i> 查询</button>
|
<button class="btn btn-p" onclick="TrackingPage.search()"><i class="fas fa-search"></i> 查询</button>
|
||||||
|
</div>
|
||||||
<div id="track-result" class="mt-4"></div>
|
<div id="track-result" class="mt-4"></div>
|
||||||
</div></div>
|
</div>`;
|
||||||
`;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mount(param) {
|
||||||
|
const directId = param && !param.startsWith('?') ? param : '';
|
||||||
|
if (directId) { this.showDetail(directId); }
|
||||||
|
else if (this.token) { this.search(); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- 列表 ----
|
||||||
async 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('请输入追踪标识');
|
if (!token) return alert('请输入追踪标识');
|
||||||
|
this.token = token;
|
||||||
const ct = document.getElementById('track-result');
|
const ct = document.getElementById('track-result');
|
||||||
U.loading(ct);
|
U.loading(ct);
|
||||||
try {
|
try {
|
||||||
const tickets = await API.get(`/tickets/tracking?token=${token}`);
|
const tickets = await API.get(`/tickets/tracking?token=${encodeURIComponent(token)}`);
|
||||||
ct.innerHTML = tickets.length ? `
|
ct.innerHTML = tickets.length ? `
|
||||||
<div class="table-wrap"><table><thead><tr><th>#</th><th>类型</th><th>标题</th><th>状态</th><th>时间</th></tr></thead><tbody>${tickets.map(t => `
|
<div class="card"><div class="card-h"><span>我的工单 (${tickets.length})</span><span class="ts tm">点击查看完整对话与处理进度</span></div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>#</th><th>类型</th><th>标题</th><th>状态</th><th>提交时间</th><th>操作</th></tr></thead><tbody>${tickets.map(t => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${t.id}</td><td>${U.badge(t.type,'type')}</td>
|
<td>${t.id}</td><td>${U.badge(t.type,'type')}</td>
|
||||||
<td><strong>${U.esc(t.title)}</strong></td>
|
<td><strong>${U.esc(t.title)}</strong></td>
|
||||||
<td>${U.badge(t.status,'status')}</td><td class="ts">${U.date(t.created_at)}</td>
|
<td>${U.badge(t.status,'status')}</td><td class="ts">${U.date(t.created_at)}</td>
|
||||||
</tr>`).join('')}</tbody></table></div>
|
<td><button class="btn btn-p btn-sm" onclick="TrackingPage.showDetail(${t.id})"><i class="fas fa-eye"></i> 查看</button></td>
|
||||||
<p class="tm ts mt-4">如需获取更详细的跟踪和回复通知,请<a href="#/register" style="color:var(--p)">注册账号</a></p>
|
</tr>`).join('')}</tbody></table></div></div>
|
||||||
|
<p class="tm ts mt-4" style="text-align:center">如需邮件通知与完整功能, 请<a href="#/register" style="color:var(--p)">注册账号</a></p>
|
||||||
` : '<div class="empty"><i class="fas fa-inbox"></i><p>未找到工单</p></div>';
|
` : '<div class="empty"><i class="fas fa-inbox"></i><p>未找到工单</p></div>';
|
||||||
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- 单工单详情(对话流 + 处理日志 + 匿名回复) ----
|
||||||
|
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 = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="detail-grid"><div>
|
||||||
|
<div class="card"><div class="card-h"><span><i class="fas fa-comments"></i> 对话记录 (${chat.length})</span>${U.badge(t.status,'status')} ${U.badge(t.type,'type')} ${U.badge(t.priority,'priority')}</div><div class="card-b">
|
||||||
|
${chat.length ? `<div class="chat-flow">${chat.map(r => `
|
||||||
|
<div class="chat-row ${r.is_staff ? 'staff' : 'user'}">
|
||||||
|
<div class="chat-bubble">
|
||||||
|
<div class="chat-meta"><span class="chat-name">${U.esc(r.username||(r.is_staff?'客服':'我'))}</span><span class="ts tm">${U.date(r.created_at)}</span></div>
|
||||||
|
<div class="chat-text" style="white-space:pre-wrap">${U.esc(r.content)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`).join('')}</div>` : '<p class="tm">暂无对话记录</p>'}
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card"><div class="card-h">补充信息</div><div class="card-b">
|
||||||
|
<form id="trk-reply-form"><div class="form-group"><textarea id="trk-reply-content" rows="3" placeholder="补充说明或回复..."></textarea></div><button type="submit" class="btn btn-p btn-sm"><i class="fas fa-reply"></i> 发送</button></form>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="card"><div class="card-h">基本信息</div><div class="card-b">
|
||||||
|
<div class="detail-row"><span class="lbl">标题</span><span class="val fb">${U.esc(t.title)}</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">提交者</span><span class="val">${U.esc(t.reporter_game_name)}${t.reporter_game_uid?' (UID: '+U.esc(t.reporter_game_uid)+')':''}</span></div>
|
||||||
|
${isRep?`<div class="detail-row"><span class="lbl">被举报人</span><span class="val">${U.esc(t.target_game_name||'?')} (UID: ${U.esc(t.target_game_uid||'?')})</span></div>`:''}
|
||||||
|
${t.reason?`<div class="detail-row"><span class="lbl">${isRep?'原因':isAppeal?'申诉理由':'说明'}</span><span class="val">${U.esc(t.reason)}</span></div>`:''}
|
||||||
|
${t.description?`<div class="detail-row"><span class="lbl">${isRep?'详情':isAppeal?'详细说明':'建议内容'}</span><span class="val" style="white-space:pre-wrap">${U.esc(t.description)}</span></div>`:''}
|
||||||
|
<div class="detail-row"><span class="lbl">提交时间</span><span class="val">${U.date(t.created_at)}</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>`:''}
|
||||||
|
|
||||||
|
<div class="card"><div class="card-h"><i class="fas fa-history"></i> 处理日志 (${logItems.length})</div><div class="card-b">
|
||||||
|
${logItems.length ? `<div class="timeline">${logItems.map(li => `<div class="timeline-item"><div class="th"><span class="tn">${U.esc(li.who||'系统')}</span><span>${U.date(li.time)}</span></div><div class="tc">${U.esc(li.text)}</div></div>`).join('')}</div>` : '<p class="tm">暂无处理记录</p>'}
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card"><div class="card-h">处理信息</div><div class="card-b">
|
||||||
|
<div class="detail-row"><span class="lbl">负责人</span><span class="val">${t.assignee_name?`<span class="badge bg-admin">${U.esc(t.assignee_name)}</span>`:'<span class="tm">待认领</span>'}</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">更新时间</span><span class="val">${U.date(t.updated_at)}</span></div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="mt-2"><button class="btn btn-o btn-sm" onclick="TrackingPage.search()"><i class="fas fa-arrow-left"></i> 返回列表</button></div>
|
||||||
|
</div></div>`;
|
||||||
|
|
||||||
|
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); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user