Files
MC_Report/public/js/pages/ticket-create.js
canglan 1979cab5ec feat: anonymous ticket tracking as standalone public page
- tracking: promoted to public route (before auth gate) with full single-ticket view
- backend: GET /tickets/tracking/:id validates cookie tracking_token, returns responses/attachments/transfers
- tracking.js: rewrite - token list + chat-flow detail (player/staff bubbles) + processing log + anon reply
- ticket-create: anon submit now lands on #/tracking instead of home
- home: tracking tab navigates to standalone page
2026-08-23 17:50:42 +08:00

245 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* MC Report System
* Copyright (C) 2026 Sea Network Technology Studio
* Author: CangLan <admin@sea-studio.top>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const TicketCreate = {
files: [],
render(container, { type, prefill, backLink, hideTabs }) {
const user = Auth.user();
const isReport = type === 'report';
const isSuggestion = type === 'suggestion';
const isAppeal = type === 'appeal';
const typeLabel = isReport ? '举报' : isAppeal ? '申诉' : '建议';
this.files = [];
this.identities = [];
// 登录用户: 拉取多来源身份(网易端+皮肤站可同时绑定)
if (user) {
API.get('/auth/identities').then(list => {
this.identities = list || [];
const sel = container.querySelector('#identity-select');
const nameGroup = container.querySelector('#reporter-name-group');
if (sel) {
if (this.identities.length > 0) {
// 有身份: 显示身份选择, 隐藏游戏名输入框(身份即代表提交者)
sel.innerHTML = this.identities.map((it, i) => `<option value="${i}">${U.esc(it.source)}${U.esc(it.game_name)}</option>`).join('');
sel.closest('.form-group').classList.remove('hidden');
if (nameGroup) {
nameGroup.classList.add('hidden');
const inp = nameGroup.querySelector('input[name="reporter_game_name"]');
if (inp) inp.required = false; // 隐藏字段不再参与原生必填校验
}
} else {
// 无身份: 保留游戏名输入框兜底
sel.closest('.form-group').classList.add('hidden');
if (nameGroup) nameGroup.classList.remove('hidden');
}
}
}).catch(() => {});
}
// 子服务器列表(server_groups: 分组/子服)
this.servers = [];
API.get('/polls/server-list').then(list => {
this.servers = list || [];
const sel = container.querySelector('#server-select');
if (sel) {
sel.innerHTML = '<option value="">不指定</option>' + this.servers.map(g =>
`<option value="${U.escJs(g.group_name)}/${U.escJs(g.server_name)}">${U.esc(g.group_name)} / ${U.esc(g.server_name)}</option>`).join('');
}
}).catch(() => {});
container.innerHTML = `
${hideTabs ? '' : `<div class="tab-nav" id="type-tabs">
<button class="${isReport?'active':''}" data-type="report">举报</button>
<button class="${isSuggestion?'active':''}" data-type="suggestion">建议</button>
<button class="${isAppeal?'active':''}" data-type="appeal">申诉</button>
</div>`}
<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="form-group">
<label>子服务器</label>
<select name="server" id="server-select"><option value="">不指定</option></select>
</div>
<div class="form-group">
<label>优先级</label>
<select name="priority">
<option value="low">低</option><option value="medium" selected>中</option><option value="high">高</option><option value="urgent">紧急</option>
</select>
</div>
${user ? `<div class="form-group hidden" id="identity-group">
<label>使用身份 *</label>
<select id="identity-select"></select>
</div>` : ''}
<div class="form-group" id="reporter-name-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>
${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>
<div class="form-group" style="display:flex;align-items:center;gap:8px;margin-top:12px">
<input type="checkbox" name="is_admin_complaint" value="1" id="cb-admin" style="width:auto">
<label for="cb-admin" style="margin:0;font-weight:500;text-transform:none;letter-spacing:0;color:var(--d)"><i class="fas fa-exclamation-triangle"></i> 投诉管理员(仅服主可见)</label>
</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 &nbsp; 每个最大50MB</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);
container.querySelectorAll('#type-tabs button').forEach(btn => {
btn.addEventListener('click', () => {
TicketCreate.render(container, { type: btn.dataset.type, prefill, backLink });
});
});
},
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 > 50 * 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);
// 身份选择: 选中多来源身份时覆盖游戏名/UID
const sel = container.querySelector('#identity-select');
if (sel && sel.value !== '') {
const it = this.identities[parseInt(sel.value)];
if (it) {
fd.set('reporter_game_name', it.game_name);
fd.set('reporter_game_uid', it.game_uid || '');
}
}
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('tracking'); // 匿名: 直达追踪页查看工单与回复
} catch (ex) {
alert(ex.message);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane"></i> 提交';
}
});
}
};