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)
This commit is contained in:
@@ -10,6 +10,7 @@ const App = {
|
||||
{ id: 'features', label: '更新内容', icon: 'fa-lightbulb', roles: ['owner'] },
|
||||
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] },
|
||||
{ id: 'external-api', label: '外部API', icon: 'fa-key', roles: ['owner'] },
|
||||
{ id: 'sources', label: '来源管理', icon: 'fa-tags', roles: ['owner'] },
|
||||
{ id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner'] },
|
||||
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner'] },
|
||||
{ id: 'logs', label: '系统日志', icon: 'fa-clipboard-list', roles: ['owner','admin'] },
|
||||
@@ -94,6 +95,7 @@ const App = {
|
||||
case 'users': this.renderMain('用户管理', UsersPage, param); break;
|
||||
case 'notifications': this.renderMain('通知配置', NotificationsPage, param); break;
|
||||
case 'external-api': this.renderMain('外部API', ExternalApiPage, param); break;
|
||||
case 'sources': this.renderMain('来源管理', SourcesPage, param); break;
|
||||
case 'templates': this.renderMain('邮件模板', TemplatesPage, param); break;
|
||||
case 'settings': this.renderMain('系统设置', SettingsPage, param); break;
|
||||
case 'export': this.renderMain('数据导出', ExportPage, param); break;
|
||||
@@ -176,6 +178,7 @@ window.UsersPage = UsersPage;
|
||||
window.TemplatesPage = TemplatesPage;
|
||||
window.NotificationsPage = NotificationsPage;
|
||||
window.ExternalApiPage = ExternalApiPage;
|
||||
window.SourcesPage = SourcesPage;
|
||||
window.SettingsPage = SettingsPage;
|
||||
window.ExportPage = ExportPage;
|
||||
window.PollsPage = PollsPage;
|
||||
|
||||
@@ -39,12 +39,16 @@ const BansPage = {
|
||||
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="BansPage.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-source"><option value="">未知</option></select></div><div class="form-group"><label>类型</label><select id="ban-type" onchange="BansPage.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="1天">1天</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>
|
||||
`);
|
||||
U.sources().then(list => {
|
||||
const sel = document.getElementById('ban-source');
|
||||
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('ban-form').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -80,12 +84,16 @@ const BansPage = {
|
||||
U.modal('编辑封禁', `
|
||||
<form id="be-form">
|
||||
<div class="grid-2"><div class="form-group"><label>玩家名</label><input id="be-name" value="${name}"></div><div class="form-group"><label>UID</label><input id="be-uid" value="${uid}"></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>来源</label><select id="be-source"><option value="">未知</option><option value="netease" ${source==='netease'?'selected':''}>网易端</option><option value="skin" ${source==='skin'?'selected':''}>皮肤站</option></select></div><div class="form-group"><label>状态</label><select id="be-status"><option value="active" ${status==='active'?'selected':''}>生效中</option><option value="lifted" ${status==='lifted'?'selected':''}>已解除</option><option value="expired" ${status==='expired'?'selected':''}>已过期</option></select></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>来源</label><select id="be-source"><option value="">未知</option></select></div><div class="form-group"><label>状态</label><select id="be-status"><option value="active" ${status==='active'?'selected':''}>生效中</option><option value="lifted" ${status==='lifted'?'selected':''}>已解除</option><option value="expired" ${status==='expired'?'selected':''}>已过期</option></select></div></div>
|
||||
<div class="grid-2"><div class="form-group"><label>时长</label><input id="be-dur" value="${duration}"></div></div>
|
||||
<div class="form-group"><label>原因</label><textarea id="be-reason" rows="2">${reason}</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>
|
||||
`);
|
||||
U.sources().then(list => {
|
||||
const sel = document.getElementById('be-source');
|
||||
if (sel) sel.innerHTML = '<option value="">未知</option>' + list.map(s => `<option value="${U.esc(s.code)}" ${s.code===source?'selected':''}>${U.esc(s.label)}</option>`).join('');
|
||||
}).catch(()=>{});
|
||||
document.getElementById('be-form').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,14 @@ const Dashboard = {
|
||||
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);
|
||||
// 渲染完成后将来源 code 替换为动态标签
|
||||
U.sources().then(() => {
|
||||
ct.querySelectorAll('[data-src]').forEach(el => {
|
||||
const code = el.dataset.src;
|
||||
const hit = (U._sourcesCache||[]).find(s => s.code === code);
|
||||
el.textContent = hit ? hit.label : (code || '-');
|
||||
});
|
||||
}).catch(()=>{});
|
||||
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
},
|
||||
|
||||
@@ -42,12 +50,12 @@ const Dashboard = {
|
||||
<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><span class="badge bg-admin" data-src="${U.esc(it.source)}">${U.esc(it.source||'-')}</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>
|
||||
<p class="tm ts mt-4"><i class="fas fa-info-circle"></i> 同一账号可绑定多个来源身份,提交工单时选择使用哪个身份。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,12 +79,16 @@ const Dashboard = {
|
||||
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-source"><option value="">未知</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>
|
||||
`);
|
||||
U.sources().then(list => {
|
||||
const sel = document.getElementById('ban-source');
|
||||
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('ban-form').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -90,9 +102,9 @@ const Dashboard = {
|
||||
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><select id="ident-source"><option value="">加载中...</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" id="ident-uid-group" style="display:none"><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">
|
||||
@@ -105,14 +117,21 @@ const Dashboard = {
|
||||
<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>
|
||||
`);
|
||||
// 动态加载来源
|
||||
U.sources().then(async list => {
|
||||
const sel = document.getElementById('ident-source');
|
||||
sel.innerHTML = list.length
|
||||
? list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).join('')
|
||||
: '<option value="">不指定</option>';
|
||||
toggleSource();
|
||||
}).catch(()=>{});
|
||||
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);
|
||||
const src = document.getElementById('ident-source').value;
|
||||
const isSkin = src === 'skin';
|
||||
document.getElementById('ident-uid-group').style.display = isSkin ? 'none' : '';
|
||||
document.getElementById('ident-skin-group').classList.toggle('hidden', !isSkin);
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -25,10 +25,12 @@ const ExternalApiPage = {
|
||||
<button class="btn btn-d btn-sm" onclick="ExternalApiPage.del(${c.id})"><i class="fas fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>`).join('')}</tbody></table></div>`}
|
||||
<div class="alert alert-i" style="margin-top:12px"><b>鉴权方式(唯一):</b> 请求头 <code>x-api-client-id</code> + <code>x-api-secret</code>。无凭据或凭据错误一律返回 401;secret 仅创建时显示一次,请立即保存。支持多个客户端,可单独停用/删除。</div>
|
||||
<div class="alert alert-i" style="margin-top:12px"><b>鉴权流程:</b> 先调 <code>POST /api/external/auth/session</code> 用 <code>x-api-client-id</code> + <code>x-api-secret</code> 换取 SESSION(24小时有效), 之后所有接口请求头用 <code>Authorization: Bearer <SESSION></code>。secret 仅创建时显示一次,请立即保存。</div>
|
||||
</div></div>
|
||||
|
||||
<div class="card"><div class="card-h"><i class="fas fa-book"></i> 接口速览(免用户登录, 需上述鉴权头)</div><div class="card-b"><div class="table-wrap"><table><thead><tr><th>方法</th><th>路径</th><th>说明</th></tr></thead><tbody>
|
||||
<div class="card"><div class="card-h"><i class="fas fa-book"></i> 接口速览(除换取SESSION外, 均需 Bearer SESSION)</div><div class="card-b"><div class="table-wrap"><table><thead><tr><th>方法</th><th>路径</th><th>说明</th></tr></thead><tbody>
|
||||
<tr><td>POST</td><td><code>/api/external/auth/session</code></td><td>ID+Secret 换取 SESSION(唯一使用 ID+Secret 的接口)</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/external/auth/register</code></td><td>插件注册账号(需 SESSION)</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/external/tickets</code></td><td>提交工单(举报/建议/申诉),返回 id + tracking_token;可带 <code>server</code>(别名或「分组/子服」)</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/external/tickets/track?token=xxx</code></td><td>按追踪码查工单状态 + 回复,从提交到结束全程可查</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/external/all-tickets?server=&status=&type=&page=&limit=</code></td><td>工单列表(可按子服过滤)</td></tr>
|
||||
|
||||
@@ -2,6 +2,17 @@ const RegisterPage = {
|
||||
captcha: null,
|
||||
codeId: null,
|
||||
|
||||
async mount() {
|
||||
// 动态加载来源下拉(不设默认来源)
|
||||
U.sources().then(list => {
|
||||
const sel = document.getElementById('rs');
|
||||
if (sel) {
|
||||
sel.innerHTML = '<option value="">不指定</option>' + list.map(s => `<option value="${U.esc(s.code)}">${U.esc(s.label)}</option>`).join('');
|
||||
this.toggleSource();
|
||||
}
|
||||
}).catch(()=>{});
|
||||
},
|
||||
|
||||
async render() {
|
||||
try { this.captcha = await API.get('/captcha'); } catch { this.captcha = null; }
|
||||
return `
|
||||
@@ -15,11 +26,11 @@ const RegisterPage = {
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>帐号来源</label><select id="rs" onchange="RegisterPage.toggleSource()"><option value="netease">网易端</option><option value="skin">皮肤站</option></select></div>
|
||||
<div class="form-group"><label>帐号来源</label><select id="rs" onchange="RegisterPage.toggleSource()"><option value="">加载中...</option></select></div>
|
||||
<div class="form-group"><label id="rs-label1">游戏名称</label><input id="rgn" required placeholder="Minecraft ID"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="uid-group"><label id="rs-label2">网易UID</label><input id="rgu" placeholder="仅网易端必填"></div>
|
||||
<div class="form-group" id="uid-group" style="display:none"><label id="rs-label2">UID(可选)</label><input id="rgu" placeholder="选填"></div>
|
||||
|
||||
${this.captcha ? `
|
||||
<div class="form-group"><label>人机验证</label>
|
||||
@@ -55,9 +66,10 @@ const RegisterPage = {
|
||||
toggleSource() {
|
||||
const src = document.getElementById('rs').value;
|
||||
document.getElementById('rs-label1').textContent = '游戏名称';
|
||||
document.getElementById('rs-label2').textContent = src === 'netease' ? '网易UID' : '皮肤站UUID(自动获取)';
|
||||
document.getElementById('uid-group').style.display = src === 'netease' ? '' : 'none';
|
||||
document.getElementById('rgu').required = src === 'netease';
|
||||
const isSkin = src === 'skin';
|
||||
document.getElementById('rs-label2').textContent = isSkin ? '皮肤站UUID(可选)' : 'UID(可选)';
|
||||
document.getElementById('uid-group').style.display = isSkin ? 'none' : '';
|
||||
document.getElementById('rgu').required = false;
|
||||
},
|
||||
|
||||
async refreshCaptcha() {
|
||||
|
||||
85
public/js/pages/sources.js
Normal file
85
public/js/pages/sources.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const SourcesPage = {
|
||||
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" onclick="SourcesPage.showAdd()"><i class="fas fa-plus"></i> 添加来源</button>';
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const ct = document.getElementById('page-content');
|
||||
try {
|
||||
// 管理视图: 含停用项
|
||||
const rows = await API.get('/sources');
|
||||
ct.innerHTML = rows.length === 0
|
||||
? '<div class="empty"><i class="fas fa-tag"></i><p>暂无来源</p></div>'
|
||||
: `<div class="card table-wrap"><table><thead><tr><th>code</th><th>名称</th><th>排序</th><th>状态</th><th>操作</th></tr></thead><tbody>${rows.map(s => `
|
||||
<tr>
|
||||
<td><code>${U.esc(s.code)}</code></td>
|
||||
<td>${U.esc(s.label)}</td>
|
||||
<td>${s.sort_order}</td>
|
||||
<td>${s.enabled ? '<span class="badge bg-resolved">启用</span>' : '<span class="badge bg-rejected">停用</span>'}</td>
|
||||
<td>
|
||||
<button class="btn btn-o btn-sm" onclick="SourcesPage.showEdit('${U.escJs(s.code)}','${U.escJs(s.label)}',${s.enabled},${s.sort_order})"><i class="fas fa-edit"></i> 编辑</button>
|
||||
<button class="btn btn-d btn-sm" onclick="SourcesPage.del('${U.escJs(s.code)}')"><i class="fas fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>`).join('')}</tbody></table></div>
|
||||
<p class="help" style="margin-top:8px">来源用于标识玩家身份归属(如: 网易端/皮肤站/正版/离线)。已被身份数据使用的来源不能删除, 只能停用。</p>`;
|
||||
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||
},
|
||||
|
||||
showAdd() {
|
||||
U.modal('添加来源', `
|
||||
<form id="src-form">
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>code *</label><input id="src-code" required placeholder="如: official" pattern="[a-z0-9_]{1,20}"></div>
|
||||
<div class="form-group"><label>名称 *</label><input id="src-label" required placeholder="如: 正版"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>排序</label><input id="src-sort" type="number" value="0"></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('src-form').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await API.post('/sources', {
|
||||
code: document.getElementById('src-code').value,
|
||||
label: document.getElementById('src-label').value,
|
||||
sort_order: document.getElementById('src-sort').value,
|
||||
});
|
||||
App.closeModal(); this.load();
|
||||
} catch(ex){alert(ex.message);}
|
||||
};
|
||||
},
|
||||
|
||||
showEdit(code, label, enabled, sortOrder) {
|
||||
U.modal('编辑来源', `
|
||||
<form id="src-edit-form">
|
||||
<div class="form-group"><label>code</label><input value="${U.esc(code)}" disabled></div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>名称 *</label><input id="se-label" required value="${U.esc(label)}"></div>
|
||||
<div class="form-group"><label>排序</label><input id="se-sort" type="number" value="${sortOrder}"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>状态</label><select id="se-enabled"><option value="1" ${enabled?'selected':''}>启用</option><option value="0" ${!enabled?'selected':''}>停用</option></select></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('src-edit-form').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await API.put(`/sources/${code}`, {
|
||||
label: document.getElementById('se-label').value,
|
||||
enabled: document.getElementById('se-enabled').value === '1',
|
||||
sort_order: document.getElementById('se-sort').value,
|
||||
});
|
||||
App.closeModal(); this.load();
|
||||
} catch(ex){alert(ex.message);}
|
||||
};
|
||||
},
|
||||
|
||||
async del(code) {
|
||||
if (!await U.confirm(`确认删除来源 ${code}?`)) return;
|
||||
try { await API.delete(`/sources/${code}`); this.load(); } catch(ex){alert(ex.message);}
|
||||
}
|
||||
};
|
||||
@@ -16,7 +16,7 @@ const TicketCreate = {
|
||||
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}">${it.source === 'netease' ? '网易端' : '皮肤站'} — ${U.esc(it.game_name)}${it.game_uid ? ' (UID: ' + U.esc(it.game_uid) + ')' : ''}</option>`).join('');
|
||||
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(() => {});
|
||||
@@ -58,8 +58,8 @@ const TicketCreate = {
|
||||
<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" required placeholder="游戏UID" value="${U.esc(prefill?.game_uid || user?.game_uid || '')}" ${prefill?.game_uid ? 'readonly' : ''}>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ const UsersPage = {
|
||||
const ct = document.getElementById('page-content');
|
||||
try {
|
||||
const users = await API.get('/users');
|
||||
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>
|
||||
@@ -19,14 +18,21 @@ const UsersPage = {
|
||||
<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><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||'netease'}"><i class="fas fa-edit"></i></button></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>`; }
|
||||
},
|
||||
|
||||
@@ -44,13 +50,18 @@ const UsersPage = {
|
||||
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="netease">网易端</option><option value="skin">皮肤站</option></select></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" required></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 = [];
|
||||
@@ -93,7 +104,7 @@ const UsersPage = {
|
||||
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="netease" ${source==='netease'?'selected':''}>网易端</option><option value="skin" ${source==='skin'?'selected':''}>皮肤站</option></select></div></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>
|
||||
@@ -102,7 +113,7 @@ const UsersPage = {
|
||||
<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 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>'}
|
||||
@@ -110,10 +121,10 @@ const UsersPage = {
|
||||
</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="netease">网易端</option><option value="skin">皮肤站</option>
|
||||
<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">
|
||||
<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>
|
||||
@@ -139,9 +150,31 @@ const UsersPage = {
|
||||
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 isSkin = document.getElementById('eu-ident-source').value === 'skin';
|
||||
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;
|
||||
|
||||
@@ -30,6 +30,36 @@ const U = {
|
||||
return String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"').replace(/\n/g,'\\n');
|
||||
},
|
||||
|
||||
// ---- 动态来源(缓存, 注册/绑定身份/用户管理等处共用) ----
|
||||
_sourcesCache: null,
|
||||
async sources(force) {
|
||||
if (force || !this._sourcesCache) {
|
||||
try { this._sourcesCache = await API.get('/sources'); }
|
||||
catch { this._sourcesCache = []; }
|
||||
}
|
||||
return this._sourcesCache;
|
||||
},
|
||||
// 生成下拉 HTML; includeEmpty 时带"不指定"选项
|
||||
async sourcesOptions(selected, includeEmpty) {
|
||||
const list = await this.sources();
|
||||
const opts = (includeEmpty ? '<option value="">不指定</option>' : '') +
|
||||
list.map(s => `<option value="${this.esc(s.code)}" ${s.code===selected?'selected':''}>${this.esc(s.label)}</option>`).join('');
|
||||
return opts;
|
||||
},
|
||||
// code → 显示标签(找不到时显示原 code)
|
||||
async sourceLabel(code) {
|
||||
if (!code) return '-';
|
||||
const list = await this.sources();
|
||||
const hit = list.find(s => s.code === code);
|
||||
return hit ? hit.label : code;
|
||||
},
|
||||
async sourceBadge(code) {
|
||||
if (!code) return '-';
|
||||
const list = await this.sources();
|
||||
const hit = list.find(s => s.code === code);
|
||||
return `<span class="badge bg-admin">${this.esc(hit ? hit.label : code)}</span>`;
|
||||
},
|
||||
|
||||
showAlert(containerId, type, msg) {
|
||||
const ct = document.getElementById(containerId || 'page-content');
|
||||
if (!ct) return;
|
||||
|
||||
Reference in New Issue
Block a user