feat: MC Report System - MySQL + Express + Vanilla JS SPA

This commit is contained in:
2026-07-12 01:23:19 +08:00
commit 247a4e851d
52 changed files with 5710 additions and 0 deletions

45
public/js/api.js Normal file
View 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
View 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
View 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'); },
};

View 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); }
});
}
};

View 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> 提交';
}
});
}
};

View 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>
`;
}
};

View 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
View 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>`; });
}
};

View 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
View 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
View 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 &nbsp; admin/admin123 &nbsp; 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> 登录';
}
});
}
};

View 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
View 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>
`}
`;
}
};

View 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> 注册';
}
});
}
};

View 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>
`}
`;
}
};

View 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>`;
}
}
};

View 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,'&quot;')}" required></div>
<div class="form-group"><label>邮件正文 (HTML)</label><textarea id="et-body" rows="14" required>${U.esc(t.body).replace(/"/g,'&quot;')}</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); }
}
};

View 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> 提交';
}
});
}
};

View 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
View 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);
}
};

View 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>`; }
}
};

View 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
View 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
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
},
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);
});
}
};