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:
@@ -259,6 +259,19 @@ tbody tr:last-child td{border-bottom:none}
|
||||
font-size:13.5px;line-height:1.7;white-space:pre-wrap;border:1px solid var(--g2);
|
||||
}
|
||||
.timeline-item.staff .tc{background:var(--pl);border-color:rgba(99,102,241,.2)}
|
||||
/* 对话流(工单详情单页) */
|
||||
.chat-flow{display:flex;flex-direction:column;gap:12px;max-height:520px;overflow-y:auto;padding:4px 2px}
|
||||
.chat-row{display:flex}
|
||||
.chat-row.user{justify-content:flex-start}
|
||||
.chat-row.staff{justify-content:flex-end}
|
||||
.chat-bubble{max-width:78%;padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.6}
|
||||
.chat-row.user .chat-bubble{background:var(--g1);border:1px solid var(--g2);border-top-left-radius:2px}
|
||||
.chat-row.staff .chat-bubble{background:var(--pl);border:1px solid rgba(99,102,241,.25);border-top-right-radius:2px;color:var(--g8)}
|
||||
.chat-meta{display:flex;align-items:center;gap:8px;margin-bottom:3px;font-size:11px}
|
||||
.chat-row.user .chat-meta{flex-direction:row}
|
||||
.chat-row.staff .chat-meta{flex-direction:row-reverse}
|
||||
.chat-name{font-weight:600}
|
||||
.chat-text{word-break:break-word}
|
||||
|
||||
/* ===== Empty / Loading / Alert ===== */
|
||||
.empty{text-align:center;padding:60px 24px;color:var(--g5)}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
const App = {
|
||||
navItems: [
|
||||
{ id: 'dashboard', label: '控制台', icon: 'fa-chart-simple', roles: ['owner','admin','player'] },
|
||||
{ id: 'tickets', label: '工单列表', icon: 'fa-list', roles: ['owner','admin'] },
|
||||
{ id: 'tickets-create', label: '提交工单', icon: 'fa-plus-circle', roles: ['player'] },
|
||||
{ id: 'bans', label: '封禁列表', icon: 'fa-ban', roles: ['owner','admin','player'] },
|
||||
{ id: 'unclaimed', label: '待处理工单', icon: 'fa-inbox', roles: ['owner','admin'] },
|
||||
@@ -163,11 +164,13 @@ const App = {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = () => {
|
||||
// 页面脚本顶层 const 在全局词法环境, 用间接 eval 挂到 window,
|
||||
// 供 inline onclick / data-action 的 window[obj] 查找
|
||||
// 页面脚本顶层 const 在全局词法环境, 用内联 script 挂到 window
|
||||
// (不能用 eval: CSP scriptSrc 无 'unsafe-eval', (0,eval) 会被拦截导致 data-action 按钮失效)
|
||||
const objs = this.PAGE_OBJS[src.split('/').pop()] || [];
|
||||
for (const o of objs) {
|
||||
try { if (typeof window[o] === 'undefined') window[o] = (0, eval)(o); } catch {}
|
||||
if (objs.length) {
|
||||
const inline = document.createElement('script');
|
||||
inline.textContent = objs.map(o => `window.${o} = ${o};`).join('\n');
|
||||
document.head.appendChild(inline);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ const NotificationsPage = {
|
||||
<option value="email">邮件通知</option>
|
||||
<option value="generic">通用Webhook(企微/QQ/飞书)</option>
|
||||
</select></div>
|
||||
<div class="form-group" id="nc-url-group"><label>Webhook URL</label><input id="nc-url" placeholder="https://..."></div>
|
||||
<div class="form-group" id="nc-url-group"><label id="nc-url-label">Webhook URL</label><input id="nc-url" placeholder="https://..."></div>
|
||||
<div class="form-group"><label>触发事件</label>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">${EventOptions('all')}</div>
|
||||
<input type="hidden" id="nc-events" value="all">
|
||||
@@ -68,12 +68,25 @@ const NotificationsPage = {
|
||||
const type = document.getElementById('nc-type').value;
|
||||
try { await API.post('/notifications',{name:document.getElementById('nc-name').value, type, webhook_url:document.getElementById('nc-url').value, events:document.getElementById('nc-events').value}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
|
||||
};
|
||||
this.toggleFields();
|
||||
},
|
||||
|
||||
toggleFields() {
|
||||
const type = document.getElementById('nc-type')?.value || document.getElementById('ne-type')?.value;
|
||||
const urlGroup = document.querySelector('#nc-url-group') || document.querySelector('#ne-url-group');
|
||||
if (urlGroup) urlGroup.style.display = type === 'email' ? 'none' : '';
|
||||
const urlLabel = document.querySelector('#nc-url-label') || document.querySelector('#ne-url-label');
|
||||
if (!urlGroup) return;
|
||||
if (type === 'email') {
|
||||
urlGroup.style.display = '';
|
||||
urlLabel.textContent = '收件人邮箱 (逗号分隔)';
|
||||
const inp = urlGroup.querySelector('input');
|
||||
if (inp) { inp.placeholder = 'admin@example.com, staff@example.com'; inp.type = 'email'; }
|
||||
} else {
|
||||
urlGroup.style.display = '';
|
||||
urlLabel.textContent = 'Webhook URL';
|
||||
const inp = urlGroup.querySelector('input');
|
||||
if (inp) { inp.placeholder = 'https://...'; inp.type = 'text'; }
|
||||
}
|
||||
},
|
||||
|
||||
showEdit(id, name, type, url, events, active) {
|
||||
@@ -84,7 +97,7 @@ const NotificationsPage = {
|
||||
<option value="email" ${type==='email'?'selected':''}>邮件通知</option>
|
||||
<option value="generic" ${type==='generic'?'selected':''}>通用Webhook</option>
|
||||
</select></div>
|
||||
<div class="form-group" id="ne-url-group" style="${type==='email'?'display:none':''}"><label>Webhook URL</label><input id="ne-url" value="${url}"></div>
|
||||
<div class="form-group" id="ne-url-group" style="${type==='email'?'display:none':''}"><label id="ne-url-label">Webhook URL</label><input id="ne-url" value="${url}"></div>
|
||||
<div class="form-group"><label>触发事件</label>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">${EventOptions(events)}</div>
|
||||
<input type="hidden" id="ne-events" value="${events}">
|
||||
@@ -93,6 +106,7 @@ const NotificationsPage = {
|
||||
<div class="modal-f"><button type="button" class="btn btn-o" data-action="App:closeModal">取消</button><button type="submit" class="btn btn-p">保存</button></div></form>
|
||||
`);
|
||||
bindEventToggles();
|
||||
this.toggleFields();
|
||||
document.getElementById('ne-form').onsubmit = async e => { e.preventDefault();
|
||||
const newType = document.getElementById('ne-type').value;
|
||||
try { await API.put(`/notifications/${id}`,{name:document.getElementById('ne-name').value, webhook_url:document.getElementById('ne-url')?.value, events:document.getElementById('ne-events').value, active:document.getElementById('ne-active').value==='1', type:newType}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
|
||||
|
||||
@@ -29,8 +29,8 @@ const SourcesPage = {
|
||||
async load() {
|
||||
const ct = document.getElementById('page-content');
|
||||
try {
|
||||
// 管理视图: 含停用项
|
||||
const rows = await API.get('/sources');
|
||||
// 管理视图: 全量含 enabled(可显示/切换启用停用)
|
||||
const rows = await API.get('/sources?manage=1');
|
||||
ct.innerHTML = rows.length === 0
|
||||
? '<div class="empty"><i class="fas fa-tag"></i><p>暂无来源</p></div>'
|
||||
: `<div class="card table-wrap"><table><thead><tr><th>code</th><th>名称</th><th>排序</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows.map(s => `
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user