feat: MC Report System - MySQL + Express + Vanilla JS SPA
This commit is contained in:
45
public/js/api.js
Normal file
45
public/js/api.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const API = {
|
||||
base: '/api',
|
||||
_key: window.__API_KEY__ || '',
|
||||
|
||||
async req(method, path, data) {
|
||||
const token = Auth.token();
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
if (this._key) headers['x-api-key'] = this._key;
|
||||
const opts = { method, headers };
|
||||
if (data instanceof FormData) opts.body = data;
|
||||
else if (data && method !== 'GET') { headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(data); }
|
||||
opts.headers = headers;
|
||||
const res = await fetch(this.base + path, opts);
|
||||
const ct = res.headers.get('content-type');
|
||||
if (ct && ct.includes('application/json')) {
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || '请求失败');
|
||||
return json;
|
||||
}
|
||||
if (!res.ok) throw new Error('请求失败');
|
||||
return res;
|
||||
},
|
||||
|
||||
get(p) { return this.req('GET', p); },
|
||||
post(p, d) { return this.req('POST', p, d); },
|
||||
put(p, d) { return this.req('PUT', p, d); },
|
||||
|
||||
async download(path) {
|
||||
const token = Auth.token();
|
||||
const h = {};
|
||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||
if (this._key) h['x-api-key'] = this._key;
|
||||
const res = await fetch(this.base + path, { headers: h });
|
||||
if (!res.ok) throw new Error('下载失败');
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const disp = res.headers.get('content-disposition');
|
||||
a.download = disp?.match(/filename=(.+)/)?.[1] || 'export.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
106
public/js/app.js
Normal file
106
public/js/app.js
Normal file
@@ -0,0 +1,106 @@
|
||||
const App = {
|
||||
navItems: [
|
||||
{ id: 'dashboard', label: '控制台', icon: 'fa-chart-simple', roles: ['owner','admin','player'] },
|
||||
{ id: 'unclaimed', label: '待处理工单', icon: 'fa-inbox', roles: ['owner','admin'] },
|
||||
{ id: 'tickets', label: '工单列表', icon: 'fa-ticket-alt', roles: ['owner','admin','player'] },
|
||||
{ id: 'tracking', label: '追踪工单', icon: 'fa-search', roles: ['owner','admin','player'] },
|
||||
{ id: 'users', label: '用户管理', icon: 'fa-users', roles: ['owner','admin'] },
|
||||
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner','admin'] },
|
||||
{ id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner','admin'] },
|
||||
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner','admin'] },
|
||||
{ id: 'export', label: '数据导出', icon: 'fa-download', roles: ['owner'] },
|
||||
],
|
||||
|
||||
init() {
|
||||
window.addEventListener('hashchange', () => this.route());
|
||||
this.route();
|
||||
},
|
||||
|
||||
afterLogin() { location.hash = '#/dashboard'; },
|
||||
logout() { Auth.logout(); },
|
||||
closeModal() { document.getElementById('modal-overlay').classList.add('hidden'); },
|
||||
navigate(page, param) { location.hash = `#/${page}` + (param ? `/${param}` : ''); },
|
||||
|
||||
async route() {
|
||||
const hash = location.hash || '#/home';
|
||||
const parts = hash.replace('#/', '').split('/');
|
||||
const page = parts[0];
|
||||
const param = parts.slice(1).join('/');
|
||||
|
||||
if (page !== 'install') {
|
||||
try {
|
||||
const s = await API.get('/install/status');
|
||||
if (!s.installed) { location.hash = '#/install'; return; }
|
||||
} catch { location.hash = '#/install'; return; }
|
||||
}
|
||||
|
||||
if (page === 'verify') {
|
||||
const params = new URLSearchParams((location.hash.split('?')[1] || ''));
|
||||
const token = params.get('token');
|
||||
if (token) {
|
||||
API.get(`/auth/verify-email?token=${token}`).then(r => { alert(r.message || '验证成功'); location.hash = '#/login'; }).catch(e => { alert(e.message); location.hash = '#/home'; });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (page === 'install') { this.showPublic('install'); return; }
|
||||
if (page === 'home' || page === 'login' || page === 'register') { this.showPublic(page); return; }
|
||||
if (!Auth.logged()) { location.hash = '#/login'; return; }
|
||||
|
||||
this.showLayout();
|
||||
this.renderSidebar(page);
|
||||
|
||||
switch (page) {
|
||||
case 'dashboard': this.renderMain('控制台', Dashboard, param); break;
|
||||
case 'unclaimed': this.renderMain('待处理工单', UnclaimedPage, param); break;
|
||||
case 'tickets':
|
||||
if (param === 'create') {
|
||||
const q = new URLSearchParams((location.hash.split('?')[1] || ''));
|
||||
document.getElementById('page-title').textContent = '创建工单';
|
||||
document.getElementById('page-actions').innerHTML = '<button class="btn btn-o btn-sm" onclick="App.navigate(\'tickets\')"><i class="fas fa-arrow-left"></i> 返回</button>';
|
||||
TicketCreate.render(document.getElementById('page-content'), { type: q.get('type') || 'report', prefill: { game_name: Auth.user()?.game_name, game_uid: Auth.user()?.game_uid }, backLink: "App.navigate('tickets')" });
|
||||
} else this.renderMain('工单列表', TicketsPage, param);
|
||||
break;
|
||||
case 'ticket': this.renderMain('工单详情', TicketDetail, param); break;
|
||||
case 'tracking': this.renderMain('追踪工单', TrackingPage, param); break;
|
||||
case 'users': this.renderMain('用户管理', UsersPage, param); break;
|
||||
case 'notifications': this.renderMain('通知配置', NotificationsPage, param); break;
|
||||
case 'templates': this.renderMain('邮件模板', TemplatesPage, param); break;
|
||||
case 'settings': this.renderMain('系统设置', SettingsPage, param); break;
|
||||
case 'export': this.renderMain('数据导出', ExportPage, param); break;
|
||||
default: this.renderMain('控制台', Dashboard, param);
|
||||
}
|
||||
},
|
||||
|
||||
showPublic(page) {
|
||||
document.getElementById('main-layout').classList.add('hidden');
|
||||
const pub = document.getElementById('public-page');
|
||||
pub.classList.remove('hidden');
|
||||
const comp = page === 'login' ? LoginPage : page === 'register' ? RegisterPage : page === 'install' ? InstallPage : HomePage;
|
||||
comp.render().then(html => { pub.innerHTML = html; if (comp.mount) comp.mount(); });
|
||||
},
|
||||
|
||||
showLayout() {
|
||||
document.getElementById('public-page').classList.add('hidden');
|
||||
document.getElementById('main-layout').classList.remove('hidden');
|
||||
const u = Auth.user();
|
||||
document.getElementById('user-info').innerHTML = `<div class="av">${(u.game_name||u.username).charAt(0).toUpperCase()}</div><div class="dt"><div class="nm">${u.game_name||u.username}</div><div class="rl">${U.ROLE[u.role]}</div></div>`;
|
||||
},
|
||||
|
||||
renderSidebar(current) {
|
||||
const u = Auth.user();
|
||||
document.getElementById('sidebar-nav').innerHTML = this.navItems
|
||||
.filter(i => i.roles.includes(u.role))
|
||||
.map(i => `<div class="nav-item ${current===i.id?'active':''}" onclick="App.navigate('${i.id}')"><i class="fas ${i.icon}"></i><span>${i.label}</span></div>`).join('');
|
||||
},
|
||||
|
||||
async renderMain(title, page, param) {
|
||||
document.getElementById('page-title').textContent = title;
|
||||
document.getElementById('page-actions').innerHTML = '';
|
||||
const ct = document.getElementById('page-content');
|
||||
ct.innerHTML = await page.render(param);
|
||||
if (page.mount) await page.mount(param);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => App.init());
|
||||
30
public/js/auth.js
Normal file
30
public/js/auth.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const Auth = {
|
||||
TK: 'mc_token',
|
||||
US: 'mc_user',
|
||||
|
||||
token() { return localStorage.getItem(this.TK); },
|
||||
user() { try { return JSON.parse(localStorage.getItem(this.US)); } catch { return null; } },
|
||||
logged() { return !!this.token(); },
|
||||
|
||||
async login(username, password) {
|
||||
const d = await API.post('/auth/login', { username, password });
|
||||
localStorage.setItem(this.TK, d.token);
|
||||
localStorage.setItem(this.US, JSON.stringify(d.user));
|
||||
return d.user;
|
||||
},
|
||||
|
||||
async register(data) {
|
||||
return API.post('/auth/register', data);
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(this.TK);
|
||||
localStorage.removeItem(this.US);
|
||||
location.hash = '#/login';
|
||||
},
|
||||
|
||||
role(...r) { const u = this.user(); return u && r.includes(u.role); },
|
||||
isStaff() { return this.role('owner', 'admin'); },
|
||||
isAdmin() { return this.role('owner', 'admin'); },
|
||||
isOwner() { return this.role('owner'); },
|
||||
};
|
||||
322
public/js/pages/complaint-detail.js
Normal file
322
public/js/pages/complaint-detail.js
Normal file
@@ -0,0 +1,322 @@
|
||||
const ComplaintDetailPage = {
|
||||
complaintId: null,
|
||||
|
||||
async render(id) {
|
||||
this.complaintId = id;
|
||||
return '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||
},
|
||||
|
||||
async mount() {
|
||||
const container = document.getElementById('page-content');
|
||||
const backBtn = '<button class="btn btn-outline btn-sm" onclick="App.navigate(\'complaints\')"><i class="fas fa-arrow-left"></i> 返回</button>';
|
||||
document.getElementById('page-title').textContent = `投诉 #${this.complaintId}`;
|
||||
document.getElementById('page-actions').innerHTML = backBtn;
|
||||
|
||||
try {
|
||||
const complaint = await API.get(`/complaints/${this.complaintId}`);
|
||||
this.renderDetail(container, complaint);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="alert alert-danger">加载失败: ${err.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderDetail(container, c) {
|
||||
const user = Auth.getUser();
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="detail-grid">
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span>基本信息</span>
|
||||
<div>${Utils.statusBadge(c.status)} ${Utils.priorityBadge(c.priority)}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="detail-row"><span class="label">标题</span><span class="value font-bold">${Utils.escapeHtml(c.title)}</span></div>
|
||||
<div class="detail-row"><span class="label">描述</span><span class="value">${Utils.escapeHtml(c.description)}</span></div>
|
||||
<div class="detail-row"><span class="label">分类</span><span class="value">${Utils.categoryBadge(c.category)}</span></div>
|
||||
<div class="detail-row"><span class="label">玩家</span><span class="value">${Utils.escapeHtml(c.player_name)}</span></div>
|
||||
${c.player_email ? `<div class="detail-row"><span class="label">邮箱</span><span class="value">${Utils.escapeHtml(c.player_email)}</span></div>` : ''}
|
||||
${c.player_contact ? `<div class="detail-row"><span class="label">联系方式</span><span class="value">${Utils.escapeHtml(c.player_contact)}</span></div>` : ''}
|
||||
<div class="detail-row"><span class="label">创建时间</span><span class="value">${Utils.formatDate(c.created_at)}</span></div>
|
||||
<div class="detail-row"><span class="label">更新时间</span><span class="value">${Utils.formatDate(c.updated_at)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${c.plan ? this.renderPlanSection(c) : ''}
|
||||
${c.implementation ? this.renderImplementationSection(c) : ''}
|
||||
${c.review ? this.renderReviewSection(c) : ''}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">沟通记录</div>
|
||||
<div class="card-body">
|
||||
${c.responses && c.responses.length > 0 ? `
|
||||
<div class="timeline">
|
||||
${c.responses.map(r => `
|
||||
<div class="timeline-item ${r.response_type === 'system' ? 'system' : ''}">
|
||||
<div class="timeline-header">
|
||||
<span class="name">${Utils.escapeHtml(r.user_name) || '系统'}</span>
|
||||
${r.user_role ? `<span class="badge badge-${r.user_role}">${Utils.roleLabels[r.user_role]}</span>` : ''}
|
||||
<span>${Utils.formatDate(r.created_at)}</span>
|
||||
</div>
|
||||
<div class="timeline-content">${Utils.escapeHtml(r.content)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '<p class="text-muted">暂无记录</p>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">添加回复</div>
|
||||
<div class="card-body">
|
||||
<form id="add-response-form">
|
||||
<div class="form-group">
|
||||
<textarea id="response-content" rows="3" placeholder="输入回复内容..." required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="fas fa-reply"></i> 发送</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-header">人员分配</div>
|
||||
<div class="card-body">
|
||||
<div class="detail-row"><span class="label">规划者</span><span class="value">${c.planner_name || '<span class="text-muted">未分配</span>'}</span></div>
|
||||
<div class="detail-row"><span class="label">实施者</span><span class="value">${c.implementer_name || '<span class="text-muted">未分配</span>'}</span></div>
|
||||
<div class="detail-row"><span class="label">审查者</span><span class="value">${c.reviewer_name || '<span class="text-muted">未分配</span>'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">操作</div>
|
||||
<div class="card-body" style="display:flex;flex-direction:column;gap:8px">
|
||||
${user && (user.role === 'planner' || user.role === 'admin') && !c.plan ? `
|
||||
<button class="btn btn-primary btn-sm w-full" onclick="ComplaintDetailPage.createPlan(${c.id})">
|
||||
<i class="fas fa-clipboard-list"></i> 创建处理计划
|
||||
</button>
|
||||
` : ''}
|
||||
${user && (user.role === 'planner' || user.role === 'admin') && c.plan && c.plan.status === 'draft' ? `
|
||||
<button class="btn btn-success btn-sm w-full" onclick="ComplaintDetailPage.assignImplementer(${c.id}, ${c.plan.id})">
|
||||
<i class="fas fa-user-tag"></i> 分配实施者
|
||||
</button>
|
||||
` : ''}
|
||||
${user && (user.role === 'implementer' || user.role === 'admin') && c.plan && c.implementer_name === user.display_name && !c.implementation ? `
|
||||
<button class="btn btn-info btn-sm w-full" onclick="ComplaintDetailPage.startImplementation(${c.id}, ${c.plan.id})">
|
||||
<i class="fas fa-code"></i> 开始实施
|
||||
</button>
|
||||
` : ''}
|
||||
${user && (user.role === 'reviewer' || user.role === 'admin') && c.implementation && c.implementation.status === 'completed' && !c.review ? `
|
||||
<button class="btn btn-warning btn-sm w-full" onclick="ComplaintDetailPage.startReview(${c.id}, ${c.implementation.id})">
|
||||
<i class="fas fa-search"></i> 开始审查
|
||||
</button>
|
||||
` : ''}
|
||||
${user && (user.role === 'admin') ? `
|
||||
<button class="btn btn-outline btn-sm w-full" onclick="ComplaintDetailPage.showUpdateStatusModal(${c.id})">
|
||||
<i class="fas fa-edit"></i> 修改状态
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('add-response-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const content = document.getElementById('response-content').value.trim();
|
||||
if (!content) return;
|
||||
try {
|
||||
await API.post(`/complaints/${c.id}/response`, { content });
|
||||
document.getElementById('response-content').value = '';
|
||||
this.mount();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
renderPlanSection(c) {
|
||||
const p = c.plan;
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span>处理计划</span>
|
||||
<span>${Utils.statusBadge(p.status)}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="detail-row"><span class="label">标题</span><span class="value font-bold">${Utils.escapeHtml(p.title)}</span></div>
|
||||
<div class="detail-row"><span class="label">描述</span><span class="value">${Utils.escapeHtml(p.description)}</span></div>
|
||||
${p.technical_approach ? `<div class="detail-row"><span class="label">技术方案</span><span class="value" style="white-space:pre-wrap">${Utils.escapeHtml(p.technical_approach)}</span></div>` : ''}
|
||||
${p.expected_effort ? `<div class="detail-row"><span class="label">预计工时</span><span class="value">${p.expected_effort}</span></div>` : ''}
|
||||
${p.reject_reason ? `<div class="detail-row"><span class="label">驳回原因</span><span class="value" style="color:var(--danger)">${Utils.escapeHtml(p.reject_reason)}</span></div>` : ''}
|
||||
<div class="detail-row"><span class="label">规划者</span><span class="value">${p.planner_name || '-'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
renderImplementationSection(c) {
|
||||
const i = c.implementation;
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span>实施情况</span>
|
||||
<span>${Utils.statusBadge(i.status)}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${i.code_reference ? `<div class="detail-row"><span class="label">代码参考</span><span class="value" style="white-space:pre-wrap">${Utils.escapeHtml(i.code_reference)}</span></div>` : ''}
|
||||
${i.implementation_notes ? `<div class="detail-row"><span class="label">实施说明</span><span class="value" style="white-space:pre-wrap">${Utils.escapeHtml(i.implementation_notes)}</span></div>` : ''}
|
||||
${i.reject_reason ? `<div class="detail-row"><span class="label">驳回原因</span><span class="value" style="color:var(--danger)">${Utils.escapeHtml(i.reject_reason)}</span></div>` : ''}
|
||||
<div class="detail-row"><span class="label">实施者</span><span class="value">${i.implementer_name || '-'}</span></div>
|
||||
${i.status === 'in_progress' ? `
|
||||
<div class="mt-4">
|
||||
<form id="complete-implementation-form">
|
||||
<div class="form-group">
|
||||
<label>代码参考/PR链接</label>
|
||||
<input type="text" id="impl-code" placeholder="Git提交号/PR链接等">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>实施说明</label>
|
||||
<textarea id="impl-notes" rows="3" placeholder="实施过程中的说明..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success btn-sm"><i class="fas fa-check"></i> 标记完成</button>
|
||||
</form>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
renderReviewSection(c) {
|
||||
const r = c.review;
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span>审查结果</span>
|
||||
<span>${Utils.statusBadge(r.status)}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${r.review_notes ? `<div class="detail-row"><span class="label">审查意见</span><span class="value" style="white-space:pre-wrap">${Utils.escapeHtml(r.review_notes)}</span></div>` : ''}
|
||||
${r.issues_found ? `<div class="detail-row"><span class="label">发现的问题</span><span class="value" style="white-space:pre-wrap">${Utils.escapeHtml(r.issues_found)}</span></div>` : ''}
|
||||
${r.reject_reason ? `<div class="detail-row"><span class="label">驳回原因</span><span class="value" style="color:var(--danger)">${Utils.escapeHtml(r.reject_reason)}</span></div>` : ''}
|
||||
<div class="detail-row"><span class="label">审查者</span><span class="value">${r.reviewer_name || '-'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
await this.mount();
|
||||
},
|
||||
|
||||
async createPlan(complaintId) {
|
||||
Utils.openModal('创建处理计划', `
|
||||
<form id="create-plan-form">
|
||||
<div class="form-group">
|
||||
<label>计划标题 *</label>
|
||||
<input type="text" id="plan-title" required placeholder="例如: 修复闪退问题">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>计划描述 *</label>
|
||||
<textarea id="plan-description" required rows="4" placeholder="详细描述处理方案..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>技术方案</label>
|
||||
<textarea id="plan-tech" rows="3" placeholder="技术实现方案..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>预计工时</label>
|
||||
<input type="text" id="plan-effort" placeholder="例如: 2小时">
|
||||
</div>
|
||||
<div class="modal-footer" style="padding:16px 0 0;border-top:1px solid var(--gray-200)">
|
||||
<button type="button" class="btn btn-outline" onclick="App.closeModal()">取消</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> 创建</button>
|
||||
</div>
|
||||
</form>
|
||||
`);
|
||||
document.getElementById('create-plan-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await API.post('/plans', {
|
||||
complaint_id: complaintId,
|
||||
title: document.getElementById('plan-title').value,
|
||||
description: document.getElementById('plan-description').value,
|
||||
technical_approach: document.getElementById('plan-tech').value,
|
||||
expected_effort: document.getElementById('plan-effort').value,
|
||||
});
|
||||
App.closeModal();
|
||||
this.refresh();
|
||||
} catch (err) { alert(err.message); }
|
||||
});
|
||||
},
|
||||
|
||||
async assignImplementer(complaintId, planId) {
|
||||
const users = await API.get('/users/implementers');
|
||||
if (!users.length) { alert('暂无可用实施者'); return; }
|
||||
Utils.openModal('分配实施者', `
|
||||
<form id="assign-impl-form">
|
||||
<div class="form-group">
|
||||
<label>选择实施者</label>
|
||||
<select id="assign-impl-user">
|
||||
${users.map(u => `<option value="${u.id}">${u.display_name} (${u.username})</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding:16px 0 0;border-top:1px solid var(--gray-200)">
|
||||
<button type="button" class="btn btn-outline" onclick="App.closeModal()">取消</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="fas fa-user-tag"></i> 分配</button>
|
||||
</div>
|
||||
</form>
|
||||
`);
|
||||
document.getElementById('assign-impl-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await API.post(`/plans/${planId}/assign`, { implementer_id: parseInt(document.getElementById('assign-impl-user').value) });
|
||||
App.closeModal();
|
||||
this.refresh();
|
||||
} catch (err) { alert(err.message); }
|
||||
});
|
||||
},
|
||||
|
||||
async startImplementation(complaintId, planId) {
|
||||
try {
|
||||
await API.post('/implementations', { plan_id: planId });
|
||||
this.refresh();
|
||||
} catch (err) { alert(err.message); }
|
||||
},
|
||||
|
||||
async startReview(complaintId, implementationId) {
|
||||
try {
|
||||
await API.post('/reviews', { implementation_id: implementationId });
|
||||
this.refresh();
|
||||
} catch (err) { alert(err.message); }
|
||||
},
|
||||
|
||||
showUpdateStatusModal(complaintId) {
|
||||
Utils.openModal('修改状态', `
|
||||
<form id="update-status-form">
|
||||
<div class="form-group">
|
||||
<label>新状态</label>
|
||||
<select id="new-status">
|
||||
${Object.entries(Utils.statusLabels).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding:16px 0 0;border-top:1px solid var(--gray-200)">
|
||||
<button type="button" class="btn btn-outline" onclick="App.closeModal()">取消</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> 保存</button>
|
||||
</div>
|
||||
</form>
|
||||
`);
|
||||
document.getElementById('update-status-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await API.put(`/complaints/${complaintId}`, { status: document.getElementById('new-status').value });
|
||||
App.closeModal();
|
||||
this.refresh();
|
||||
} catch (err) { alert(err.message); }
|
||||
});
|
||||
}
|
||||
};
|
||||
188
public/js/pages/complaints.js
Normal file
188
public/js/pages/complaints.js
Normal file
@@ -0,0 +1,188 @@
|
||||
const ComplaintsPage = {
|
||||
async render() {
|
||||
return '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||
},
|
||||
|
||||
async mount() {
|
||||
const container = document.getElementById('page-content');
|
||||
document.getElementById('page-title').textContent = '投诉列表';
|
||||
document.getElementById('page-actions').innerHTML = '<button class="btn btn-primary" onclick="ComplaintsPage.showCreateModal()"><i class="fas fa-plus"></i> 提交投诉</button>';
|
||||
|
||||
await this.loadList(container);
|
||||
},
|
||||
|
||||
async loadList(container, filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.priority) params.set('priority', filters.priority);
|
||||
if (filters.category) params.set('category', filters.category);
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
const qs = params.toString();
|
||||
|
||||
container.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||
|
||||
try {
|
||||
const complaints = await API.get('/complaints' + (qs ? '?' + qs : ''));
|
||||
this.renderList(container, complaints, filters);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="alert alert-danger">加载失败: ${err.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderList(container, complaints, filters = {}) {
|
||||
container.innerHTML = `
|
||||
<div class="filter-bar">
|
||||
<select id="filter-status" onchange="ComplaintsPage.applyFilters()">
|
||||
<option value="">全部状态</option>
|
||||
${Object.entries(Utils.statusLabels).map(([k,v]) => `<option value="${k}" ${filters.status === k ? 'selected' : ''}>${v}</option>`).join('')}
|
||||
</select>
|
||||
<select id="filter-priority" onchange="ComplaintsPage.applyFilters()">
|
||||
<option value="">全部优先级</option>
|
||||
${Object.entries(Utils.priorityLabels).map(([k,v]) => `<option value="${k}" ${filters.priority === k ? 'selected' : ''}>${v}</option>`).join('')}
|
||||
</select>
|
||||
<select id="filter-category" onchange="ComplaintsPage.applyFilters()">
|
||||
<option value="">全部分类</option>
|
||||
${Object.entries(Utils.categoryLabels).map(([k,v]) => `<option value="${k}" ${filters.category === k ? 'selected' : ''}>${v}</option>`).join('')}
|
||||
</select>
|
||||
<input id="filter-search" placeholder="搜索标题/描述/玩家..." value="${filters.search || ''}" onkeyup="if(event.key==='Enter')ComplaintsPage.applyFilters()">
|
||||
<button class="btn btn-outline btn-sm" onclick="ComplaintsPage.applyFilters()"><i class="fas fa-search"></i> 搜索</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="ComplaintsPage.resetFilters()"><i class="fas fa-undo"></i> 重置</button>
|
||||
</div>
|
||||
|
||||
${complaints.length === 0 ? `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-inbox"></i>
|
||||
<p>暂无投诉记录</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>标题</th>
|
||||
<th>玩家</th>
|
||||
<th>分类</th>
|
||||
<th>优先级</th>
|
||||
<th>状态</th>
|
||||
<th>规划者</th>
|
||||
<th>实施者</th>
|
||||
<th>审查者</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${complaints.map(c => `
|
||||
<tr>
|
||||
<td>${c.id}</td>
|
||||
<td><strong><a href="#" onclick="App.navigate('complaint',${c.id});return false" style="color:var(--primary);text-decoration:none">${Utils.escapeHtml(c.title)}</a></strong></td>
|
||||
<td>${Utils.escapeHtml(c.player_name)}</td>
|
||||
<td>${Utils.categoryBadge(c.category)}</td>
|
||||
<td>${Utils.priorityBadge(c.priority)}</td>
|
||||
<td>${Utils.statusBadge(c.status)}</td>
|
||||
<td>${c.planner_name || '-'}</td>
|
||||
<td>${c.implementer_name || '-'}</td>
|
||||
<td>${c.reviewer_name || '-'}</td>
|
||||
<td class="date">${Utils.formatDate(c.updated_at)}</td>
|
||||
<td><button class="btn btn-sm btn-outline" onclick="App.navigate('complaint',${c.id})"><i class="fas fa-eye"></i></button></td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
},
|
||||
|
||||
applyFilters() {
|
||||
const filters = {
|
||||
status: document.getElementById('filter-status').value,
|
||||
priority: document.getElementById('filter-priority').value,
|
||||
category: document.getElementById('filter-category').value,
|
||||
search: document.getElementById('filter-search').value,
|
||||
};
|
||||
const container = document.getElementById('page-content');
|
||||
this.loadList(container, filters);
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
const container = document.getElementById('page-content');
|
||||
this.loadList(container, {});
|
||||
},
|
||||
|
||||
showCreateModal() {
|
||||
Utils.openModal('提交投诉', `
|
||||
<form id="create-complaint-form">
|
||||
<div class="form-group">
|
||||
<label>标题 *</label>
|
||||
<input type="text" id="c-title" required placeholder="投诉标题">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述 *</label>
|
||||
<textarea id="c-description" required placeholder="请详细描述问题..." rows="4"></textarea>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label>分类</label>
|
||||
<select id="c-category">
|
||||
${Object.entries(Utils.categoryLabels).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>优先级</label>
|
||||
<select id="c-priority">
|
||||
<option value="low">低</option>
|
||||
<option value="medium" selected>中</option>
|
||||
<option value="high">高</option>
|
||||
<option value="critical">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>玩家名称 *</label>
|
||||
<input type="text" id="c-player_name" required placeholder="游戏内昵称">
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label>邮箱</label>
|
||||
<input type="email" id="c-player_email" placeholder="选填">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>联系方式</label>
|
||||
<input type="text" id="c-player_contact" placeholder="手机/微信/QQ">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding:16px 0 0;border-top:1px solid var(--gray-200)">
|
||||
<button type="button" class="btn btn-outline" onclick="App.closeModal()">取消</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="fas fa-paper-plane"></i> 提交</button>
|
||||
</div>
|
||||
</form>
|
||||
`);
|
||||
document.getElementById('create-complaint-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提交中...';
|
||||
try {
|
||||
await API.post('/complaints', {
|
||||
title: document.getElementById('c-title').value,
|
||||
description: document.getElementById('c-description').value,
|
||||
category: document.getElementById('c-category').value,
|
||||
priority: document.getElementById('c-priority').value,
|
||||
player_name: document.getElementById('c-player_name').value,
|
||||
player_email: document.getElementById('c-player_email').value,
|
||||
player_contact: document.getElementById('c-player_contact').value,
|
||||
});
|
||||
App.closeModal();
|
||||
this.mount();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> 提交';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
47
public/js/pages/dashboard.js
Normal file
47
public/js/pages/dashboard.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const Dashboard = {
|
||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||
|
||||
async mount() {
|
||||
const ct = document.getElementById('page-content');
|
||||
document.getElementById('page-title').textContent = '控制台';
|
||||
document.getElementById('page-actions').innerHTML = `
|
||||
<button class="btn btn-p btn-sm" onclick="App.navigate('tickets','create?type=report')"><i class="fas fa-flag"></i> 举报</button>
|
||||
<button class="btn btn-s btn-sm" onclick="App.navigate('tickets','create?type=suggestion')"><i class="fas fa-lightbulb"></i> 建议</button>
|
||||
`;
|
||||
|
||||
try {
|
||||
const [stats, exports] = await Promise.all([
|
||||
API.get('/tickets/stats'),
|
||||
Auth.isAdmin() ? API.get('/export/dashboard') : null,
|
||||
]);
|
||||
this.renderContent(ct, stats, exports);
|
||||
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
},
|
||||
|
||||
renderContent(ct, stats, exports) {
|
||||
const byStatus = stats.byStatus || {};
|
||||
|
||||
ct.innerHTML = `
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="si" style="background:#eef2ff;color:var(--p)"><i class="fas fa-ticket-alt"></i></div><div><div class="sv">${stats.total}</div><div class="sl">工单总数</div></div></div>
|
||||
<div class="stat-card"><div class="si" style="background:#fef3c7;color:#d97706"><i class="fas fa-clock"></i></div><div><div class="sv">${byStatus.pending || 0}</div><div class="sl">待处理</div></div></div>
|
||||
<div class="stat-card"><div class="si" style="background:#ede9fe;color:#7c3aed"><i class="fas fa-cogs"></i></div><div><div class="sv">${byStatus.processing || 0}</div><div class="sl">处理中</div></div></div>
|
||||
<div class="stat-card"><div class="si" style="background:#d1fae5;color:var(--s)"><i class="fas fa-check-circle"></i></div><div><div class="sv">${byStatus.resolved || 0}</div><div class="sl">已解决</div></div></div>
|
||||
</div>
|
||||
|
||||
${Auth.isAdmin() && exports ? `
|
||||
<div class="grid-2">
|
||||
<div class="card"><div class="card-h">类型分布</div><div class="card-b">
|
||||
${(exports.byType||[]).map(r => `<div class="detail-row"><span class="lbl">${U.TYPE[r.type]||r.type}</span><span class="val fb">${r.c} 条</span></div>`).join('')}
|
||||
</div></div>
|
||||
<div class="card"><div class="card-h">状态分布</div><div class="card-b">
|
||||
${(exports.byStatus||[]).map(r => `<div class="detail-row"><span class="lbl">${U.STATUS[r.status]||r.status}</span><span class="val fb">${r.c} 条</span></div>`).join('')}
|
||||
</div></div>
|
||||
</div>
|
||||
${exports.monthly?.length ? `
|
||||
<div class="card"><div class="card-h">月度趋势</div><div class="card-b table-wrap"><table><thead><tr><th>月份</th><th>数量</th></tr></thead><tbody>${exports.monthly.map(m=>`<tr><td>${m.m}</td><td>${m.c}</td></tr>`).join('')}</tbody></table></div></div>` : ''}
|
||||
` : ''}
|
||||
<p class="tm ts tc">提示: 可在"工单列表"中查看和管理所有工单</p>
|
||||
`;
|
||||
}
|
||||
};
|
||||
88
public/js/pages/export-page.js
Normal file
88
public/js/pages/export-page.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const ExportPage = {
|
||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||
|
||||
mount() {
|
||||
const ct = document.getElementById('page-content');
|
||||
document.getElementById('page-title').textContent = '数据导出';
|
||||
document.getElementById('page-actions').innerHTML = '';
|
||||
|
||||
ct.innerHTML = `
|
||||
<div class="grid-2">
|
||||
<div class="card"><div class="card-h"><i class="fas fa-file-csv"></i> 导出工单</div><div class="card-b">
|
||||
<form id="exp-tickets">
|
||||
<div class="grid-2"><div class="form-group"><label>类型</label><select id="et-type"><option value="">全部</option>${Object.entries(U.TYPE).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select></div>
|
||||
<div class="form-group"><label>状态</label><select id="et-status"><option value="">全部</option>${Object.entries(U.STATUS).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>开始日期</label><input type="date" id="et-start"></div><div class="form-group"><label>结束日期</label><input type="date" id="et-end"></div></div>
|
||||
<div class="form-group"><label>格式</label><select id="et-fmt"><option value="csv">CSV (Excel)</option><option value="json">JSON</option></select></div>
|
||||
<button type="submit" class="btn btn-p btn-sm"><i class="fas fa-download"></i> 导出</button>
|
||||
</form>
|
||||
</div></div>
|
||||
|
||||
<div class="card"><div class="card-h"><i class="fas fa-user-slash"></i> 被举报人统计导出</div><div class="card-b">
|
||||
<p class="tm mb-4">导出所有被举报玩家列表,含被举报次数、处理结果、常见原因等</p>
|
||||
<form id="exp-reported">
|
||||
<div class="form-group"><label>格式</label><select id="er-fmt"><option value="csv">CSV (Excel)</option><option value="json">JSON</option></select></div>
|
||||
<button type="submit" class="btn btn-w btn-sm"><i class="fas fa-download"></i> 导出被举报人统计</button>
|
||||
</form>
|
||||
</div></div>
|
||||
|
||||
<div class="card"><div class="card-h"><i class="fas fa-history"></i> 导出操作日志</div><div class="card-b">
|
||||
<form id="exp-logs">
|
||||
<div class="grid-2"><div class="form-group"><label>开始日期</label><input type="date" id="el-start"></div><div class="form-group"><label>结束日期</label><input type="date" id="el-end"></div></div>
|
||||
<div class="form-group"><label>格式</label><select id="el-fmt"><option value="csv">CSV (Excel)</option><option value="json">JSON</option></select></div>
|
||||
<button type="submit" class="btn btn-i btn-sm"><i class="fas fa-download"></i> 导出</button>
|
||||
</form>
|
||||
</div></div>
|
||||
|
||||
<div class="card"><div class="card-h"><i class="fas fa-chart-bar"></i> 统计报表</div><div class="card-b">
|
||||
<p class="tm mb-4">查看系统整体统计和趋势数据</p>
|
||||
<button class="btn btn-p btn-sm" onclick="ExportPage.viewStats()"><i class="fas fa-chart-simple"></i> 查看统计</button>
|
||||
</div></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('exp-tickets').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const p = new URLSearchParams({
|
||||
format: document.getElementById('et-fmt').value,
|
||||
type: document.getElementById('et-type').value,
|
||||
status: document.getElementById('et-status').value,
|
||||
start_date: document.getElementById('et-start').value,
|
||||
end_date: document.getElementById('et-end').value,
|
||||
});
|
||||
try { await API.download(`/export/tickets?${p}`); } catch (ex) { alert(ex.message); }
|
||||
};
|
||||
|
||||
document.getElementById('exp-reported').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const p = new URLSearchParams({ format: document.getElementById('er-fmt').value });
|
||||
try { await API.download(`/export/reported-players?${p}`); } catch (ex) { alert(ex.message); }
|
||||
};
|
||||
|
||||
document.getElementById('exp-logs').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const p = new URLSearchParams({
|
||||
format: document.getElementById('el-fmt').value,
|
||||
start_date: document.getElementById('el-start').value,
|
||||
end_date: document.getElementById('el-end').value,
|
||||
});
|
||||
try { await API.download(`/export/audit-logs?${p}`); } catch (ex) { alert(ex.message); }
|
||||
};
|
||||
},
|
||||
|
||||
async viewStats() {
|
||||
try {
|
||||
const d = await API.get('/export/dashboard');
|
||||
U.modal('系统统计', `
|
||||
<div class="detail-section"><h4>概览</h4>
|
||||
<div class="detail-row"><span class="lbl">工单总数</span><span class="val fb">${d.total}</span></div>
|
||||
<div class="detail-row"><span class="lbl">今日新增</span><span class="val fb">${d.today}</span></div>
|
||||
<div class="detail-row"><span class="lbl">用户数</span><span class="val fb">${d.usersTotal}</span></div>
|
||||
</div>
|
||||
<div class="detail-section"><h4>状态分布</h4>${(d.byStatus||[]).map(r=>`<div class="detail-row"><span class="lbl">${U.STATUS[r.status]||r.status}</span><span class="val fb">${r.c}</span></div>`).join('')}</div>
|
||||
<div class="detail-section"><h4>类型分布</h4>${(d.byType||[]).map(r=>`<div class="detail-row"><span class="lbl">${U.TYPE[r.type]||r.type}</span><span class="val fb">${r.c}</span></div>`).join('')}</div>
|
||||
${d.monthly?.length?`<div class="detail-section"><h4>月度趋势</h4>${d.monthly.map(m=>`<div class="detail-row"><span class="lbl">${m.m}</span><span class="val fb">${m.c} 条</span></div>`).join('')}</div>`:''}
|
||||
`, '600px');
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
};
|
||||
82
public/js/pages/home.js
Normal file
82
public/js/pages/home.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const HomePage = {
|
||||
async render() {
|
||||
return `<div id="home-content" style="max-width:800px;margin:0 auto;padding:30px 0">
|
||||
<div class="tab-nav">
|
||||
<button class="active" data-tab="report"><i class="fas fa-flag"></i> 举报</button>
|
||||
<button data-tab="suggestion"><i class="fas fa-lightbulb"></i> 建议</button>
|
||||
<button data-tab="appeal"><i class="fas fa-gavel"></i> 申诉</button>
|
||||
<button data-tab="tracking"><i class="fas fa-search"></i> 追踪</button>
|
||||
</div>
|
||||
<div id="home-tab"></div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
mount() {
|
||||
const tabBtns = document.querySelectorAll('.tab-nav button');
|
||||
const tabContent = document.getElementById('home-tab');
|
||||
let currentTab = 'report';
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
tabBtns.forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||||
if (tab === 'tracking') renderTracking(tabContent);
|
||||
else TicketCreate.render(tabContent, { type: tab, prefill: {}, backLink: null });
|
||||
}
|
||||
|
||||
function renderTracking(ct) {
|
||||
const token = document.cookie.split('; ').find(r => r.startsWith('tracking_token='))?.split('=')[1];
|
||||
ct.innerHTML = `
|
||||
<div class="card"><div class="card-b">
|
||||
<div class="form-group"><label>追踪标识 (Cookie自动读取)</label>
|
||||
<input id="track-token" value="${token || ''}" placeholder="粘贴追踪标识">
|
||||
</div>
|
||||
<button class="btn btn-p" onclick="HomePage.searchTracking()"><i class="fas fa-search"></i> 查找我的工单</button>
|
||||
<div id="track-result" class="mt-4"></div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
tabBtns.forEach(b => b.onclick = () => switchTab(b.dataset.tab));
|
||||
switchTab('report');
|
||||
|
||||
this.searchTracking = async () => {
|
||||
const token = document.getElementById('track-token').value.trim();
|
||||
if (!token) return alert('请输入追踪标识');
|
||||
const ct = document.getElementById('track-result');
|
||||
ct.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i></div>';
|
||||
try {
|
||||
const tickets = await API.get(`/tickets/tracking?token=${token}`);
|
||||
ct.innerHTML = tickets.length ? `
|
||||
<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><td>${t.id}</td><td>${U.badge(t.type,'type')}</td><td>${U.esc(t.title)}</td>
|
||||
<td>${U.badge(t.status,'status')}</td><td>${U.date(t.created_at)}</td>
|
||||
<td><button class="btn btn-o btn-sm" onclick="App.navigate('tracking');App.showTrackDetail(${t.id},'${token}')">查看</button></td></tr>
|
||||
`).join('')}</tbody></table></div>` : '<div class="empty"><i class="fas fa-inbox"></i><p>未找到工单</p></div>';
|
||||
} catch (ex) { ct.innerHTML = `<div class="alert alert-e">${ex.message}</div>`; }
|
||||
};
|
||||
},
|
||||
|
||||
showTrackDetail(id, token) {
|
||||
U.modal(`工单 #${id}`, `<div id="track-detail-content"></div>`, '700px');
|
||||
const ct = document.getElementById('track-detail-content');
|
||||
U.loading(ct);
|
||||
API.get(`/tickets/tracking?token=${token}`).then(tickets => {
|
||||
const t = tickets.find(x => x.id === id);
|
||||
if (!t) { ct.innerHTML = '<p>未找到</p>'; return; }
|
||||
ct.innerHTML = `
|
||||
<div class="detail-section"><h4>基本信息</h4>
|
||||
<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.badge(t.type,'type')}</span></div>
|
||||
<div class="detail-row"><span class="lbl">状态</span><span class="val">${U.badge(t.status,'status')}</span></div>
|
||||
<div class="detail-row"><span class="lbl">提交者</span><span class="val">${U.esc(t.reporter_game_name)} (UID: ${U.esc(t.reporter_game_uid)})</span></div>
|
||||
${t.type==='report'?`
|
||||
<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>`:''}
|
||||
${t.description?`<div class="detail-row"><span class="lbl">详情</span><span class="val">${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>
|
||||
<p class="tm ts">如需回复,请<a href="#/register" style="color:var(--p)">注册账号</a>后关联此工单。</p>`;
|
||||
}).catch(e => { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; });
|
||||
}
|
||||
};
|
||||
62
public/js/pages/implementations.js
Normal file
62
public/js/pages/implementations.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const ImplementationsPage = {
|
||||
async render() {
|
||||
return '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||
},
|
||||
|
||||
async mount() {
|
||||
const container = document.getElementById('page-content');
|
||||
document.getElementById('page-title').textContent = '实施管理';
|
||||
document.getElementById('page-actions').innerHTML = '';
|
||||
|
||||
try {
|
||||
const impls = await API.get('/implementations');
|
||||
this.renderList(container, impls);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="alert alert-danger">加载失败: ${err.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderList(container, impls) {
|
||||
container.innerHTML = `
|
||||
${impls.length === 0 ? `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-code"></i>
|
||||
<p>暂无实施记录</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>关联投诉</th>
|
||||
<th>关联计划</th>
|
||||
<th>状态</th>
|
||||
<th>实施者</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${impls.map(i => `
|
||||
<tr>
|
||||
<td>${i.id}</td>
|
||||
<td><a href="#" onclick="App.navigate('complaint',${i.complaint_id});return false" style="color:var(--primary)">#${i.complaint_id} ${Utils.escapeHtml(i.complaint_title)}</a></td>
|
||||
<td class="text-sm text-muted">${Utils.escapeHtml(i.plan_title)}</td>
|
||||
<td>${Utils.statusBadge(i.status)}</td>
|
||||
<td>${i.implementer_name || '-'}</td>
|
||||
<td class="date">${Utils.formatDate(i.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="App.navigate('complaint',${i.complaint_id})"><i class="fas fa-eye"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
};
|
||||
109
public/js/pages/install.js
Normal file
109
public/js/pages/install.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const InstallPage = {
|
||||
step: 1,
|
||||
|
||||
async render() {
|
||||
const status = await API.get('/install/status');
|
||||
if (status.installed) { location.hash = '#/login'; return ''; }
|
||||
return `<div style="max-width:600px;margin:0 auto;padding:30px 0"><div id="install-steps"></div></div>`;
|
||||
},
|
||||
|
||||
mount() {
|
||||
this.showStep(1);
|
||||
},
|
||||
|
||||
showStep(n) {
|
||||
this.step = n;
|
||||
const ct = document.getElementById('install-steps');
|
||||
if (n === 1) this.renderDBStep(ct);
|
||||
else if (n === 2) this.renderAdminStep(ct);
|
||||
},
|
||||
|
||||
renderDBStep(ct) {
|
||||
ct.innerHTML = `
|
||||
<div class="card"><div class="card-h"><i class="fas fa-database"></i> 第1步:数据库配置</div><div class="card-b">
|
||||
<form id="install-db">
|
||||
<div class="grid-2"><div class="form-group"><label>MySQL 主机</label><input id="db-host" value="localhost"></div>
|
||||
<div class="form-group"><label>端口</label><input id="db-port" value="3306" type="number"></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>用户名</label><input id="db-user" value="root" required></div>
|
||||
<div class="form-group"><label>密码</label><input type="password" id="db-pass"></div></div>
|
||||
<div class="form-group"><label>数据库名</label><input id="db-name" value="mc_report" required><span class="help">如不存在将自动创建</span></div>
|
||||
<div id="db-err" class="alert alert-e hidden"></div>
|
||||
<button type="submit" class="btn btn-p"><i class="fas fa-check"></i> 测试连接并继续</button>
|
||||
</form>
|
||||
</div></div>
|
||||
`;
|
||||
document.getElementById('install-db').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 测试中...';
|
||||
try {
|
||||
await API.post('/install/check-db', {
|
||||
host: document.getElementById('db-host').value,
|
||||
port: document.getElementById('db-port').value,
|
||||
user: document.getElementById('db-user').value,
|
||||
password: document.getElementById('db-pass').value,
|
||||
database: document.getElementById('db-name').value,
|
||||
});
|
||||
this.dbConfig = {
|
||||
host: document.getElementById('db-host').value,
|
||||
port: document.getElementById('db-port').value,
|
||||
user: document.getElementById('db-user').value,
|
||||
pass: document.getElementById('db-pass').value,
|
||||
name: document.getElementById('db-name').value,
|
||||
};
|
||||
this.showStep(2);
|
||||
} catch (ex) {
|
||||
document.getElementById('db-err').textContent = ex.message;
|
||||
document.getElementById('db-err').classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> 测试连接并继续';
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
renderAdminStep(ct) {
|
||||
ct.innerHTML = `
|
||||
<div class="card"><div class="card-h"><i class="fas fa-user-crown"></i> 第2步:创建服主账号</div><div class="card-b">
|
||||
<form id="install-admin">
|
||||
<div class="grid-2"><div class="form-group"><label>站点名称</label><input id="ia-sitename" value="MC举报系统"></div>
|
||||
<div class="form-group"><label>站点地址</label><input id="ia-siteurl" value="${location.origin}"></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>服主用户名 *</label><input id="ia-username" required placeholder="登录用"></div>
|
||||
<div class="form-group"><label>邮箱 *</label><input type="email" id="ia-email" required></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>密码 *</label><input type="password" id="ia-password" required minlength="6"></div>
|
||||
<div class="form-group"><label>游戏名称</label><input id="ia-game" placeholder="Minecraft ID"></div></div>
|
||||
<div class="form-group"><label>游戏UID</label><input id="ia-uid" placeholder="游戏唯一ID"></div>
|
||||
<div id="ia-err" class="alert alert-e hidden"></div>
|
||||
<div id="ia-warn" class="alert alert-i"><strong>注意:</strong>安装完成后需要重启服务器才能加载业务路由。请记好账号密码。</div>
|
||||
<button type="submit" class="btn btn-p btn-lg w-full"><i class="fas fa-check"></i> 完成安装</button>
|
||||
</form>
|
||||
</div></div>
|
||||
`;
|
||||
document.getElementById('install-admin').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 安装中...';
|
||||
try {
|
||||
await API.post('/install/complete', {
|
||||
...this.dbConfig,
|
||||
db_host: this.dbConfig.host, db_port: this.dbConfig.port,
|
||||
db_user: this.dbConfig.user, db_pass: this.dbConfig.pass, db_name: this.dbConfig.name,
|
||||
site_name: document.getElementById('ia-sitename').value,
|
||||
site_url: document.getElementById('ia-siteurl').value,
|
||||
admin_username: document.getElementById('ia-username').value,
|
||||
admin_password: document.getElementById('ia-password').value,
|
||||
admin_email: document.getElementById('ia-email').value,
|
||||
admin_game_name: document.getElementById('ia-game').value,
|
||||
admin_game_uid: document.getElementById('ia-uid').value,
|
||||
});
|
||||
document.getElementById('install-admin').innerHTML = '<div class="alert alert-s"><h3><i class="fas fa-check-circle"></i> 安装成功!</h3><p>配置已保存。请重启服务器后刷新页面。</p></div>';
|
||||
} catch (ex) {
|
||||
document.getElementById('ia-err').textContent = ex.message;
|
||||
document.getElementById('ia-err').classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> 完成安装';
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
41
public/js/pages/login.js
Normal file
41
public/js/pages/login.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const LoginPage = {
|
||||
render() {
|
||||
return `
|
||||
<div class="pub-card" style="background:white;border-radius:16px;padding:36px 32px;width:420px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.35)">
|
||||
<div class="logo"><i class="fas fa-shield-halved"></i><h2>MC举报系统</h2><p>登录账号</p></div>
|
||||
<form id="login-form">
|
||||
<div class="form-group"><input id="lu" placeholder="用户名" required autofocus></div>
|
||||
<div class="form-group"><input type="password" id="lp" placeholder="密码" required></div>
|
||||
<div id="le" class="alert alert-e hidden"></div>
|
||||
<button type="submit" class="btn btn-p btn-lg w-full"><i class="fas fa-sign-in-alt"></i> 登录</button>
|
||||
</form>
|
||||
<div style="margin-top:16px;text-align:center;font-size:13px">
|
||||
<a href="#/register" style="color:var(--p);text-decoration:none">还没有账号?立即注册</a>
|
||||
<span style="color:var(--g300);margin:0 8px">|</span>
|
||||
<a href="#/home" style="color:var(--g500);text-decoration:none">匿名提交</a>
|
||||
</div>
|
||||
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--g200);font-size:11px;color:var(--g400);text-align:center">
|
||||
演示: owner/admin123 admin/admin123 player1/player123
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
mount() {
|
||||
document.getElementById('login-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 登录中...';
|
||||
try {
|
||||
await Auth.login(document.getElementById('lu').value.trim(), document.getElementById('lp').value.trim());
|
||||
App.afterLogin();
|
||||
} catch (err) {
|
||||
const el = document.getElementById('le');
|
||||
el.textContent = err.message;
|
||||
el.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-sign-in-alt"></i> 登录';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
72
public/js/pages/notifications.js
Normal file
72
public/js/pages/notifications.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const NotificationsPage = {
|
||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||
|
||||
async mount() {
|
||||
document.getElementById('page-title').textContent = '通知配置';
|
||||
document.getElementById('page-actions').innerHTML = '<button class="btn btn-p btn-sm" onclick="NotificationsPage.showCreate()"><i class="fas fa-plus"></i> 添加Webhook</button>';
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const ct = document.getElementById('page-content');
|
||||
try {
|
||||
const cfgs = await API.get('/notifications');
|
||||
ct.innerHTML = cfgs.length ? cfgs.map(c => `
|
||||
<div class="card"><div class="card-h"><span><i class="fas fa-${c.type==='webhook'?'link':'envelope'}"></i> ${U.esc(c.name)}</span><span>${c.active?`<span class="badge bg-resolved">启用</span>`:`<span class="badge bg-rejected">禁用</span>`}</span></div><div class="card-b">
|
||||
<div class="detail-row"><span class="lbl">类型</span><span class="val">${c.type==='webhook'?'Webhook':'邮件'}</span></div>
|
||||
${c.webhook_url?`<div class="detail-row"><span class="lbl">地址</span><span class="val ts" style="word-break:break-all">${U.esc(c.webhook_url)}</span></div>`:''}
|
||||
<div class="detail-row"><span class="lbl">事件</span><span class="val">${EventsLabel(c.events)}</span></div>
|
||||
<div class="mt-2 flex g2">
|
||||
<button class="btn btn-o btn-sm" onclick="NotificationsPage.showEdit(${c.id},'${U.esc(c.name)}','${c.type}','${U.esc(c.webhook_url)}','${c.events}',${c.active})"><i class="fas fa-edit"></i> 编辑</button>
|
||||
<button class="btn btn-i btn-sm" onclick="NotificationsPage.test(${c.id})"><i class="fas fa-paper-plane"></i> 测试</button>
|
||||
<button class="btn btn-d btn-sm" onclick="NotificationsPage.del(${c.id})"><i class="fas fa-trash"></i> 删除</button>
|
||||
</div>
|
||||
</div></div>
|
||||
`).join('') : '<div class="empty"><i class="fas fa-bell-slash"></i><p>暂无通知配置</p><p class="ts tm">添加Webhook后可自动推送工单状态变更通知</p></div>';
|
||||
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
},
|
||||
|
||||
showCreate() {
|
||||
U.modal('添加Webhook', `
|
||||
<form id="nc-form"><div class="form-group"><label>名称 *</label><input id="nc-name" placeholder="如: Discord通知" required></div>
|
||||
<div class="form-group"><label>类型 *</label><select id="nc-type"><option value="webhook">Webhook</option></select></div>
|
||||
<div class="form-group"><label>Webhook URL *</label><input id="nc-url" placeholder="https://discord.com/api/webhooks/..." required></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">
|
||||
</div>
|
||||
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">创建</button></div></form>
|
||||
`);
|
||||
bindEventToggles();
|
||||
document.getElementById('nc-form').onsubmit = async e => { e.preventDefault();
|
||||
try { await API.post('/notifications',{name:document.getElementById('nc-name').value,type:'webhook',webhook_url:document.getElementById('nc-url').value,events:document.getElementById('nc-events').value}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
|
||||
};
|
||||
},
|
||||
|
||||
showEdit(id,name,type,url,events,active) {
|
||||
U.modal('编辑通知', `
|
||||
<form id="ne-form"><div class="form-group"><label>名称</label><input id="ne-name" value="${name}"></div>
|
||||
<div class="form-group"><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}">
|
||||
</div>
|
||||
<div class="form-group"><label>状态</label><select id="ne-active"><option value="1" ${active?'selected':''}>启用</option><option value="0" ${!active?'selected':''}>禁用</option></select></div>
|
||||
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">保存</button></div></form>
|
||||
`);
|
||||
bindEventToggles();
|
||||
document.getElementById('ne-form').onsubmit = async e => { e.preventDefault();
|
||||
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'}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
|
||||
};
|
||||
},
|
||||
|
||||
async test(id) { try { alert((await API.post(`/notifications/${id}/test`)).message); } catch(ex){alert(ex.message);} },
|
||||
async del(id) { if(await U.confirm('确认删除此通知配置?')){ await API.req('DELETE',`/notifications/${id}`); this.load(); } }
|
||||
};
|
||||
|
||||
function EventOptions(current) { return ['all','ticket_created','ticket_claimed','ticket_transferred','ticket_updated'].map(e=>`<label style="cursor:pointer;font-size:12px"><input type="checkbox" value="${e}" class="ev-check" ${current.split(',').map(s=>s.trim()).includes(e)?'checked':''}> ${EventsLabelSingle(e)}</label>`).join(''); }
|
||||
function EventsLabel(v) { return v==='all'?'全部事件':v.split(',').map(s=>s.trim()).map(EventsLabelSingle).join(', '); }
|
||||
function EventsLabelSingle(e) { return {all:'全部',ticket_created:'工单创建',ticket_claimed:'工单认领',ticket_transferred:'工单转交',ticket_updated:'工单更新'}[e]||e; }
|
||||
function bindEventToggles() {
|
||||
document.querySelectorAll('.ev-check').forEach(cb=>{ cb.onchange=()=>{ const all=document.querySelectorAll('.ev-check'); const v=Array.from(all).filter(c=>c.checked).map(c=>c.value).join(','); const hid=cb.closest('form').querySelector('input[type="hidden"]'); if(hid)hid.value=v; }; });
|
||||
}
|
||||
62
public/js/pages/plans.js
Normal file
62
public/js/pages/plans.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const PlansPage = {
|
||||
async render() {
|
||||
return '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||
},
|
||||
|
||||
async mount() {
|
||||
const container = document.getElementById('page-content');
|
||||
document.getElementById('page-title').textContent = '计划管理';
|
||||
document.getElementById('page-actions').innerHTML = '';
|
||||
|
||||
try {
|
||||
const plans = await API.get('/plans');
|
||||
this.renderList(container, plans);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="alert alert-danger">加载失败: ${err.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderList(container, plans) {
|
||||
container.innerHTML = `
|
||||
${plans.length === 0 ? `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-clipboard-list"></i>
|
||||
<p>暂无计划</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>计划标题</th>
|
||||
<th>关联投诉</th>
|
||||
<th>状态</th>
|
||||
<th>规划者</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${plans.map(p => `
|
||||
<tr>
|
||||
<td>${p.id}</td>
|
||||
<td><strong>${Utils.escapeHtml(p.title)}</strong></td>
|
||||
<td><a href="#" onclick="App.navigate('complaint',${p.complaint_id});return false" style="color:var(--primary)">#${p.complaint_id} ${Utils.escapeHtml(p.complaint_title)}</a></td>
|
||||
<td>${Utils.statusBadge(p.status)}</td>
|
||||
<td>${p.planner_name || '-'}</td>
|
||||
<td class="date">${Utils.formatDate(p.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="App.navigate('complaint',${p.complaint_id})"><i class="fas fa-eye"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
};
|
||||
69
public/js/pages/register.js
Normal file
69
public/js/pages/register.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const RegisterPage = {
|
||||
captcha: null,
|
||||
|
||||
async render() {
|
||||
try { this.captcha = await API.get('/captcha'); } catch { this.captcha = null; }
|
||||
return `
|
||||
<div class="pub-card wide" style="background:white;border-radius:16px;padding:36px 32px;width:540px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.35)">
|
||||
<div class="logo"><i class="fas fa-user-plus"></i><h2>注册账号</h2><p>加入MC举报系统</p></div>
|
||||
<form id="reg-form">
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>站点用户名 *</label><input id="ru" required placeholder="登录用"></div>
|
||||
<div class="form-group"><label>邮箱 *</label><input type="email" id="re" required placeholder="接收通知"></div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>游戏名称 *</label><input id="rgn" required placeholder="Minecraft ID"></div>
|
||||
<div class="form-group"><label>游戏UID *</label><input id="rgu" required placeholder="游戏唯一ID"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>密码 *</label><input type="password" id="rp" required placeholder="至少6位"></div>
|
||||
${this.captcha ? `
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:10px">
|
||||
<div style="flex-shrink:0;background:var(--g100);padding:6px 14px;border-radius:var(--r);font-weight:700;font-size:16px;letter-spacing:1px;user-select:none" id="captcha-q">${this.captcha.question}</div>
|
||||
<input type="text" id="captcha-a" placeholder="输入答案" required style="max-width:100px">
|
||||
<input type="hidden" id="captcha-id" value="${this.captcha.id}">
|
||||
</div>` : ''}
|
||||
<div id="regerr" class="alert alert-e hidden"></div>
|
||||
<div id="regok" class="alert alert-s hidden"></div>
|
||||
<button type="submit" class="btn btn-p btn-lg w-full"><i class="fas fa-user-plus"></i> 注册</button>
|
||||
</form>
|
||||
<div style="margin-top:14px;text-align:center;font-size:13px">
|
||||
<a href="#/login" style="color:var(--p);text-decoration:none">已有账号?登录</a>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
mount() {
|
||||
document.getElementById('reg-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button');
|
||||
const err = document.getElementById('regerr');
|
||||
const ok = document.getElementById('regok');
|
||||
err.classList.add('hidden');
|
||||
ok.classList.add('hidden');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 注册中...';
|
||||
|
||||
try {
|
||||
await Auth.register({
|
||||
username: document.getElementById('ru').value.trim(),
|
||||
password: document.getElementById('rp').value,
|
||||
email: document.getElementById('re').value.trim(),
|
||||
game_name: document.getElementById('rgn').value.trim(),
|
||||
game_uid: document.getElementById('rgu').value.trim(),
|
||||
captcha_id: document.getElementById('captcha-id')?.value,
|
||||
captcha_answer: document.getElementById('captcha-a')?.value.trim(),
|
||||
});
|
||||
ok.textContent = '注册成功!请查收验证邮件(如SMTP未配置则自动激活)';
|
||||
ok.classList.remove('hidden');
|
||||
btn.innerHTML = '<i class="fas fa-user-plus"></i> 注册';
|
||||
btn.disabled = false;
|
||||
setTimeout(() => location.hash = '#/login', 3000);
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-user-plus"></i> 注册';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
62
public/js/pages/reviews.js
Normal file
62
public/js/pages/reviews.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const ReviewsPage = {
|
||||
async render() {
|
||||
return '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||
},
|
||||
|
||||
async mount() {
|
||||
const container = document.getElementById('page-content');
|
||||
document.getElementById('page-title').textContent = '审查管理';
|
||||
document.getElementById('page-actions').innerHTML = '';
|
||||
|
||||
try {
|
||||
const reviews = await API.get('/reviews');
|
||||
this.renderList(container, reviews);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="alert alert-danger">加载失败: ${err.message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderList(container, reviews) {
|
||||
container.innerHTML = `
|
||||
${reviews.length === 0 ? `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-search"></i>
|
||||
<p>暂无审查记录</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>关联投诉</th>
|
||||
<th>状态</th>
|
||||
<th>审查者</th>
|
||||
<th>审查意见</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${reviews.map(r => `
|
||||
<tr>
|
||||
<td>${r.id}</td>
|
||||
<td><a href="#" onclick="App.navigate('complaint',${r.complaint_id});return false" style="color:var(--primary)">#${r.complaint_id} ${Utils.escapeHtml(r.complaint_title)}</a></td>
|
||||
<td>${Utils.statusBadge(r.status)}</td>
|
||||
<td>${r.reviewer_name || '-'}</td>
|
||||
<td class="text-sm text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.review_notes ? Utils.escapeHtml(r.review_notes.slice(0, 50)) : '-'}</td>
|
||||
<td class="date">${Utils.formatDate(r.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="App.navigate('complaint',${r.complaint_id})"><i class="fas fa-eye"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
};
|
||||
61
public/js/pages/settings-page.js
Normal file
61
public/js/pages/settings-page.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const SettingsPage = {
|
||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||
|
||||
async mount() {
|
||||
document.getElementById('page-title').textContent = '系统设置';
|
||||
document.getElementById('page-actions').innerHTML = '';
|
||||
try {
|
||||
const settings = await API.get('/settings/settings');
|
||||
const ct = document.getElementById('page-content');
|
||||
ct.innerHTML = `
|
||||
<div class="card"><div class="card-h">站点设置</div><div class="card-b">
|
||||
<form id="site-form">
|
||||
<div class="grid-2"><div class="form-group"><label>站点名称</label><input id="s-name" value="${U.esc(settings.site_name?.value||'')}"></div>
|
||||
<div class="form-group"><label>站点地址</label><input id="s-url" value="${U.esc(settings.site_url?.value||'')}"></div></div>
|
||||
<button type="submit" class="btn btn-p btn-sm"><i class="fas fa-save"></i> 保存站点设置</button>
|
||||
</form>
|
||||
</div></div>
|
||||
|
||||
<div class="card"><div class="card-h">SMTP 邮件设置</div><div class="card-b">
|
||||
<form id="smtp-form">
|
||||
<div class="grid-2"><div class="form-group"><label>SMTP服务器</label><input id="s-host" value="${U.esc(settings.smtp_host?.value||'')}"></div>
|
||||
<div class="form-group"><label>端口</label><input id="s-port" value="${U.esc(settings.smtp_port?.value||'587')}"></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>用户名</label><input id="s-user" value="${U.esc(settings.smtp_user?.value||'')}"></div>
|
||||
<div class="form-group"><label>密码</label><input type="password" id="s-pass" value="${U.esc(settings.smtp_pass?.value||'')}"></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>发件地址</label><input id="s-from" value="${U.esc(settings.smtp_from?.value||'')}"></div>
|
||||
<div class="form-group"><label>SSL</label><select id="s-secure"><option value="0" ${settings.smtp_secure?.value!=='1'?'selected':''}>否(587)</option><option value="1" ${settings.smtp_secure?.value==='1'?'selected':''}>是(465)</option></select></div></div>
|
||||
<button type="submit" class="btn btn-p btn-sm"><i class="fas fa-save"></i> 保存SMTP设置</button>
|
||||
</form>
|
||||
</div></div>
|
||||
`;
|
||||
|
||||
document.getElementById('site-form').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await API.put('/settings/settings', {
|
||||
site_name: {value: document.getElementById('s-name').value, label:'站点名称'},
|
||||
site_url: {value: document.getElementById('s-url').value, label:'站点地址'},
|
||||
});
|
||||
alert('已保存');
|
||||
} catch (ex) { alert(ex.message); }
|
||||
};
|
||||
|
||||
document.getElementById('smtp-form').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await API.put('/settings/settings', {
|
||||
smtp_host: {value: document.getElementById('s-host').value, label:'SMTP服务器'},
|
||||
smtp_port: {value: document.getElementById('s-port').value, label:'SMTP端口'},
|
||||
smtp_secure: {value: document.getElementById('s-secure').value, label:'SSL'},
|
||||
smtp_user: {value: document.getElementById('s-user').value, label:'SMTP用户名'},
|
||||
smtp_pass: {value: document.getElementById('s-pass').value, label:'SMTP密码'},
|
||||
smtp_from: {value: document.getElementById('s-from').value, label:'发件地址'},
|
||||
});
|
||||
alert('已保存');
|
||||
} catch (ex) { alert(ex.message); }
|
||||
};
|
||||
} catch (e) {
|
||||
document.getElementById('page-content').innerHTML = `<div class="alert alert-e">${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
};
|
||||
48
public/js/pages/templates.js
Normal file
48
public/js/pages/templates.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const TemplatesPage = {
|
||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||
|
||||
async mount() {
|
||||
document.getElementById('page-title').textContent = '邮件模板';
|
||||
document.getElementById('page-actions').innerHTML = '';
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const ct = document.getElementById('page-content');
|
||||
try {
|
||||
const templates = await API.get('/settings/email-templates');
|
||||
ct.innerHTML = templates.map(t => `
|
||||
<div class="card"><div class="card-h"><span>${U.esc(t.name)}</span><code class="ts tm">${t.code}</code></div><div class="card-b">
|
||||
<div class="detail-row"><span class="lbl">主题</span><span class="val">${U.esc(t.subject)}</span></div>
|
||||
${t.description ? `<div class="detail-row"><span class="lbl">说明</span><span class="val tm">${U.esc(t.description)}</span></div>` : ''}
|
||||
<div class="mt-2"><button class="btn btn-p btn-sm" onclick="TemplatesPage.edit('${t.code}', '${U.esc(t.name)}')"><i class="fas fa-edit"></i> 编辑模板</button></div>
|
||||
</div></div>
|
||||
`).join('');
|
||||
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
},
|
||||
|
||||
async edit(code, name) {
|
||||
try {
|
||||
const t = await API.get(`/settings/email-templates/${code}`);
|
||||
U.modal(`编辑: ${name}`, `
|
||||
<form id="et-form">
|
||||
<div class="form-group"><label>邮件主题</label><input id="et-subject" value="${U.esc(t.subject).replace(/"/g,'"')}" required></div>
|
||||
<div class="form-group"><label>邮件正文 (HTML)</label><textarea id="et-body" rows="14" required>${U.esc(t.body).replace(/"/g,'"')}</textarea></div>
|
||||
<div class="alert alert-i ts">可用变量: ${t.description?.match(/\{\{[^}]+\}\}/g)?.join(', ') || '{{site_name}}'}</div>
|
||||
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">保存</button></div>
|
||||
</form>
|
||||
`, '800px');
|
||||
document.getElementById('et-form').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await API.put(`/settings/email-templates/${code}`, {
|
||||
subject: document.getElementById('et-subject').value,
|
||||
body: document.getElementById('et-body').value,
|
||||
});
|
||||
App.closeModal();
|
||||
this.load();
|
||||
} catch (ex) { alert(ex.message); }
|
||||
};
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
};
|
||||
152
public/js/pages/ticket-create.js
Normal file
152
public/js/pages/ticket-create.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const TicketCreate = {
|
||||
files: [],
|
||||
|
||||
render(container, { type, prefill, backLink }) {
|
||||
const user = Auth.user();
|
||||
const isReport = type === 'report';
|
||||
const isSuggestion = type === 'suggestion';
|
||||
const isAppeal = type === 'appeal';
|
||||
const typeLabel = isReport ? '举报' : isAppeal ? '申诉' : '建议';
|
||||
this.files = [];
|
||||
|
||||
container.innerHTML = `
|
||||
<form id="ticket-form" enctype="multipart/form-data">
|
||||
<input type="hidden" name="type" value="${type}">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">提交${typeLabel}</div>
|
||||
<div class="card-b">
|
||||
<div class="form-group">
|
||||
<label>${typeLabel}标题 *</label>
|
||||
<input name="title" required placeholder="${isReport ? '如:玩家破坏建筑' : isAppeal ? '如:申请解除封禁' : '如:增加领地保护功能'}">
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label>您的游戏名称 *</label>
|
||||
<input name="reporter_game_name" required placeholder="Minecraft ID" value="${U.esc(prefill?.game_name || user?.game_name || '')}" ${prefill?.game_name ? 'readonly' : ''}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>您的游戏UID *</label>
|
||||
<input name="reporter_game_uid" required placeholder="游戏UID" value="${U.esc(prefill?.game_uid || user?.game_uid || '')}" ${prefill?.game_uid ? 'readonly' : ''}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${isReport ? `
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>对方游戏名称 (至少一项)</label><input name="target_game_name" placeholder="被举报者Minecraft ID"></div>
|
||||
<div class="form-group"><label>对方游戏UID (至少一项)</label><input name="target_game_uid" placeholder="被举报者UID"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>举报原因 *</label>
|
||||
<input name="reason" list="reason-list" required placeholder="选择或自行输入举报原因">
|
||||
<datalist id="reason-list">
|
||||
<option value="破坏建筑"><option value="盗窃物品"><option value="外挂作弊">
|
||||
<option value="语言骚扰"><option value="恶意PVP"><option value="利用漏洞">
|
||||
<option value="其他违规"><option value="诈骗"><option value="恶意辱骂">
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="form-group"><label>详细描述</label><textarea name="description" rows="4" placeholder="请详细描述事件经过..."></textarea></div>
|
||||
` : isAppeal ? `
|
||||
<div class="form-group"><label>申诉理由 *</label><input name="reason" required placeholder="如:误封、证据不足被封、违规行为已纠正等"></div>
|
||||
<div class="form-group"><label>详细说明 *</label><textarea name="description" rows="5" placeholder="请详细说明情况,包括被封时间、可能原因、为什么应解封等..." required></textarea></div>
|
||||
` : `
|
||||
<div class="form-group"><label>建议内容 *</label><textarea name="description" rows="5" placeholder="请详细描述您的建议..." required></textarea></div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">上传文件 (图片/视频,每个最大20MB)</div>
|
||||
<div class="card-b">
|
||||
<div class="file-upload" id="file-drop">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<p>点击选择文件或拖拽到此处</p>
|
||||
<p class="help">支持: JPG/PNG/GIF/WebP/MP4/WebM</p>
|
||||
</div>
|
||||
<input type="file" id="file-input" multiple accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm" style="display:none">
|
||||
<div class="file-list" id="file-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
${backLink ? `<button type="button" class="btn btn-o" onclick="${backLink}">返回</button>` : ''}
|
||||
<button type="submit" class="btn btn-p btn-lg"><i class="fas fa-paper-plane"></i> 提交</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
this.bindFileUpload(container);
|
||||
this.bindSubmit(container, type);
|
||||
},
|
||||
|
||||
bindFileUpload(container) {
|
||||
const drop = container.querySelector('#file-drop');
|
||||
const input = container.querySelector('#file-input');
|
||||
const list = container.querySelector('#file-list');
|
||||
|
||||
drop.onclick = () => input.click();
|
||||
input.onchange = () => this.addFiles(input.files, list);
|
||||
drop.ondragover = e => { e.preventDefault(); drop.style.borderColor = 'var(--p)'; };
|
||||
drop.ondragleave = () => { drop.style.borderColor = 'var(--g300)'; };
|
||||
drop.ondrop = e => {
|
||||
e.preventDefault();
|
||||
drop.style.borderColor = 'var(--g300)';
|
||||
this.addFiles(e.dataTransfer.files, list);
|
||||
};
|
||||
},
|
||||
|
||||
addFiles(fileList, listEl) {
|
||||
const allowed = ['image/jpeg','image/png','image/gif','image/webp','video/mp4','video/webm'];
|
||||
for (const f of fileList) {
|
||||
if (!allowed.includes(f.type)) { alert(`不支持的文件类型: ${f.name}`); continue; }
|
||||
if (f.size > 20 * 1024 * 1024) { alert(`文件过大: ${f.name}`); continue; }
|
||||
if (this.files.length >= 5) { alert('最多5个文件'); break; }
|
||||
this.files.push(f);
|
||||
}
|
||||
this.refreshFileList(listEl);
|
||||
},
|
||||
|
||||
refreshFileList(listEl) {
|
||||
listEl.innerHTML = this.files.map((f, i) => `
|
||||
<div class="file-item">
|
||||
<i class="fas fa-${f.type.startsWith('video') ? 'video' : 'image'}"></i>
|
||||
${U.esc(f.name)} (${(f.size / 1024 / 1024).toFixed(2)}MB)
|
||||
<button type="button" onclick="TicketCreate.removeFile(${i})"><i class="fas fa-times"></i></button>
|
||||
</div>`).join('');
|
||||
},
|
||||
|
||||
removeFile(i) { this.files.splice(i, 1); this.refreshFileList(document.getElementById('file-list')); },
|
||||
|
||||
async bindSubmit(container, type) {
|
||||
const form = container.querySelector('#ticket-form');
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
|
||||
if (type === 'report') {
|
||||
const tgn = fd.get('target_game_name');
|
||||
const tgu = fd.get('target_game_uid');
|
||||
if (!tgn && !tgu) { return alert('举报需至少填写对方游戏名或UID之一'); }
|
||||
}
|
||||
|
||||
for (const f of this.files) fd.append('files', f);
|
||||
|
||||
const btn = form.querySelector('[type="submit"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提交中...';
|
||||
try {
|
||||
const res = await API.req('POST', '/tickets', fd);
|
||||
if (res.tracking_token) {
|
||||
document.cookie = `tracking_token=${res.tracking_token};path=/;max-age=${365*24*3600};SameSite=Lax`;
|
||||
}
|
||||
alert('提交成功!工单编号: #' + res.id);
|
||||
if (Auth.logged()) App.navigate('tickets');
|
||||
else App.navigate('home');
|
||||
} catch (ex) {
|
||||
alert(ex.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> 提交';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
106
public/js/pages/ticket-detail.js
Normal file
106
public/js/pages/ticket-detail.js
Normal file
@@ -0,0 +1,106 @@
|
||||
const TicketDetail = {
|
||||
id: null,
|
||||
|
||||
async render(id) { this.id = id; return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||
|
||||
async mount() {
|
||||
const id = this.id;
|
||||
document.getElementById('page-title').textContent = `工单 #${id}`;
|
||||
document.getElementById('page-actions').innerHTML = `<button class="btn btn-o btn-sm" onclick="App.navigate('tickets')"><i class="fas fa-arrow-left"></i> 返回列表</button>`;
|
||||
try { this.render(document.getElementById('page-content'), await API.get(`/tickets/${id}`)); } catch (e) { document.getElementById('page-content').innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
},
|
||||
|
||||
render(ct, t) {
|
||||
const isStaff = Auth.isAdmin();
|
||||
const isOwner = Auth.isOwner();
|
||||
const isRep = t.type === 'report';
|
||||
const canClaim = isStaff && (!t.assigned_to || t.status === 'pending');
|
||||
const canTransfer = isStaff && t.assigned_to === Auth.user().id && t.status === 'processing';
|
||||
const isMyTicket = t.assigned_to === Auth.user().id;
|
||||
|
||||
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="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)} (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>
|
||||
<div class="detail-row"><span class="lbl">原因</span><span class="val">${U.esc(t.reason)}</span></div>`:''}
|
||||
${t.description?`<div class="detail-row"><span class="lbl">${isRep?'详情':'建议'}</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.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">沟通记录</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></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">处理信息</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>
|
||||
${t.claimed_at?`<div class="detail-row"><span class="lbl">认领时间</span><span class="val">${U.date(t.claimed_at)}</span></div>`:''}
|
||||
</div></div>
|
||||
|
||||
${isStaff?`
|
||||
<div class="card"><div class="card-h">管理操作</div><div class="card-b" style="display:flex;flex-direction:column;gap:6px">
|
||||
${canClaim?`<button class="btn btn-s btn-sm w-full" onclick="TicketDetail.claimTicket(${t.id})"><i class="fas fa-hand-paper"></i> 接单认领</button>`:''}
|
||||
${isMyTicket?`<button class="btn btn-p btn-sm w-full" onclick="TicketDetail.editNote(${t.id})"><i class="fas fa-pen"></i> 更新处理备注</button>`:''}
|
||||
${canTransfer?`<button class="btn btn-w btn-sm w-full" onclick="TicketDetail.transferTicket(${t.id})"><i class="fas fa-exchange-alt"></i> 转单</button>`:''}
|
||||
${isOwner||isMyTicket?`<button class="btn btn-o btn-sm w-full" onclick="TicketDetail.changeStatus(${t.id})"><i class="fas fa-tag"></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 => {
|
||||
e.preventDefault(); const c = document.getElementById('reply-content').value.trim(); if(!c)return;
|
||||
try { await API.post(`/tickets/${id}/response`, {content:c}); this.mount(); } catch(ex){alert(ex.message);}
|
||||
});
|
||||
},
|
||||
|
||||
async claimTicket(id) {
|
||||
U.modal('接单认领', `<form id="claim-form"><div class="form-group"><label>初始回复 (玩家可见)</label><textarea id="claim-reply" rows="3" placeholder="接单时发送给玩家的回复,如:已收到举报,正在核查中..."></textarea></div><div class="form-group"><label>处理备注 (内部)</label><textarea id="claim-note" rows="2" placeholder="内部处理备注,玩家不可见"></textarea></div><div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-s"><i class="fas fa-hand-paper"></i> 确认接单</button></div></form>`);
|
||||
document.getElementById('claim-form').onsubmit = async e => { e.preventDefault();
|
||||
try { await API.post(`/tickets/${id}/claim`, {claim_note: document.getElementById('claim-note').value, initial_reply: document.getElementById('claim-reply').value}); App.closeModal(); this.mount(); } catch(ex){alert(ex.message);}
|
||||
};
|
||||
},
|
||||
|
||||
async editNote(id) {
|
||||
const t = await API.get(`/tickets/${id}`);
|
||||
U.modal('更新处理备注', `<form id="note-form"><div class="form-group"><textarea id="upd-note" rows="4">${U.esc(t.claim_note||'')}</textarea></div><div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">保存</button></div></form>`);
|
||||
document.getElementById('note-form').onsubmit = async e => { e.preventDefault();
|
||||
try { await API.put(`/tickets/${id}`, {claim_note: document.getElementById('upd-note').value}); App.closeModal(); this.mount(); } catch(ex){alert(ex.message);}
|
||||
};
|
||||
},
|
||||
|
||||
async transferTicket(id) {
|
||||
const users = await API.get('/users');
|
||||
const staff = users.filter(u => (u.role==='owner'||u.role==='admin') && u.id !== Auth.user().id);
|
||||
U.modal('转单', `<form id="tf-form"><div class="form-group"><label>转交给</label><select id="tf-to">${staff.map(u=>`<option value="${u.id}">${U.esc(u.game_name||u.username)} (${U.esc(u.username)})</option>`).join('')}</select></div><div class="form-group"><label>转交原因</label><textarea id="tf-reason" rows="2" placeholder="说明转交原因..."></textarea></div><div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-w"><i class="fas fa-exchange-alt"></i> 确认转交</button></div></form>`);
|
||||
document.getElementById('tf-form').onsubmit = async e => { e.preventDefault();
|
||||
try { await API.post(`/tickets/${id}/transfer`, {to_user_id: parseInt(document.getElementById('tf-to').value), reason: document.getElementById('tf-reason').value}); App.closeModal(); this.mount(); } catch(ex){alert(ex.message);}
|
||||
};
|
||||
},
|
||||
|
||||
async changeStatus(id) {
|
||||
U.modal('变更状态', `<form id="cs-form"><div class="form-group"><label>新状态</label><select id="cs-status">${Object.entries(U.STATUS).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select></div><div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">保存</button></div></form>`);
|
||||
document.getElementById('cs-form').onsubmit = async e => { e.preventDefault();
|
||||
try { await API.put(`/tickets/${id}`, {status:document.getElementById('cs-status').value}); App.closeModal(); this.mount(); } catch(ex){alert(ex.message);}
|
||||
};
|
||||
}
|
||||
};
|
||||
108
public/js/pages/tickets.js
Normal file
108
public/js/pages/tickets.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const TicketsPage = {
|
||||
tab: 'all',
|
||||
filters: {},
|
||||
|
||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||
|
||||
async mount() {
|
||||
document.getElementById('page-title').textContent = '工单列表';
|
||||
document.getElementById('page-actions').innerHTML = `
|
||||
<button class="btn btn-p btn-sm" onclick="App.navigate('tickets','create?type=report')"><i class="fas fa-flag"></i> 举报</button>
|
||||
<button class="btn btn-s btn-sm" onclick="App.navigate('tickets','create?type=suggestion')"><i class="fas fa-lightbulb"></i> 建议</button>
|
||||
`;
|
||||
this.renderLayout();
|
||||
await this.loadTab(this.tab);
|
||||
},
|
||||
|
||||
renderLayout() {
|
||||
const ct = document.getElementById('page-content');
|
||||
const isStaff = Auth.isAdmin();
|
||||
ct.innerHTML = `
|
||||
<div class="tab-nav" id="ticket-tabs">
|
||||
<button data-tab="all" class="active">全部</button>
|
||||
<button data-tab="pending">待认领</button>
|
||||
<button data-tab="claimed">已接单</button>
|
||||
${Auth.user().role === 'player' ? '<button data-tab="mine">我的工单</button>' : ''}
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<select id="ft-type" onchange="TicketsPage.applyFilters()"><option value="">全部类型</option>${Object.entries(U.TYPE).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="ft-status" onchange="TicketsPage.applyFilters()"><option value="">全部状态</option>${Object.entries(U.STATUS).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="ft-priority" onchange="TicketsPage.applyFilters()"><option value="">全部优先级</option>${Object.entries(U.PRIORITY).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<input id="ft-search" placeholder="搜索..." onkeyup="if(event.key==='Enter')TicketsPage.applyFilters()">
|
||||
<button class="btn btn-o btn-sm" onclick="TicketsPage.applyFilters()"><i class="fas fa-search"></i></button>
|
||||
<button class="btn btn-o btn-sm" onclick="TicketsPage.resetFilters()"><i class="fas fa-undo"></i></button>
|
||||
</div>
|
||||
<div id="ticket-list"></div>
|
||||
`;
|
||||
|
||||
document.querySelectorAll('#ticket-tabs button').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
document.querySelectorAll('#ticket-tabs button').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
this.tab = btn.dataset.tab;
|
||||
this.loadTab(this.tab);
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async loadTab(tab) {
|
||||
const list = document.getElementById('ticket-list');
|
||||
U.loading(list);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (this.filters.type) params.set('type', this.filters.type);
|
||||
if (this.filters.status) params.set('status', this.filters.status);
|
||||
if (this.filters.priority) params.set('priority', this.filters.priority);
|
||||
if (this.filters.search) params.set('search', this.filters.search);
|
||||
|
||||
if (tab === 'pending') params.set('claimed', '0');
|
||||
else if (tab === 'claimed') params.set('claimed', '1');
|
||||
else if (tab === 'mine') params.set('status', '');
|
||||
|
||||
const qs = params.toString();
|
||||
const tickets = await API.get('/tickets' + (qs ? '?' + qs : ''));
|
||||
|
||||
if (tab === 'mine') {
|
||||
this.renderList(list, tickets);
|
||||
return;
|
||||
}
|
||||
this.renderList(list, tickets);
|
||||
} catch (e) { list.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
},
|
||||
|
||||
renderList(ct, tickets) {
|
||||
ct.innerHTML = tickets.length === 0
|
||||
? `<div class="empty"><i class="fas fa-inbox"></i><p>暂无工单</p></div>`
|
||||
: `<div class="card table-wrap"><table><thead><tr>
|
||||
<th>#</th><th>类型</th><th>标题</th><th>提交者</th><th>对象</th><th>状态</th><th>优先级</th><th>负责人</th><th>时间</th><th>操作</th>
|
||||
</tr></thead><tbody>${tickets.map(t => `
|
||||
<tr>
|
||||
<td>${t.id}</td>
|
||||
<td>${U.badge(t.type,'type')}</td>
|
||||
<td><a href="#" onclick="App.navigate('ticket',${t.id});return false" style="color:var(--p);text-decoration:none;font-weight:600">${U.esc(t.title)}</a></td>
|
||||
<td>${U.esc(t.reporter_game_name)}<br><span class="tm ts">UID:${U.esc(t.reporter_game_uid)}</span></td>
|
||||
<td>${t.target_game_name?`${U.esc(t.target_game_name)}${t.target_game_uid?`<br><span class="tm ts">UID:${U.esc(t.target_game_uid)}</span>`:''}`:'-'}</td>
|
||||
<td>${U.badge(t.status,'status')}</td>
|
||||
<td>${U.badge(t.priority,'priority')}</td>
|
||||
<td>${t.assignee_name?`<span class="badge bg-admin">${U.esc(t.assignee_name)}</span>`:`<span class="tm">待认领</span>`}</td>
|
||||
<td class="ts">${U.date(t.updated_at)}</td>
|
||||
<td><button class="btn btn-o btn-sm" onclick="App.navigate('ticket',${t.id})"><i class="fas fa-eye"></i></button></td>
|
||||
</tr>`).join('')}</tbody></table></div>`;
|
||||
},
|
||||
|
||||
applyFilters() {
|
||||
this.filters = {
|
||||
type: document.getElementById('ft-type').value,
|
||||
status: document.getElementById('ft-status').value,
|
||||
priority: document.getElementById('ft-priority').value,
|
||||
search: document.getElementById('ft-search').value,
|
||||
};
|
||||
this.loadTab(this.tab);
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.filters = {};
|
||||
document.querySelectorAll('.filter-bar select, .filter-bar input').forEach(el => { if (el.tagName === 'SELECT' || el.tagName === 'INPUT') el.value = ''; });
|
||||
this.loadTab(this.tab);
|
||||
}
|
||||
};
|
||||
38
public/js/pages/tracking.js
Normal file
38
public/js/pages/tracking.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const TrackingPage = {
|
||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||
|
||||
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 = `
|
||||
<div class="card"><div class="card-b">
|
||||
<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>
|
||||
<div id="track-result" class="mt-4"></div>
|
||||
</div></div>
|
||||
`;
|
||||
},
|
||||
|
||||
async search() {
|
||||
const token = document.getElementById('track-token').value.trim();
|
||||
if (!token) return alert('请输入追踪标识');
|
||||
const ct = document.getElementById('track-result');
|
||||
U.loading(ct);
|
||||
try {
|
||||
const tickets = await API.get(`/tickets/tracking?token=${token}`);
|
||||
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 => `
|
||||
<tr>
|
||||
<td>${t.id}</td><td>${U.badge(t.type,'type')}</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>
|
||||
</tr>`).join('')}</tbody></table></div>
|
||||
<p class="tm ts mt-4">如需获取更详细的跟踪和回复通知,请<a href="#/register" style="color:var(--p)">注册账号</a></p>
|
||||
` : '<div class="empty"><i class="fas fa-inbox"></i><p>未找到工单</p></div>';
|
||||
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
}
|
||||
};
|
||||
69
public/js/pages/unclaimed.js
Normal file
69
public/js/pages/unclaimed.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const UnclaimedPage = {
|
||||
filters: {},
|
||||
|
||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||
|
||||
async mount() {
|
||||
document.getElementById('page-title').textContent = '待处理工单';
|
||||
document.getElementById('page-actions').innerHTML = '';
|
||||
|
||||
const ct = document.getElementById('page-content');
|
||||
ct.innerHTML = `
|
||||
<div class="filter-bar">
|
||||
<select id="u-type" onchange="UnclaimedPage.applyFilters()"><option value="">全部类型</option>${Object.entries(U.TYPE).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<button class="btn btn-o btn-sm" onclick="UnclaimedPage.applyFilters()"><i class="fas fa-filter"></i></button>
|
||||
<button class="btn btn-o btn-sm" onclick="UnclaimedPage.reset()"><i class="fas fa-undo"></i></button>
|
||||
</div>
|
||||
<div id="unclaimed-list"></div>
|
||||
`;
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const list = document.getElementById('unclaimed-list');
|
||||
U.loading(list);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (this.filters.type) params.set('type', this.filters.type);
|
||||
const qs = params.toString();
|
||||
const tickets = await API.get('/tickets/unclaimed' + (qs ? '?' + qs : ''));
|
||||
this.renderList(list, tickets);
|
||||
} catch (e) { list.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
},
|
||||
|
||||
renderList(ct, tickets) {
|
||||
ct.innerHTML = tickets.length === 0
|
||||
? `<div class="empty"><i class="fas fa-check-circle"></i><p>所有工单已处理完毕</p></div>`
|
||||
: `<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><th>优先级</th><th>时间</th><th>操作</th>
|
||||
</tr></thead><tbody>${tickets.map(t => `
|
||||
<tr>
|
||||
<td>${t.id}</td><td>${U.badge(t.type,'type')}</td>
|
||||
<td><a href="#" onclick="App.navigate('ticket',${t.id});return false" style="color:var(--p);text-decoration:none;font-weight:600">${U.esc(t.title)}</a></td>
|
||||
<td>${U.esc(t.reporter_game_name)}<br><span class="tm ts">UID:${U.esc(t.reporter_game_uid)}</span></td>
|
||||
<td>${t.type==='report'?`${U.esc(t.target_game_name||'?')}<br><span class="tm ts">${U.esc(t.reason||'')}</span>`
|
||||
:t.type==='appeal'?`<span class="tm ts">${U.esc(t.reason||'')}</span>`
|
||||
:`<span class="tm ts">${U.esc((t.description||'').substring(0,30))}</span>`}</td>
|
||||
<td>${U.badge(t.status,'status')}</td>
|
||||
<td>${U.badge(t.priority,'priority')}</td>
|
||||
<td class="ts">${U.date(t.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-s btn-sm" onclick="UnclaimedPage.quickClaim(${t.id})"><i class="fas fa-hand-paper"></i> 接单</button>
|
||||
<button class="btn btn-o btn-sm" onclick="App.navigate('ticket',${t.id})"><i class="fas fa-eye"></i></button>
|
||||
</td>
|
||||
</tr>`).join('')}</tbody></table></div></div>`;
|
||||
},
|
||||
|
||||
async quickClaim(id) {
|
||||
U.modal('接单认领', `<form id="qc-form"><div class="form-group"><label>初始回复 (玩家可见)</label><textarea id="qc-reply" rows="3" placeholder="接单时发送给玩家的回复..."></textarea></div><div class="form-group"><label>处理备注 (内部)</label><textarea id="qc-note" rows="2" placeholder="内部备注,玩家不可见"></textarea></div><div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-s"><i class="fas fa-hand-paper"></i> 确认接单</button></div></form>`);
|
||||
document.getElementById('qc-form').onsubmit = async e => { e.preventDefault();
|
||||
try { await API.post(`/tickets/${id}/claim`, {claim_note: document.getElementById('qc-note').value, initial_reply: document.getElementById('qc-reply').value}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
|
||||
};
|
||||
},
|
||||
|
||||
applyFilters() {
|
||||
this.filters.type = document.getElementById('u-type').value;
|
||||
this.load();
|
||||
},
|
||||
reset() { this.filters = {}; this.load(); }
|
||||
};
|
||||
84
public/js/pages/users.js
Normal file
84
public/js/pages/users.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const UsersPage = {
|
||||
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||
|
||||
async mount() {
|
||||
document.getElementById('page-title').textContent = '用户管理';
|
||||
document.getElementById('page-actions').innerHTML = '<button class="btn btn-p btn-sm" onclick="UsersPage.createUser()"><i class="fas fa-user-plus"></i> 添加用户</button>';
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const ct = document.getElementById('page-content');
|
||||
try {
|
||||
const users = await API.get('/users');
|
||||
ct.innerHTML = `
|
||||
<div class="card table-wrap"><table><thead><tr>
|
||||
<th>#</th><th>用户名</th><th>邮箱</th><th>游戏名</th><th>游戏UID</th><th>角色</th><th>状态</th><th>时间</th><th>操作</th>
|
||||
</tr></thead><tbody>${users.map(u => `
|
||||
<tr>
|
||||
<td>${u.id}</td><td>${U.esc(u.username)}</td><td>${U.esc(u.email)}</td>
|
||||
<td>${U.esc(u.game_name)}</td><td>${U.esc(u.game_uid)}</td>
|
||||
<td>${U.badge(u.role,'role')}</td>
|
||||
<td>${u.active ? '<span class="badge bg-resolved">启用</span>' : '<span class="badge bg-rejected">禁用</span>'}</td>
|
||||
<td class="ts">${U.date(u.created_at)}</td>
|
||||
<td><button class="btn btn-o btn-sm" onclick="UsersPage.editUser(${u.id}, '${U.esc(u.username)}', '${U.esc(u.email)}', '${U.esc(u.game_name)}', '${U.esc(u.game_uid)}', '${u.role}', ${u.active})"><i class="fas fa-edit"></i></button></td>
|
||||
</tr>`).join('')}</tbody></table></div>
|
||||
`;
|
||||
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
},
|
||||
|
||||
createUser() {
|
||||
U.modal('添加用户', `
|
||||
<form id="cu-form">
|
||||
<div class="grid-2"><div class="form-group"><label>用户名 *</label><input id="cu-u" required></div><div class="form-group"><label>密码 *</label><input type="password" id="cu-p" required minlength="6"></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>邮箱 *</label><input type="email" id="cu-e" required></div><div class="form-group"><label>角色 *</label><select id="cu-r"><option value="owner">服主</option><option value="admin">管理员</option><option value="player">玩家</option></select></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>游戏名 *</label><input id="cu-gn" required></div><div class="form-group"><label>游戏UID *</label><input id="cu-gu" required></div></div>
|
||||
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">创建</button></div>
|
||||
</form>
|
||||
`);
|
||||
document.getElementById('cu-form').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await API.post('/users', {
|
||||
username: document.getElementById('cu-u').value,
|
||||
password: document.getElementById('cu-p').value,
|
||||
email: document.getElementById('cu-e').value,
|
||||
role: document.getElementById('cu-r').value,
|
||||
game_name: document.getElementById('cu-gn').value,
|
||||
game_uid: document.getElementById('cu-gu').value,
|
||||
});
|
||||
App.closeModal();
|
||||
this.load();
|
||||
} catch (ex) { alert(ex.message); }
|
||||
};
|
||||
},
|
||||
|
||||
editUser(id, username, email, gameName, gameUid, role, active) {
|
||||
U.modal('编辑用户', `
|
||||
<form id="eu-form">
|
||||
<div class="form-group"><label>用户名</label><input value="${username}" disabled style="background:var(--g100)"></div>
|
||||
<div class="grid-2"><div class="form-group"><label>邮箱</label><input type="email" id="eu-e" value="${email}"></div><div class="form-group"><label>角色</label><select id="eu-r">${['owner','admin','player'].map(r=>`<option value="${r}" ${r===role?'selected':''}>${U.ROLE[r]}</option>`).join('')}</select></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>游戏名</label><input id="eu-gn" value="${gameName}"></div><div class="form-group"><label>游戏UID</label><input id="eu-gu" value="${gameUid}"></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>新密码(留空不变)</label><input type="password" id="eu-p"></div><div class="form-group"><label>状态</label><select id="eu-a"><option value="1" ${active?'selected':''}>启用</option><option value="0" ${!active?'selected':''}>禁用</option></select></div></div>
|
||||
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">保存</button></div>
|
||||
</form>
|
||||
`);
|
||||
document.getElementById('eu-form').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
email: document.getElementById('eu-e').value,
|
||||
role: document.getElementById('eu-r').value,
|
||||
game_name: document.getElementById('eu-gn').value,
|
||||
game_uid: document.getElementById('eu-gu').value,
|
||||
active: document.getElementById('eu-a').value === '1',
|
||||
};
|
||||
const pw = document.getElementById('eu-p').value;
|
||||
if (pw) data.password = pw;
|
||||
try {
|
||||
await API.put(`/users/${id}`, data);
|
||||
App.closeModal();
|
||||
this.load();
|
||||
} catch (ex) { alert(ex.message); }
|
||||
};
|
||||
}
|
||||
};
|
||||
59
public/js/utils.js
Normal file
59
public/js/utils.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const U = {
|
||||
STATUS: { pending:'待处理', processing:'处理中', awaiting_info:'待补充', resolved:'已解决', rejected:'已驳回', closed:'已关闭' },
|
||||
TYPE: { report:'举报', suggestion:'建议', appeal:'申诉' },
|
||||
ROLE: { owner:'服主', admin:'管理员', player:'玩家' },
|
||||
PRIORITY: { low:'低', medium:'中', high:'高', urgent:'紧急' },
|
||||
|
||||
badge(s, t) {
|
||||
if (t === 'status') return `<span class="badge bg-${s}">${this.STATUS[s]||s}</span>`;
|
||||
if (t === 'type') return `<span class="badge bg-${s}">${this.TYPE[s]||s}</span>`;
|
||||
if (t === 'role') return `<span class="badge bg-${s}">${this.ROLE[s]||s}</span>`;
|
||||
if (t === 'priority') return `<span class="badge bg-${s}">${this.PRIORITY[s]||s}</span>`;
|
||||
return s;
|
||||
},
|
||||
|
||||
date(d) {
|
||||
if (!d) return '-';
|
||||
const dt = new Date(d);
|
||||
if (isNaN(dt.getTime())) return d;
|
||||
const p = n => String(n).padStart(2, '0');
|
||||
return `${dt.getFullYear()}-${p(dt.getMonth()+1)}-${p(dt.getDate())} ${p(dt.getHours())}:${p(dt.getMinutes())}`;
|
||||
},
|
||||
|
||||
esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
},
|
||||
|
||||
showAlert(containerId, type, msg) {
|
||||
const ct = document.getElementById(containerId || 'page-content');
|
||||
const el = document.createElement('div');
|
||||
el.className = `alert alert-${type === 'success' ? 's' : type === 'error' ? 'e' : 'i'}`;
|
||||
el.textContent = msg;
|
||||
ct.prepend(el);
|
||||
setTimeout(() => el.remove(), 6000);
|
||||
},
|
||||
|
||||
loading(ct) { ct.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>'; },
|
||||
|
||||
modal(title, html, w) {
|
||||
const o = document.getElementById('modal-overlay');
|
||||
document.getElementById('modal-title').textContent = title;
|
||||
document.getElementById('modal-body').innerHTML = html;
|
||||
if (w) o.querySelector('.modal-box').style.width = w;
|
||||
o.classList.remove('hidden');
|
||||
},
|
||||
|
||||
confirm(msg) {
|
||||
return new Promise(resolve => {
|
||||
this.modal('确认', `
|
||||
<p>${msg}</p>
|
||||
<div class="modal-f"><button class="btn btn-o btn-confirm-cancel">取消</button><button class="btn btn-p btn-confirm-ok">确认</button></div>
|
||||
`);
|
||||
setTimeout(() => {
|
||||
document.querySelector('.btn-confirm-cancel')?.addEventListener('click', () => { App.closeModal(); resolve(false); });
|
||||
document.querySelector('.btn-confirm-ok')?.addEventListener('click', () => { App.closeModal(); resolve(true); });
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user