feat: admin user management supports multi-identity bind/unbind

- users list: identity_count column (backend subquery)
- user detail: returns identities array
- POST /users/:id/identities, DELETE /users/:id/identities/:identityId
  (owner protection, dup checks, keep >=1, audit logs)
- users page: identity column in table + manage section in edit modal
- API docs updated
This commit is contained in:
2026-08-17 01:34:20 +08:00
parent 4c221ad6f1
commit 25a766591e
3 changed files with 131 additions and 4 deletions

View File

@@ -14,12 +14,13 @@ const UsersPage = {
const srcLabel = { netease:'网易', skin:'皮肤站' };
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><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>${srcLabel[u.source] || u.source || '网易'}</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>
@@ -79,9 +80,10 @@ const UsersPage = {
},
async loadUserServers(id, username, email, gameName, gameUid, role, active, source) {
let groups = [], userServers = [];
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);
@@ -96,6 +98,23 @@ const UsersPage = {
<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-${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>
${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">
<select id="eu-ident-source" style="padding:6px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px"><option value="netease">网易端</option><option value="skin">皮肤站</option></select>
<input id="eu-ident-name" placeholder="游戏名" style="flex:1;padding:6px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px">
<input id="eu-ident-uid" placeholder="UID(网易必填)" style="flex:1;padding:6px;border:1px solid var(--g300);border-radius:var(--r);font-size:12px">
<button type="button" class="btn btn-p btn-sm" onclick="UsersPage.addIdentity(${id})"><i class="fas fa-plus"></i> 绑定</button>
</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>
`);
@@ -112,5 +131,47 @@ const UsersPage = {
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); }
};
},
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); }
}
};