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

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