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:
@@ -75,11 +75,11 @@ async function initSchema(connection) {
|
||||
password VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(100) NOT NULL UNIQUE,
|
||||
game_name VARCHAR(50) NOT NULL,
|
||||
game_uid VARCHAR(50) NOT NULL,
|
||||
game_uid VARCHAR(50) NOT NULL DEFAULT '',
|
||||
role ENUM('owner','admin','player') NOT NULL DEFAULT 'player',
|
||||
active TINYINT(1) NOT NULL DEFAULT 0,
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
source ENUM('netease','skin') NOT NULL DEFAULT 'netease',
|
||||
source VARCHAR(20) NOT NULL DEFAULT '',
|
||||
verify_token VARCHAR(255),
|
||||
verify_expires DATETIME,
|
||||
reset_token VARCHAR(255),
|
||||
@@ -91,7 +91,7 @@ async function initSchema(connection) {
|
||||
await createIfNotExists('user_identities', `CREATE TABLE user_identities (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
source ENUM('netease','skin') NOT NULL,
|
||||
source VARCHAR(20) NOT NULL,
|
||||
game_name VARCHAR(50) NOT NULL,
|
||||
game_uid VARCHAR(50) NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -99,6 +99,25 @@ async function initSchema(connection) {
|
||||
INDEX idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
|
||||
await createIfNotExists('sources', `CREATE TABLE sources (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(20) NOT NULL UNIQUE,
|
||||
label VARCHAR(50) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
|
||||
await createIfNotExists('api_sessions', `CREATE TABLE api_sessions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
client_id VARCHAR(64) NOT NULL,
|
||||
session_token VARCHAR(128) NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_client (client_id),
|
||||
INDEX idx_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
|
||||
await createIfNotExists('tickets', `CREATE TABLE tickets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT,
|
||||
@@ -108,7 +127,7 @@ async function initSchema(connection) {
|
||||
status ENUM('pending','processing','awaiting_info','appealing','resolved','rejected','closed') NOT NULL DEFAULT 'pending',
|
||||
priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium',
|
||||
reporter_game_name VARCHAR(50) NOT NULL,
|
||||
reporter_game_uid VARCHAR(50) NOT NULL,
|
||||
reporter_game_uid VARCHAR(50) NOT NULL DEFAULT '',
|
||||
target_game_name VARCHAR(50),
|
||||
target_game_uid VARCHAR(50),
|
||||
reason VARCHAR(500),
|
||||
@@ -391,6 +410,20 @@ async function migrateAdditions(db) {
|
||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS api_clients (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, client_id VARCHAR(64) NOT NULL UNIQUE, secret_hash VARCHAR(255) NOT NULL, active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_active (active)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||
try { await db.execute("ALTER TABLE tickets ADD COLUMN server_name VARCHAR(100) NOT NULL DEFAULT ''"); } catch {}
|
||||
try { await db.execute("ALTER TABLE bans ADD COLUMN server_name VARCHAR(100) NOT NULL DEFAULT ''"); } catch {}
|
||||
|
||||
// ---- 动态来源: sources 表 + users/user_identities.source 改 VARCHAR + game_uid 可空 ----
|
||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS sources (id INT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(20) NOT NULL UNIQUE, label VARCHAR(50) NOT NULL, enabled TINYINT(1) NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||
try {
|
||||
// 预置来源(幂等), 老数据来源保持兼容
|
||||
await db.execute("INSERT IGNORE INTO sources(code, label, sort_order) VALUES ('netease','网易端',1), ('skin','皮肤站',2)");
|
||||
} catch {}
|
||||
try { await db.execute("ALTER TABLE users MODIFY source VARCHAR(20) NOT NULL DEFAULT ''"); } catch {}
|
||||
try { await db.execute("ALTER TABLE users MODIFY game_uid VARCHAR(50) NOT NULL DEFAULT ''"); } catch {}
|
||||
try { await db.execute("ALTER TABLE user_identities MODIFY source VARCHAR(20) NOT NULL"); } catch {}
|
||||
try { await db.execute("ALTER TABLE tickets MODIFY reporter_game_uid VARCHAR(50) NOT NULL DEFAULT ''"); } catch {}
|
||||
|
||||
// ---- API SESSION 表 ----
|
||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS api_sessions (id INT AUTO_INCREMENT PRIMARY KEY, client_id VARCHAR(64) NOT NULL, session_token VARCHAR(128) NOT NULL UNIQUE, expires_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_client (client_id), INDEX idx_expires (expires_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||
}
|
||||
|
||||
async function seedTemplates(db) {
|
||||
|
||||
@@ -46,7 +46,15 @@ router.post('/register', validateLengths({
|
||||
if (!username || !password || !email || !game_name) {
|
||||
return res.status(400).json({ error: '所有字段均为必填' });
|
||||
}
|
||||
if (source === 'netease' && !game_uid) return res.status(400).json({ error: '网易端必须填写UID' });
|
||||
// 来源必须是已启用的动态来源(不设默认)
|
||||
let srcValid = false;
|
||||
if (source) {
|
||||
try {
|
||||
const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [source]);
|
||||
srcValid = !!srcRow;
|
||||
} catch { srcValid = true; } // sources 表不存在时兼容旧部署
|
||||
}
|
||||
if (source && !srcValid) return res.status(400).json({ error: '无效的来源' });
|
||||
|
||||
if (!code_id || !code) return res.status(400).json({ error: '请先完成邮箱验证' });
|
||||
const codeRow = await getRow("SELECT * FROM captchas WHERE id = ? AND created_at > NOW() - INTERVAL 10 MINUTE", [code_id]);
|
||||
@@ -74,24 +82,24 @@ router.post('/register', validateLengths({
|
||||
if (existingUser || existingEmail) {
|
||||
return res.status(400).json({ error: '用户名或邮箱已被使用' });
|
||||
}
|
||||
if (game_name && (source || 'netease') === 'netease') {
|
||||
const dup = await getRow('SELECT id FROM users WHERE game_name = ? AND source = ?', [game_name, source || 'netease']);
|
||||
if (game_name && source) {
|
||||
const dup = await getRow('SELECT id FROM users WHERE game_name = ? AND source = ?', [game_name, source]);
|
||||
if (dup) return res.status(400).json({ error: '该游戏名在此来源下已注册' });
|
||||
}
|
||||
// 多来源兼容: 该游戏名可能已被其他账号绑定为副身份
|
||||
try {
|
||||
const dupIdent = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source || 'netease', game_name]);
|
||||
const dupIdent = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source || '', game_name]);
|
||||
if (dupIdent) return res.status(400).json({ error: '该游戏名已绑定其他账号' });
|
||||
} catch {}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 10);
|
||||
const r = await query(
|
||||
'INSERT INTO users(username, password, email, game_name, game_uid, active, email_verified, source) VALUES (?,?,?,?,?,?,?,?)',
|
||||
[username, hashed, email, game_name, game_uid||'', 1, 1, source || 'netease']
|
||||
[username, hashed, email, game_name, game_uid||'', 1, 1, source || '']
|
||||
);
|
||||
try {
|
||||
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
|
||||
[r.insertId, source || 'netease', game_name, game_uid || '']);
|
||||
[r.insertId, source || '', game_name, game_uid || '']);
|
||||
} catch {}
|
||||
res.status(201).json({ message: '注册成功,请登录' });
|
||||
} catch (err) {
|
||||
@@ -184,18 +192,23 @@ router.get('/identities', authenticate, async (req, res) => {
|
||||
router.post('/identities', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { source, game_name, game_uid } = req.body;
|
||||
if (!['netease','skin'].includes(source)) return res.status(400).json({ error: '无效的来源' });
|
||||
if (!game_name) return res.status(400).json({ error: '游戏名不能为空' });
|
||||
if (source === 'netease' && !game_uid) return res.status(400).json({ error: '网易端必须填写UID' });
|
||||
// 来源动态校验(启用中的来源; sources 表缺失时兼容)
|
||||
if (source) {
|
||||
try {
|
||||
const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [source]);
|
||||
if (!srcRow) return res.status(400).json({ error: '无效的来源' });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const dup = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND source = ?', [req.user.id, source]);
|
||||
const dup = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND source = ?', [req.user.id, source || '']);
|
||||
if (dup) return res.status(400).json({ error: '该来源已绑定,可先删除再重新绑定' });
|
||||
|
||||
const dupName = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source, game_name]);
|
||||
const dupName = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source || '', game_name]);
|
||||
if (dupName) return res.status(400).json({ error: '该游戏名已绑定其他账号' });
|
||||
|
||||
const r = await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
|
||||
[req.user.id, source, game_name, game_uid || '']);
|
||||
[req.user.id, source || '', game_name, game_uid || '']);
|
||||
res.status(201).json({ id: r.insertId, message: '绑定成功' });
|
||||
} catch (err) {
|
||||
console.error('[auth]', err);
|
||||
|
||||
@@ -10,19 +10,46 @@ const { logSystem } = require('../logger');
|
||||
const router = express.Router();
|
||||
|
||||
// ============ 鉴权 ============
|
||||
// 外部 API 唯一鉴权方式: ID + Secret(api_clients 表)
|
||||
// 无凭据 / 凭据错误 / 客户端停用 → 一律 401(不允许匿名访问)
|
||||
// 流程: 客户端用 ID + Secret 换取 SESSION(短期有效), 之后所有请求用
|
||||
// Authorization: Bearer <SESSION>
|
||||
// 无 SESSION / SESSION 无效或过期 / 客户端停用 → 一律 401
|
||||
|
||||
const SESSION_TTL_HOURS = 24;
|
||||
|
||||
function genClientId() {
|
||||
// 16 位纯数字, 随机不递增
|
||||
let id = '';
|
||||
for (let i = 0; i < 16; i++) id += crypto.randomInt(0, 10);
|
||||
return id;
|
||||
}
|
||||
|
||||
function genSecret() {
|
||||
// SeaReport- + 32 位随机字符串
|
||||
return 'SeaReport-' + crypto.randomBytes(16).toString('hex'); // 16 bytes = 32 hex chars
|
||||
}
|
||||
|
||||
async function createSession(clientId) {
|
||||
const token = crypto.randomBytes(48).toString('hex');
|
||||
await query(
|
||||
'INSERT INTO api_sessions(client_id, session_token, expires_at) VALUES (?,?,DATE_ADD(NOW(), INTERVAL ? HOUR))',
|
||||
[clientId, token, SESSION_TTL_HOURS]
|
||||
);
|
||||
return { session_token: token, expires_in: SESSION_TTL_HOURS * 3600 };
|
||||
}
|
||||
|
||||
async function clientAuth(req, res, next) {
|
||||
const cid = req.headers['x-api-client-id'];
|
||||
const secret = req.headers['x-api-secret'];
|
||||
if (!cid || !secret) return res.status(401).json({ error: '未授权: 缺少 x-api-client-id / x-api-secret' });
|
||||
const h = req.headers.authorization;
|
||||
if (!h || !h.startsWith('Bearer ')) return res.status(401).json({ error: '未授权: 缺少 Authorization: Bearer <SESSION>' });
|
||||
const token = h.split(' ')[1];
|
||||
try {
|
||||
const client = await getRow('SELECT * FROM api_clients WHERE client_id = ?', [cid]);
|
||||
if (!client || !client.active || !bcrypt.compareSync(secret, client.secret_hash)) {
|
||||
return res.status(401).json({ error: '客户端鉴权失败' });
|
||||
}
|
||||
req.apiClient = client;
|
||||
const row = await getRow(
|
||||
`SELECT s.client_id, s.expires_at, c.active AS client_active
|
||||
FROM api_sessions s JOIN api_clients c ON c.client_id = s.client_id
|
||||
WHERE s.session_token = ? AND s.expires_at > NOW()`,
|
||||
[token]
|
||||
);
|
||||
if (!row || !row.client_active) return res.status(401).json({ error: 'SESSION 无效或已过期' });
|
||||
req.apiClient = { client_id: row.client_id };
|
||||
next();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: '鉴权服务异常' });
|
||||
@@ -47,8 +74,8 @@ router.post('/clients', authenticate, async (req, res) => {
|
||||
if (!['owner','admin'].includes(req.user.role)) return res.status(403).json({ error: '无权限' });
|
||||
const { name } = req.body;
|
||||
if (!name) return res.status(400).json({ error: '名称必填' });
|
||||
const clientId = 'c_' + crypto.randomBytes(12).toString('hex');
|
||||
const secret = 's_' + crypto.randomBytes(24).toString('hex');
|
||||
const clientId = genClientId();
|
||||
const secret = genSecret();
|
||||
await query('INSERT INTO api_clients(name, client_id, secret_hash) VALUES (?,?,?)', [name, clientId, bcrypt.hashSync(secret, 10)]);
|
||||
await logSystem('info', 'api', `创建外部API客户端: ${name}`, { client_id: clientId });
|
||||
res.json({ client_id: clientId, secret, message: '请立即保存 secret(仅显示一次)' });
|
||||
@@ -82,24 +109,43 @@ router.delete('/clients/:id', authenticate, async (req, res) => {
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ---- 以下接口: 外部鉴权(client_id + secret, 唯一方式) ----
|
||||
// ============ 换取 SESSION(ID + Secret → Bearer SESSION) ============
|
||||
// 仅此一个接口用 ID+Secret 鉴权, 其余接口一律 Bearer SESSION
|
||||
router.post('/auth/session', async (req, res) => {
|
||||
try {
|
||||
const cid = req.headers['x-api-client-id'];
|
||||
const secret = req.headers['x-api-secret'];
|
||||
if (!cid || !secret) return res.status(401).json({ error: '缺少 x-api-client-id / x-api-secret' });
|
||||
const client = await getRow('SELECT * FROM api_clients WHERE client_id = ?', [cid]);
|
||||
if (!client || !client.active || !bcrypt.compareSync(secret, client.secret_hash)) {
|
||||
return res.status(401).json({ error: '客户端鉴权失败' });
|
||||
}
|
||||
// 清理该客户端旧 SESSION(单会话)
|
||||
await query('DELETE FROM api_sessions WHERE client_id = ?', [cid]);
|
||||
const sess = await createSession(cid);
|
||||
await logSystem('info', 'api', `外部API客户端换取SESSION: ${client.name}`, { client_id: cid });
|
||||
res.json({ ...sess, client_id: cid, message: `SESSION 有效期 ${SESSION_TTL_HOURS} 小时, 请用 Authorization: Bearer <session> 访问其余接口` });
|
||||
} catch (e) { res.status(500).json({ error: '服务器错误' }); }
|
||||
});
|
||||
|
||||
// ---- 以下接口: 外部鉴权(Bearer SESSION, 唯一方式) ----
|
||||
router.use(clientAuth);
|
||||
|
||||
// ============ 外部注册(需客户端鉴权) ============
|
||||
router.post('/auth/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password, email, game_name, game_uid, source } = req.body;
|
||||
if (!username || !password || !email || !game_name || !game_uid) return res.status(400).json({ error: '所有字段必填' });
|
||||
if (!username || !password || !email || !game_name) return res.status(400).json({ error: '所有字段必填' });
|
||||
if (password.length < 6) return res.status(400).json({ error: '密码至少6位' });
|
||||
if (await getRow('SELECT id FROM users WHERE username = ?', [username])) return res.status(400).json({ error: '注册失败,请检查输入信息' });
|
||||
if (await getRow('SELECT id FROM users WHERE email = ?', [email])) return res.status(400).json({ error: '注册失败,请检查输入信息' });
|
||||
|
||||
const hashed = bcrypt.hashSync(password, 10);
|
||||
const verifyToken = uuid();
|
||||
const r = await query('INSERT INTO users(username,password,email,game_name,game_uid,verify_token,verify_expires,source) VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 24 HOUR),?)', [username, hashed, email, game_name, game_uid, verifyToken, source || 'skin']);
|
||||
const r = await query('INSERT INTO users(username,password,email,game_name,game_uid,verify_token,verify_expires,source) VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 24 HOUR),?)', [username, hashed, email, game_name, game_uid || '', verifyToken, source || '']);
|
||||
try {
|
||||
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
|
||||
[r.insertId, source || 'skin', game_name, game_uid]);
|
||||
[r.insertId, source || '', game_name, game_uid || '']);
|
||||
} catch {}
|
||||
|
||||
const site = await getRow("SELECT v FROM settings WHERE k='site_url'");
|
||||
@@ -119,7 +165,7 @@ router.post('/tickets', async (req, res) => {
|
||||
try {
|
||||
const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, is_admin_complaint, server } = req.body;
|
||||
if (!type || !['report','suggestion','appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' });
|
||||
if (!title || !reporter_game_name || !reporter_game_uid) return res.status(400).json({ error: '必填字段不完整' });
|
||||
if (!title || !reporter_game_name) return res.status(400).json({ error: '必填字段不完整' });
|
||||
if (type === 'report' && !reason) return res.status(400).json({ error: '请填写举报原因' });
|
||||
if (type === 'suggestion' && !description) return res.status(400).json({ error: '建议内容不能为空' });
|
||||
if (type === 'appeal' && (!reason || !description)) return res.status(400).json({ error: '请填写完整申诉信息' });
|
||||
@@ -130,8 +176,8 @@ router.post('/tickets', async (req, res) => {
|
||||
const sv = await resolveServer(server);
|
||||
const trackingToken = uuid();
|
||||
const r = await query(`INSERT INTO tickets(type,title,reporter_game_name,reporter_game_uid,target_game_name,target_game_uid,reason,description,tracking_token,is_admin_complaint,server_name) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[type, title, reporter_game_name, reporter_game_uid, target_game_name||'', target_game_uid||'', reason||'', description||'', trackingToken, is_admin_complaint?1:0, sv.server_name]);
|
||||
await query("INSERT INTO responses(ticket_id,content,is_staff) VALUES (?,?,0)", [r.insertId, `游戏内提交\n类型: ${type}\n提交人: ${reporter_game_name} (UID: ${reporter_game_uid})${sv.server_name?'\n服务器: '+sv.server_name:''}`]);
|
||||
[type, title, reporter_game_name, reporter_game_uid||'', target_game_name||'', target_game_uid||'', reason||'', description||'', trackingToken, is_admin_complaint?1:0, sv.server_name]);
|
||||
await query("INSERT INTO responses(ticket_id,content,is_staff) VALUES (?,?,0)", [r.insertId, `游戏内提交\n类型: ${type}\n提交人: ${reporter_game_name}${reporter_game_uid?' (UID: '+reporter_game_uid+')':''}${sv.server_name?'\n服务器: '+sv.server_name:''}`]);
|
||||
res.status(201).json({ id: r.insertId, tracking_token: trackingToken, server: sv.server_name });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
54
backend/routes/sources.js
Normal file
54
backend/routes/sources.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const express = require('express');
|
||||
const { query, getRow } = require('../db');
|
||||
const { authenticate, requireRole } = require('../middleware/auth');
|
||||
const { logSystem } = require('../logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---- 来源列表(公开: 注册页/绑定身份页需要) ----
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await query('SELECT code, label, sort_order FROM sources WHERE enabled = 1 ORDER BY sort_order, id');
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ---- 管理(owner) ----
|
||||
router.post('/', authenticate, requireRole('owner'), async (req, res) => {
|
||||
try {
|
||||
const { code, label, sort_order } = req.body;
|
||||
if (!code || !label) return res.status(400).json({ error: 'code 和 label 必填' });
|
||||
if (!/^[a-z0-9_]{1,20}$/.test(code)) return res.status(400).json({ error: 'code 仅允许小写字母数字下划线(≤20)' });
|
||||
const dup = await getRow('SELECT id FROM sources WHERE code = ?', [code]);
|
||||
if (dup) return res.status(400).json({ error: '该来源已存在' });
|
||||
const r = await query('INSERT INTO sources(code, label, sort_order) VALUES (?,?,?)', [code, label, parseInt(sort_order) || 0]);
|
||||
await logSystem('info', 'source', `新增来源: ${code}(${label})`);
|
||||
res.status(201).json({ id: r.insertId, message: '已添加' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.put('/:code', authenticate, requireRole('owner'), async (req, res) => {
|
||||
try {
|
||||
const { label, enabled, sort_order } = req.body;
|
||||
const row = await getRow('SELECT id FROM sources WHERE code = ?', [req.params.code]);
|
||||
if (!row) return res.status(404).json({ error: '来源不存在' });
|
||||
await query('UPDATE sources SET label = ?, enabled = ?, sort_order = ? WHERE code = ?',
|
||||
[label || req.params.code, enabled ? 1 : 0, parseInt(sort_order) || 0, req.params.code]);
|
||||
await logSystem('info', 'source', `更新来源: ${req.params.code}`, { label, enabled: !!enabled });
|
||||
res.json({ message: '已更新' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/:code', authenticate, requireRole('owner'), async (req, res) => {
|
||||
try {
|
||||
// 有关联数据时禁止删除(防误删)
|
||||
const usedUsers = await getRow('SELECT COUNT(*) as c FROM users WHERE source = ?', [req.params.code]);
|
||||
const usedIdent = await getRow('SELECT COUNT(*) as c FROM user_identities WHERE source = ?', [req.params.code]);
|
||||
if (usedUsers.c > 0 || usedIdent.c > 0) return res.status(400).json({ error: `该来源已被 ${usedUsers.c + usedIdent.c} 条身份数据使用, 请先停用` });
|
||||
await query('DELETE FROM sources WHERE code = ?', [req.params.code]);
|
||||
await logSystem('info', 'source', `删除来源: ${req.params.code}`);
|
||||
res.json({ message: '已删除' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -116,7 +116,7 @@ router.post('/', ticketAnonLimiter, optionalAuth, upload.array('files', 5), fina
|
||||
}
|
||||
if (!rgn) { rgn = req.user.game_name; rgu = req.user.game_uid; }
|
||||
}
|
||||
if (!rgn || !rgu) return res.status(400).json({ error: '请填写游戏名称和UID' });
|
||||
if (!rgn) return res.status(400).json({ error: '请填写游戏名称' });
|
||||
if (type === 'report') { if (!target_game_name && !target_game_uid) return res.status(400).json({ error: '举报需至少填写对方游戏名或UID之一' }); if (!reason) return res.status(400).json({ error: '请填写举报原因' }); }
|
||||
if (type === 'suggestion' && !description) return res.status(400).json({ error: '建议内容不能为空' });
|
||||
if (type === 'appeal') { if (!reason) return res.status(400).json({ error: '请填写申诉理由' }); if (!description) return res.status(400).json({ error: '请填写详细申诉内容' }); }
|
||||
@@ -143,7 +143,7 @@ router.post('/', ticketAnonLimiter, optionalAuth, upload.array('files', 5), fina
|
||||
await conn.beginTransaction();
|
||||
const [r] = await conn.execute(`INSERT INTO tickets(type,title,user_id,reporter_game_name,reporter_game_uid,
|
||||
target_game_name,target_game_uid,reason,description,tracking_token,is_admin_complaint,parent_ticket_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[type, title, req.user?.id||null, rgn, rgu,
|
||||
[type, title, req.user?.id||null, rgn, rgu||'',
|
||||
type==='report'?(target_game_name||''):null, type==='report'?(target_game_uid||''):null,
|
||||
(type==='report'||type==='appeal'||type==='result_appeal')?reason:null,
|
||||
(type==='suggestion'||type==='appeal'||type==='result_appeal')?description:'',
|
||||
|
||||
@@ -33,18 +33,23 @@ router.post('/:id/identities', authenticate, requireRole('owner','admin'), async
|
||||
if (u.role === 'owner' && req.user.role !== 'owner') return res.status(403).json({ error: '无权修改服主' });
|
||||
|
||||
const { source, game_name, game_uid } = req.body;
|
||||
if (!['netease','skin'].includes(source)) return res.status(400).json({ error: '无效的来源' });
|
||||
if (!game_name) return res.status(400).json({ error: '游戏名不能为空' });
|
||||
if (source === 'netease' && !game_uid) return res.status(400).json({ error: '网易端必须填写UID' });
|
||||
// 来源动态校验
|
||||
if (source) {
|
||||
try {
|
||||
const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [source]);
|
||||
if (!srcRow) return res.status(400).json({ error: '无效的来源' });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const dup = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND source = ?', [uid, source]);
|
||||
const dup = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND source = ?', [uid, source || '']);
|
||||
if (dup) return res.status(400).json({ error: '该来源已绑定,可先删除再重新绑定' });
|
||||
|
||||
const dupName = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source, game_name]);
|
||||
const dupName = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source || '', game_name]);
|
||||
if (dupName) return res.status(400).json({ error: '该游戏名已绑定其他账号' });
|
||||
|
||||
const r = await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
|
||||
[uid, source, game_name, game_uid || '']);
|
||||
[uid, source || '', game_name, game_uid || '']);
|
||||
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)",
|
||||
[req.user.id, req.user.username, 'add_identity', 'user', uid, `为 ${u.username || uid} 绑定 ${source} 身份 ${game_name}`]);
|
||||
res.status(201).json({ id: r.insertId, message: '绑定成功' });
|
||||
@@ -78,17 +83,17 @@ router.delete('/:id/identities/:identityId', authenticate, requireRole('owner','
|
||||
|
||||
router.post('/', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||
const { username, password, email, game_name, game_uid, role, source } = req.body;
|
||||
if (!username||!password||!email||!game_name||!game_uid||!role) return res.status(400).json({ error: '所有字段必填' });
|
||||
if (!username||!password||!email||!game_name||!role) return res.status(400).json({ error: '所有字段必填' });
|
||||
if (!['owner','admin','player'].includes(role)) return res.status(400).json({ error: '无效角色' });
|
||||
if (role === 'owner' && req.user.role !== 'owner') return res.status(403).json({ error: '仅服主可创建服主账号' });
|
||||
if (password.length < 6) return res.status(400).json({ error: '密码至少6位' });
|
||||
if (await getRow('SELECT id FROM users WHERE username = ?', [username])) return res.status(400).json({ error: '用户名已存在' });
|
||||
if (await getRow('SELECT id FROM users WHERE email = ?', [email])) return res.status(400).json({ error: '邮箱已被使用' });
|
||||
const hashed = bcrypt.hashSync(password, 10);
|
||||
const r = await query('INSERT INTO users(username, password, email, game_name, game_uid, role, source, active, email_verified) VALUES (?,?,?,?,?,?,?,1,1)', [username, hashed, email, game_name, game_uid, role, source || 'netease']);
|
||||
const r = await query('INSERT INTO users(username, password, email, game_name, game_uid, role, source, active, email_verified) VALUES (?,?,?,?,?,?,?,1,1)', [username, hashed, email, game_name, game_uid||'', role, source || '']);
|
||||
try {
|
||||
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
|
||||
[r.insertId, source || 'netease', game_name, game_uid]);
|
||||
[r.insertId, source || '', game_name, game_uid || '']);
|
||||
} catch {}
|
||||
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'create_user', 'user', r.insertId, `创建用户 ${username}`]);
|
||||
res.status(201).json({ id: r.insertId, message: '创建成功' });
|
||||
@@ -102,7 +107,16 @@ router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res)
|
||||
if (req.body.email) fields.email = req.body.email;
|
||||
if (req.body.game_name) fields.game_name = req.body.game_name;
|
||||
if (req.body.game_uid) fields.game_uid = req.body.game_uid;
|
||||
if (req.body.source) { if (!['netease','skin'].includes(req.body.source)) return res.status(400).json({ error: '无效来源' }); fields.source = req.body.source; }
|
||||
if (req.body.source) {
|
||||
// 动态来源校验
|
||||
let srcOk = false;
|
||||
try {
|
||||
const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [req.body.source]);
|
||||
srcOk = !!srcRow;
|
||||
} catch { srcOk = true; }
|
||||
if (!srcOk) return res.status(400).json({ error: '无效来源' });
|
||||
fields.source = req.body.source;
|
||||
}
|
||||
if (req.body.role) { if (!['owner','admin','player'].includes(req.body.role)) return res.status(400).json({ error: '无效角色' }); fields.role = req.body.role; }
|
||||
if (req.body.active !== undefined) fields.active = req.body.active ? 1 : 0;
|
||||
if (req.body.password) { if (req.body.password.length < 6) return res.status(400).json({ error: '密码至少6位' }); fields.password = bcrypt.hashSync(req.body.password, 10); }
|
||||
@@ -116,7 +130,7 @@ router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res)
|
||||
if (cur?.game_name) {
|
||||
try {
|
||||
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE game_name = VALUES(game_name), game_uid = VALUES(game_uid)',
|
||||
[req.params.id, cur.source || 'netease', cur.game_name, cur.game_uid || '']);
|
||||
[req.params.id, cur.source || '', cur.game_name, cur.game_uid || '']);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ function loadBusinessRoutes() {
|
||||
businessRouter.use('/features', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/features'));
|
||||
businessRouter.use('/bans', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/bans'));
|
||||
businessRouter.use('/logs', methodGuard(['GET']), generalLimiter, require('./routes/logs'));
|
||||
businessRouter.use('/sources', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/sources'));
|
||||
businessRouter.use('/external', require('./routes/external'));
|
||||
|
||||
businessRouter.get('/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`));
|
||||
|
||||
@@ -346,11 +346,12 @@ Base URL: `http://<host>:3100/api`
|
||||
| PUT /external/clients/:id | Bearer(owner/admin) | 启用/停用客户端 |
|
||||
| DELETE /external/clients/:id | Bearer(owner/admin) | 删除客户端 |
|
||||
|
||||
### 外部鉴权(唯一方式)
|
||||
### 外部鉴权(SESSION 机制)
|
||||
|
||||
| 方式 | 请求头 | 说明 |
|
||||
| 阶段 | 请求头 | 说明 |
|
||||
|------|--------|------|
|
||||
| ID + Secret(唯一) | `x-api-client-id` + `x-api-secret` | 后台可创建/停用多个客户端;无凭据或凭据错误一律 401 |
|
||||
| 换取 SESSION | `x-api-client-id` + `x-api-secret` | 仅 `POST /external/auth/session` 使用;ID 为 16 位随机数字,Secret 为 `SeaReport-` + 32 位 |
|
||||
| 后续请求 | `Authorization: Bearer <SESSION>` | 所有其余接口;24 小时有效,重换即旧 SESSION 失效,客户端停用立即失效 |
|
||||
|
||||
### 子服务器定位(`server` 参数)
|
||||
|
||||
|
||||
@@ -2,31 +2,60 @@
|
||||
|
||||
适用对象:QQ 官方机器人、游戏服务器插件、统计面板等**无法做网页登录**的外部系统。
|
||||
|
||||
- Base URL:`https://<你的域名>/api/external`
|
||||
- Base URL:`https://report.sea-studio.top/api/external`
|
||||
- 数据格式:JSON(`Content-Type: application/json`)
|
||||
- 本文档所有接口**不需要用户登录**,只需要客户端凭据(见下)
|
||||
- 本文档所有接口都需要客户端凭据(见下)
|
||||
|
||||
---
|
||||
|
||||
## 一、鉴权(唯一方式:ID + Secret)
|
||||
## 一、鉴权流程(ID + Secret 换取 SESSION)
|
||||
|
||||
在站点后台 →「外部API」页面创建客户端,获得一对凭据:
|
||||
|
||||
```text
|
||||
Client ID: c_1a2b3c4d5e6f7a8b9c0d1e2f
|
||||
Secret: s_9f8e7d6c5b4a39281726354a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e
|
||||
Client ID: 1829473056482917 # 16 位纯数字, 随机生成
|
||||
Secret: SeaReport-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4 # SeaReport- + 32 位
|
||||
```
|
||||
|
||||
> ⚠️ Secret 只在创建时显示一次,请立即保存。支持创建多个客户端、单独停用/删除,互不影响。
|
||||
> 无凭据 / 凭据错误 / 客户端停用 → 一律返回 `401`。
|
||||
|
||||
调用时在请求头携带:
|
||||
### 第一步:换取 SESSION(唯一使用 ID+Secret 的接口)
|
||||
|
||||
```
|
||||
POST /api/external/auth/session
|
||||
```
|
||||
|
||||
请求头:
|
||||
|
||||
| 请求头 | 值 |
|
||||
|--------|-----|
|
||||
| `x-api-client-id` | 你的 Client ID |
|
||||
| `x-api-secret` | 你的 Secret |
|
||||
|
||||
**成功响应 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_token": "5f8a2b3c...96位十六进制",
|
||||
"expires_in": 86400,
|
||||
"client_id": "1829473056482917",
|
||||
"message": "SESSION 有效期 24 小时, 请用 Authorization: Bearer <session> 访问其余接口"
|
||||
}
|
||||
```
|
||||
|
||||
### 第二步:后续请求携带 SESSION
|
||||
|
||||
所有其余接口(工单/封禁/统计等)使用:
|
||||
|
||||
| 请求头 | 值 |
|
||||
|--------|-----|
|
||||
| `Authorization` | `Bearer <session_token>` |
|
||||
|
||||
- SESSION 有效期 **24 小时**;过期后重新调用第一步换取。
|
||||
- 同一客户端重新换取时,**旧 SESSION 立即失效**(单会话)。
|
||||
- 客户端被停用 → 已发 SESSION 立即失效(401)。
|
||||
- 无 SESSION / SESSION 无效或过期 → 一律 `401`。
|
||||
|
||||
---
|
||||
|
||||
## 二、快速开始(Python / Node 示例)
|
||||
@@ -37,20 +66,21 @@ Secret: s_9f8e7d6c5b4a39281726354a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e
|
||||
import requests
|
||||
|
||||
BASE = "https://你的域名/api/external"
|
||||
HEADERS = {
|
||||
"x-api-client-id": "c_1a2b3c4d5e6f7a8b9c0d1e2f",
|
||||
"x-api-secret": "s_9f8e7d6c...",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# 提交举报工单
|
||||
# 1) 用 ID + Secret 换取 SESSION
|
||||
r = requests.post(f"{BASE}/auth/session", headers={
|
||||
"x-api-client-id": "1829473056482917",
|
||||
"x-api-secret": "SeaReport-a1b2c3d4...",
|
||||
})
|
||||
session = r.json()["session_token"]
|
||||
HEADERS = {"Authorization": f"Bearer {session}", "Content-Type": "application/json"}
|
||||
|
||||
# 2) 提交举报工单(带 SESSION)
|
||||
r = requests.post(f"{BASE}/tickets", headers=HEADERS, json={
|
||||
"type": "report",
|
||||
"title": "恶意破坏",
|
||||
"reporter_game_name": "Steve",
|
||||
"reporter_game_uid": "df273bda10b94aa18db345574d5a1e1d",
|
||||
"target_game_name": "Alex",
|
||||
"target_game_uid": "SKIN_UUID_002",
|
||||
"reason": "刷屏+破坏他人建筑",
|
||||
"description": "多次警告无效",
|
||||
"server": "survival", # 可选: 子服别名
|
||||
@@ -62,13 +92,16 @@ print(r.json()) # {"id": 123, "tracking_token": "..."}
|
||||
|
||||
```javascript
|
||||
const BASE = 'https://你的域名/api/external';
|
||||
const H = {
|
||||
'x-api-client-id': 'c_1a2b3c...',
|
||||
'x-api-secret': 's_9f8e7d6c...',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// 查询工单进度(从提交到结束全程可查)
|
||||
// 1) 换取 SESSION
|
||||
const authRes = await fetch(`${BASE}/auth/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-client-id': '1829473056482917', 'x-api-secret': 'SeaReport-a1b2c3d4...' },
|
||||
});
|
||||
const { session_token } = await authRes.json();
|
||||
const H = { Authorization: `Bearer ${session_token}`, 'Content-Type': 'application/json' };
|
||||
|
||||
// 2) 查询工单进度(从提交到结束全程可查)
|
||||
const res = await fetch(`${BASE}/tickets/track?token=你的tracking_token`, { headers: H });
|
||||
console.log(await res.json());
|
||||
```
|
||||
@@ -91,6 +124,12 @@ console.log(await res.json());
|
||||
|
||||
## 四、接口清单
|
||||
|
||||
### 0. 换取 SESSION(见第一节, 其余接口均需 Bearer SESSION)
|
||||
|
||||
```
|
||||
POST /api/external/auth/session
|
||||
```
|
||||
|
||||
### 1. 提交工单
|
||||
|
||||
```
|
||||
@@ -102,7 +141,7 @@ POST /api/external/tickets
|
||||
| type | string | ✅ | `report` / `suggestion` / `appeal` |
|
||||
| title | string | ✅ | 标题 |
|
||||
| reporter_game_name | string | ✅ | 提交人游戏名 |
|
||||
| reporter_game_uid | string | ✅ | 提交人 UID / UUID |
|
||||
| reporter_game_uid | string | 否 | 提交人 UID / UUID(选填) |
|
||||
| target_game_name | string | 条件 | 被举报人游戏名(report 建议填) |
|
||||
| target_game_uid | string | 条件 | 被举报人 UID |
|
||||
| reason | string | 条件 | 举报原因(report/appeal 必填) |
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
<script src="js/pages/servers.js"></script>
|
||||
<script src="js/pages/bans.js"></script>
|
||||
<script src="js/pages/logs.js"></script>
|
||||
<script src="js/pages/sources.js"></script>
|
||||
<script src="js/pages/features.js"></script>
|
||||
<script src="js/pages/feature-detail.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
|
||||
@@ -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