Files
MC_Report/public/js/pages/dashboard.js
canglan 1d669fd1d0 feat: skin site UUID lookup - auto-fetch UUID from player name
- GET /settings/uuid-lookup?site=&name= : proxy Yggdrasil API
  (POST {site}/api/yggdrasil/api/profiles/minecraft), SSRF guard,
  10s timeout, name regex, UUID formatting (with dashes)
- GET /settings/skin-site : public read of configured skin site
- settings page: 皮肤站地址 config
- bind identity modals (player dashboard + admin users): 获取UUID button,
  auto-fill site from settings, auto-fill UID from lookup
- verified live against littleskin.cn (Steve -> df273bda...)
- API docs updated
2026-08-17 02:05:21 +08:00

178 lines
12 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 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>
<button class="btn btn-w btn-sm" onclick="App.navigate('tickets','create?type=appeal')"><i class="fas fa-gavel"></i> 申诉</button>
`;
try {
const [stats, exports] = await Promise.all([
API.get('/tickets/stats'),
Auth.isOwner() ? API.get('/export/dashboard') : null,
]);
const bans = Auth.isAdmin() ? await API.get('/bans/active').catch(()=>[]) : [];
const identities = await API.get('/auth/identities').catch(()=>[]);
this.renderContent(ct, stats, exports, bans, identities);
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
},
renderContent(ct, stats, exports, bans, identities) {
const byStatus = stats.byStatus || {};
const banData = bans || [];
const idents = identities || [];
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() ? `
<div class="card"><div class="card-h"><i class="fas fa-ban"></i> 封禁列表 ${Auth.isOwner() ? `<button class="btn btn-p btn-sm" onclick="Dashboard.showAddBan()">添加</button>`:''}</div>
<div class="card-b">${banData.length === 0 ? '<span class="tm">暂无封禁记录</span>' : `<table><thead><tr><th>玩家</th><th>类型</th><th>原因</th><th>时长</th><th>时间</th></tr></thead><tbody>${banData.map(b=>`<tr><td>${U.esc(b.player_name)}</td><td>${b.type==='ban'?'封禁':b.type==='mute'?'禁言':'其他'}</td><td class="ts">${U.esc(b.reason||'')}</td><td>${b.duration||'永久'}</td><td class="ts">${U.date(b.created_at)}</td></tr>`).join('')}</tbody></table>`}</div>
</div>` : ''}
<div class="card"><div class="card-h"><i class="fas fa-id-card"></i> 我的身份 ${idents.length < 2 ? `<button class="btn btn-p btn-sm" onclick="Dashboard.showAddIdentity()">绑定${idents.length === 1 ? '另一来源' : '身份'}</button>` : ''}</div>
<div class="card-b">
${idents.length === 0 ? '<span class="tm">暂无身份,点击右上角绑定</span>' : `<table><thead><tr><th>来源</th><th>游戏名</th><th>UID</th><th>绑定时间</th><th>操作</th></tr></thead><tbody>${idents.map(it=>`<tr>
<td><span class="badge bg-${it.source==='netease'?'processing':'admin'}">${it.source==='netease'?'网易端':'皮肤站'}</span></td>
<td>${U.esc(it.game_name)}</td><td class="ts">${U.esc(it.game_uid||'-')}</td>
<td class="ts">${U.date(it.created_at)}</td>
<td>${idents.length > 1 ? `<button class="btn btn-d btn-sm" onclick="Dashboard.removeIdentity(${it.id})"><i class="fas fa-unlink"></i> 解绑</button>` : '<span class="tm ts">主身份</span>'}</td>
</tr>`).join('')}</tbody></table>`}
<p class="tm ts mt-4"><i class="fas fa-info-circle"></i> 同一账号可同时绑定网易端与皮肤站身份,提交工单时选择使用哪个身份。</p>
</div>
</div>
${Auth.isOwner() && 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>
`;
},
showAddBan() {
U.modal('添加封禁', `
<form id="ban-form">
<div class="grid-2"><div class="form-group"><label>玩家名 *</label><input id="ban-name" required></div><div class="form-group"><label>UID</label><input id="ban-uid"></div></div>
<div class="grid-2"><div class="form-group"><label>来源</label><select id="ban-source"><option value="">未知</option><option value="netease">网易端</option><option value="skin">皮肤站</option></select></div><div class="form-group"><label>类型</label><select id="ban-type" onchange="Dashboard.checkMinDuration()"><option value="ban">封禁</option><option value="mute">禁言</option><option value="warn">警告</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>时长</label><select id="ban-dur"><option value="">永久</option><option value="3天">3天</option><option value="7天">7天</option><option value="30天">30天</option></select></div></div>
<div class="form-group"><label>原因</label><textarea id="ban-reason" rows="2"></textarea></div>
<div class="modal-f"><button type="button" class="btn btn-o" data-action="App:closeModal">取消</button><button type="submit" class="btn btn-p">添加</button></div>
</form>
`);
document.getElementById('ban-form').onsubmit = async e => {
e.preventDefault();
try {
await API.post('/bans', { player_name: document.getElementById('ban-name').value, player_uid: document.getElementById('ban-uid').value, source: document.getElementById('ban-source').value, type: document.getElementById('ban-type').value, duration: document.getElementById('ban-dur').value, reason: document.getElementById('ban-reason').value });
App.closeModal(); this.mount();
} catch(ex) { alert(ex.message); }
};
setTimeout(() => Dashboard.checkMinDuration(), 100);
},
showAddIdentity() {
U.modal('绑定身份', `
<form id="ident-form">
<div class="form-group"><label>来源</label><select id="ident-source"><option value="netease">网易端</option><option value="skin">皮肤站</option></select></div>
<div class="form-group"><label>游戏名 *</label><input id="ident-name" required placeholder="Minecraft ID"></div>
<div class="form-group" id="ident-uid-group"><label>网易UID</label><input id="ident-uid" placeholder="仅网易端必填"></div>
<div class="form-group hidden" id="ident-skin-group">
<label>皮肤站地址</label>
<div style="display:flex;gap:6px">
<input id="ident-site" placeholder="https://littleskin.cn" style="flex:1">
<button type="button" class="btn btn-o btn-sm" id="ident-fetch-btn" style="flex:0 0 auto;white-space:nowrap"><i class="fas fa-magnifying-glass"></i> 获取UUID</button>
</div>
<span class="help">输入皮肤站用户名后点「获取UUID」自动填入下方UID</span>
</div>
<div id="ident-err" class="alert alert-e hidden"></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>
`);
const toggleSource = () => {
const isNetease = document.getElementById('ident-source').value === 'netease';
document.getElementById('ident-uid-group').style.display = isNetease ? '' : 'none';
document.getElementById('ident-uid').required = isNetease;
document.getElementById('ident-skin-group').classList.toggle('hidden', isNetease);
};
document.getElementById('ident-source').onchange = toggleSource;
toggleSource();
// 自动带出站点配置的皮肤站地址
API.get('/settings/skin-site').then(r => {
if (r.site && !document.getElementById('ident-site').value) document.getElementById('ident-site').value = r.site;
}).catch(()=>{});
document.getElementById('ident-fetch-btn').onclick = async () => {
const site = document.getElementById('ident-site').value.trim();
const name = document.getElementById('ident-name').value.trim();
if (!site) return alert('请填写皮肤站地址');
if (!name) return alert('请先填写游戏名(皮肤站用户名)');
const btn = document.getElementById('ident-fetch-btn');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 查询中';
try {
const r = await API.get(`/settings/uuid-lookup?site=${encodeURIComponent(site)}&name=${encodeURIComponent(name)}`);
if (r.found) {
document.getElementById('ident-uid').value = r.uuid;
alert('已获取 UUID: ' + r.uuid);
} else {
alert(r.message || '未找到该用户名');
}
} catch (ex) { alert(ex.message); }
btn.disabled = false; btn.innerHTML = '<i class="fas fa-magnifying-glass"></i> 获取UUID';
};
document.getElementById('ident-form').onsubmit = async e => {
e.preventDefault();
try {
await API.post('/auth/identities', {
source: document.getElementById('ident-source').value,
game_name: document.getElementById('ident-name').value,
game_uid: document.getElementById('ident-uid').value,
});
App.closeModal();
this.mount();
} catch (ex) {
const el = document.getElementById('ident-err');
el.textContent = ex.message;
el.classList.remove('hidden');
}
};
},
async removeIdentity(id) {
if (!await U.confirm('确认解绑该身份?')) return;
try { await API.delete(`/auth/identities/${id}`); this.mount(); }
catch (ex) { alert(ex.message); }
},
async checkMinDuration() {
const name = document.getElementById('ban-name')?.value;
const uid = document.getElementById('ban-uid')?.value;
const type = document.getElementById('ban-type')?.value;
if (!name && !uid) return;
try {
const r = await API.get('/bans/min-duration?player_name='+name+'&player_uid='+uid+'&type='+type);
const sel = document.getElementById('ban-dur');
if (r.minDays > 0) {
sel.innerHTML = `<option value="${r.minDays}天">≥ ${r.minDays}天</option><option value="30天">30天</option><option value="">永久</option>`;
} else {
sel.innerHTML = `<option value="">永久</option><option value="1天">1天</option><option value="3天">3天</option><option value="7天">7天</option><option value="30天">30天</option>`;
}
} catch {}
}
};