Files
MC_Report/public/js/pages/ticket-create.js
canglan 65edbaf157 refactor: session-based external auth, dynamic sources, drop netease UID
Auth (external API):
- ID: 16-digit random (non-sequential); Secret: SeaReport- + 32 hex
- POST /auth/session: ID+Secret -> Bearer SESSION (24h, single-session,
  old session invalidated on re-issue, disabled client invalidates)
- clientAuth now validates Bearer SESSION via api_sessions JOIN api_clients

Sources (dynamic, no default, open-source friendly):
- sources table + CRUD route (/api/sources, owner; delete guarded by usage)
- users/user_identities.source ENUM -> VARCHAR, seeded netease/skin
- register/admin create/identity bind: validate against enabled sources
- UI: 来源管理 page; source dropdowns loaded dynamically everywhere
  (register, dashboard identity, users admin, bans), labels dynamic

UID removal:
- game_uid/reporter_game_uid no longer required (db default '', validations
  dropped, frontend fields optional)

Docs: EXTERNAL-API.md session flow + new credential format; API.md updated
Verified: 37 checks (syntax, session logic, source CRUD, UID removal, docs)
2026-08-19 20:08:06 +08:00

204 lines
9.6 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.
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');
if (sel && this.identities.length > 1) {
sel.innerHTML = this.identities.map((it, i) => `<option value="${i}">${U.esc(it.source)}${U.esc(it.game_name)}${it.game_uid ? ' (UID: ' + U.esc(it.game_uid) + ')' : ''}</option>`).join('');
sel.closest('.form-group').classList.remove('hidden');
}
}).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="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>
<span class="help">同一账号可绑定网易端 + 皮肤站身份</span>
</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" 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>
<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('home');
} catch (ex) {
alert(ex.message);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane"></i> 提交';
}
});
}
};