Files
MC_Report/public/js/pages/users.js
canglan 8c5ce78fd0 chore: add AGPLv3 copyright header to all source files
- 52 JS files (backend + public/js): header with
  Copyright (C) 2026 Sea Network Technology Studio
  Author: CangLan <admin@sea-studio.top>
  + AGPLv3 notice
- idempotent (skips if header present), all syntax-checked
2026-08-19 20:27:05 +08:00

265 lines
17 KiB
JavaScript

/*
* 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 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" data-action="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><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><span class="badge bg-admin" data-src="${U.esc(u.source||'')}">${U.esc(u.source||'-')}</span></td>
<td>${(u.identity_count||0) > 1 ? `<span class="badge bg-processing">${u.identity_count} 个</span>` : (u.identity_count||0) === 1 ? '<span class="badge bg-admin">1</span>' : '<span class="tm ts">-</span>'}</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" data-action="UsersPage:editUser:${u.id}:${U.escJs(u.username)}:${U.escJs(u.email)}:${U.escJs(u.game_name)}:${U.escJs(u.game_uid)}:${u.role}:${u.active}:${u.source||''}"><i class="fas fa-edit"></i></button></td>
</tr>`).join('')}</tbody></table></div>
`;
// 来源 code → 动态标签
U.sources().then(() => {
ct.querySelectorAll('[data-src]').forEach(el => {
const hit = (U._sourcesCache||[]).find(s => s.code === el.dataset.src);
if (hit) el.textContent = hit.label;
});
}).catch(()=>{});
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
},
createUser() {
this.loadServersForCreate();
},
async loadServersForCreate() {
let groups = [];
try { groups = await API.get('/polls/groups'); } catch {}
const checks = groups.map(g => {
return `<label style="display:inline-flex;align-items:center;gap:4px;margin:2px 8px 2px 0;font-size:12px;white-space:nowrap"><input type="checkbox" value="${g.group_name}::${g.server_name}" class="cu-srv"> ${g.group_name}/${g.server_name}</label>`;
}).join('');
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-s"><option value="">不指定</option></select></div></div>
<div class="grid-2"><div class="form-group"><label>角色 *</label><select id="cu-r" onchange="UsersPage.toggleCreateServers()">${Auth.isOwner()?'<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" placeholder="选填"></div></div>
<div class="form-group hidden" id="cu-servers"><label>管辖子服务器</label><div style="max-height:120px;overflow-y:auto;border:1px solid var(--g200);border-radius:var(--r);padding:8px">${checks||'<span class="tm">暂无可选服务器</span>'}</div></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>
`);
// 动态加载来源下拉
U.sources().then(list => {
const sel = document.getElementById('cu-s');
if (sel) sel.innerHTML = '<option value="">不指定</option>' + list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).join('');
}).catch(()=>{});
document.getElementById('cu-form').onsubmit = async e => {
e.preventDefault();
const servers = [];
document.querySelectorAll('.cu-srv:checked').forEach(cb => { const [g,s] = cb.value.split('::'); servers.push({group_name:g,server_name:s}); });
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,
source: document.getElementById('cu-s').value,
admin_servers: servers,
});
App.closeModal(); this.load();
} catch (ex) { alert(ex.message); }
};
setTimeout(() => UsersPage.toggleCreateServers(), 50);
},
toggleCreateServers() {
const role = document.getElementById('cu-r').value;
const div = document.getElementById('cu-servers');
div.classList.toggle('hidden', role === 'player');
},
editUser(id, username, email, gameName, gameUid, role, active, source) {
this.loadUserServers(id, username, email, gameName, gameUid, role, active, source);
},
async loadUserServers(id, username, email, gameName, gameUid, role, active, source) {
let groups = [], userServers = [], identities = [];
try { groups = await API.get('/polls/groups'); } catch {}
try { userServers = await API.get('/users/'+id+'/servers'); } catch {}
try { identities = await API.get('/users/'+id).then(u => u.identities || []); } catch {}
const checks = groups.map(g => {
const key = g.group_name+'::'+g.server_name;
const has = userServers.some(s => s.group_name===g.group_name && s.server_name===g.server_name);
return `<label style="display:inline-flex;align-items:center;gap:4px;margin:2px 8px 2px 0;font-size:12px;white-space:nowrap"><input type="checkbox" value="${key}" class="eu-srv" ${has?'checked':''}> ${g.group_name}/${g.server_name}</label>`;
}).join('');
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-s"><option value="${U.esc(source||'')}" selected>${U.esc(source||'不指定')}</option></select></div></div>
<div class="grid-2"><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="form-group"><label>管辖子服务器</label><div style="max-height:120px;overflow-y:auto;border:1px solid var(--g200);border-radius:var(--r);padding:8px">${checks||'<span class="tm">无可用服务器(先在投票→分组管理中添加)</span>'}</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="form-group"><label>已绑定身份 (多来源)</label>
<div style="border:1px solid var(--g200);border-radius:var(--r);padding:8px;margin-bottom:6px" id="identities-box">
${identities.length===0?'<span class="tm">暂无身份记录</span>':identities.map(it=>`
<div style="display:flex;align-items:center;gap:6px;padding:4px 0">
<span class="badge bg-admin" data-src="${U.esc(it.source)}">${U.esc(it.source||'-')}</span>
<span>${U.esc(it.game_name)}${it.game_uid?' (UID: '+U.esc(it.game_uid)+')':''}</span>
<span class="tm ts">${U.date(it.created_at)}</span>
${identities.length>1?`<button type="button" class="btn btn-d btn-sm" onclick="UsersPage.removeIdentity(${id},${it.id})"><i class="fas fa-unlink"></i></button>`:'<span class="tm ts">主身份</span>'}
</div>`).join('')}
</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:nowrap">
<select id="eu-ident-source" style="flex:0 0 92px;width:92px;min-width:92px;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px;background:white">
<option value="">加载中...</option>
</select>
<input id="eu-ident-name" placeholder="游戏名" style="flex:1 1 30%;min-width:0;width:auto;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px">
<input id="eu-ident-uid" placeholder="UID(可选)" style="flex:1 1 30%;min-width:0;width:auto;padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px">
<button type="button" class="btn btn-o btn-sm" id="eu-ident-fetch" style="flex:0 0 auto;white-space:nowrap" title="皮肤站用户名查UUID"><i class="fas fa-magnifying-glass"></i></button>
<button type="button" class="btn btn-p btn-sm" style="flex:0 0 auto;white-space:nowrap" onclick="UsersPage.addIdentity(${id})"><i class="fas fa-plus"></i> 绑定</button>
</div>
<div class="form-group hidden" id="eu-ident-site-group" style="margin-top:6px">
<input id="eu-ident-site" placeholder="皮肤站地址 (如 https://littleskin.cn)" style="padding:6px 8px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px;width:100%">
<span class="help">选皮肤站来源时,可输入用户名点搜索图标自动获取UUID</span>
</div>
</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('eu-form').onsubmit = async e => {
e.preventDefault();
const servers = [];
document.querySelectorAll('.eu-srv:checked').forEach(cb => { const [g,s] = cb.value.split('::'); servers.push({group_name:g,server_name:s}); });
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', source: document.getElementById('eu-s').value,
admin_servers: servers,
};
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); }
};
// 动态加载来源(主身份下拉 + 身份绑定下拉), 并替换 data-src 标签
U.sources().then(list => {
const opts = list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).join('');
const sel = document.getElementById('eu-s');
if (sel && opts) {
const cur = sel.value;
sel.innerHTML = opts;
if (cur && list.some(s => s.code === cur)) sel.value = cur;
}
const sel2 = document.getElementById('eu-ident-source');
if (sel2 && opts) {
sel2.innerHTML = opts;
euToggleSource();
}
document.querySelectorAll('#eu-form [data-src]').forEach(el => {
const hit = list.find(s => s.code === el.dataset.src);
if (hit) el.textContent = hit.label;
});
}).catch(()=>{});
// 来源切换: 皮肤站时显示 UUID 查询辅助
const euToggleSource = () => {
const sel = document.getElementById('eu-ident-source');
if (!sel) return;
const isSkin = sel.value === 'skin';
document.getElementById('eu-ident-site-group').classList.toggle('hidden', !isSkin);
};
document.getElementById('eu-ident-source').onchange = euToggleSource;
euToggleSource();
// 自动带出站点配置的皮肤站地址
API.get('/settings/skin-site').then(r => {
if (r.site && !document.getElementById('eu-ident-site').value) document.getElementById('eu-ident-site').value = r.site;
}).catch(()=>{});
// 皮肤站用户名 → UUID 查询
document.getElementById('eu-ident-fetch').onclick = async () => {
const site = document.getElementById('eu-ident-site').value.trim();
const name = document.getElementById('eu-ident-name').value.trim();
if (!site) return alert('请填写皮肤站地址(来源为皮肤站时显示)');
if (!name) return alert('请先填写游戏名(皮肤站用户名)');
const btn = document.getElementById('eu-ident-fetch');
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('eu-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>';
};
},
async addIdentity(userId) {
const source = document.getElementById('eu-ident-source').value;
const name = document.getElementById('eu-ident-name').value.trim();
const uid = document.getElementById('eu-ident-uid').value.trim();
if (!name) return alert('请填写游戏名');
try {
await API.post(`/users/${userId}/identities`, { source, game_name: name, game_uid: uid });
alert('绑定成功');
// 刷新弹窗中的身份列表
const u = await API.get('/users/'+userId);
const box = document.getElementById('identities-box');
const list = u.identities || [];
box.innerHTML = list.length===0 ? '<span class="tm">暂无身份记录</span>' : list.map(it=>`
<div style="display:flex;align-items:center;gap:6px;padding:4px 0">
<span class="badge bg-${it.source==='netease'?'processing':'admin'}">${it.source==='netease'?'网易端':'皮肤站'}</span>
<span>${U.esc(it.game_name)}${it.game_uid?' (UID: '+U.esc(it.game_uid)+')':''}</span>
<span class="tm ts">${U.date(it.created_at)}</span>
${list.length>1?`<button type="button" class="btn btn-d btn-sm" onclick="UsersPage.removeIdentity(${userId},${it.id})"><i class="fas fa-unlink"></i></button>`:'<span class="tm ts">主身份</span>'}
</div>`).join('');
document.getElementById('eu-ident-name').value = '';
document.getElementById('eu-ident-uid').value = '';
} catch (ex) { alert(ex.message); }
},
async removeIdentity(userId, identityId) {
if (!await U.confirm('确认解绑该身份?')) return;
try {
await API.delete(`/users/${userId}/identities/${identityId}`);
alert('已解绑');
const u = await API.get('/users/'+userId);
const box = document.getElementById('identities-box');
const list = u.identities || [];
box.innerHTML = list.length===0 ? '<span class="tm">暂无身份记录</span>' : list.map(it=>`
<div style="display:flex;align-items:center;gap:6px;padding:4px 0">
<span class="badge bg-${it.source==='netease'?'processing':'admin'}">${it.source==='netease'?'网易端':'皮肤站'}</span>
<span>${U.esc(it.game_name)}${it.game_uid?' (UID: '+U.esc(it.game_uid)+')':''}</span>
<span class="tm ts">${U.date(it.created_at)}</span>
${list.length>1?`<button type="button" class="btn btn-d btn-sm" onclick="UsersPage.removeIdentity(${userId},${it.id})"><i class="fas fa-unlink"></i></button>`:'<span class="tm ts">主身份</span>'}
</div>`).join('');
} catch (ex) { alert(ex.message); }
}
};