fix: data-action buttons, sources enabled, email notify, ticket detail single-page

- app.js: mount page objects via inline script (CSP blocks eval → data-action buttons dead)
- app.js: add tickets list entry to sidebar nav (admin could not find claimed tickets)
- sources: GET /sources?manage=1 returns enabled field for admin view
- mailer: sendNotifyEmail consumes email-type notification_configs (webhook_url = recipients)
- tickets: trigger notify on created/claimed/transferred/updated/status-change
- notifications: email type shows recipient field, validates email list
- ticket-detail: single-page chat flow (player/staff bubbles) + processing log timeline
- css: chat bubble styles
This commit is contained in:
2026-08-22 20:00:12 +08:00
parent e5a1d693f1
commit c5ceba7cf0
9 changed files with 120 additions and 37 deletions

View File

@@ -38,21 +38,44 @@ const TicketDetail = {
const canTransfer = isStaff && t.assigned_to === Auth.user().id && t.status === 'processing';
const isMyTicket = t.assigned_to === Auth.user().id;
// 对话流 vs 处理日志: 普通回复进对话, 系统动作/状态变更/认领/转交进日志
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, kind: 'system', text: r.content, who: r.username||'系统' })),
...(t.transfers||[]).map(tf => ({ time: tf.created_at, kind: 'transfer', text: `转交: ${tf.from_game_name||tf.from_username}${tf.to_game_name||tf.to_username}${tf.reason?' ('+tf.reason+')':''}`, who: tf.from_username||'' })),
].sort((a, b) => new Date(a.time) - new Date(b.time));
if (t.claimed_at) logItems.push({ time: t.claimed_at, kind: 'claim', 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>基本信息</span><div>${U.badge(t.status,'status')} ${U.badge(t.type,'type')} ${U.badge(t.priority,'priority')}</div></div><div class="card-b">
<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">${isStaff?'回复 (公开)':'补充信息'}</div><div class="card-b">
<form id="reply-form"><div class="form-group"><textarea id="reply-content" rows="3" placeholder="${isStaff?'输入回复内容...':'补充说明...'}" required></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"><span>基本信息</span></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)}</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>
<div class="detail-row"><span class="lbl">原因</span><span class="val">${U.esc(t.reason)}</span></div>`:''}
${isAppeal?`<div class="detail-row"><span class="lbl">申诉理由</span><span class="val">${U.esc(t.reason)}</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 class="detail-row"><span class="lbl">更新时间</span><span class="val">${U.date(t.updated_at)}</span></div>
</div></div>
${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>`:''}
@@ -61,16 +84,12 @@ const TicketDetail = {
${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">沟通记录</div><div class="card-b">
${t.responses?.length?`<div class="timeline">${t.responses.map(r=>`<div class="timeline-item ${r.is_staff?'staff':''}"><div class="th"><span class="tn">${U.esc(r.username||'系统')}</span>${r.role?`<span class="badge bg-${r.role}">${U.ROLE[r.role]}</span>`:''}<span>${U.date(r.created_at)}</span></div><div class="tc">${U.esc(r.content)}</div></div>`).join('')}</div>`:'<p class="tm">暂无记录</p>'}
<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">${isStaff?'回复 (公开)':'补充信息'}</div><div class="card-b">
<form id="reply-form"><div class="form-group"><textarea id="reply-content" rows="3" placeholder="${isStaff?'输入回复内容...':'补充说明...'}" required></textarea></div><button type="submit" class="btn btn-p btn-sm"><i class="fas fa-reply"></i> 发送</button></form>
</div></div>
</div>
${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>`:''}
<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.submitter?`${U.esc(t.submitter)} (${U.esc(t.user_game_name||'')})`:'匿名'}</span></div>
<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>
@@ -91,11 +110,6 @@ const TicketDetail = {
<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>
</div></div>`:''}
</div></div>`;
document.getElementById('reply-form')?.addEventListener('submit', async e => {