feat: 接齐 SimpCore 开放 API 全量接口并新增 API 数据浏览弹层
以前只用了「邦国领土」这一类接口,控制台也只能看到一行「已启用/未启用」, 大量服务端公开信息拿得到却看不到。现在按官方文档 https://open.bgjq.top/api-docs.html 补齐。 新增 - SimpApiClient 从 7 个接口补到 16 个:/info/players、/info/unions/{key}/members、 /tech、/contributions、/info/buffs、/info/diplomacy、/info/bans、/info/logs、 /info/announcements - SimpApiV2Client(新):/players、/players/{name}/equipment、/time、/dragon。 文档明确 API 与 API-V2 是两套独立、互不通用的密钥体系,因此单独实例与配置 (apiV2Enabled / apiV2Key,或环境变量 SIMP_API_V2_KEY);V2 配额很小 (末影龙 1 次/分钟),一律按需查询、不做轮询 - 控制台「查看 API 数据」弹层,五个页签: 概览(两套密钥状态/请求数/缓存/健康检查)· 邦国(27 个,点开看成员/科技/捐献/领土统计) · 玩家(1600+ 可搜索,点开看详情与所属邦国)· 全服(加成/外交/封禁/日志/公告) · 实时 V2(在线玩家/游戏时间/末影龙/装备) - /api/simp/* 一整套只读代理接口,统一处理「未配 Key / 上游错误」并给出可读提示 修复(解析字段名以实测响应为准) - 捐献:响应只有 player_uuid 没有玩家名 -> 先用成员表补全、查不到再退到全服玩家表; 时间字段是 contributed_at;补充 reason(用途)列;amount 可为负(邦国支出) - 公告:message 是 Minecraft 聊天组件 JSON 字符串 -> 新增 componentToText() 压平为纯文本 - 封禁:实际字段 banned_player_name / banned_by_player_name / banned_at / unbanned - 日志:实际字段 username / action / timestamp - 玩家表:上下线时间是 last_join_time / last_quit_time - 王城坐标:服务端可能返回 null,原来 Number(null) 会算成 0, 伪装成「王城在世界原点 (0,0)」;现在显式判空返回 null 变更 - 快捷发送去掉 /spawn 与 /list,只留 /u return、/u claimmode、/u restore confirm 测试 326 -> 336,全部通过。
这个提交包含在:
+655
-1
@@ -252,7 +252,9 @@ const CONFIG_SCHEMA = {
|
||||
fields: [
|
||||
{ path: 'apiEnabled', label: '启用 API 数据源', type: 'checkbox' },
|
||||
{ path: 'apiKey', label: 'API Key', type: 'password', hint: '留空则回退读环境变量 SIMP_API_KEY' },
|
||||
{ path: 'apiRefreshIntervalSeconds', label: 'API 轮询间隔(秒)', type: 'number', min: 60 }
|
||||
{ path: 'apiRefreshIntervalSeconds', label: 'API 轮询间隔(秒)', type: 'number', min: 60 },
|
||||
{ path: 'apiV2Enabled', label: '启用 API-V2(实时状态)', type: 'checkbox', hint: 'V2 是独立的一套密钥体系,需在密钥中心单独申请' },
|
||||
{ path: 'apiV2Key', label: 'API-V2 Key', type: 'password', hint: '留空则回退读环境变量 SIMP_API_V2_KEY;V2 配额很小,不做自动轮询' }
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -746,6 +748,11 @@ function renderStatus(status) {
|
||||
$('btnExportExp').title = `累计 ${status.expansionLog.total} 条,今日 ${status.expansionLog.today} 条`;
|
||||
}
|
||||
|
||||
// ===== SimpCore API 状态(弹层标题栏一行概览)=====
|
||||
if (status.data) {
|
||||
updateSimpStatus(status.data);
|
||||
}
|
||||
|
||||
// ===== 大地图爬取进度 =====
|
||||
if (status.bigMap && status.bigMap.crawl) {
|
||||
const crawl = status.bigMap.crawl;
|
||||
@@ -917,6 +924,641 @@ function bindMobileNav() {
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== SimpCore 开放 API 数据浏览 ====================
|
||||
//
|
||||
// 官方文档里的接口远不止「邦国领土」那一类:玩家 / 邦国成员 / 科技 / 捐献 /
|
||||
// 全服加成 / 外交 / 封禁 / 上下线日志 / 公告,以及另一套独立密钥的 API-V2
|
||||
// (在线玩家 / 装备 / 游戏时间 / 末影龙)。
|
||||
//
|
||||
// 限流是按「Key × 接口」每分钟算的,V2 里末影龙甚至只有 1 次/分钟,
|
||||
// 所以这里一律**按需点击加载 + 会话内缓存**,绝不做轮询。
|
||||
|
||||
const simpView = {
|
||||
tab: 'overview',
|
||||
globalKind: 'buffs',
|
||||
detail: null,
|
||||
/** tab → 已加载的数据,避免反复点同一个页签时重复打接口 */
|
||||
cache: {},
|
||||
loading: false
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新 API 数据弹层标题栏的一行状态。
|
||||
* @param {object} data `/api/status` 里的 `data` 段
|
||||
*/
|
||||
function updateSimpStatus(data) {
|
||||
const el2 = $('simpStatus');
|
||||
if (!el2) return;
|
||||
const parts = [`API ${data.apiEnabled ? '已启用' : '未启用'}`];
|
||||
if (data.apiError) parts.push(data.apiError);
|
||||
parts.push(`V2 ${data.apiV2Enabled ? '已启用' : '未启用'}`);
|
||||
if (data.apiV2Error) parts.push(data.apiV2Error);
|
||||
el2.textContent = parts.join(' · ');
|
||||
el2.style.color = (data.apiError || data.apiV2Error) ? 'var(--red)' : 'var(--muted)';
|
||||
}
|
||||
|
||||
/** 打开 API 数据弹层 */
|
||||
function openSimp() {
|
||||
$('simpOverlay').classList.remove('hidden');
|
||||
renderSimp();
|
||||
}
|
||||
|
||||
function closeSimp() {
|
||||
$('simpOverlay').classList.add('hidden');
|
||||
}
|
||||
|
||||
/** 清掉会话缓存并重新加载当前页签 */
|
||||
function reloadSimp() {
|
||||
simpView.cache = {};
|
||||
simpView.detail = null;
|
||||
renderSimp(true);
|
||||
}
|
||||
|
||||
/** 切到某个页签 */
|
||||
function setSimpTab(tab) {
|
||||
simpView.tab = tab;
|
||||
simpView.detail = null;
|
||||
for (const el of $('simpTabs').querySelectorAll('.tab')) {
|
||||
el.classList.toggle('active', el.dataset.simp === tab);
|
||||
}
|
||||
renderSimp();
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求 API 数据并做会话内缓存。
|
||||
*
|
||||
* @param {string} key 缓存键
|
||||
* @param {string} path 接口路径
|
||||
* @param {boolean} [force] 忽略缓存
|
||||
* @returns {Promise<object>} `{ok, data, message}`
|
||||
*/
|
||||
async function simpFetch(key, path, force) {
|
||||
if (!force && simpView.cache[key]) return simpView.cache[key];
|
||||
try {
|
||||
const res = await api(path);
|
||||
const value = { ok: true, data: res.data, status: res.status };
|
||||
simpView.cache[key] = value;
|
||||
return value;
|
||||
} catch (err) {
|
||||
return { ok: false, message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染当前页签 */
|
||||
async function renderSimp(force) {
|
||||
const body = $('simpBody');
|
||||
if (!body) return;
|
||||
const renderers = {
|
||||
overview: renderSimpOverview,
|
||||
unions: renderSimpUnions,
|
||||
players: renderSimpPlayers,
|
||||
global: renderSimpGlobal,
|
||||
v2: renderSimpV2
|
||||
};
|
||||
const render = renderers[simpView.tab] || renderers.overview;
|
||||
if (simpView.loading) return;
|
||||
simpView.loading = true;
|
||||
body.innerHTML = '<div class="simp-empty">正在加载…</div>';
|
||||
try {
|
||||
await render(body, force);
|
||||
} catch (err) {
|
||||
body.innerHTML = '';
|
||||
body.appendChild(el('div', 'simp-error', '加载失败:' + err.message));
|
||||
} finally {
|
||||
simpView.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 通用小工具 ----------
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined && text !== null) node.textContent = String(text);
|
||||
return node;
|
||||
}
|
||||
|
||||
function simpSection(title) {
|
||||
const section = el('div', 'simp-section');
|
||||
if (title) section.appendChild(el('h3', null, title));
|
||||
return section;
|
||||
}
|
||||
|
||||
function simpEmpty(text) {
|
||||
return el('div', 'simp-empty', text);
|
||||
}
|
||||
|
||||
/** 统计卡片网格 */
|
||||
function simpStats(pairs) {
|
||||
const grid = el('div', 'simp-grid');
|
||||
for (const [label, value] of pairs) {
|
||||
const card = el('div', 'simp-stat');
|
||||
card.appendChild(el('div', 'label', label));
|
||||
card.appendChild(el('div', 'value', value === null || value === undefined || value === '' ? '-' : value));
|
||||
grid.appendChild(card);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用表格。
|
||||
*
|
||||
* @param {Array<[string, (row: object) => *]>} columns 列定义(表头 + 取值函数)
|
||||
* @param {Array<object>} rows 数据行
|
||||
* @param {(row: object) => void} [onClick] 行点击回调
|
||||
* @param {string[]} [numericColumns] 需要右对齐的列下标
|
||||
*/
|
||||
function simpTable(columns, rows, onClick, numericColumns) {
|
||||
const numeric = new Set(numericColumns || []);
|
||||
const table = el('table', 'simp-table');
|
||||
const thead = document.createElement('thead');
|
||||
const headRow = document.createElement('tr');
|
||||
columns.forEach(([label], index) => {
|
||||
const th = el('th', numeric.has(index) ? 'num' : null, label);
|
||||
headRow.appendChild(th);
|
||||
});
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement('tbody');
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
if (onClick) {
|
||||
tr.className = 'clickable';
|
||||
tr.addEventListener('click', () => onClick(row));
|
||||
}
|
||||
columns.forEach(([, getter], index) => {
|
||||
const value = getter(row);
|
||||
const td = el('td', numeric.has(index) ? 'num' : null,
|
||||
value === null || value === undefined || value === '' ? '-' : value);
|
||||
tr.appendChild(td);
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
}
|
||||
|
||||
function simpResult(box, result, emptyText) {
|
||||
if (!result.ok) {
|
||||
box.appendChild(el('div', 'simp-error', result.message || '查询失败'));
|
||||
return false;
|
||||
}
|
||||
const data = result.data;
|
||||
if (Array.isArray(data) && data.length === 0) {
|
||||
box.appendChild(simpEmpty(emptyText || '没有数据'));
|
||||
return false;
|
||||
}
|
||||
if (data === null || data === undefined) {
|
||||
box.appendChild(simpEmpty(emptyText || '没有数据'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 让输入框回车触发回调 */
|
||||
function onEnter(input, handler) {
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') handler();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 概览 ----------
|
||||
|
||||
async function renderSimpOverview(body) {
|
||||
const status = await simpFetch('status', '/api/simp/status', true);
|
||||
if (status.ok) {
|
||||
const { v1, v2, note } = status.data;
|
||||
const s1 = simpSection('API(领土 / 邦国 / 玩家 查询)');
|
||||
s1.appendChild(simpStats([
|
||||
['Key', v1.hasKey ? '已配置' : '未配置'],
|
||||
['轮询', v1.enabled ? `已启用(每 ${v1.pollIntervalSeconds} 秒)` : '未启用'],
|
||||
['接口地址', v1.baseUrl],
|
||||
['最近 HTTP', v1.lastHttpStatus || '-'],
|
||||
['累计请求', v1.requestCount],
|
||||
['缓存命中', v1.cacheHits],
|
||||
['缓存条目', v1.cachedPaths],
|
||||
['最近错误', v1.lastError || '无']
|
||||
]));
|
||||
body.appendChild(s1);
|
||||
|
||||
const s2 = simpSection('API-V2(实时状态,独立密钥)');
|
||||
s2.appendChild(simpStats([
|
||||
['Key', v2.hasKey ? '已配置' : '未配置'],
|
||||
['启用', v2.enabled ? '是' : '否'],
|
||||
['接口地址', v2.baseUrl],
|
||||
['最近 HTTP', v2.lastHttpStatus || '-'],
|
||||
['累计请求', v2.requestCount],
|
||||
['最近错误', v2.lastError || '无']
|
||||
]));
|
||||
body.appendChild(s2);
|
||||
|
||||
body.appendChild(el('p', 'hint', note || ''));
|
||||
} else {
|
||||
body.appendChild(el('div', 'simp-error', status.message || '读取状态失败'));
|
||||
}
|
||||
|
||||
const actions = simpSection('连通性');
|
||||
const row = el('div', 'row row-compact');
|
||||
const healthBtn = el('button', 'btn btn-sm', '健康检查(免鉴权)');
|
||||
healthBtn.addEventListener('click', async () => {
|
||||
healthBtn.disabled = true;
|
||||
try {
|
||||
const res = await api('/api/simp/health');
|
||||
toast(res.data.reachable ? 'API 服务可达' : 'API 服务不可达');
|
||||
} catch (err) {
|
||||
toast('健康检查失败:' + err.message, true);
|
||||
} finally {
|
||||
healthBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
const reloadBtn = el('button', 'btn btn-sm', '清空缓存并重载');
|
||||
reloadBtn.addEventListener('click', reloadSimp);
|
||||
row.appendChild(healthBtn);
|
||||
row.appendChild(reloadBtn);
|
||||
actions.appendChild(row);
|
||||
body.appendChild(actions);
|
||||
}
|
||||
|
||||
// ---------- 邦国 ----------
|
||||
|
||||
async function renderSimpUnions(body, force) {
|
||||
const bar = el('div', 'row row-compact');
|
||||
const search = el('input', 'input input-grow');
|
||||
search.type = 'text';
|
||||
search.placeholder = '搜索邦国名 / 君主';
|
||||
search.value = simpView.unionSearch || '';
|
||||
const searchBtn = el('button', 'btn btn-sm', '搜索');
|
||||
bar.appendChild(search);
|
||||
bar.appendChild(searchBtn);
|
||||
body.appendChild(bar);
|
||||
|
||||
const box = el('div');
|
||||
body.appendChild(box);
|
||||
|
||||
const load = async () => {
|
||||
simpView.unionSearch = search.value.trim();
|
||||
box.innerHTML = '<div class="simp-empty">正在加载…</div>';
|
||||
const query = simpView.unionSearch ? '?search=' + encodeURIComponent(simpView.unionSearch) : '';
|
||||
const result = await simpFetch('unions' + query, '/api/simp/unions' + query, force);
|
||||
box.innerHTML = '';
|
||||
if (!simpResult(box, result, '没有匹配的邦国')) return;
|
||||
const rows = result.data;
|
||||
const summary = el('div', 'simp-empty', `共 ${rows.length} 个邦国(点击行查看成员 / 科技 / 捐献 / 领土统计)`);
|
||||
box.appendChild(summary);
|
||||
box.appendChild(simpTable([
|
||||
['邦国', (r) => r.name],
|
||||
['君主', (r) => r.leader],
|
||||
['成员', (r) => r.memberCount],
|
||||
['区块数', (r) => r.chunkCount],
|
||||
['领土面积(方块²)', (r) => r.blockArea],
|
||||
['特产', (r) => r.specialOutput],
|
||||
['王城区块', (r) => (r.capital ? `${r.capital.x}, ${r.capital.z}` : '-')]
|
||||
], rows, (row) => showUnionDetail(row), [2, 3, 4, 6]));
|
||||
};
|
||||
searchBtn.addEventListener('click', () => load());
|
||||
onEnter(search, () => load());
|
||||
await load();
|
||||
|
||||
if (simpView.detail && simpView.detail.kind === 'union') {
|
||||
body.appendChild(await buildUnionDetail(simpView.detail.key));
|
||||
}
|
||||
}
|
||||
|
||||
async function showUnionDetail(row) {
|
||||
simpView.detail = { kind: 'union', key: row.name };
|
||||
// 不 force:详情用缓存里的邦国列表,只按需拉成员 / 科技 / 捐献
|
||||
renderSimp();
|
||||
}
|
||||
|
||||
async function buildUnionDetail(key) {
|
||||
const wrap = el('div', 'simp-detail');
|
||||
const head = el('div', 'close-row');
|
||||
head.appendChild(el('h4', null, '邦国详情:' + key));
|
||||
const close = el('button', 'btn btn-sm', '收起');
|
||||
close.addEventListener('click', () => {
|
||||
simpView.detail = null;
|
||||
renderSimp();
|
||||
});
|
||||
head.appendChild(close);
|
||||
wrap.appendChild(head);
|
||||
|
||||
const [detail, members, tech, contributions] = await Promise.all([
|
||||
simpFetch(`union:${key}`, '/api/simp/unions/' + encodeURIComponent(key)),
|
||||
simpFetch(`unionMembers:${key}`, '/api/simp/unions/' + encodeURIComponent(key) + '/members'),
|
||||
simpFetch(`unionTech:${key}`, '/api/simp/unions/' + encodeURIComponent(key) + '/tech'),
|
||||
simpFetch(`unionContrib:${key}`, '/api/simp/unions/' + encodeURIComponent(key) + '/contributions?limit=50')
|
||||
]);
|
||||
|
||||
if (detail.ok && detail.data && detail.data.territory) {
|
||||
const t = detail.data.territory;
|
||||
wrap.appendChild(el('h3', null, '领土统计'));
|
||||
wrap.appendChild(simpStats([
|
||||
['区块数', t.chunkCount],
|
||||
['领土面积(方块²)', t.blockArea],
|
||||
['外接矩形', `${t.bounds.minX},${t.bounds.minZ} ~ ${t.bounds.maxX},${t.bounds.maxZ}`],
|
||||
['平均忠诚度', t.avgLoyalty === null ? '-' : t.avgLoyalty],
|
||||
['最早占领', t.earliestClaimAt || '-'],
|
||||
['最近占领', t.latestClaimAt || '-']
|
||||
]));
|
||||
}
|
||||
|
||||
wrap.appendChild(el('h3', null, `成员(${members.ok ? members.data.length : 0})`));
|
||||
if (members.ok && members.data.length > 0) {
|
||||
wrap.appendChild(simpTable([
|
||||
['玩家', (r) => r.name],
|
||||
['职位', (r) => r.role],
|
||||
['职业', (r) => r.job],
|
||||
['称号', (r) => r.title],
|
||||
['加入时间', (r) => r.joinedAt]
|
||||
], members.data));
|
||||
} else {
|
||||
wrap.appendChild(simpEmpty(members.ok ? '无成员数据' : members.message));
|
||||
}
|
||||
|
||||
wrap.appendChild(el('h3', null, `已激活科技(${tech.ok ? tech.data.length : 0})`));
|
||||
if (tech.ok && tech.data.length > 0) {
|
||||
wrap.appendChild(simpTable([
|
||||
['科技', (r) => r.name],
|
||||
['效果', (r) => r.effect],
|
||||
['等级', (r) => r.level],
|
||||
['到期', (r) => r.expiresAt]
|
||||
], tech.data, null, [2]));
|
||||
} else {
|
||||
wrap.appendChild(simpEmpty(tech.ok ? '当前没有生效中的科技' : tech.message));
|
||||
}
|
||||
|
||||
wrap.appendChild(el('h3', null, `最近捐献 / 收支(${contributions.ok ? contributions.data.length : 0})`));
|
||||
if (contributions.ok && contributions.data.length > 0) {
|
||||
wrap.appendChild(simpTable([
|
||||
['玩家', (r) => r.name || (r.uuid ? r.uuid.slice(0, 8) : '-')],
|
||||
['用途', (r) => r.reason],
|
||||
['材料', (r) => r.material],
|
||||
['数量', (r) => r.amount],
|
||||
['时间', (r) => r.createdAt]
|
||||
], contributions.data, null, [3]));
|
||||
wrap.appendChild(el('p', 'hint', '正数为捐献,负数为邦国支出(例如「恢复民心」消耗金锭 / 钻石)。'));
|
||||
} else {
|
||||
wrap.appendChild(simpEmpty(contributions.ok ? '没有捐献记录' : contributions.message));
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---------- 玩家 ----------
|
||||
|
||||
async function renderSimpPlayers(body, force) {
|
||||
const bar = el('div', 'row row-compact');
|
||||
const search = el('input', 'input input-grow');
|
||||
search.type = 'text';
|
||||
search.placeholder = '搜索玩家名 / 邦国';
|
||||
search.value = simpView.playerSearch || '';
|
||||
const searchBtn = el('button', 'btn btn-sm', '搜索');
|
||||
const meBtn = el('button', 'btn btn-sm', '查当前机器人');
|
||||
bar.appendChild(search);
|
||||
bar.appendChild(searchBtn);
|
||||
bar.appendChild(meBtn);
|
||||
body.appendChild(bar);
|
||||
|
||||
const box = el('div');
|
||||
body.appendChild(box);
|
||||
|
||||
const load = async (keyword) => {
|
||||
simpView.playerSearch = keyword === undefined ? search.value.trim() : keyword;
|
||||
search.value = simpView.playerSearch;
|
||||
box.innerHTML = '<div class="simp-empty">正在加载…</div>';
|
||||
const query = simpView.playerSearch ? '?search=' + encodeURIComponent(simpView.playerSearch) : '';
|
||||
const result = await simpFetch('players' + query, '/api/simp/players' + query, force);
|
||||
box.innerHTML = '';
|
||||
if (!simpResult(box, result, '没有匹配的玩家')) return;
|
||||
const rows = result.data;
|
||||
box.appendChild(el('div', 'simp-empty', `共 ${rows.length} 名玩家(点击行查看详情)`));
|
||||
box.appendChild(simpTable([
|
||||
['玩家', (r) => r.name],
|
||||
['邦国', (r) => r.unionName],
|
||||
['职位', (r) => r.role],
|
||||
['职业', (r) => r.job],
|
||||
['称号', (r) => r.title]
|
||||
], rows, (row) => {
|
||||
simpView.detail = { kind: 'player', key: row.name };
|
||||
renderSimp();
|
||||
}));
|
||||
};
|
||||
searchBtn.addEventListener('click', () => load());
|
||||
onEnter(search, () => load());
|
||||
meBtn.addEventListener('click', () => load(selectedBotId || ''));
|
||||
await load();
|
||||
|
||||
if (simpView.detail && simpView.detail.kind === 'player') {
|
||||
const key = simpView.detail.key;
|
||||
const wrap = el('div', 'simp-detail');
|
||||
const head = el('div', 'close-row');
|
||||
head.appendChild(el('h4', null, '玩家详情:' + key));
|
||||
const close = el('button', 'btn btn-sm', '收起');
|
||||
close.addEventListener('click', () => {
|
||||
simpView.detail = null;
|
||||
renderSimp();
|
||||
});
|
||||
head.appendChild(close);
|
||||
wrap.appendChild(head);
|
||||
|
||||
const [player, v2] = await Promise.all([
|
||||
simpFetch(`player:${key}`, '/api/simp/players/' + encodeURIComponent(key)),
|
||||
simpFetch(`equip:${key}`, '/api/simp/v2/equipment/' + encodeURIComponent(key))
|
||||
]);
|
||||
if (player.ok && player.data) {
|
||||
const p = player.data;
|
||||
wrap.appendChild(simpStats([
|
||||
['玩家', p.name],
|
||||
['UUID', p.uuid],
|
||||
['邦国', p.unionName || '无'],
|
||||
['职位', p.role],
|
||||
['职业', p.job],
|
||||
['称号', p.title]
|
||||
]));
|
||||
if (p.union) {
|
||||
wrap.appendChild(el('h3', null, '所属邦国'));
|
||||
wrap.appendChild(simpStats([
|
||||
['名称', p.union.name],
|
||||
['君主', p.union.leader],
|
||||
['成员数', p.union.memberCount],
|
||||
['区块数', p.union.chunkCount],
|
||||
['特产', p.union.specialOutput]
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
wrap.appendChild(el('div', 'simp-error', player.message || '没有该玩家'));
|
||||
}
|
||||
|
||||
wrap.appendChild(el('h3', null, '装备与手持(API-V2)'));
|
||||
if (v2.ok && v2.data) {
|
||||
const slots = [
|
||||
['主手', v2.data.mainHand], ['副手', v2.data.offHand],
|
||||
['头盔', v2.data.helmet], ['胸甲', v2.data.chestplate],
|
||||
['护腿', v2.data.leggings], ['靴子', v2.data.boots]
|
||||
];
|
||||
wrap.appendChild(simpTable([
|
||||
['部位', (r) => r.slot],
|
||||
['物品', (r) => r.name],
|
||||
['数量', (r) => r.count],
|
||||
['耐久', (r) => r.durability]
|
||||
], slots.map(([slot, item]) => ({
|
||||
slot,
|
||||
name: item ? (item.displayName || item.name) : '空',
|
||||
count: item ? item.count : '-',
|
||||
durability: item && item.durability >= 0
|
||||
? `${item.durability}${item.maxDurability > 0 ? ' / ' + item.maxDurability : ''}`
|
||||
: '-'
|
||||
})), null, [2, 3]));
|
||||
} else {
|
||||
wrap.appendChild(simpEmpty(v2.ok ? '没有装备数据' : v2.message));
|
||||
}
|
||||
body.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 全服 ----------
|
||||
|
||||
const GLOBAL_KINDS = [
|
||||
['buffs', '生效加成'],
|
||||
['diplomacy', '外交关系'],
|
||||
['bans', '封禁记录'],
|
||||
['logs', '上下线日志'],
|
||||
['announcements', '公告历史']
|
||||
];
|
||||
|
||||
async function renderSimpGlobal(body, force) {
|
||||
const sub = el('div', 'simp-sub');
|
||||
for (const [kind, label] of GLOBAL_KINDS) {
|
||||
const btn = el('button', 'tab' + (simpView.globalKind === kind ? ' active' : ''), label);
|
||||
btn.addEventListener('click', () => {
|
||||
simpView.globalKind = kind;
|
||||
renderSimp();
|
||||
});
|
||||
sub.appendChild(btn);
|
||||
}
|
||||
body.appendChild(sub);
|
||||
|
||||
const kind = simpView.globalKind;
|
||||
const box = el('div');
|
||||
body.appendChild(box);
|
||||
const limitSuffix = ['bans', 'logs', 'announcements'].includes(kind) ? '?limit=100' : '';
|
||||
const result = await simpFetch('global:' + kind, '/api/simp/global/' + kind + limitSuffix, force);
|
||||
if (!simpResult(box, result, '没有数据')) return;
|
||||
|
||||
const rows = result.data;
|
||||
const columns = {
|
||||
buffs: [['邦国', (r) => r.unionName], ['科技', (r) => r.name], ['效果', (r) => r.effect], ['等级', (r) => r.level], ['到期', (r) => r.expiresAt]],
|
||||
diplomacy: [['授予方', (r) => r.grantor], ['接收方', (r) => r.grantee], ['权限级别', (r) => r.level]],
|
||||
bans: [['玩家', (r) => r.name], ['原因', (r) => r.reason], ['操作者', (r) => r.operator], ['时间', (r) => r.createdAt], ['到期', (r) => r.expiresAt]],
|
||||
logs: [['玩家', (r) => r.name], ['动作', (r) => r.action], ['详情', (r) => r.detail], ['时间', (r) => r.createdAt]],
|
||||
announcements: [['内容', (r) => r.content], ['发布者', (r) => r.author], ['时间', (r) => r.createdAt]]
|
||||
}[kind];
|
||||
box.appendChild(el('div', 'simp-empty', `共 ${rows.length} 条(服务端有缓存,数据最多延迟 1 小时)`));
|
||||
box.appendChild(simpTable(columns, rows));
|
||||
}
|
||||
|
||||
// ---------- 实时 V2 ----------
|
||||
|
||||
async function renderSimpV2(body, force) {
|
||||
const actions = el('div', 'row row-compact');
|
||||
const make = (label, key, path, renderer) => {
|
||||
const btn = el('button', 'btn btn-sm', label);
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
const box = $('simpV2Result');
|
||||
box.classList.remove('hidden');
|
||||
box.innerHTML = '<div class="simp-empty">正在查询…</div>';
|
||||
const result = await simpFetch(key, path, force);
|
||||
box.innerHTML = '';
|
||||
if (!simpResult(box, result, '没有数据')) {
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
renderer(box, result.data);
|
||||
btn.disabled = false;
|
||||
});
|
||||
return btn;
|
||||
};
|
||||
|
||||
actions.appendChild(make('在线玩家', 'v2online', '/api/simp/v2/online', (box, rows) => {
|
||||
box.appendChild(simpTable([
|
||||
['玩家', (r) => r.name],
|
||||
['世界', (r) => r.world],
|
||||
['坐标', (r) => (Number.isFinite(r.x) ? `${r.x.toFixed(0)}, ${r.y.toFixed(0)}, ${r.z.toFixed(0)}` : '-')],
|
||||
['延迟', (r) => (r.ping >= 0 ? r.ping + ' ms' : '-')],
|
||||
['模式', (r) => r.gameMode]
|
||||
], rows, null, [3]));
|
||||
}));
|
||||
actions.appendChild(make('游戏时间', 'v2time', '/api/simp/v2/time', (box, t) => {
|
||||
const ticks = t.dayTime % 24000;
|
||||
const hours = Math.floor(((ticks / 1000) + 6) % 24);
|
||||
const minutes = Math.floor(((ticks % 1000) / 1000) * 60);
|
||||
box.appendChild(simpStats([
|
||||
['游戏内时刻', `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`],
|
||||
['天数', t.day >= 0 ? t.day : '-'],
|
||||
['白天', t.isDay === null ? '-' : (t.isDay ? '是' : '否')],
|
||||
['world_time', t.worldTime]
|
||||
]));
|
||||
}));
|
||||
actions.appendChild(make('末影龙', 'v2dragon', '/api/simp/v2/dragon', (box, d) => {
|
||||
box.appendChild(simpStats([
|
||||
['存活', d.alive ? '是' : '否'],
|
||||
['生命', Number.isFinite(d.health) ? d.health : '-'],
|
||||
['阶段', d.phase || '-']
|
||||
]));
|
||||
box.appendChild(el('p', 'hint', '该接口限速 1 次/分钟,请勿频繁点击。'));
|
||||
}));
|
||||
body.appendChild(actions);
|
||||
body.appendChild(el('p', 'hint', 'API-V2 是独立于 API 的另一套密钥体系(需在密钥中心单独申请),接口级配额很小,因此不做自动轮询。'));
|
||||
|
||||
const equipRow = el('div', 'row row-compact');
|
||||
const equipInput = el('input', 'input input-grow');
|
||||
equipInput.type = 'text';
|
||||
equipInput.placeholder = '输入玩家名查装备与手持物品';
|
||||
equipInput.value = selectedBotId || '';
|
||||
const equipBtn = el('button', 'btn btn-sm', '查询装备');
|
||||
equipBtn.addEventListener('click', async () => {
|
||||
const name = equipInput.value.trim();
|
||||
if (!name) {
|
||||
toast('请先输入玩家名', true);
|
||||
return;
|
||||
}
|
||||
const box = $('simpV2Result');
|
||||
box.innerHTML = '<div class="simp-empty">正在查询…</div>';
|
||||
const result = await simpFetch('v2equip:' + name, '/api/simp/v2/equipment/' + encodeURIComponent(name), force);
|
||||
box.innerHTML = '';
|
||||
if (!simpResult(box, result, '没有装备数据')) return;
|
||||
const d = result.data;
|
||||
const slots = [
|
||||
['主手', d.mainHand], ['副手', d.offHand], ['头盔', d.helmet],
|
||||
['胸甲', d.chestplate], ['护腿', d.leggings], ['靴子', d.boots]
|
||||
];
|
||||
box.appendChild(simpTable([
|
||||
['部位', (r) => r.slot],
|
||||
['物品', (r) => r.name],
|
||||
['数量', (r) => r.count],
|
||||
['耐久', (r) => r.durability]
|
||||
], slots.map(([slot, item]) => ({
|
||||
slot,
|
||||
name: item ? (item.displayName || item.name) : '空',
|
||||
count: item ? item.count : '-',
|
||||
durability: item && item.durability >= 0
|
||||
? `${item.durability}${item.maxDurability > 0 ? ' / ' + item.maxDurability : ''}`
|
||||
: '-'
|
||||
})), null, [2, 3]));
|
||||
});
|
||||
onEnter(equipInput, () => equipBtn.click());
|
||||
equipRow.appendChild(equipInput);
|
||||
equipRow.appendChild(equipBtn);
|
||||
body.appendChild(equipRow);
|
||||
|
||||
body.appendChild(el('div', 'simp-empty', '查询结果:'));
|
||||
const resultBox = el('div', 'simp-detail hidden');
|
||||
resultBox.id = 'simpV2Result';
|
||||
body.appendChild(resultBox);
|
||||
}
|
||||
|
||||
// ==================== 运行日志 ====================
|
||||
|
||||
const MAX_LOG_LINES = 800;
|
||||
@@ -1756,6 +2398,18 @@ function bindEvents() {
|
||||
}
|
||||
});
|
||||
|
||||
// ===== SimpCore API 数据浏览 =====
|
||||
$('btnOpenSimp').addEventListener('click', openSimp);
|
||||
$('btnCloseSimp').addEventListener('click', closeSimp);
|
||||
$('simpTabs').addEventListener('click', (e) => {
|
||||
const tab = e.target.closest('.tab');
|
||||
if (tab) setSimpTab(tab.dataset.simp);
|
||||
});
|
||||
$('simpOverlay').addEventListener('click', (e) => {
|
||||
// 点遮罩空白处关闭(点弹层内部不关)
|
||||
if (e.target === $('simpOverlay')) closeSimp();
|
||||
});
|
||||
|
||||
// ===== 定时消息编辑器 =====
|
||||
$('btnAddSchedule').addEventListener('click', () => {
|
||||
$('schedContent').value = '';
|
||||
|
||||
在新工单中引用
屏蔽一个用户