feat: external API for QQ bot - client id+secret auth, ticket tracking, bans, per-server data
- db: api_clients table + server_groups.alias + tickets/bans server_name (auto-migrate) - external.js: client_id+secret auth (multi-client, bcrypt, show-once secret), legacy x-external-key compat, JWT pass-through - POST /tickets (optional server), GET /tickets/track?token= (full lifecycle) - GET/POST /bans, reported-players/stats/all-tickets with server filter - GET /servers (alias list), client CRUD (owner/admin, audit logged) - resolveServer: alias | 分组/子服 | server_name - polls.js: server_groups CRUD with alias (add/edit, dup check) - servers page: alias display + edit; new 外部API page: client mgmt + quick docs - API.md: full external API section (auth matrix, server param syntax) - verified: 37 checks incl. resolveServer sandbox (alias/group/fallback)
This commit is contained in:
@@ -103,6 +103,7 @@ async function initSchema(connection) {
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
user_id INT,
|
user_id INT,
|
||||||
type ENUM('report','suggestion','appeal','result_appeal') NOT NULL,
|
type ENUM('report','suggestion','appeal','result_appeal') NOT NULL,
|
||||||
|
server_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
title VARCHAR(100) NOT NULL,
|
title VARCHAR(100) NOT NULL,
|
||||||
status ENUM('pending','processing','awaiting_info','appealing','resolved','rejected','closed') NOT NULL DEFAULT 'pending',
|
status ENUM('pending','processing','awaiting_info','appealing','resolved','rejected','closed') NOT NULL DEFAULT 'pending',
|
||||||
priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium',
|
priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium',
|
||||||
@@ -250,9 +251,20 @@ async function initSchema(connection) {
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
group_name VARCHAR(100) NOT NULL,
|
group_name VARCHAR(100) NOT NULL,
|
||||||
server_name VARCHAR(100) NOT NULL,
|
server_name VARCHAR(100) NOT NULL,
|
||||||
|
alias VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
UNIQUE KEY uk_group_server (group_name, server_name)
|
UNIQUE KEY uk_group_server (group_name, server_name)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('api_clients', `CREATE TABLE 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`);
|
||||||
|
|
||||||
await createIfNotExists('user_servers', `CREATE TABLE user_servers (
|
await createIfNotExists('user_servers', `CREATE TABLE user_servers (
|
||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
group_name VARCHAR(100) NOT NULL,
|
group_name VARCHAR(100) NOT NULL,
|
||||||
@@ -265,6 +277,7 @@ async function initSchema(connection) {
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
player_name VARCHAR(50) NOT NULL,
|
player_name VARCHAR(50) NOT NULL,
|
||||||
player_uid VARCHAR(50),
|
player_uid VARCHAR(50),
|
||||||
|
server_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
source VARCHAR(10) DEFAULT '',
|
source VARCHAR(10) DEFAULT '',
|
||||||
reason TEXT,
|
reason TEXT,
|
||||||
type ENUM('ban','mute','warn','other') NOT NULL DEFAULT 'ban',
|
type ENUM('ban','mute','warn','other') NOT NULL DEFAULT 'ban',
|
||||||
@@ -374,6 +387,10 @@ async function migrateAdditions(db) {
|
|||||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS feature_votes (id INT AUTO_INCREMENT PRIMARY KEY, item_id INT NOT NULL, user_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_item_user (item_id, user_id), INDEX idx_item (item_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
try { await db.execute(`CREATE TABLE IF NOT EXISTS feature_votes (id INT AUTO_INCREMENT PRIMARY KEY, item_id INT NOT NULL, user_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_item_user (item_id, user_id), INDEX idx_item (item_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS feature_comments (id INT AUTO_INCREMENT PRIMARY KEY, item_id INT NOT NULL, user_id INT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_item (item_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
try { await db.execute(`CREATE TABLE IF NOT EXISTS feature_comments (id INT AUTO_INCREMENT PRIMARY KEY, item_id INT NOT NULL, user_id INT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_item (item_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||||
try { await db.execute(`CREATE TABLE IF NOT EXISTS server_groups (id INT AUTO_INCREMENT PRIMARY KEY, group_name VARCHAR(100) NOT NULL, server_name VARCHAR(100) NOT NULL, UNIQUE KEY uk_group_server (group_name, server_name)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
try { await db.execute(`CREATE TABLE IF NOT EXISTS server_groups (id INT AUTO_INCREMENT PRIMARY KEY, group_name VARCHAR(100) NOT NULL, server_name VARCHAR(100) NOT NULL, UNIQUE KEY uk_group_server (group_name, server_name)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {}
|
||||||
|
try { await db.execute("ALTER TABLE server_groups ADD COLUMN alias VARCHAR(100) NOT NULL DEFAULT ''"); } catch {}
|
||||||
|
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 {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedTemplates(db) {
|
async function seedTemplates(db) {
|
||||||
|
|||||||
@@ -1,24 +1,103 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { v4: uuid } = require('uuid');
|
const { v4: uuid } = require('uuid');
|
||||||
const { query, getRow, getConfig, getPool } = require('../db');
|
const { query, getRow, getConfig } = require('../db');
|
||||||
const { generateToken, authenticate } = require('../middleware/auth');
|
const { generateToken, authenticate } = require('../middleware/auth');
|
||||||
const { sendEmail } = require('../mailer');
|
const { sendEmail } = require('../mailer');
|
||||||
|
const { logSystem } = require('../logger');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ============ 鉴权 ============
|
||||||
|
// 支持三种方式(免用户登录):
|
||||||
|
// 1. x-external-key: 旧版单 key(config.external_api_key)
|
||||||
|
// 2. x-api-client-id + x-api-secret: api_clients 表多客户端(推荐)
|
||||||
|
// 3. Authorization: Bearer <jwt>: 用户登录态(插件用)
|
||||||
|
|
||||||
function getExternalKey() {
|
function getExternalKey() {
|
||||||
try { return getConfig()?.external_api_key || process.env.EXTERNAL_API_KEY || ''; } catch { return process.env.EXTERNAL_API_KEY || ''; }
|
try { return getConfig()?.external_api_key || process.env.EXTERNAL_API_KEY || ''; } catch { return process.env.EXTERNAL_API_KEY || ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function externalAuth(req, res, next) {
|
async function clientAuth(req, res, next) {
|
||||||
const required = getExternalKey();
|
// 方式1: 旧版单 key
|
||||||
if (!required) return next();
|
const legacy = getExternalKey();
|
||||||
if (req.headers['x-external-key'] === required) return next();
|
if (legacy && req.headers['x-external-key'] === legacy) return next();
|
||||||
return res.status(401).json({ error: '未授权' });
|
|
||||||
|
// 方式2: client_id + secret
|
||||||
|
const cid = req.headers['x-api-client-id'];
|
||||||
|
const secret = req.headers['x-api-secret'];
|
||||||
|
if (cid && secret) {
|
||||||
|
try {
|
||||||
|
const client = await getRow('SELECT * FROM api_clients WHERE client_id = ?', [cid]);
|
||||||
|
if (client && client.active && bcrypt.compareSync(secret, client.secret_hash)) return next();
|
||||||
|
} catch {}
|
||||||
|
return res.status(401).json({ error: '客户端鉴权失败' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方式3: 用户 JWT(交给 authenticate 处理, 这里放行)
|
||||||
|
if (req.headers['authorization']?.startsWith('Bearer ')) return next();
|
||||||
|
|
||||||
|
// 方式1 配置了 key 但没带 → 拒绝(避免裸奔)
|
||||||
|
if (legacy) return res.status(401).json({ error: '未授权' });
|
||||||
|
// 什么都没配置: 允许(兼容旧部署)
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Auth (no external key needed) ----
|
// 服务器 alias 解析: '分组/服务器' 或 alias 或 server_name
|
||||||
|
async function resolveServer(q) {
|
||||||
|
if (!q) return { group_name: '', server_name: '', alias: '' };
|
||||||
|
if (q.includes('/')) {
|
||||||
|
const [g, s] = q.split('/');
|
||||||
|
return { group_name: g.trim(), server_name: s.trim(), alias: '' };
|
||||||
|
}
|
||||||
|
const row = await getRow('SELECT group_name, server_name, alias FROM server_groups WHERE alias = ? OR server_name = ? LIMIT 1', [q, q]);
|
||||||
|
if (row) return { group_name: row.group_name, server_name: row.server_name, alias: row.alias };
|
||||||
|
return { group_name: '', server_name: q, alias: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 客户端管理(owner) ============
|
||||||
|
router.post('/clients', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
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');
|
||||||
|
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(仅显示一次)' });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/clients', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!['owner','admin'].includes(req.user.role)) return res.status(403).json({ error: '无权限' });
|
||||||
|
const rows = await query('SELECT id, name, client_id, active, created_at FROM api_clients ORDER BY id DESC');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/clients/:id', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!['owner','admin'].includes(req.user.role)) return res.status(403).json({ error: '无权限' });
|
||||||
|
const { active } = req.body;
|
||||||
|
await query('UPDATE api_clients SET active = ? WHERE id = ?', [active ? 1 : 0, req.params.id]);
|
||||||
|
await logSystem('info', 'api', `更新外部API客户端 #${req.params.id}`, { active: !!active });
|
||||||
|
res.json({ message: '已更新' });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/clients/:id', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!['owner','admin'].includes(req.user.role)) return res.status(403).json({ error: '无权限' });
|
||||||
|
await query('DELETE FROM api_clients WHERE id = ?', [req.params.id]);
|
||||||
|
await logSystem('info', 'api', `删除外部API客户端 #${req.params.id}`);
|
||||||
|
res.json({ message: '已删除' });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Auth (no external key needed) ============
|
||||||
router.post('/auth/register', async (req, res) => {
|
router.post('/auth/register', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, password, email, game_name, game_uid, source } = req.body;
|
const { username, password, email, game_name, game_uid, source } = req.body;
|
||||||
@@ -58,25 +137,14 @@ router.post('/auth/login', async (req, res) => {
|
|||||||
} catch (e) { res.status(500).json({ error: '服务器错误' }); }
|
} catch (e) { res.status(500).json({ error: '服务器错误' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Plugin API (JWT auth) ----
|
// ---- 以下接口: 免用户登录(client_id+secret / x-external-key / JWT) ----
|
||||||
router.use(externalAuth);
|
router.use(clientAuth);
|
||||||
|
|
||||||
router.get('/my-tickets', authenticate, async (req, res) => {
|
|
||||||
const { limit } = req.query;
|
|
||||||
const rows = await query(`SELECT id, type, title, status, priority, created_at, updated_at FROM tickets WHERE user_id = ? ORDER BY updated_at DESC LIMIT ?`, [req.user.id, Math.min(parseInt(limit)||10, 50)]);
|
|
||||||
res.json({ list: rows });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/my-tickets/:id', authenticate, async (req, res) => {
|
|
||||||
const t = await getRow(`SELECT id, type, title, status, priority, reporter_game_name, reporter_game_uid,
|
|
||||||
target_game_name, target_game_uid, reason, description, assigned_to, claim_note, created_at, updated_at
|
|
||||||
FROM tickets WHERE id = ? AND user_id = ?`, [req.params.id, req.user.id]);
|
|
||||||
if (!t) return res.status(404).json({ error: '工单不存在' });
|
|
||||||
res.json(t);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// ============ 工单: 提交 ============
|
||||||
|
// server: '分组/服务器名' 或 alias 或 server_name(可选)
|
||||||
router.post('/tickets', async (req, res) => {
|
router.post('/tickets', async (req, res) => {
|
||||||
const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, is_admin_complaint } = req.body;
|
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 (!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 || !reporter_game_uid) return res.status(400).json({ error: '必填字段不完整' });
|
||||||
if (type === 'report' && !reason) return res.status(400).json({ error: '请填写举报原因' });
|
if (type === 'report' && !reason) return res.status(400).json({ error: '请填写举报原因' });
|
||||||
@@ -86,20 +154,55 @@ router.post('/tickets', async (req, res) => {
|
|||||||
const countRow = await getRow("SELECT COUNT(*) as c FROM tickets WHERE (reporter_game_name = ? OR reporter_game_uid = ?) AND status IN ('pending','processing','awaiting_info','appealing')", [reporter_game_name, reporter_game_uid]);
|
const countRow = await getRow("SELECT COUNT(*) as c FROM tickets WHERE (reporter_game_name = ? OR reporter_game_uid = ?) AND status IN ('pending','processing','awaiting_info','appealing')", [reporter_game_name, reporter_game_uid]);
|
||||||
if (countRow.c >= 5) return res.status(400).json({ error: '待处理工单已达上限' });
|
if (countRow.c >= 5) return res.status(400).json({ error: '待处理工单已达上限' });
|
||||||
|
|
||||||
|
const sv = await resolveServer(server);
|
||||||
const trackingToken = uuid();
|
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) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
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]);
|
[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})`]);
|
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:''}`]);
|
||||||
res.status(201).json({ id: r.insertId, tracking_token: trackingToken });
|
res.status(201).json({ id: r.insertId, tracking_token: trackingToken, server: sv.server_name });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Java Server API (x-external-key only) ----
|
// ============ 工单: 追踪(token 即凭据, 提交人可查) ============
|
||||||
|
router.get('/tickets/track', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { token } = req.query;
|
||||||
|
if (!token) return res.status(400).json({ error: '缺少 tracking_token' });
|
||||||
|
const t = await getRow(`SELECT id, type, title, status, priority, reporter_game_name, reporter_game_uid,
|
||||||
|
target_game_name, target_game_uid, reason, description, assigned_to, claim_note, server_name, created_at, updated_at
|
||||||
|
FROM tickets WHERE tracking_token = ?`, [token]);
|
||||||
|
if (!t) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
t.responses = await query('SELECT content, is_staff, created_at FROM responses WHERE ticket_id = ? ORDER BY created_at ASC', [t.id]);
|
||||||
|
res.json(t);
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 我的工单(JWT 用户)
|
||||||
|
router.get('/my-tickets', authenticate, async (req, res) => {
|
||||||
|
const { limit } = req.query;
|
||||||
|
const rows = await query(`SELECT id, type, title, status, priority, server_name, created_at, updated_at FROM tickets WHERE user_id = ? ORDER BY updated_at DESC LIMIT ?`, [req.user.id, Math.min(parseInt(limit)||10, 50)]);
|
||||||
|
res.json({ list: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/my-tickets/:id', authenticate, async (req, res) => {
|
||||||
|
const t = await getRow(`SELECT id, type, title, status, priority, reporter_game_name, reporter_game_uid,
|
||||||
|
target_game_name, target_game_uid, reason, description, assigned_to, claim_note, server_name, created_at, updated_at
|
||||||
|
FROM tickets WHERE id = ? AND user_id = ?`, [req.params.id, req.user.id]);
|
||||||
|
if (!t) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
res.json(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ 工单列表(带 server 过滤) ============
|
||||||
router.get('/all-tickets', async (req, res) => {
|
router.get('/all-tickets', async (req, res) => {
|
||||||
const { type, status, page, limit } = req.query;
|
const { type, status, server, page, limit } = req.query;
|
||||||
let q = `SELECT id, type, title, status, priority, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, assigned_to, claim_note, created_at, updated_at FROM tickets WHERE 1=1`;
|
let q = `SELECT id, type, title, status, priority, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, assigned_to, claim_note, server_name, created_at, updated_at FROM tickets WHERE 1=1`;
|
||||||
const p = [];
|
const p = [];
|
||||||
if (type) { q += ' AND type = ?'; p.push(type); }
|
if (type) { q += ' AND type = ?'; p.push(type); }
|
||||||
if (status) { q += ' AND status = ?'; p.push(status); }
|
if (status) { q += ' AND status = ?'; p.push(status); }
|
||||||
|
if (server) {
|
||||||
|
const sv = await resolveServer(server);
|
||||||
|
q += ' AND server_name = ?'; p.push(sv.server_name);
|
||||||
|
}
|
||||||
const pg = parseInt(page) || 1;
|
const pg = parseInt(page) || 1;
|
||||||
const lm = Math.min(parseInt(limit) || 50, 200);
|
const lm = Math.min(parseInt(limit) || 50, 200);
|
||||||
q += ` ORDER BY updated_at DESC LIMIT ${(pg-1)*lm}, ${lm}`;
|
q += ` ORDER BY updated_at DESC LIMIT ${(pg-1)*lm}, ${lm}`;
|
||||||
@@ -114,23 +217,72 @@ router.get('/all-tickets/:id', async (req, res) => {
|
|||||||
res.json(t);
|
res.json(t);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ 封禁 ============
|
||||||
|
// 拉取封禁: ?server=alias|server_name|分组/服务器&status=active|expired|appealed|lifted&player=&limit=
|
||||||
|
router.get('/bans', async (req, res) => {
|
||||||
|
const { server, status, player, page, limit } = req.query;
|
||||||
|
let q = 'SELECT id, player_name, player_uid, source, reason, type, duration, server_name, status, created_at, expires_at FROM bans WHERE 1=1';
|
||||||
|
const p = [];
|
||||||
|
if (server) {
|
||||||
|
const sv = await resolveServer(server);
|
||||||
|
q += ' AND server_name = ?'; p.push(sv.server_name);
|
||||||
|
}
|
||||||
|
if (status) { q += ' AND status = ?'; p.push(status); }
|
||||||
|
if (player) { q += ' AND (player_name = ? OR player_uid = ?)'; p.push(player, player); }
|
||||||
|
const pg = parseInt(page) || 1;
|
||||||
|
const lm = Math.min(parseInt(limit) || 100, 500);
|
||||||
|
q += ` ORDER BY created_at DESC LIMIT ${(pg-1)*lm}, ${lm}`;
|
||||||
|
res.json(await query(q, p));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 新增封禁(可带 server)
|
||||||
|
router.post('/bans', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { player_name, player_uid, source, reason, type, duration, expires_at, server, status } = req.body;
|
||||||
|
if (!player_name) return res.status(400).json({ error: 'player_name 必填' });
|
||||||
|
const sv = await resolveServer(server);
|
||||||
|
const r = await query(`INSERT INTO bans(player_name, player_uid, source, reason, type, duration, server_name, status, expires_at) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||||
|
[player_name, player_uid||'', source||'', reason||'', ['ban','mute','warn','other'].includes(type)?type:'ban', duration||'', sv.server_name, ['active','expired','appealed','lifted'].includes(status)?status:'active', expires_at||null]);
|
||||||
|
await logSystem('info', 'api', `外部创建封禁: ${player_name}`, { server: sv.server_name, type: type||'ban' });
|
||||||
|
res.status(201).json({ id: r.insertId });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ 被举报人统计(带 server 过滤) ============
|
||||||
router.get('/reported-players', async (req, res) => {
|
router.get('/reported-players', async (req, res) => {
|
||||||
const rows = await query(`SELECT target_game_name, target_game_uid, COUNT(*) as total,
|
const { server } = req.query;
|
||||||
|
let q = `SELECT target_game_name, target_game_uid, COUNT(*) as total,
|
||||||
SUM(CASE WHEN status IN ('pending','processing','awaiting_info','appealing') THEN 1 ELSE 0 END) as active,
|
SUM(CASE WHEN status IN ('pending','processing','awaiting_info','appealing') THEN 1 ELSE 0 END) as active,
|
||||||
SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as resolved,
|
SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as resolved,
|
||||||
MAX(created_at) as last_report
|
MAX(created_at) as last_report
|
||||||
FROM tickets WHERE type='report' AND target_game_name IS NOT NULL AND target_game_name != ''
|
FROM tickets WHERE type='report' AND target_game_name IS NOT NULL AND target_game_name != ''`;
|
||||||
GROUP BY target_game_name, target_game_uid ORDER BY total DESC LIMIT 500`);
|
const p = [];
|
||||||
|
if (server) {
|
||||||
|
const sv = await resolveServer(server);
|
||||||
|
q += ' AND server_name = ?'; p.push(sv.server_name);
|
||||||
|
}
|
||||||
|
q += ' GROUP BY target_game_name, target_game_uid ORDER BY total DESC LIMIT 500';
|
||||||
|
res.json(await query(q, p));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ 服务器列表(含 alias) ============
|
||||||
|
router.get('/servers', async (req, res) => {
|
||||||
|
const rows = await query('SELECT group_name, server_name, alias FROM server_groups ORDER BY group_name, server_name');
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/stats', async (req, res) => {
|
router.get('/stats', async (req, res) => {
|
||||||
const [[{c:total}], byType, byStatus] = await Promise.all([
|
const { server } = req.query;
|
||||||
query('SELECT COUNT(*) as c FROM tickets'),
|
let q = 'SELECT COUNT(*) as c FROM tickets';
|
||||||
query('SELECT type, COUNT(*) as c FROM tickets GROUP BY type'),
|
const p = [];
|
||||||
query('SELECT status, COUNT(*) as c FROM tickets GROUP BY status'),
|
if (server) {
|
||||||
]);
|
const sv = await resolveServer(server);
|
||||||
res.json({ total, byType, byStatus });
|
q += ' WHERE server_name = ?'; p.push(sv.server_name);
|
||||||
|
}
|
||||||
|
const total = await getRow(q, p);
|
||||||
|
const byType = await query('SELECT type, COUNT(*) as c FROM tickets ' + (server ? 'WHERE server_name = ? ' : '') + 'GROUP BY type', p);
|
||||||
|
const byStatus = await query('SELECT status, COUNT(*) as c FROM tickets ' + (server ? 'WHERE server_name = ? ' : '') + 'GROUP BY status', p);
|
||||||
|
res.json({ total: total.c, byType, byStatus });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -9,12 +9,27 @@ router.get('/groups', authenticate, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/groups', authenticate, requireRole('owner'), async (req, res) => {
|
router.post('/groups', authenticate, requireRole('owner'), async (req, res) => {
|
||||||
const { group_name, server_name } = req.body;
|
const { group_name, server_name, alias } = req.body;
|
||||||
if (!group_name || !server_name) return res.status(400).json({ error: '分组名和子服名为必填' });
|
if (!group_name || !server_name) return res.status(400).json({ error: '分组名和子服名为必填' });
|
||||||
await query('INSERT IGNORE INTO server_groups(group_name, server_name) VALUES (?,?)', [group_name, server_name]);
|
if (alias) {
|
||||||
|
const dup = await getRow('SELECT id FROM server_groups WHERE alias = ?', [alias]);
|
||||||
|
if (dup) return res.status(400).json({ error: '该外部别名已被使用' });
|
||||||
|
}
|
||||||
|
await query('INSERT IGNORE INTO server_groups(group_name, server_name, alias) VALUES (?,?,?)', [group_name, server_name, alias || '']);
|
||||||
res.status(201).json({ message: '已添加' });
|
res.status(201).json({ message: '已添加' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.put('/groups/:id', authenticate, requireRole('owner'), async (req, res) => {
|
||||||
|
const { group_name, server_name, alias } = req.body;
|
||||||
|
if (!group_name || !server_name) return res.status(400).json({ error: '分组名和子服名为必填' });
|
||||||
|
if (alias) {
|
||||||
|
const dup = await getRow('SELECT id FROM server_groups WHERE alias = ? AND id != ?', [alias, req.params.id]);
|
||||||
|
if (dup) return res.status(400).json({ error: '该外部别名已被使用' });
|
||||||
|
}
|
||||||
|
await query('UPDATE server_groups SET group_name = ?, server_name = ?, alias = ? WHERE id = ?', [group_name, server_name, alias || '', req.params.id]);
|
||||||
|
res.json({ message: '已更新' });
|
||||||
|
});
|
||||||
|
|
||||||
router.delete('/groups/:id', authenticate, requireRole('owner'), async (req, res) => {
|
router.delete('/groups/:id', authenticate, requireRole('owner'), async (req, res) => {
|
||||||
await query('DELETE FROM server_groups WHERE id = ?', [req.params.id]);
|
await query('DELETE FROM server_groups WHERE id = ?', [req.params.id]);
|
||||||
res.json({ message: '已删除' });
|
res.json({ message: '已删除' });
|
||||||
|
|||||||
52
docs/API.md
52
docs/API.md
@@ -329,15 +329,41 @@ Base URL: `http://<host>:3100/api`
|
|||||||
|
|
||||||
| 端点 | 认证 | 说明 |
|
| 端点 | 认证 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| POST /external/auth/register | external-key | 插件注册(邮箱验证链接) |
|
| POST /external/auth/register | — | 插件注册(邮箱验证链接) |
|
||||||
| POST /external/auth/login | external-key | 插件登录,返回 JWT |
|
| POST /external/auth/login | — | 插件登录,返回 JWT |
|
||||||
| GET /external/my-tickets | external-key + Bearer | 我的工单列表(limit ≤50) |
|
| GET /external/my-tickets | Bearer | 我的工单列表(limit ≤50) |
|
||||||
| GET /external/my-tickets/:id | external-key + Bearer | 我的工单详情 |
|
| GET /external/my-tickets/:id | Bearer | 我的工单详情 |
|
||||||
| POST /external/tickets | external-key | 游戏内提交工单(自动写主身份) |
|
| POST /external/tickets | client | 提交工单(带 `server` 可选;返回 id + tracking_token) |
|
||||||
| GET /external/all-tickets | external-key | 全部工单(分页 page/limit ≤200) |
|
| GET /external/tickets/track?token= | client | 按追踪码查工单状态+回复(提交→处理→结束全流程) |
|
||||||
| GET /external/all-tickets/:id | external-key | 工单详情(含回复/附件) |
|
| GET /external/all-tickets | client | 全部工单(分页 page/limit ≤200,可按 `server`/`status`/`type` 过滤) |
|
||||||
| GET /external/reported-players | external-key | 被举报统计 |
|
| GET /external/all-tickets/:id | client | 工单详情(含回复/附件) |
|
||||||
| GET /external/stats | external-key | 工单统计 |
|
| GET /external/bans?server=&status=&player= | client | 拉取封禁列表(可按子服/状态/玩家过滤) |
|
||||||
|
| POST /external/bans | client | 新增封禁(可带 `server`) |
|
||||||
|
| GET /external/reported-players?server= | client | 被举报统计(可按子服过滤) |
|
||||||
|
| GET /external/servers | client | 服务器列表(含外部别名) |
|
||||||
|
| GET /external/stats?server= | client | 工单统计(可按子服过滤) |
|
||||||
|
| POST /external/clients | Bearer(owner/admin) | 创建 API 客户端,返回 client_id + secret(仅显示一次) |
|
||||||
|
| GET /external/clients | Bearer(owner/admin) | 客户端列表 |
|
||||||
|
| PUT /external/clients/:id | Bearer(owner/admin) | 启用/停用客户端 |
|
||||||
|
| DELETE /external/clients/:id | Bearer(owner/admin) | 删除客户端 |
|
||||||
|
|
||||||
|
### 外部鉴权(三选一)
|
||||||
|
|
||||||
|
| 方式 | 请求头 | 适用 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 多客户端(推荐) | `x-api-client-id` + `x-api-secret` | QQ机器人等外部系统,后台可创建/停用多个客户端 |
|
||||||
|
| 旧版单 key | `x-external-key` | 兼容旧部署(`config.external_api_key`) |
|
||||||
|
| 用户 JWT | `Authorization: Bearer <jwt>` | 插件/已登录用户 |
|
||||||
|
|
||||||
|
### 子服务器定位(`server` 参数)
|
||||||
|
|
||||||
|
每个子服可在「服务器管理」配置**外部别名(alias)**,外部 API 的 `server` 参数支持三种写法:
|
||||||
|
|
||||||
|
- **别名**: `survival`(推荐,改名不影响)
|
||||||
|
- **分组/子服**: `网易服务器/生存服`
|
||||||
|
- **子服名**: `生存服`(同名时取第一个)
|
||||||
|
|
||||||
|
工单/封禁按提交时的 `server` 归属存储,查询时用同一参数即可精确拉取对应子服数据。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -365,3 +391,11 @@ Base URL: `http://<host>:3100/api`
|
|||||||
5. 工单提交:登录用户可选择任一已绑定身份(后端校验归属);匿名仍走表单字段
|
5. 工单提交:登录用户可选择任一已绑定身份(后端校验归属);匿名仍走表单字段
|
||||||
|
|
||||||
> 老数据无需手工处理,服务启动时自动迁移;`users` 表原有字段(主身份)保持不变,完全向后兼容。
|
> 老数据无需手工处理,服务启动时自动迁移;`users` 表原有字段(主身份)保持不变,完全向后兼容。
|
||||||
|
|
||||||
|
外部 API 功能新增迁移(幂等,服务启动自动执行):
|
||||||
|
|
||||||
|
1. 创建 `api_clients` 表(多客户端鉴权)
|
||||||
|
2. `server_groups` 加 `alias` 列(子服外部别名)
|
||||||
|
3. `tickets` / `bans` 加 `server_name` 列(按子服归属存储)
|
||||||
|
|
||||||
|
> 老工单/封禁 `server_name` 为空串,不影响原有查询;新提交的数据自动带子服归属。
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
<script src="js/pages/users.js"></script>
|
<script src="js/pages/users.js"></script>
|
||||||
<script src="js/pages/templates.js"></script>
|
<script src="js/pages/templates.js"></script>
|
||||||
<script src="js/pages/notifications.js"></script>
|
<script src="js/pages/notifications.js"></script>
|
||||||
|
<script src="js/pages/external-api.js"></script>
|
||||||
<script src="js/pages/settings-page.js"></script>
|
<script src="js/pages/settings-page.js"></script>
|
||||||
<script src="js/pages/export-page.js"></script>
|
<script src="js/pages/export-page.js"></script>
|
||||||
<script src="js/pages/polls.js"></script>
|
<script src="js/pages/polls.js"></script>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const App = {
|
|||||||
{ id: 'polls', label: '投票', icon: 'fa-poll', roles: ['owner'] },
|
{ id: 'polls', label: '投票', icon: 'fa-poll', roles: ['owner'] },
|
||||||
{ id: 'features', label: '更新内容', icon: 'fa-lightbulb', roles: ['owner'] },
|
{ id: 'features', label: '更新内容', icon: 'fa-lightbulb', roles: ['owner'] },
|
||||||
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] },
|
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] },
|
||||||
|
{ id: 'external-api', label: '外部API', icon: 'fa-key', roles: ['owner'] },
|
||||||
{ id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner'] },
|
{ id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner'] },
|
||||||
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner'] },
|
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner'] },
|
||||||
{ id: 'logs', label: '系统日志', icon: 'fa-clipboard-list', roles: ['owner','admin'] },
|
{ id: 'logs', label: '系统日志', icon: 'fa-clipboard-list', roles: ['owner','admin'] },
|
||||||
@@ -92,6 +93,7 @@ const App = {
|
|||||||
case 'tracking': this.renderMain('追踪工单', TrackingPage, param); break;
|
case 'tracking': this.renderMain('追踪工单', TrackingPage, param); break;
|
||||||
case 'users': this.renderMain('用户管理', UsersPage, param); break;
|
case 'users': this.renderMain('用户管理', UsersPage, param); break;
|
||||||
case 'notifications': this.renderMain('通知配置', NotificationsPage, param); break;
|
case 'notifications': this.renderMain('通知配置', NotificationsPage, param); break;
|
||||||
|
case 'external-api': this.renderMain('外部API', ExternalApiPage, param); break;
|
||||||
case 'templates': this.renderMain('邮件模板', TemplatesPage, param); break;
|
case 'templates': this.renderMain('邮件模板', TemplatesPage, param); break;
|
||||||
case 'settings': this.renderMain('系统设置', SettingsPage, param); break;
|
case 'settings': this.renderMain('系统设置', SettingsPage, param); break;
|
||||||
case 'export': this.renderMain('数据导出', ExportPage, param); break;
|
case 'export': this.renderMain('数据导出', ExportPage, param); break;
|
||||||
@@ -173,6 +175,7 @@ window.UnclaimedPage = UnclaimedPage;
|
|||||||
window.UsersPage = UsersPage;
|
window.UsersPage = UsersPage;
|
||||||
window.TemplatesPage = TemplatesPage;
|
window.TemplatesPage = TemplatesPage;
|
||||||
window.NotificationsPage = NotificationsPage;
|
window.NotificationsPage = NotificationsPage;
|
||||||
|
window.ExternalApiPage = ExternalApiPage;
|
||||||
window.SettingsPage = SettingsPage;
|
window.SettingsPage = SettingsPage;
|
||||||
window.ExportPage = ExportPage;
|
window.ExportPage = ExportPage;
|
||||||
window.PollsPage = PollsPage;
|
window.PollsPage = PollsPage;
|
||||||
|
|||||||
81
public/js/pages/external-api.js
Normal file
81
public/js/pages/external-api.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
const ExternalApiPage = {
|
||||||
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
document.getElementById('page-title').textContent = '外部API';
|
||||||
|
document.getElementById('page-actions').innerHTML = '<button class="btn btn-p btn-sm" onclick="ExternalApiPage.showCreate()"><i class="fas fa-plus"></i> 创建客户端</button>';
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
try {
|
||||||
|
const clients = await API.get('/external/clients');
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="card"><div class="card-h"><i class="fas fa-key"></i> API 客户端</div><div class="card-b">
|
||||||
|
${clients.length === 0 ? '<div class="empty"><i class="fas fa-key"></i><p>暂无客户端</p></div>' : `<div class="table-wrap"><table><thead><tr><th>#</th><th>名称</th><th>Client ID</th><th>状态</th><th>创建时间</th><th>操作</th></tr></thead><tbody>${clients.map(c => `
|
||||||
|
<tr>
|
||||||
|
<td>${c.id}</td>
|
||||||
|
<td>${U.esc(c.name)}</td>
|
||||||
|
<td><code style="font-size:11px">${U.esc(c.client_id)}</code></td>
|
||||||
|
<td>${c.active ? '<span class="badge bg-resolved">启用</span>' : '<span class="badge bg-rejected">停用</span>'}</td>
|
||||||
|
<td class="ts">${U.date(c.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-o btn-sm" onclick="ExternalApiPage.toggle(${c.id},${c.active?0:1})">${c.active?'停用':'启用'}</button>
|
||||||
|
<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>(推荐);旧版单 key 仍可用 <code>x-external-key</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>
|
||||||
|
<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>
|
||||||
|
<tr><td>GET</td><td><code>/api/external/all-tickets/:id</code></td><td>工单详情(含回复/附件)</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/external/bans?server=&status=&player=</code></td><td>拉取封禁列表</td></tr>
|
||||||
|
<tr><td>POST</td><td><code>/api/external/bans</code></td><td>新增封禁(可带 server)</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/external/reported-players?server=</code></td><td>被举报人统计</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/external/servers</code></td><td>服务器列表(含别名)</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/external/stats?server=</code></td><td>工单统计</td></tr>
|
||||||
|
<tr><td>POST</td><td><code>/api/external/clients</code></td><td>创建客户端(需登录态, 本页操作)</td></tr>
|
||||||
|
</tbody></table></div>
|
||||||
|
<p class="help" style="margin-top:8px">完整请求/响应示例见 <code>docs/API.md</code> 外部API章节。</p></div></div>
|
||||||
|
`;
|
||||||
|
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async showCreate() {
|
||||||
|
U.modal('创建 API 客户端', `
|
||||||
|
<form id="apikey-form">
|
||||||
|
<div class="form-group"><label>名称 *</label><input id="ak-name" required placeholder="如:QQ官方机器人"></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('apikey-form').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const r = await API.post('/external/clients', { name: document.getElementById('ak-name').value });
|
||||||
|
App.closeModal();
|
||||||
|
U.modal('客户端已创建', `
|
||||||
|
<p>请立即保存以下凭据(secret 只显示一次):</p>
|
||||||
|
<div class="alert alert-i" style="word-break:break-all;font-family:monospace;font-size:12px">
|
||||||
|
<div>Client ID: <b>${U.esc(r.client_id)}</b></div>
|
||||||
|
<div style="margin-top:6px">Secret: <b>${U.esc(r.secret)}</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-f"><button type="button" class="btn btn-p" data-action="App:closeModal">我已保存</button></div>
|
||||||
|
`);
|
||||||
|
this.load();
|
||||||
|
} catch(ex){alert(ex.message);}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggle(id, active) {
|
||||||
|
try { await API.put(`/external/clients/${id}`, { active: !!active }); this.load(); } catch(ex){alert(ex.message);}
|
||||||
|
},
|
||||||
|
|
||||||
|
async del(id) {
|
||||||
|
if (!await U.confirm('确认删除该客户端?')) return;
|
||||||
|
try { await API.delete(`/external/clients/${id}`); this.load(); } catch(ex){alert(ex.message);}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -29,7 +29,8 @@ const ServersPage = {
|
|||||||
${servers.map(s => `
|
${servers.map(s => `
|
||||||
<div style="display:flex;align-items:center;gap:6px;background:var(--g1);padding:6px 12px;border-radius:var(--r);font-size:13px">
|
<div style="display:flex;align-items:center;gap:6px;background:var(--g1);padding:6px 12px;border-radius:var(--r);font-size:13px">
|
||||||
<i class="fas fa-server ts tm"></i> ${U.esc(s.server_name)}
|
<i class="fas fa-server ts tm"></i> ${U.esc(s.server_name)}
|
||||||
${Auth.isOwner() ? `<button class="btn btn-d btn-sm" style="padding:2px 6px;font-size:10px" onclick="ServersPage.del(${s.id})"><i class="fas fa-times"></i></button>` : ''}
|
${s.alias ? `<span class="badge bg-admin" title="外部别名">${U.esc(s.alias)}</span>` : ''}
|
||||||
|
${Auth.isOwner() ? `<button class="btn btn-o btn-sm" style="padding:2px 6px;font-size:10px" onclick="ServersPage.showEdit(${s.id})"><i class="fas fa-edit"></i></button><button class="btn btn-d btn-sm" style="padding:2px 6px;font-size:10px" onclick="ServersPage.del(${s.id})"><i class="fas fa-times"></i></button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
@@ -43,14 +44,49 @@ const ServersPage = {
|
|||||||
<form id="add-srv-form">
|
<form id="add-srv-form">
|
||||||
<div class="grid-2"><div class="form-group"><label>分组名 *</label><input id="as-group" required placeholder="如:网易服务器"></div>
|
<div class="grid-2"><div class="form-group"><label>分组名 *</label><input id="as-group" required placeholder="如:网易服务器"></div>
|
||||||
<div class="form-group"><label>子服名 *</label><input id="as-server" required placeholder="如:生存服"></div></div>
|
<div class="form-group"><label>子服名 *</label><input id="as-server" required placeholder="如:生存服"></div></div>
|
||||||
|
<div class="form-group"><label>外部别名(可选)</label><input id="as-alias" placeholder="如:survival —— 供外部API按别名拉取该子服数据"><span class="help">外部 API 可用别名或「分组/子服名」定位此服务器</span></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>
|
<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>
|
</form>
|
||||||
`);
|
`);
|
||||||
document.getElementById('add-srv-form').onsubmit = async e => {
|
document.getElementById('add-srv-form').onsubmit = async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try { await API.post('/polls/groups', { group_name: document.getElementById('as-group').value, server_name: document.getElementById('as-server').value }); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
|
try {
|
||||||
|
await API.post('/polls/groups', {
|
||||||
|
group_name: document.getElementById('as-group').value,
|
||||||
|
server_name: document.getElementById('as-server').value,
|
||||||
|
alias: document.getElementById('as-alias').value.trim(),
|
||||||
|
});
|
||||||
|
App.closeModal(); this.load();
|
||||||
|
} catch(ex){alert(ex.message);}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async showEdit(id) {
|
||||||
|
try {
|
||||||
|
const groups = await API.get('/polls/groups');
|
||||||
|
const s = groups.find(x => x.id === id);
|
||||||
|
if (!s) return alert('未找到');
|
||||||
|
U.modal('编辑服务器', `
|
||||||
|
<form id="edit-srv-form">
|
||||||
|
<div class="grid-2"><div class="form-group"><label>分组名 *</label><input id="es-group" required value="${U.esc(s.group_name)}"></div>
|
||||||
|
<div class="form-group"><label>子服名 *</label><input id="es-server" required value="${U.esc(s.server_name)}"></div></div>
|
||||||
|
<div class="form-group"><label>外部别名(可选)</label><input id="es-alias" value="${U.esc(s.alias||'')}" placeholder="如:survival"><span class="help">外部 API 可用别名或「分组/子服名」定位此服务器</span></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('edit-srv-form').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await API.put(`/polls/groups/${id}`, {
|
||||||
|
group_name: document.getElementById('es-group').value,
|
||||||
|
server_name: document.getElementById('es-server').value,
|
||||||
|
alias: document.getElementById('es-alias').value.trim(),
|
||||||
|
});
|
||||||
|
App.closeModal(); this.load();
|
||||||
|
} catch(ex){alert(ex.message);}
|
||||||
|
};
|
||||||
|
} catch (ex) { alert(ex.message); }
|
||||||
|
},
|
||||||
|
|
||||||
async del(id) { if (!await U.confirm('确认删除?')) return; try { await API.req('DELETE', '/polls/groups/'+id); this.load(); } catch(ex){alert(ex.message);} }
|
async del(id) { if (!await U.confirm('确认删除?')) return; try { await API.req('DELETE', '/polls/groups/'+id); this.load(); } catch(ex){alert(ex.message);} }
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user