以前只用了「邦国领土」这一类接口,控制台也只能看到一行「已启用/未启用」, 大量服务端公开信息拿得到却看不到。现在按官方文档 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,全部通过。
2822 行
102 KiB
JavaScript
2822 行
102 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* AutoStore Bot Web 控制台前端逻辑。
|
||
*
|
||
* 六块职责:
|
||
* 1. 控制台登录(登录验证通过后才允许操作各机器人的控制台 / 聊天框);
|
||
* 2. 通过 WebSocket 接收实时状态、运行日志、公屏消息与开拓记录;
|
||
* 3. 渲染多机器人列表与选中机器人的状态面板;
|
||
* 4. 根据配置 schema 动态生成配置表单(支持「仅对当前机器人生效」的覆盖模式);
|
||
* 5. Canvas 绘制领土地图(区块级)与大地图(爬取服务器地图瓦片 + 领土多边形);
|
||
* 6. 游戏公屏聊天框与开拓日志表格。
|
||
*/
|
||
|
||
// ==================== 工具 ====================
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
/** 控制台凭证:优先 URL 上的 ?token=,其次浏览器本地保存(登录框写入) */
|
||
let TOKEN = new URLSearchParams(location.search).get('token') || localStorage.getItem('autostore_token') || '';
|
||
|
||
/** 当前选中的地图图幅(半径,方块) */
|
||
function selectedRangeBlocks() {
|
||
const el = $('bigMapRange');
|
||
const n = Number(el && el.value);
|
||
return Number.isFinite(n) && n > 0 ? n : 4096;
|
||
}
|
||
|
||
/**
|
||
* 大地图 / 领土地图应该聚焦的位置:当前选中机器人的坐标。
|
||
*
|
||
* 这两张图的用途都是「看自己的机器人」,所以默认必须跟着机器人走,
|
||
* 而不是永远停在某个固定坐标上。
|
||
*
|
||
* @returns {{x: number, z: number}|null}
|
||
*/
|
||
function botFocusPosition() {
|
||
const bots = ((lastStatus && lastStatus.bots) || []).filter((b) => b.position);
|
||
if (bots.length === 0) return null;
|
||
const target = bots.find((b) => b.id === selectedBotId) || bots[0];
|
||
return { x: target.position.x, z: target.position.z };
|
||
}
|
||
|
||
function setToken(value) {
|
||
TOKEN = value || '';
|
||
try {
|
||
if (TOKEN) localStorage.setItem('autostore_token', TOKEN);
|
||
else localStorage.removeItem('autostore_token');
|
||
} catch {
|
||
// 隐私模式下 localStorage 可能不可用,忽略
|
||
}
|
||
}
|
||
|
||
function api(path, options) {
|
||
const opts = options || {};
|
||
const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
|
||
if (TOKEN) headers['X-Auth-Token'] = TOKEN;
|
||
return fetch(path, Object.assign({}, opts, { headers })).then(async (res) => {
|
||
let body = null;
|
||
try {
|
||
body = await res.json();
|
||
} catch {
|
||
body = { ok: false, message: 'HTTP ' + res.status };
|
||
}
|
||
if (res.status === 401) {
|
||
showLogin('登录已失效,请重新登录');
|
||
throw new Error(body.message || '未授权');
|
||
}
|
||
if (!res.ok || body.ok === false) {
|
||
throw new Error(body.message || ('HTTP ' + res.status));
|
||
}
|
||
return body;
|
||
});
|
||
}
|
||
|
||
let toastTimer = null;
|
||
function toast(message, isError) {
|
||
const el = $('toast');
|
||
el.textContent = message;
|
||
el.className = 'toast show' + (isError ? ' error' : '');
|
||
if (toastTimer) clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { el.className = 'toast'; }, 3500);
|
||
}
|
||
|
||
const pad2 = (n) => String(n).padStart(2, '0');
|
||
|
||
function fmtTime(ts) {
|
||
if (!ts) return '-';
|
||
const d = new Date(ts);
|
||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
||
}
|
||
|
||
function fmtDateTime(ts) {
|
||
if (!ts) return '-';
|
||
const d = new Date(ts);
|
||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} `
|
||
+ `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
||
}
|
||
|
||
function fmtAgo(ts) {
|
||
if (!ts) return '从未';
|
||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||
if (seconds < 5) return '刚刚';
|
||
if (seconds < 60) return seconds + ' 秒前';
|
||
if (seconds < 3600) return Math.floor(seconds / 60) + ' 分钟前';
|
||
return fmtTime(ts);
|
||
}
|
||
|
||
function hexToRgba(hex, alpha) {
|
||
const value = String(hex).replace('#', '');
|
||
const r = parseInt(value.slice(0, 2), 16) || 0;
|
||
const g = parseInt(value.slice(2, 4), 16) || 0;
|
||
const b = parseInt(value.slice(4, 6), 16) || 0;
|
||
return `rgba(${r},${g},${b},${alpha})`;
|
||
}
|
||
|
||
function withToken(path) {
|
||
if (!TOKEN) return path;
|
||
return path + (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
|
||
}
|
||
|
||
// ==================== 配置 schema ====================
|
||
|
||
const STRATEGY_OPTIONS = [
|
||
{ value: 1, label: '1 - 方形外扩(优先填洞)' },
|
||
{ value: 2, label: '2 - 区域扩地(S 形扫描)' },
|
||
{ value: 3, label: '3 - 玩家周围扩地' },
|
||
{ value: 4, label: '4 - 绕国一圈' }
|
||
];
|
||
|
||
const WATER_MODE_OPTIONS = [
|
||
{ value: 'avoid', label: 'avoid - 绕开水域(推荐)' },
|
||
{ value: 'swim', label: 'swim - 游泳通过' },
|
||
{ value: 'boat', label: 'boat - 放船渡水(Grim 风险高)' }
|
||
];
|
||
|
||
/**
|
||
* 配置表单 schema:每个 tab 下若干分组,每组若干字段。
|
||
* path 为配置对象里的点分路径。
|
||
*/
|
||
const CONFIG_SCHEMA = {
|
||
strategy: [
|
||
{
|
||
title: '扩地策略',
|
||
fields: [
|
||
{ path: 'strategy', label: '策略', type: 'select', options: STRATEGY_OPTIONS },
|
||
{ path: 'stateName', label: '目标邦国', type: 'text', hint: '留空则自动识别机器人所在邦国;多机器人时建议各自指定' },
|
||
{ path: 'targetCount', label: '目标扩地数量', type: 'number', min: -1, hint: '-1 表示持续扩地直到无可用区块' },
|
||
{ path: 'maxRings', label: '扩圈上限', type: 'number', min: 0, hint: '0 表示不限制' }
|
||
]
|
||
},
|
||
{
|
||
title: '区域扩地范围(仅策略 2)',
|
||
fields: [
|
||
{ path: 'regionMinX', label: '矩形 minX(区块)', type: 'number' },
|
||
{ path: 'regionMaxX', label: '矩形 maxX(区块)', type: 'number', hint: 'maxX < minX 表示未圈选,改用中心 + 半径' },
|
||
{ path: 'regionMinZ', label: '矩形 minZ(区块)', type: 'number' },
|
||
{ path: 'regionMaxZ', label: '矩形 maxZ(区块)', type: 'number' },
|
||
{ path: 'regionCenterX', label: '中心 X(区块)', type: 'number' },
|
||
{ path: 'regionCenterZ', label: '中心 Z(区块)', type: 'number' },
|
||
{ path: 'regionRadius', label: '半径(区块)', type: 'number', min: 1 }
|
||
]
|
||
}
|
||
],
|
||
timing: [
|
||
{
|
||
title: '超时与重试',
|
||
fields: [
|
||
{ path: 'waitTimeoutSeconds', label: '等待 / 寻路超时(秒)', type: 'number', min: 5 },
|
||
{ path: 'retries', label: '超时后最大重试次数', type: 'number', min: 1 },
|
||
{ path: 'returnThresholdSeconds', label: '连续超时回城阈值(秒)', type: 'number', min: 0 },
|
||
{ path: 'returnWaitSeconds', label: '回城等待传送(秒)', type: 'number', min: 1 },
|
||
{ path: 'sideStepMaxRounds', label: '往边上走两步最大轮数', type: 'number', min: 0 }
|
||
]
|
||
},
|
||
{
|
||
title: '开垦时段限制',
|
||
fields: [
|
||
{ path: 'enforceClaimTime', label: '启用时段限制', type: 'checkbox' },
|
||
{ path: 'claimStartHour', label: '起始小时(含)', type: 'number', min: 0, max: 23 },
|
||
{ path: 'claimEndHour', label: '结束小时(不含)', type: 'number', min: 1, max: 24 }
|
||
]
|
||
}
|
||
],
|
||
pathing: [
|
||
{
|
||
title: '寻路行为',
|
||
fields: [
|
||
{ path: 'allowSprint', label: '允许疾跑', type: 'checkbox' },
|
||
{ path: 'allowSwim', label: '允许游泳', type: 'checkbox' },
|
||
{ path: 'allowJump', label: '允许跳跃', type: 'checkbox' },
|
||
{ path: 'allowBreak', label: '允许破坏方块', type: 'checkbox', hint: 'Grim 对破坏速度检查严格,默认关闭' },
|
||
{ path: 'allowPlace', label: '允许放置方块', type: 'checkbox', hint: 'Grim 对放置检查严格,默认关闭' },
|
||
{ path: 'altitudePreference', label: '高度偏好(优先走高处)', type: 'checkbox' },
|
||
{ path: 'altitudeWeight', label: '高度偏好权重', type: 'number', step: 0.01, min: 0 }
|
||
]
|
||
},
|
||
{
|
||
title: '寻路器专属',
|
||
fields: [
|
||
{ path: 'pathing.allowParkour', label: '允许跑酷跳跃', type: 'checkbox', hint: 'mineflayer 起跳时机与原版有偏差,默认关闭' },
|
||
{ path: 'pathing.allow1by1towers', label: '允许 1×1 搭塔', type: 'checkbox' },
|
||
{ path: 'pathing.maxDropDown', label: '最大下落高度(格)', type: 'number', min: 0 },
|
||
{ path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 },
|
||
{ path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' },
|
||
{ path: 'pathing.maxSegmentBlocks', label: '单段寻路最长距离(格)', type: 'number', min: 32, hint: '寻路器只能规划已加载区块范围(本服约 130 格),长距离会自动拆段;此项只是单段上限' },
|
||
{ path: 'pathing.segmentMarginBlocks', label: '分段安全边距(格)', type: 'number', min: 0, hint: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' }
|
||
]
|
||
}
|
||
],
|
||
survival: [
|
||
{
|
||
title: '生存辅助',
|
||
fields: [
|
||
{ path: 'avoidMobs', label: '避开怪物', type: 'checkbox' },
|
||
{ path: 'autoEat', label: '自动进食', type: 'checkbox' },
|
||
{ path: 'eatThreshold', label: '进食饥饿阈值', type: 'number', min: 0, max: 20 },
|
||
{ path: 'autoAttack', label: '自动攻击敌对生物', type: 'checkbox' },
|
||
{ path: 'durabilityGuard', label: '耐久保护', type: 'checkbox' },
|
||
{ path: 'durabilityThreshold', label: '耐久保护阈值(%)', type: 'number', min: 1, max: 99 }
|
||
]
|
||
},
|
||
{
|
||
title: '战斗筛选',
|
||
fields: [
|
||
{ path: 'combat.scanRange', label: '索敌半径(格)', type: 'number', step: 0.5, min: 1 },
|
||
{ path: 'combat.attackEnderman', label: '攻击末影人', type: 'checkbox', hint: 'mineflayer 读不到「是否被激怒」,默认不打' },
|
||
{ path: 'combat.attackSpiders', label: '攻击蜘蛛', type: 'checkbox', hint: 'mineflayer 读不到环境亮度,默认不打' },
|
||
{ path: 'combat.attackPiglins', label: '攻击猪灵(未穿金甲时)', type: 'checkbox' },
|
||
{ path: 'combat.avoidDangerousMobs', label: '跳过监守者 / 末影龙 / 凋灵', type: 'checkbox' }
|
||
]
|
||
},
|
||
{
|
||
title: '水域处理',
|
||
fields: [
|
||
{ path: 'waterMode', label: '水域策略', type: 'select', options: WATER_MODE_OPTIONS },
|
||
{ path: 'autoBoat', label: '启用自动用船', type: 'checkbox', hint: 'mineflayer 无载具物理,Grim 的 Vehicle 检查风险高,默认关闭' },
|
||
{ path: 'autoBoatMinWidth', label: '触发用船的最小水面宽度(格)', type: 'number', min: 1 },
|
||
{ path: 'autoBoatMinLength', label: '触发用船的最短水域长度(格)', type: 'number', min: 1 }
|
||
]
|
||
}
|
||
],
|
||
data: [
|
||
{
|
||
title: '地图数据源',
|
||
fields: [
|
||
{ path: 'refreshIntervalSeconds', label: '地图刷新间隔(秒)', type: 'number', min: 10 }
|
||
]
|
||
},
|
||
{
|
||
title: 'SimpCore 开放 API',
|
||
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: 'apiV2Enabled', label: '启用 API-V2(实时状态)', type: 'checkbox', hint: 'V2 是独立的一套密钥体系,需在密钥中心单独申请' },
|
||
{ path: 'apiV2Key', label: 'API-V2 Key', type: 'password', hint: '留空则回退读环境变量 SIMP_API_V2_KEY;V2 配额很小,不做自动轮询' }
|
||
]
|
||
}
|
||
],
|
||
bigmap: [
|
||
{
|
||
title: '大地图(爬取服务器地图站点)',
|
||
fields: [
|
||
{ path: 'bigMap.enabled', label: '启用大地图', type: 'checkbox' },
|
||
{ path: 'bigMap.world', label: '世界目录名', type: 'text', hint: '留空则回退环境变量 DYNMAP_WORLD' },
|
||
{ path: 'bigMap.zoom', label: '启动预爬取层级', type: 'number', min: 0, max: 8, hint: '0 = 全世界一张大图(本站 10×10=100 张瓦片)' },
|
||
{ path: 'bigMap.maxZoom', label: '最大可浏览层级', type: 'number', min: 0, max: 8, hint: '本站 squaremap 为 3(该层级 1 方块 = 1 像素)' },
|
||
{ path: 'bigMap.rangeBlocks', label: '覆盖半径(方块)', type: 'number', min: 256, hint: '默认 20480,与服务器世界边界一致' },
|
||
{ path: 'bigMap.crawlOnStart', label: '启动时自动预爬取', type: 'checkbox' },
|
||
{ path: 'bigMap.concurrency', label: '瓦片下载并发数', type: 'number', min: 1, max: 16 }
|
||
]
|
||
}
|
||
],
|
||
grim: [
|
||
{
|
||
title: 'Grim 反作弊合规层',
|
||
fields: [
|
||
{ path: 'grim.enabled', label: '启用合规层', type: 'checkbox', hint: '仅在本地测试服才建议关闭' },
|
||
{ path: 'grim.maxRotationPerTickDeg', label: '每 tick 最大旋转(度)', type: 'number', step: 0.1, min: 1, max: 180, hint: 'mineflayer 物理限速约 8.6°/tick,实际取两者较小值' },
|
||
{ path: 'grim.attackReach', label: '攻击距离(格)', type: 'number', step: 0.1, min: 1, max: 6 },
|
||
{ path: 'grim.requireLineOfSight', label: '攻击前校验视线', type: 'checkbox' },
|
||
{ path: 'grim.respectAttackCooldown', label: '遵守攻击冷却', type: 'checkbox' },
|
||
{ path: 'grim.stopMovementWhileEating', label: '进食时停止移动', type: 'checkbox' },
|
||
{ path: 'grim.blockSprintJump', label: '禁止疾跑跳跃', type: 'checkbox', hint: '疾跑跳本身是原版合法动作,仅在实测被标记时开启' },
|
||
{ path: 'grim.blockConflictingActions', label: '拦截冲突动作组合', type: 'checkbox' },
|
||
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
|
||
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' },
|
||
{ path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' },
|
||
{ path: 'grim.sendPlayerInput', label: '每 tick 补发 player_input', type: 'checkbox', hint: '1.21.2+ 移动预测依赖它;mineflayer 地面移动时完全不发,导致反作弊判定「无输入却位移」并把机器人每 tick 拉回(实测本服必需)' },
|
||
{ path: 'grim.sendTickEnd', label: '每 tick 补发 tick_end', type: 'checkbox', hint: '原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界' }
|
||
]
|
||
}
|
||
],
|
||
server: [
|
||
{
|
||
title: '服务器交互',
|
||
fields: [
|
||
{ path: 'server.claimCommand', label: '开垦模式指令', type: 'text', hint: '不带斜杠;留空表示服务器自动开启' },
|
||
{ path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_BOTS / MC_PASSWORD' },
|
||
{ path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' },
|
||
{ path: 'server.authCommand', label: '账号绑定 / 二次验证模板', type: 'text', hint: '如简幻通的 auth {password};扫码类验证无法自动完成,留空即可(日志会提示人工验证一次)' },
|
||
{ path: 'server.preLoginCommands', label: '允许免登录发送的指令前缀', type: 'list', hint: '逗号分隔,默认 login,l,register,reg,auth;这些指令在登录验证前也放行,避免死锁' },
|
||
{ path: 'server.allowChatBeforeLogin', label: '登录前允许发公屏消息', type: 'checkbox', hint: '服务器明确要求验证时会自动放行(验证码常直接当聊天内容发出);此项用于无提示但必须先说话的服务器' },
|
||
{ path: 'server.loginConfirmTimeoutMs', label: '登录结果确认超时(毫秒)', type: 'number', min: 0, hint: '超时未收到登录提示则按已登录放行;0 表示禁用兜底' },
|
||
{ path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 },
|
||
{ path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' },
|
||
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 },
|
||
{ path: 'server.reconnectMaxDelayMs', label: '重连间隔上限(毫秒)', type: 'number', min: 1000, hint: '连续重连失败时按指数退避,最多退到这个间隔' }
|
||
]
|
||
}
|
||
]
|
||
};
|
||
|
||
// ==================== 全局状态 ====================
|
||
|
||
/** 当前选中的机器人 id */
|
||
let selectedBotId = '';
|
||
/** 最近一次状态负载 */
|
||
let lastStatus = null;
|
||
/** 机器人 id → 该机器人的设置快照 */
|
||
const botSettings = new Map();
|
||
|
||
// ==================== 登录 ====================
|
||
|
||
let appStarted = false;
|
||
|
||
function showLogin(message) {
|
||
$('loginOverlay').classList.remove('hidden');
|
||
if (message) $('loginError').textContent = message;
|
||
}
|
||
|
||
function hideLogin() {
|
||
$('loginOverlay').classList.add('hidden');
|
||
$('loginError').textContent = '';
|
||
}
|
||
|
||
async function checkSession() {
|
||
try {
|
||
const res = await fetch('/api/session', {
|
||
headers: TOKEN ? { 'X-Auth-Token': TOKEN } : {}
|
||
}).then((r) => r.json());
|
||
const data = res.data || {};
|
||
$('authBadge').textContent = data.required ? (data.authenticated ? '已登录' : '未登录') : '免登录';
|
||
$('authBadge').className = 'badge ' + (data.authenticated || !data.required ? 'badge-online' : 'badge-idle');
|
||
if (data.required && !data.authenticated) {
|
||
showLogin();
|
||
return false;
|
||
}
|
||
hideLogin();
|
||
return true;
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
async function doLogin() {
|
||
const password = $('loginPassword').value;
|
||
if (!password) {
|
||
$('loginError').textContent = '请输入密码 / 令牌';
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch('/api/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password })
|
||
}).then((r) => r.json());
|
||
if (!res.ok) {
|
||
$('loginError').textContent = res.message || '登录失败';
|
||
return;
|
||
}
|
||
setToken(password);
|
||
hideLogin();
|
||
$('authBadge').textContent = '已登录';
|
||
$('authBadge').className = 'badge badge-online';
|
||
if (appStarted) {
|
||
// 之前已初始化过(退出登录后再登录):只需恢复实时推送
|
||
wsEnabled = true;
|
||
connectWs();
|
||
} else {
|
||
await startApp();
|
||
}
|
||
toast('登录成功');
|
||
} catch (err) {
|
||
$('loginError').textContent = '登录失败:' + err.message;
|
||
}
|
||
}
|
||
|
||
function doLogout() {
|
||
setToken('');
|
||
wsEnabled = false;
|
||
$('authBadge').textContent = '未登录';
|
||
$('authBadge').className = 'badge badge-idle';
|
||
if (ws) { try { ws.close(); } catch { /* 忽略 */ } ws = null; }
|
||
showLogin('已退出登录');
|
||
}
|
||
|
||
// ==================== 配置读写 ====================
|
||
|
||
let currentConfig = null;
|
||
let activeTab = 'strategy';
|
||
|
||
function getByPath(obj, path) {
|
||
return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
|
||
}
|
||
|
||
function setByPath(obj, path, value) {
|
||
const keys = path.split('.');
|
||
let cursor = obj;
|
||
for (let i = 0; i < keys.length - 1; i++) {
|
||
if (cursor[keys[i]] == null || typeof cursor[keys[i]] !== 'object') cursor[keys[i]] = {};
|
||
cursor = cursor[keys[i]];
|
||
}
|
||
cursor[keys[keys.length - 1]] = value;
|
||
}
|
||
|
||
function renderConfig() {
|
||
const body = $('configBody');
|
||
body.innerHTML = '';
|
||
const groups = CONFIG_SCHEMA[activeTab] || [];
|
||
for (const group of groups) {
|
||
const title = document.createElement('div');
|
||
title.className = 'group-title';
|
||
title.textContent = group.title;
|
||
body.appendChild(title);
|
||
for (const field of group.fields) {
|
||
body.appendChild(buildField(field));
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildField(field) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'field';
|
||
const label = document.createElement('label');
|
||
label.textContent = field.label;
|
||
wrap.appendChild(label);
|
||
|
||
const value = getByPath(currentConfig, field.path);
|
||
|
||
if (field.type === 'checkbox') {
|
||
const inline = document.createElement('div');
|
||
inline.className = 'field-inline';
|
||
const input = document.createElement('input');
|
||
input.type = 'checkbox';
|
||
input.checked = value === true;
|
||
input.dataset.path = field.path;
|
||
input.dataset.type = 'checkbox';
|
||
inline.appendChild(input);
|
||
wrap.appendChild(inline);
|
||
} else if (field.type === 'select') {
|
||
const select = document.createElement('select');
|
||
select.className = 'input';
|
||
select.dataset.path = field.path;
|
||
select.dataset.type = 'select';
|
||
for (const option of field.options) {
|
||
const opt = document.createElement('option');
|
||
opt.value = String(option.value);
|
||
opt.textContent = option.label;
|
||
if (String(value) === String(option.value)) opt.selected = true;
|
||
select.appendChild(opt);
|
||
}
|
||
wrap.appendChild(select);
|
||
} else {
|
||
const input = document.createElement('input');
|
||
input.className = 'input';
|
||
input.type = field.type === 'password' ? 'password' : (field.type === 'number' ? 'number' : 'text');
|
||
if (field.min !== undefined) input.min = field.min;
|
||
if (field.max !== undefined) input.max = field.max;
|
||
if (field.step !== undefined) input.step = field.step;
|
||
if (field.type === 'list') {
|
||
// 字符串数组用逗号分隔编辑,避免为几个短列表单独做一套增删 UI
|
||
input.value = Array.isArray(value) ? value.join(', ') : '';
|
||
} else {
|
||
input.value = value === undefined || value === null ? '' : String(value);
|
||
}
|
||
input.dataset.path = field.path;
|
||
input.dataset.type = field.type;
|
||
wrap.appendChild(input);
|
||
}
|
||
|
||
if (field.hint) {
|
||
const hint = document.createElement('div');
|
||
hint.className = 'hint';
|
||
hint.textContent = field.hint;
|
||
wrap.appendChild(hint);
|
||
}
|
||
return wrap;
|
||
}
|
||
|
||
function collectConfig() {
|
||
const result = JSON.parse(JSON.stringify(currentConfig || {}));
|
||
for (const input of $('configBody').querySelectorAll('[data-path]')) {
|
||
const path = input.dataset.path;
|
||
const type = input.dataset.type;
|
||
let value;
|
||
if (type === 'checkbox') value = input.checked;
|
||
else if (type === 'select') {
|
||
const raw = input.value;
|
||
value = /^-?\d+$/.test(raw) ? Number(raw) : raw;
|
||
} else if (type === 'number') value = input.value === '' ? 0 : Number(input.value);
|
||
else if (type === 'list') {
|
||
value = input.value.split(',').map((item) => item.trim()).filter(Boolean);
|
||
} else value = input.value;
|
||
setByPath(result, path, value);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function loadConfig() {
|
||
try {
|
||
if ($('chkBotOverride').checked && selectedBotId) {
|
||
const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings');
|
||
currentConfig = res.data.config;
|
||
} else {
|
||
const res = await api('/api/config');
|
||
currentConfig = res.data;
|
||
}
|
||
renderConfig();
|
||
} catch (err) {
|
||
toast('读取配置失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
async function saveConfig() {
|
||
try {
|
||
const config = collectConfig();
|
||
if ($('chkBotOverride').checked && selectedBotId) {
|
||
const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ config })
|
||
});
|
||
currentConfig = res.data.config;
|
||
toast('已保存为「' + selectedBotId + '」的专属配置');
|
||
} else {
|
||
const res = await api('/api/config', { method: 'POST', body: JSON.stringify(config) });
|
||
currentConfig = res.data;
|
||
toast('全局配置已保存并应用');
|
||
}
|
||
renderConfig();
|
||
} catch (err) {
|
||
toast('保存配置失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
// ==================== 机器人列表 ====================
|
||
|
||
const BOT_STATE_TEXT = { online: '已连接', connecting: '连接中', offline: '已断开', idle: '未连接' };
|
||
|
||
/** 上次渲染机器人列表用的签名:状态每秒推送一次,签名不变就不重建 DOM */
|
||
let botListSignature = '';
|
||
|
||
function botSignature(bots) {
|
||
return bots.map((b) => [
|
||
b.id, b.state, b.enabled, b.source, b.task ? b.task.state : '', b.task ? b.task.completedClaims : '',
|
||
b.position ? Math.round(b.position.x) : '', b.position ? Math.round(b.position.z) : ''
|
||
].join(':')).join('|');
|
||
}
|
||
|
||
function renderBotList(status) {
|
||
const bots = status.bots || [];
|
||
$('botCount').textContent = String(bots.length);
|
||
const signature = botSignature(bots) + '#' + selectedBotId;
|
||
if (signature === botListSignature) return;
|
||
botListSignature = signature;
|
||
|
||
const list = $('botList');
|
||
list.innerHTML = '';
|
||
if (bots.length === 0) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'exp-empty';
|
||
empty.textContent = '未配置任何机器人:请在 .env 设置 MC_BOTS 或 MC_USERNAME / MC_PASSWORD';
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
for (const bot of bots) {
|
||
const item = document.createElement('div');
|
||
item.className = 'bot-item' + (bot.id === selectedBotId ? ' active' : '');
|
||
item.dataset.botId = bot.id;
|
||
|
||
const dot = document.createElement('span');
|
||
dot.className = 'dot ' + (bot.enabled === false ? 'disabled' : (bot.state || 'idle'));
|
||
item.appendChild(dot);
|
||
|
||
const info = document.createElement('div');
|
||
const name = document.createElement('div');
|
||
name.className = 'name';
|
||
name.textContent = bot.label && bot.label !== bot.username
|
||
? `${bot.label}(${bot.username})` : (bot.username || bot.id);
|
||
const sub = document.createElement('div');
|
||
sub.className = 'sub';
|
||
const pos = bot.position ? `${bot.position.x}, ${bot.position.z}` : '未知位置';
|
||
sub.textContent = `${BOT_STATE_TEXT[bot.state] || bot.state} · ${pos}`;
|
||
info.appendChild(name);
|
||
info.appendChild(sub);
|
||
item.appendChild(info);
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'meta';
|
||
meta.textContent = (bot.task ? bot.task.state : 'IDLE') + ' · ' + (bot.task ? bot.task.completedClaims : 0);
|
||
if (bot.source !== 'env') {
|
||
// 控制台新增的账号可以在这里直接移除;.env 里的必须改 .env
|
||
const remove = document.createElement('button');
|
||
remove.className = 'remove';
|
||
remove.textContent = '×';
|
||
remove.title = '移除该玩家';
|
||
remove.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
removeBot(bot.id, bot.username);
|
||
});
|
||
meta.appendChild(remove);
|
||
} else {
|
||
meta.title = '该玩家在 .env 的 MC_BOTS 中配置,需修改 .env 后重启才能移除';
|
||
}
|
||
item.appendChild(meta);
|
||
|
||
item.addEventListener('click', () => selectBot(bot.id));
|
||
list.appendChild(item);
|
||
}
|
||
|
||
// 机器人选择下拉
|
||
const select = $('botSelect');
|
||
const previous = select.value;
|
||
select.innerHTML = '';
|
||
for (const bot of bots) {
|
||
const opt = document.createElement('option');
|
||
opt.value = bot.id;
|
||
opt.textContent = bot.username || bot.id;
|
||
select.appendChild(opt);
|
||
}
|
||
const desired = bots.some((b) => b.id === selectedBotId) ? selectedBotId
|
||
: (bots.some((b) => b.id === previous) ? previous : (status.defaultBotId || (bots[0] && bots[0].id) || ''));
|
||
select.value = desired;
|
||
if (desired && desired !== selectedBotId) selectBot(desired, true);
|
||
|
||
// 开拓日志的机器人过滤器
|
||
const expFilter = $('expFilterBot');
|
||
const expPrevious = expFilter.value;
|
||
expFilter.innerHTML = '<option value="">全部</option>';
|
||
for (const bot of bots) {
|
||
const opt = document.createElement('option');
|
||
opt.value = bot.id;
|
||
opt.textContent = bot.username || bot.id;
|
||
expFilter.appendChild(opt);
|
||
}
|
||
expFilter.value = expPrevious;
|
||
}
|
||
|
||
function selectBot(id, silent) {
|
||
if (!id) return;
|
||
const changed = id !== selectedBotId;
|
||
selectedBotId = id;
|
||
botListSignature = '';
|
||
scheduleSignature = '';
|
||
mapView.centered = false;
|
||
bigMapView.fitted = false;
|
||
$('botSelect').value = id;
|
||
$('consoleTarget').textContent = id;
|
||
for (const item of document.querySelectorAll('.bot-item')) {
|
||
item.classList.toggle('active', item.dataset.botId === id);
|
||
}
|
||
if (changed || !silent) {
|
||
loadConfig();
|
||
loadChat();
|
||
}
|
||
if (lastStatus) renderStatus(lastStatus);
|
||
}
|
||
|
||
// ==================== 状态渲染 ====================
|
||
|
||
function selectedBot(status) {
|
||
const bots = (status && status.bots) || [];
|
||
return bots.find((b) => b.id === selectedBotId) || bots[0] || null;
|
||
}
|
||
|
||
function renderStatus(status) {
|
||
lastStatus = status;
|
||
renderBotList(status);
|
||
|
||
const bot = selectedBot(status) || {};
|
||
const task = bot.task || {};
|
||
|
||
// ===== 顶栏徽标 =====
|
||
const connBadge = $('connBadge');
|
||
connBadge.textContent = BOT_STATE_TEXT[bot.state] || bot.state || '未知';
|
||
connBadge.className = 'badge ' + (bot.state === 'online' ? 'badge-online' : 'badge-offline');
|
||
|
||
const taskBadge = $('taskBadge');
|
||
taskBadge.textContent = task.state || 'IDLE';
|
||
taskBadge.className = 'badge ' + (task.running ? 'badge-running' : (task.state === 'COMPLETE' ? 'badge-online' : 'badge-idle'));
|
||
|
||
// ===== 连接卡片 =====
|
||
$('kServer').textContent = bot.host || '-';
|
||
$('kUsername').textContent = bot.username || '-';
|
||
$('kVersion').textContent = bot.version || '-';
|
||
$('kPos').textContent = bot.position
|
||
? `${bot.position.x} / ${bot.position.y} / ${bot.position.z}`
|
||
: '-';
|
||
$('kChunk').textContent = bot.chunk ? `${bot.chunk.x}, ${bot.chunk.z}` : '-';
|
||
$('kHealth').textContent = bot.health === null || bot.health === undefined
|
||
? '-'
|
||
: `${bot.health.toFixed(1)} / ${bot.food === null || bot.food === undefined ? '-' : bot.food}`;
|
||
$('kBotState').textContent = (bot.riding ? '乘船中 · ' : '') + (bot.inWater ? '水中' : '陆地');
|
||
const loginEl = $('kLogin');
|
||
loginEl.textContent = bot.loginFailed ? '登录被拒绝' : (bot.loggedIn ? '已通过' : '未通过');
|
||
loginEl.style.color = bot.loginFailed ? 'var(--red)' : (bot.loggedIn ? 'var(--green)' : 'var(--yellow)');
|
||
$('kReconnect').textContent = String(bot.reconnectAttempts || 0);
|
||
if (document.activeElement !== $('chkEnabled')) $('chkEnabled').checked = bot.enabled !== false;
|
||
if (document.activeElement !== $('chkAutoStart')) $('chkAutoStart').checked = bot.autoStart === true;
|
||
|
||
const canOperate = bot.canOperate === true;
|
||
$('consoleHint').textContent = canOperate
|
||
? '以 / 开头按游戏指令发送,否则按公屏消息发送。'
|
||
: '该机器人尚未通过游戏内登录验证,发送会被拒绝(服务器要求验证时会自动放行,用于发验证码)。';
|
||
$('consoleHint').style.color = canOperate ? 'var(--muted)' : 'var(--yellow)';
|
||
|
||
// ===== 任务卡片 =====
|
||
$('kTaskState').textContent = task.state || 'IDLE';
|
||
$('kTaskStatus').textContent = task.status || '-';
|
||
$('kTaskStateName').textContent = task.stateName || '-';
|
||
$('kTarget').textContent = task.target ? `${task.target.x}, ${task.target.z}` : '-';
|
||
$('kQueue').textContent = String(task.queuedTargets || 0);
|
||
$('kCompleted').textContent = String(task.completedClaims || 0);
|
||
$('kRetry').textContent = String(task.retryCount || 0);
|
||
$('kProgress').textContent = (task.lastProgress || 0) + '%';
|
||
$('kTimeout').textContent = Math.round((task.consecutiveTimeoutMs || 0) / 1000) + 's';
|
||
$('kClaimTime').textContent = task.withinClaimTime ? '时段内' : '时段外';
|
||
$('progressBar').style.width = (task.lastProgress || 0) + '%';
|
||
|
||
// ===== 数据源卡片 =====
|
||
const data = status.data || {};
|
||
$('kMarkersUrl').textContent = data.markersUrl || '-';
|
||
$('kLastFetch').textContent = data.lastSuccessfulFetchMillis ? fmtAgo(data.lastSuccessfulFetchMillis) : '从未';
|
||
$('kDataError').textContent = data.lastError || '无';
|
||
$('kDataError').style.color = data.lastError ? 'var(--red)' : '';
|
||
$('kStateCount').textContent = String(data.stateCount || 0);
|
||
$('kClaimed').textContent = String(data.claimedChunks || 0);
|
||
$('kSession').textContent = String(data.sessionClaims || 0);
|
||
$('kFlying').textContent = String(data.flyingChunks || 0);
|
||
$('kApi').textContent = data.apiEnabled
|
||
? `已启用(${data.apiTerritoryCount || 0} 国,最近成功 ${data.apiLastSuccessMillis ? fmtAgo(data.apiLastSuccessMillis) : '从未'})`
|
||
: '未启用';
|
||
if (data.apiError) $('kApi').textContent += ' · ' + data.apiError;
|
||
|
||
// ===== 开拓日志统计 =====
|
||
if (status.expansionLog) {
|
||
$('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;
|
||
const btn = $('btnCrawlAround');
|
||
btn.textContent = crawl.running ? `爬取中 ${crawl.done}/${crawl.total}` : '爬取周边';
|
||
btn.disabled = crawl.running === true;
|
||
}
|
||
|
||
// ===== 定时消息 =====
|
||
renderSchedules(bot.schedules || []);
|
||
|
||
// ===== Grim 卡片 =====
|
||
const grim = bot.grim;
|
||
if (grim) {
|
||
$('kGrimEnabled').textContent = grim.enabled ? '已启用' : '已关闭';
|
||
$('kGrimRot').textContent = `${grim.maxRotationPerTickDeg}°(实际 ${grim.effectiveStepDeg}°/s)`;
|
||
$('kGrimSettled').textContent = grim.rotationSettled ? '是' : '否';
|
||
$('kGrimReach').textContent = grim.attackReach + ' 格';
|
||
$('kGrimCooldown').textContent = grim.attackCooldownMs > 0 ? grim.attackCooldownMs + ' ms' : '就绪';
|
||
const packets = grim.protocolPackets;
|
||
if (packets) {
|
||
// 这两个补包是「机器人能不能走」的关键,直接显示发送计数最直观
|
||
const parts = [];
|
||
if (packets.playerInputEnabled) {
|
||
parts.push(packets.playerInputSupported
|
||
? `player_input ${packets.playerInputSent}`
|
||
: 'player_input 当前版本不支持');
|
||
}
|
||
if (packets.tickEndEnabled) parts.push(`tick_end ${packets.tickEndSent}`);
|
||
$('kGrimPackets').textContent = parts.length > 0 ? parts.join(' · ') : '已关闭';
|
||
$('kGrimPackets').style.color = parts.length > 0 ? 'var(--green)' : 'var(--yellow)';
|
||
}
|
||
$('kGrimBlocked').textContent = String(grim.blockedActions || 0);
|
||
$('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常';
|
||
$('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)';
|
||
const issues = $('grimIssues');
|
||
issues.innerHTML = '';
|
||
for (const issue of grim.issues || []) {
|
||
const li = document.createElement('li');
|
||
li.textContent = issue;
|
||
issues.appendChild(li);
|
||
}
|
||
}
|
||
|
||
drawMap();
|
||
}
|
||
|
||
// ==================== 定时消息 ====================
|
||
|
||
/** 上次渲染定时列表用的签名,避免每秒推送都重建 DOM */
|
||
let scheduleSignature = '';
|
||
|
||
/**
|
||
* 渲染当前机器人的定时发送列表。
|
||
*
|
||
* @param {Array<object>} schedules 服务端返回的任务快照(含剩余秒数)
|
||
*/
|
||
function renderSchedules(schedules) {
|
||
const list = $('scheduleList');
|
||
if (!list) return;
|
||
const signature = (schedules || [])
|
||
.map((t) => [t.id, t.enabled, t.content, t.intervalSeconds, t.nextInSeconds].join(':'))
|
||
.join('|');
|
||
if (signature === scheduleSignature) return;
|
||
scheduleSignature = signature;
|
||
|
||
list.innerHTML = '';
|
||
if (!schedules || schedules.length === 0) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'schedule-empty';
|
||
empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间重复发送同一条消息或指令。';
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
for (const task of schedules) {
|
||
const item = document.createElement('div');
|
||
item.className = 'schedule-item' + (task.enabled ? '' : ' off');
|
||
|
||
const toggle = document.createElement('input');
|
||
toggle.type = 'checkbox';
|
||
toggle.checked = task.enabled;
|
||
toggle.title = '启用 / 停用';
|
||
toggle.addEventListener('change', () => toggleSchedule(task, toggle.checked));
|
||
item.appendChild(toggle);
|
||
|
||
const content = document.createElement('div');
|
||
content.className = 'content';
|
||
const code = document.createElement('code');
|
||
code.textContent = task.content;
|
||
code.title = task.content;
|
||
content.appendChild(code);
|
||
const sub = document.createElement('div');
|
||
sub.className = 'sub';
|
||
sub.textContent = `${task.kind === 'command' ? '指令' : '公屏'} · 每 ${task.intervalSeconds} 秒`
|
||
+ (task.label ? ` · ${task.label}` : '');
|
||
content.appendChild(sub);
|
||
item.appendChild(content);
|
||
|
||
const next = document.createElement('div');
|
||
next.className = 'sub next';
|
||
next.textContent = task.enabled && task.nextInSeconds !== null && task.nextInSeconds !== undefined
|
||
? `${task.nextInSeconds}s 后`
|
||
: '已停用';
|
||
item.appendChild(next);
|
||
|
||
const remove = document.createElement('button');
|
||
remove.className = 'remove';
|
||
remove.textContent = '×';
|
||
remove.title = '删除该定时任务';
|
||
remove.addEventListener('click', () => removeSchedule(task));
|
||
item.appendChild(remove);
|
||
|
||
list.appendChild(item);
|
||
}
|
||
}
|
||
|
||
/** 保存整份定时任务列表 */
|
||
async function saveSchedules(schedules) {
|
||
if (!selectedBotId) return;
|
||
try {
|
||
await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/schedules', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ schedules })
|
||
});
|
||
scheduleSignature = '';
|
||
toast('定时任务已保存');
|
||
} catch (err) {
|
||
toast('保存定时任务失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
/** 取当前列表的原始定义(去掉服务端附加的剩余秒数字段) */
|
||
function currentSchedules() {
|
||
const bot = selectedBot(lastStatus || {});
|
||
return ((bot && bot.schedules) || []).map((t) => ({
|
||
id: t.id,
|
||
label: t.label,
|
||
content: t.content,
|
||
intervalSeconds: t.intervalSeconds,
|
||
enabled: t.enabled
|
||
}));
|
||
}
|
||
|
||
function toggleSchedule(task, enabled) {
|
||
const next = currentSchedules().map((t) => (t.id === task.id ? Object.assign({}, t, { enabled }) : t));
|
||
saveSchedules(next);
|
||
}
|
||
|
||
function removeSchedule(task) {
|
||
const next = currentSchedules().filter((t) => t.id !== task.id);
|
||
saveSchedules(next);
|
||
}
|
||
|
||
// ==================== 移动端导航 ====================
|
||
|
||
/** 小屏底部导航:点击滚动到对应区块 */
|
||
function bindMobileNav() {
|
||
const nav = $('mobileNav');
|
||
if (!nav) return;
|
||
nav.addEventListener('click', (e) => {
|
||
const item = e.target.closest('.mobile-nav-item');
|
||
if (!item) return;
|
||
for (const el of nav.querySelectorAll('.mobile-nav-item')) {
|
||
el.classList.toggle('active', el === item);
|
||
}
|
||
const target = $(item.dataset.scroll);
|
||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
}
|
||
|
||
// ==================== 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;
|
||
|
||
function appendLog(entry) {
|
||
const view = $('logView');
|
||
const line = document.createElement('span');
|
||
line.className = 'log-line log-' + (entry.level || 'info');
|
||
const time = document.createElement('span');
|
||
time.className = 'log-time';
|
||
time.textContent = '[' + entry.time + ']';
|
||
line.appendChild(time);
|
||
line.appendChild(document.createTextNode(entry.message));
|
||
view.appendChild(line);
|
||
while (view.childNodes.length > MAX_LOG_LINES) view.removeChild(view.firstChild);
|
||
if ($('chkAutoScroll').checked) view.scrollTop = view.scrollHeight;
|
||
}
|
||
|
||
function renderLogs(entries) {
|
||
$('logView').innerHTML = '';
|
||
for (const entry of entries) appendLog(entry);
|
||
}
|
||
|
||
// ==================== 公屏聊天 ====================
|
||
|
||
const MAX_CHAT_LINES = 600;
|
||
/** 已渲染的公屏消息(用于「全部机器人」切换时重绘) */
|
||
let chatEntries = [];
|
||
|
||
const CHAT_KIND_CLASS = {
|
||
chat: 'chat-line',
|
||
system: 'chat-line chat-system',
|
||
action: 'chat-line chat-action',
|
||
out: 'chat-line chat-out',
|
||
command: 'chat-line chat-command'
|
||
};
|
||
|
||
function chatMatchesFilter(entry) {
|
||
if ($('chkChatAllBots').checked) return true;
|
||
return entry.botId === selectedBotId;
|
||
}
|
||
|
||
function appendChat(entry, skipScroll) {
|
||
const view = $('chatView');
|
||
const line = document.createElement('span');
|
||
line.className = CHAT_KIND_CLASS[entry.kind] || 'chat-line';
|
||
|
||
const time = document.createElement('span');
|
||
time.className = 'chat-time';
|
||
time.textContent = '[' + fmtTime(entry.t) + ']';
|
||
line.appendChild(time);
|
||
|
||
const bot = document.createElement('span');
|
||
bot.className = 'chat-bot';
|
||
bot.textContent = (entry.bot || entry.botId) + ' ›';
|
||
line.appendChild(bot);
|
||
|
||
if (entry.username) {
|
||
const user = document.createElement('span');
|
||
user.className = 'chat-user';
|
||
user.textContent = entry.username + ':';
|
||
line.appendChild(user);
|
||
}
|
||
line.appendChild(document.createTextNode(entry.message));
|
||
|
||
view.appendChild(line);
|
||
while (view.childNodes.length > MAX_CHAT_LINES) view.removeChild(view.firstChild);
|
||
if (!skipScroll && $('chkChatAutoScroll').checked) view.scrollTop = view.scrollHeight;
|
||
}
|
||
|
||
function renderChat() {
|
||
$('chatView').innerHTML = '';
|
||
for (const entry of chatEntries) {
|
||
if (chatMatchesFilter(entry)) appendChat(entry, true);
|
||
}
|
||
const view = $('chatView');
|
||
if ($('chkChatAutoScroll').checked) view.scrollTop = view.scrollHeight;
|
||
}
|
||
|
||
async function loadChat() {
|
||
try {
|
||
const ids = $('chkChatAllBots').checked
|
||
? (((lastStatus && lastStatus.bots) || []).map((b) => b.id))
|
||
: [selectedBotId];
|
||
const usable = ids.filter(Boolean);
|
||
if (usable.length === 0) {
|
||
chatEntries = [];
|
||
renderChat();
|
||
return;
|
||
}
|
||
const results = await Promise.all(usable.map((id) => api(
|
||
'/api/bots/' + encodeURIComponent(id) + '/chat?limit=200'
|
||
).then((res) => res.data || []).catch(() => [])));
|
||
chatEntries = results.flat().sort((a, b) => a.t - b.t).slice(-MAX_CHAT_LINES);
|
||
renderChat();
|
||
} catch (err) {
|
||
toast('读取公屏日志失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
// ==================== 开拓日志 ====================
|
||
|
||
const MAX_EXP_ROWS = 500;
|
||
let expEntries = [];
|
||
|
||
function expMatchesFilter(entry) {
|
||
const filter = $('expFilterBot').value;
|
||
return !filter || entry.botId === filter;
|
||
}
|
||
|
||
function appendExp(entry, skipScroll) {
|
||
const body = $('expBody');
|
||
const row = document.createElement('tr');
|
||
const cells = [
|
||
fmtDateTime(entry.t),
|
||
entry.bot || entry.botId,
|
||
entry.state || '-',
|
||
`${entry.cx}, ${entry.cz}`,
|
||
entry.bx === null || entry.bx === undefined ? '-' : `${entry.bx}, ${entry.bz}`,
|
||
entry.source || '-'
|
||
];
|
||
for (const text of cells) {
|
||
const td = document.createElement('td');
|
||
td.textContent = text;
|
||
row.appendChild(td);
|
||
}
|
||
body.appendChild(row);
|
||
while (body.childNodes.length > MAX_EXP_ROWS) body.removeChild(body.firstChild);
|
||
if (!skipScroll && $('chkExpAutoScroll').checked) {
|
||
const view = $('expView');
|
||
view.scrollTop = view.scrollHeight;
|
||
}
|
||
}
|
||
|
||
function renderExp() {
|
||
$('expBody').innerHTML = '';
|
||
const filtered = expEntries.filter(expMatchesFilter);
|
||
if (filtered.length === 0) {
|
||
const body = $('expBody');
|
||
const row = document.createElement('tr');
|
||
const td = document.createElement('td');
|
||
td.colSpan = 6;
|
||
td.className = 'exp-empty';
|
||
td.textContent = '暂无开拓记录';
|
||
row.appendChild(td);
|
||
body.appendChild(row);
|
||
return;
|
||
}
|
||
for (const entry of filtered) appendExp(entry, true);
|
||
const view = $('expView');
|
||
if ($('chkExpAutoScroll').checked) view.scrollTop = view.scrollHeight;
|
||
}
|
||
|
||
async function loadExpansionLog() {
|
||
try {
|
||
const res = await api('/api/expansion-log?limit=300');
|
||
expEntries = (res.data && res.data.entries) || [];
|
||
renderExp();
|
||
} catch (err) {
|
||
toast('读取开拓日志失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
// ==================== 地图 ====================
|
||
|
||
/** 当前地图视图:big(大地图) | territory(领土地图) */
|
||
let mapMode = 'big';
|
||
|
||
const mapView = {
|
||
centerX: 0,
|
||
centerZ: 0,
|
||
scale: 2,
|
||
dragging: false,
|
||
lastX: 0,
|
||
lastY: 0,
|
||
data: null,
|
||
centered: false
|
||
};
|
||
|
||
const bigMapView = {
|
||
/** 视图中心(方块坐标) */
|
||
centerX: 0,
|
||
centerZ: 0,
|
||
/** 缩放:每个方块占多少 CSS 像素 */
|
||
pxPerBlock: 0.02,
|
||
data: null,
|
||
markers: null,
|
||
/** 瓦片图片缓存:`z/x_y` → HTMLImageElement */
|
||
tiles: new Map(),
|
||
pending: new Set(),
|
||
fitted: false,
|
||
dragging: false,
|
||
lastX: 0,
|
||
lastY: 0
|
||
};
|
||
|
||
function canvasContext() {
|
||
const canvas = $('mapCanvas');
|
||
return { canvas, ctx: canvas.getContext('2d'), rect: canvas.getBoundingClientRect() };
|
||
}
|
||
|
||
function initMap() {
|
||
const canvas = $('mapCanvas');
|
||
|
||
const resize = () => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
|
||
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
|
||
canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
drawMap();
|
||
};
|
||
window.addEventListener('resize', resize);
|
||
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
// 只让当前视图接管拖拽,否则两个视图会同时平移
|
||
if (mapMode === 'big') {
|
||
bigMapView.dragging = true;
|
||
bigMapView.lastX = e.clientX;
|
||
bigMapView.lastY = e.clientY;
|
||
} else {
|
||
mapView.dragging = true;
|
||
mapView.lastX = e.clientX;
|
||
mapView.lastY = e.clientY;
|
||
}
|
||
});
|
||
window.addEventListener('mouseup', () => {
|
||
mapView.dragging = false;
|
||
bigMapView.dragging = false;
|
||
});
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (mapView.dragging) {
|
||
const dx = e.clientX - mapView.lastX;
|
||
const dy = e.clientY - mapView.lastY;
|
||
mapView.lastX = e.clientX;
|
||
mapView.lastY = e.clientY;
|
||
mapView.centerX -= dx / mapView.scale;
|
||
mapView.centerZ -= dy / mapView.scale;
|
||
drawMap();
|
||
} else if (bigMapView.dragging) {
|
||
const dx = e.clientX - bigMapView.lastX;
|
||
const dy = e.clientY - bigMapView.lastY;
|
||
bigMapView.lastX = e.clientX;
|
||
bigMapView.lastY = e.clientY;
|
||
bigMapView.centerX -= dx / bigMapView.pxPerBlock;
|
||
bigMapView.centerZ -= dy / bigMapView.pxPerBlock;
|
||
drawMap();
|
||
}
|
||
});
|
||
canvas.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
if (mapMode === 'big') {
|
||
const factor = e.deltaY < 0 ? 1.2 : 1 / 1.2;
|
||
bigMapView.pxPerBlock = Math.min(4, Math.max(0.002, bigMapView.pxPerBlock * factor));
|
||
drawMap();
|
||
return;
|
||
}
|
||
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||
mapView.scale = Math.min(24, Math.max(0.15, mapView.scale * factor));
|
||
drawMap();
|
||
}, { passive: false });
|
||
|
||
setTimeout(resize, 0);
|
||
}
|
||
|
||
/** 区块坐标 → 画布坐标(领土地图视图) */
|
||
function chunkToScreen(cx, cz) {
|
||
const rect = $('mapCanvas').getBoundingClientRect();
|
||
const scale = mapView.scale;
|
||
return {
|
||
x: rect.width / 2 + (cx - mapView.centerX) * scale,
|
||
y: rect.height / 2 + (cz - mapView.centerZ) * scale,
|
||
scale
|
||
};
|
||
}
|
||
|
||
/** 方块坐标 → 画布坐标(大地图视图) */
|
||
function blockToScreen(bx, bz) {
|
||
const rect = $('mapCanvas').getBoundingClientRect();
|
||
const px = bigMapView.pxPerBlock;
|
||
return {
|
||
x: rect.width / 2 + (bx - bigMapView.centerX) * px,
|
||
y: rect.height / 2 + (bz - bigMapView.centerZ) * px
|
||
};
|
||
}
|
||
|
||
/** 大地图瓦片层级:让每张瓦片在屏幕上约为 512 像素 */
|
||
function currentTileZoom() {
|
||
const data = bigMapView.data;
|
||
const maxZoom = data ? data.maxZoom : 3;
|
||
const raw = Math.round(maxZoom + Math.log2(bigMapView.pxPerBlock));
|
||
return Math.min(maxZoom, Math.max(0, raw));
|
||
}
|
||
|
||
/** 层级 z 下每张瓦片覆盖的方块数(与后端 bigMapService.blocksPerTile 一致) */
|
||
function blocksPerTile(z) {
|
||
const data = bigMapView.data;
|
||
const maxZoom = data ? data.maxZoom : 3;
|
||
const tilePixels = data ? data.tilePixels : 512;
|
||
return tilePixels * Math.pow(2, maxZoom - z);
|
||
}
|
||
|
||
function drawMap() {
|
||
if (mapMode === 'big') drawBigMap();
|
||
else drawTerritoryMap();
|
||
}
|
||
|
||
// ==================== 大地图渲染 ====================
|
||
|
||
function drawBigMap() {
|
||
const { ctx, rect } = canvasContext();
|
||
const w = rect.width;
|
||
const h = rect.height;
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.fillStyle = '#05070a';
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
const data = bigMapView.data;
|
||
if (!data || data.enabled === false) {
|
||
ctx.fillStyle = '#48505e';
|
||
ctx.font = '13px "Microsoft YaHei", sans-serif';
|
||
ctx.fillText(data && data.enabled === false ? '大地图已关闭(配置 → 大地图)' : '正在读取大地图信息…', 16, 24);
|
||
return;
|
||
}
|
||
|
||
const px = bigMapView.pxPerBlock;
|
||
const zoom = currentTileZoom();
|
||
const perTile = blocksPerTile(zoom);
|
||
|
||
// ===== 可见方块范围 → 需要的瓦片下标范围 =====
|
||
const minBlockX = bigMapView.centerX - w / 2 / px;
|
||
const maxBlockX = bigMapView.centerX + w / 2 / px;
|
||
const minBlockZ = bigMapView.centerZ - h / 2 / px;
|
||
const maxBlockZ = bigMapView.centerZ + h / 2 / px;
|
||
const minTileX = Math.floor(minBlockX / perTile);
|
||
const maxTileX = Math.floor(maxBlockX / perTile);
|
||
const minTileZ = Math.floor(minBlockZ / perTile);
|
||
const maxTileZ = Math.floor(maxBlockZ / perTile);
|
||
|
||
let drawn = 0;
|
||
let missing = 0;
|
||
for (let tx = minTileX; tx <= maxTileX; tx++) {
|
||
for (let tz = minTileZ; tz <= maxTileZ; tz++) {
|
||
const key = `${zoom}/${tx}_${tz}`;
|
||
const img = bigMapView.tiles.get(key);
|
||
if (img && img.complete && img.naturalWidth > 0) {
|
||
const x0 = w / 2 + (tx * perTile - bigMapView.centerX) * px;
|
||
const y0 = h / 2 + (tz * perTile - bigMapView.centerZ) * px;
|
||
ctx.drawImage(img, x0, y0, perTile * px, perTile * px);
|
||
drawn++;
|
||
} else {
|
||
missing++;
|
||
requestTile(zoom, tx, tz, key);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 领土标记(多边形直接按方块坐标绘制)=====
|
||
if ($('chkTerritory').checked && bigMapView.markers) {
|
||
ctx.save();
|
||
ctx.translate(w / 2 - bigMapView.centerX * px, h / 2 - bigMapView.centerZ * px);
|
||
ctx.scale(px, px);
|
||
ctx.lineWidth = 1 / px;
|
||
for (const state of bigMapView.markers.states) {
|
||
if (!state.path) continue;
|
||
ctx.fillStyle = hexToRgba(state.color, 0.26);
|
||
ctx.fill(state.path, 'evenodd');
|
||
ctx.strokeStyle = hexToRgba(state.color, 0.85);
|
||
ctx.stroke(state.path);
|
||
}
|
||
// 本次会话新占领:只有区块没有多边形,按 16×16 方块画
|
||
ctx.fillStyle = 'rgba(63,185,80,0.9)';
|
||
for (const flat of bigMapView.sessionChunks || []) {
|
||
for (let i = 0; i < flat.length; i += 2) {
|
||
ctx.fillRect(flat[i] * 16, flat[i + 1] * 16, 16, 16);
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// ===== 王城 =====
|
||
if (bigMapView.markers) {
|
||
for (const state of bigMapView.markers.states) {
|
||
if (!state.capital) continue;
|
||
const p = blockToScreen(state.capital.x * 16, state.capital.z * 16);
|
||
if (p.x < -30 || p.y < -30 || p.x > w + 30 || p.y > h + 30) continue;
|
||
ctx.fillStyle = '#bc8cff';
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
if (px >= 0.02) {
|
||
ctx.fillStyle = 'rgba(188,140,255,0.9)';
|
||
ctx.font = '11px "Microsoft YaHei", sans-serif';
|
||
ctx.fillText(state.name, p.x + 6, p.y + 4);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 所有机器人的坐标 =====
|
||
drawBotMarkers(ctx, (bot) => blockToScreen(bot.position.x, bot.position.z));
|
||
|
||
const crawl = data.crawl || {};
|
||
$('mapInfo').textContent = `大地图 · 层级 ${zoom}(${perTile} 方块/瓦片) · `
|
||
+ `${px < 0.05 ? (1 / px).toFixed(0) + ' 方块/像素' : px.toFixed(2) + ' 像素/方块'} · `
|
||
+ `中心 ${Math.round(bigMapView.centerX)}, ${Math.round(bigMapView.centerZ)}`
|
||
+ (missing > 0 ? ` · 加载中 ${missing} 张` : ` · 已绘制 ${drawn} 张`)
|
||
+ (crawl.running ? ` · 预爬取 ${crawl.done}/${crawl.total}` : '');
|
||
}
|
||
|
||
/** 请求一张瓦片(带凭证),加载完成后重绘 */
|
||
function requestTile(zoom, tx, tz, key) {
|
||
if (bigMapView.pending.has(key)) return;
|
||
bigMapView.pending.add(key);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
bigMapView.pending.delete(key);
|
||
bigMapView.tiles.set(key, img);
|
||
// 只缓存最近使用的瓦片,避免长时间缩放把内存吃满
|
||
if (bigMapView.tiles.size > 600) {
|
||
const oldest = bigMapView.tiles.keys().next();
|
||
if (!oldest.done) bigMapView.tiles.delete(oldest.value);
|
||
}
|
||
drawMap();
|
||
};
|
||
img.onerror = () => {
|
||
bigMapView.pending.delete(key);
|
||
};
|
||
// 路由形如 /api/bigmap/tile/:z/:x/:y.png(源站的 {z}/{x}_{y}.png 在服务端代理)
|
||
img.src = withToken(`/api/bigmap/tile/${zoom}/${tx}/${tz}.png`);
|
||
}
|
||
|
||
/** 在画布上绘制所有机器人的坐标点(含名字) */
|
||
function drawBotMarkers(ctx, project) {
|
||
const bots = (lastStatus && lastStatus.bots) || [];
|
||
const showLabels = $('chkBotLabels').checked;
|
||
const rect = $('mapCanvas').getBoundingClientRect();
|
||
for (const bot of bots) {
|
||
if (!bot.position) continue;
|
||
const p = project(bot);
|
||
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue;
|
||
if (p.x < -40 || p.y < -40 || p.x > rect.width + 40 || p.y > rect.height + 40) continue;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
|
||
ctx.fillStyle = bot.state === 'online' ? '#ffd33d' : '#6e7681';
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#0d1117';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
if (showLabels) {
|
||
const label = bot.username || bot.id;
|
||
ctx.font = '11px "Microsoft YaHei", sans-serif';
|
||
const width = ctx.measureText(label).width;
|
||
ctx.fillStyle = 'rgba(5,7,10,0.72)';
|
||
ctx.fillRect(p.x + 8, p.y - 14, width + 8, 16);
|
||
ctx.fillStyle = bot.state === 'online' ? '#ffd33d' : '#8b949e';
|
||
ctx.fillText(label, p.x + 12, p.y - 2);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== 领土地图渲染 ====================
|
||
|
||
function drawTerritoryMap() {
|
||
const { ctx, rect } = canvasContext();
|
||
const w = rect.width;
|
||
const h = rect.height;
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.fillStyle = '#05070a';
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
const data = mapView.data;
|
||
const scale = mapView.scale;
|
||
const cell = Math.max(1, scale);
|
||
|
||
const drawChunks = (flat, color) => {
|
||
ctx.fillStyle = color;
|
||
for (let i = 0; i < flat.length; i += 2) {
|
||
const p = chunkToScreen(flat[i], flat[i + 1]);
|
||
if (p.x < -cell || p.y < -cell || p.x > w + cell || p.y > h + cell) continue;
|
||
ctx.fillRect(p.x, p.y, cell, cell);
|
||
}
|
||
};
|
||
|
||
// 网格
|
||
if ($('chkGrid').checked && scale >= 1.2) {
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.045)';
|
||
ctx.lineWidth = 1;
|
||
const halfX = w / 2 / scale;
|
||
const halfZ = h / 2 / scale;
|
||
const startX = Math.floor(mapView.centerX - halfX);
|
||
const endX = Math.ceil(mapView.centerX + halfX);
|
||
const startZ = Math.floor(mapView.centerZ - halfZ);
|
||
const endZ = Math.ceil(mapView.centerZ + halfZ);
|
||
ctx.beginPath();
|
||
for (let x = startX; x <= endX; x++) {
|
||
const sx = Math.round(chunkToScreen(x, 0).x) + 0.5;
|
||
ctx.moveTo(sx, 0); ctx.lineTo(sx, h);
|
||
}
|
||
for (let z = startZ; z <= endZ; z++) {
|
||
const sy = Math.round(chunkToScreen(0, z).y) + 0.5;
|
||
ctx.moveTo(0, sy); ctx.lineTo(w, sy);
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
if (!data) {
|
||
ctx.fillStyle = '#48505e';
|
||
ctx.font = '13px "Microsoft YaHei", sans-serif';
|
||
ctx.fillText('暂无地图数据,等待 markers.json 拉取…', 16, 24);
|
||
return;
|
||
}
|
||
|
||
if ($('chkFull').checked && data.otherChunks) {
|
||
for (const state of data.otherChunks) {
|
||
drawChunks(state.chunks, hexToRgba(state.color, 0.55));
|
||
}
|
||
}
|
||
|
||
drawChunks(data.self || [], 'rgba(47,129,247,0.85)');
|
||
drawChunks(data.session || [], 'rgba(63,185,80,0.95)');
|
||
if ($('chkFlying').checked) drawChunks(data.flying || [], 'rgba(248,81,73,0.95)');
|
||
|
||
// 王城
|
||
for (const state of data.states || []) {
|
||
if (!state.capital) continue;
|
||
const p = chunkToScreen(state.capital.x, state.capital.z);
|
||
if (p.x < -20 || p.y < -20 || p.x > w + 20 || p.y > h + 20) continue;
|
||
ctx.fillStyle = '#bc8cff';
|
||
ctx.beginPath();
|
||
ctx.arc(p.x + scale / 2, p.y + scale / 2, Math.max(3, scale * 0.7), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
if (scale >= 1) {
|
||
ctx.fillStyle = 'rgba(188,140,255,0.85)';
|
||
ctx.font = '11px "Microsoft YaHei", sans-serif';
|
||
ctx.fillText(state.name, p.x + 6, p.y + 4);
|
||
}
|
||
}
|
||
|
||
// 当前目标
|
||
if (data.target) {
|
||
const p = chunkToScreen(data.target.x, data.target.z);
|
||
ctx.strokeStyle = '#d29922';
|
||
ctx.lineWidth = 2;
|
||
const size = Math.max(8, scale * 2);
|
||
ctx.strokeRect(p.x - size / 2 + scale / 2, p.y - size / 2 + scale / 2, size, size);
|
||
ctx.beginPath();
|
||
ctx.moveTo(p.x - size, p.y + scale / 2);
|
||
ctx.lineTo(p.x + size, p.y + scale / 2);
|
||
ctx.moveTo(p.x + scale / 2, p.y - size);
|
||
ctx.lineTo(p.x + scale / 2, p.y + size);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 所有机器人的坐标(多机器人一览)
|
||
drawBotMarkers(ctx, (bot) => chunkToScreen(bot.chunk.x, bot.chunk.z));
|
||
|
||
$('mapInfo').textContent = `领土地图 · 缩放 ${scale.toFixed(2)} px/区块 · `
|
||
+ `中心 ${Math.round(mapView.centerX)}, ${Math.round(mapView.centerZ)}`;
|
||
}
|
||
|
||
// ==================== 地图数据加载 ====================
|
||
|
||
let mapLoading = false;
|
||
let lastFullFetchAt = 0;
|
||
const FULL_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||
|
||
/**
|
||
* 拉取领土地图数据。
|
||
*
|
||
* `full=1` 会带上全部邦国的区块(实测 11.5 万区块约 1.5MB JSON),
|
||
* 因此只在「用户勾选全部邦国」「手动刷新」「超过 5 分钟」时才拉全量。
|
||
*/
|
||
async function loadMap(forceFull) {
|
||
if (mapLoading) return;
|
||
mapLoading = true;
|
||
try {
|
||
const cached = mapView.data;
|
||
const needFull = $('chkFull').checked
|
||
&& (forceFull === true || !cached || !cached.otherChunks
|
||
|| Date.now() - lastFullFetchAt > FULL_REFRESH_INTERVAL_MS);
|
||
const path = '/api/map?full=' + (needFull ? '1' : '0')
|
||
+ (selectedBotId ? '&bot=' + encodeURIComponent(selectedBotId) : '');
|
||
const res = await api(path);
|
||
const next = res.data;
|
||
if (needFull) lastFullFetchAt = Date.now();
|
||
else if (cached && cached.otherChunks) next.otherChunks = cached.otherChunks;
|
||
mapView.data = next;
|
||
if (!mapView.centered) focusTerritoryOnBotCountry(next);
|
||
drawMap();
|
||
} catch (err) {
|
||
toast('读取地图失败:' + err.message, true);
|
||
} finally {
|
||
mapLoading = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把领土地图对准「机器人所在的邦国」。
|
||
*
|
||
* 之前这里用的是控制器的 playerChunk,而控制器在任务启动前是 (0,0),
|
||
* 结果地图永远停在世界的原点附近 —— 而用户关心的是自己机器人所在的那个邦国。
|
||
* 现在按「机器人所在区块 → 包含它的邦国 → 该邦国的王城/外接矩形」来定位。
|
||
*
|
||
* @param {object} data `/api/map` 返回的数据
|
||
*/
|
||
function focusTerritoryOnBotCountry(data) {
|
||
if (!data) return;
|
||
const focus = botFocusPosition();
|
||
const botChunk = focus ? { x: Math.floor(focus.x / 16), z: Math.floor(focus.z / 16) } : null;
|
||
|
||
// 1) 优先用机器人所在区块反查邦国(最准确)
|
||
let state = null;
|
||
if (botChunk) {
|
||
state = (data.states || []).find((s) => s.bounds
|
||
&& botChunk.x >= s.bounds.minX && botChunk.x <= s.bounds.maxX
|
||
&& botChunk.z >= s.bounds.minZ && botChunk.z <= s.bounds.maxZ);
|
||
}
|
||
// 2) 退而求其次:用服务端标记的己方邦国
|
||
if (!state && data.selfState) state = (data.states || []).find((s) => s.name === data.selfState);
|
||
// 3) 再退:直接用机器人所在区块
|
||
if (!state && botChunk) {
|
||
mapView.centerX = botChunk.x;
|
||
mapView.centerZ = botChunk.z;
|
||
mapView.scale = 2;
|
||
mapView.centered = true;
|
||
return;
|
||
}
|
||
if (!state) return;
|
||
|
||
if (state.capital) {
|
||
mapView.centerX = state.capital.x;
|
||
mapView.centerZ = state.capital.z;
|
||
} else if (state.bounds) {
|
||
mapView.centerX = (state.bounds.minX + state.bounds.maxX) / 2;
|
||
mapView.centerZ = (state.bounds.minZ + state.bounds.maxZ) / 2;
|
||
}
|
||
mapView.scale = 2;
|
||
mapView.centered = true;
|
||
mapView.countryName = state.name;
|
||
}
|
||
|
||
let bigMapLoading = false;
|
||
let bigMapMarkersAt = 0;
|
||
|
||
/** 拉取大地图元信息与领土标记 */
|
||
async function loadBigMap(force) {
|
||
if (bigMapLoading) return;
|
||
bigMapLoading = true;
|
||
try {
|
||
// 带上图幅与关注的机器人:服务端据此算出要爬/要用的瓦片范围
|
||
const query = '?rangeBlocks=' + selectedRangeBlocks()
|
||
+ (selectedBotId ? '&bot=' + encodeURIComponent(selectedBotId) : '');
|
||
const res = await api('/api/bigmap' + query);
|
||
const data = res.data;
|
||
bigMapView.data = data;
|
||
// 领土标记是 160KB 级别的数据,按需刷新(默认 5 分钟一次)
|
||
if (data.markers && data.markers.states && (force || Date.now() - bigMapMarkersAt > FULL_REFRESH_INTERVAL_MS)) {
|
||
setBigMapMarkers(data.markers);
|
||
bigMapMarkersAt = Date.now();
|
||
}
|
||
// 会话新占领的区块:复用领土地图的数据
|
||
if (mapView.data) bigMapView.sessionChunks = [mapView.data.session || []];
|
||
|
||
if (!bigMapView.fitted) fitBigMapToRegion();
|
||
drawMap();
|
||
} catch (err) {
|
||
toast('读取大地图失败:' + err.message, true);
|
||
} finally {
|
||
bigMapLoading = false;
|
||
}
|
||
}
|
||
|
||
/** 把领土多边形预编译为 Path2D(按方块坐标),避免每帧重复构建路径 */
|
||
function setBigMapMarkers(markers) {
|
||
const states = (markers && markers.states) || [];
|
||
for (const state of states) {
|
||
const path = new Path2D();
|
||
let has = false;
|
||
for (const flat of state.polygons || []) {
|
||
if (flat.length < 6) continue;
|
||
path.moveTo(flat[0], flat[1]);
|
||
for (let i = 2; i < flat.length; i += 2) path.lineTo(flat[i], flat[i + 1]);
|
||
path.closePath();
|
||
has = true;
|
||
}
|
||
state.path = has ? path : null;
|
||
}
|
||
bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states };
|
||
}
|
||
|
||
/**
|
||
* 让「选定图幅」刚好铺满画布,并以机器人所在位置为中心。
|
||
*
|
||
* 图幅由界面上的「图幅」下拉框决定(±512 ~ 全世界),
|
||
* 这样既能看到机器人周边细节,也能一键切到全局视角。
|
||
*/
|
||
function fitBigMapToRegion() {
|
||
const rect = $('mapCanvas').getBoundingClientRect();
|
||
const radius = selectedRangeBlocks();
|
||
const focus = botFocusPosition();
|
||
const data = bigMapView.data;
|
||
bigMapView.centerX = focus ? focus.x : (data && data.center ? data.center.x : 0);
|
||
bigMapView.centerZ = focus ? focus.z : (data && data.center ? data.center.z : 0);
|
||
bigMapView.pxPerBlock = Math.min(rect.width, rect.height) / (2 * radius + 1);
|
||
bigMapView.fitted = true;
|
||
}
|
||
|
||
/** 把视图中心移到所有机器人的几何中心(「回到机器人」按钮) */
|
||
function centerBigMapOnBots() {
|
||
const bots = ((lastStatus && lastStatus.bots) || []).filter((b) => b.position);
|
||
if (bots.length === 0) {
|
||
toast('机器人还没有坐标(未进入世界)', true);
|
||
return;
|
||
}
|
||
let x = 0;
|
||
let z = 0;
|
||
for (const bot of bots) {
|
||
x += bot.position.x;
|
||
z += bot.position.z;
|
||
}
|
||
bigMapView.centerX = x / bots.length;
|
||
bigMapView.centerZ = z / bots.length;
|
||
// 让所有机器人尽量都落在视野内
|
||
let maxSpan = 512;
|
||
for (const bot of bots) {
|
||
maxSpan = Math.max(maxSpan, Math.abs(bot.position.x - bigMapView.centerX) * 2,
|
||
Math.abs(bot.position.z - bigMapView.centerZ) * 2);
|
||
}
|
||
const rect = $('mapCanvas').getBoundingClientRect();
|
||
bigMapView.pxPerBlock = Math.min(2, Math.max(0.002, Math.min(rect.width, rect.height) / (maxSpan * 1.2)));
|
||
drawMap();
|
||
}
|
||
|
||
// ==================== WebSocket ====================
|
||
|
||
let ws = null;
|
||
let wsRetry = 0;
|
||
/** 退出登录后停用实时推送,避免无凭证的握手被反复拒绝 */
|
||
let wsEnabled = true;
|
||
|
||
function connectWs() {
|
||
if (!wsEnabled) return;
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const query = TOKEN ? '?token=' + encodeURIComponent(TOKEN) : '';
|
||
ws = new WebSocket(`${proto}://${location.host}/ws${query}`);
|
||
ws.onopen = () => { wsRetry = 0; };
|
||
ws.onmessage = (event) => {
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(event.data);
|
||
} catch {
|
||
return;
|
||
}
|
||
if (payload.type === 'status') renderStatus(payload.data);
|
||
else if (payload.type === 'log') appendLog(payload.data);
|
||
else if (payload.type === 'logs') renderLogs(payload.data);
|
||
else if (payload.type === 'chat') {
|
||
chatEntries.push(payload.data);
|
||
if (chatEntries.length > MAX_CHAT_LINES) chatEntries.shift();
|
||
if (chatMatchesFilter(payload.data)) appendChat(payload.data);
|
||
} else if (payload.type === 'expansion') {
|
||
expEntries.push(payload.data);
|
||
if (expEntries.length > MAX_EXP_ROWS) expEntries.shift();
|
||
if (expMatchesFilter(payload.data)) appendExp(payload.data);
|
||
}
|
||
};
|
||
ws.onclose = () => {
|
||
if (!wsEnabled) return;
|
||
wsRetry++;
|
||
const delay = Math.min(15000, 1000 * wsRetry);
|
||
setTimeout(connectWs, delay);
|
||
};
|
||
ws.onerror = () => { try { ws.close(); } catch { /* 忽略 */ } };
|
||
}
|
||
|
||
// ==================== 事件绑定 ====================
|
||
|
||
/**
|
||
* 绑定登录相关的按钮。
|
||
*
|
||
* 必须在 `main()` 里无条件调用:这些按钮要在「尚未登录」时就能用,
|
||
* 而 `bindEvents()` 只在 `startApp()` 里执行,`startApp()` 又只在登录成功后才跑 ——
|
||
* 把登录按钮放进去会形成死锁,表现就是「输入密码点登录没反应」。
|
||
*/
|
||
function bindAuthEvents() {
|
||
$('btnLogin').addEventListener('click', doLogin);
|
||
$('loginPassword').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') doLogin();
|
||
});
|
||
$('btnLogout').addEventListener('click', doLogout);
|
||
}
|
||
|
||
function bindEvents() {
|
||
$('botSelect').addEventListener('change', () => selectBot($('botSelect').value));
|
||
|
||
$('btnAddBot').addEventListener('click', addBot);
|
||
for (const id of ['newBotName', 'newBotPass', 'newBotLabel']) {
|
||
$(id).addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') addBot();
|
||
});
|
||
}
|
||
|
||
$('btnStart').addEventListener('click', () => startTask(selectedBotId));
|
||
$('btnStop').addEventListener('click', () => stopTask(selectedBotId));
|
||
$('btnReconnect').addEventListener('click', () => reconnectBot(selectedBotId));
|
||
|
||
$('btnStartAll').addEventListener('click', async () => {
|
||
const bots = (lastStatus && lastStatus.bots) || [];
|
||
for (const bot of bots) {
|
||
// 串行启动:避免所有机器人同时挤进服务器
|
||
await startTask(bot.id, true);
|
||
}
|
||
toast('已依次下发开始指令');
|
||
});
|
||
$('btnStopAll').addEventListener('click', async () => {
|
||
const bots = (lastStatus && lastStatus.bots) || [];
|
||
for (const bot of bots) {
|
||
try {
|
||
await api('/api/bots/' + encodeURIComponent(bot.id) + '/task/stop', { method: 'POST' });
|
||
} catch {
|
||
// 单个失败不影响其它
|
||
}
|
||
}
|
||
toast('已停止全部机器人');
|
||
});
|
||
|
||
$('btnSaveBotSetting').addEventListener('click', async () => {
|
||
if (!selectedBotId) return;
|
||
try {
|
||
await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ enabled: $('chkEnabled').checked, autoStart: $('chkAutoStart').checked })
|
||
});
|
||
toast('机器人设置已保存');
|
||
} catch (err) {
|
||
toast('保存失败:' + err.message, true);
|
||
}
|
||
});
|
||
|
||
// ===== 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 = '';
|
||
$('schedInterval').value = '300';
|
||
$('schedLabel').value = '';
|
||
$('scheduleEditor').classList.remove('hidden');
|
||
$('schedContent').focus();
|
||
});
|
||
$('btnCancelSchedule').addEventListener('click', () => {
|
||
$('scheduleEditor').classList.add('hidden');
|
||
});
|
||
$('btnSaveSchedule').addEventListener('click', async () => {
|
||
const content = $('schedContent').value.trim();
|
||
if (!content) {
|
||
toast('请填写要发送的内容', true);
|
||
return;
|
||
}
|
||
const next = currentSchedules();
|
||
next.push({
|
||
id: 'task-' + Date.now().toString(36),
|
||
content,
|
||
intervalSeconds: Number($('schedInterval').value) || 300,
|
||
label: $('schedLabel').value.trim(),
|
||
enabled: true
|
||
});
|
||
$('scheduleEditor').classList.add('hidden');
|
||
await saveSchedules(next);
|
||
});
|
||
|
||
$('btnRefreshMap').addEventListener('click', async () => {
|
||
try {
|
||
await api('/api/map/refresh', { method: 'POST' });
|
||
toast('已触发地图刷新');
|
||
loadMap(true);
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
});
|
||
|
||
$('btnFetchAll').addEventListener('click', async () => {
|
||
toast('正在拉取全服领土(可能较慢)…');
|
||
try {
|
||
const res = await api('/api/api/fetch-all', { method: 'POST' });
|
||
toast(res.message);
|
||
loadMap(true);
|
||
} catch (err) {
|
||
toast('拉取失败:' + err.message, true);
|
||
}
|
||
});
|
||
|
||
$('btnSaveConfig').addEventListener('click', saveConfig);
|
||
$('btnReloadConfig').addEventListener('click', loadConfig);
|
||
$('chkBotOverride').addEventListener('change', loadConfig);
|
||
|
||
$('configTabs').addEventListener('click', (e) => {
|
||
const tab = e.target.closest('.tab');
|
||
if (!tab) return;
|
||
activeTab = tab.dataset.tab;
|
||
for (const el of $('configTabs').querySelectorAll('.tab')) {
|
||
el.classList.toggle('active', el === tab);
|
||
}
|
||
renderConfig();
|
||
});
|
||
|
||
// ===== 地图视图切换 =====
|
||
$('mapTabs').addEventListener('click', (e) => {
|
||
const tab = e.target.closest('.tab');
|
||
if (!tab) return;
|
||
mapMode = tab.dataset.view;
|
||
for (const el of $('mapTabs').querySelectorAll('.tab')) {
|
||
el.classList.toggle('active', el === tab);
|
||
}
|
||
$('bigMapControls').classList.toggle('hidden', mapMode !== 'big');
|
||
$('territoryMapControls').classList.toggle('hidden', mapMode !== 'territory');
|
||
$('legendBig').classList.toggle('hidden', mapMode !== 'big');
|
||
$('legendTerritory').classList.toggle('hidden', mapMode !== 'territory');
|
||
if (mapMode === 'big') loadBigMap();
|
||
drawMap();
|
||
});
|
||
|
||
// ===== 日志面板切换 =====
|
||
$('logTabs').addEventListener('click', (e) => {
|
||
const tab = e.target.closest('.tab');
|
||
if (!tab) return;
|
||
const pane = tab.dataset.pane;
|
||
for (const el of $('logTabs').querySelectorAll('.tab')) {
|
||
el.classList.toggle('active', el === tab);
|
||
}
|
||
$('logView').classList.toggle('hidden', pane !== 'log');
|
||
$('chatView').classList.toggle('hidden', pane !== 'chat');
|
||
$('expView').classList.toggle('hidden', pane !== 'exp');
|
||
$('logControls').classList.toggle('hidden', pane !== 'log');
|
||
$('chatControls').classList.toggle('hidden', pane !== 'chat');
|
||
$('expControls').classList.toggle('hidden', pane !== 'exp');
|
||
if (pane === 'chat') loadChat();
|
||
if (pane === 'exp') loadExpansionLog();
|
||
});
|
||
|
||
$('chkFull').addEventListener('change', () => loadMap(true));
|
||
$('chkFlying').addEventListener('change', drawMap);
|
||
$('chkGrid').addEventListener('change', drawMap);
|
||
$('chkTerritory').addEventListener('change', drawMap);
|
||
$('chkBotLabels').addEventListener('change', drawMap);
|
||
|
||
$('btnMapCenter').addEventListener('click', () => {
|
||
const focus = botFocusPosition();
|
||
if (focus) {
|
||
mapView.centerX = Math.floor(focus.x / 16);
|
||
mapView.centerZ = Math.floor(focus.z / 16);
|
||
} else if (mapView.data && mapView.data.playerChunk) {
|
||
mapView.centerX = mapView.data.playerChunk.x;
|
||
mapView.centerZ = mapView.data.playerChunk.z;
|
||
} else {
|
||
toast('机器人还没有坐标(未进入世界)', true);
|
||
return;
|
||
}
|
||
drawMap();
|
||
});
|
||
|
||
$('btnMapFit').addEventListener('click', () => {
|
||
const data = mapView.data;
|
||
if (!data) return;
|
||
let minX = Infinity; let maxX = -Infinity; let minZ = Infinity; let maxZ = -Infinity;
|
||
const absorb = (flat) => {
|
||
for (let i = 0; i < flat.length; i += 2) {
|
||
if (flat[i] < minX) minX = flat[i];
|
||
if (flat[i] > maxX) maxX = flat[i];
|
||
if (flat[i + 1] < minZ) minZ = flat[i + 1];
|
||
if (flat[i + 1] > maxZ) maxZ = flat[i + 1];
|
||
}
|
||
};
|
||
absorb(data.self || []);
|
||
absorb(data.session || []);
|
||
if (!Number.isFinite(minX)) {
|
||
for (const state of data.states || []) {
|
||
if (!state.bounds) continue;
|
||
minX = Math.min(minX, state.bounds.minX);
|
||
maxX = Math.max(maxX, state.bounds.maxX);
|
||
minZ = Math.min(minZ, state.bounds.minZ);
|
||
maxZ = Math.max(maxZ, state.bounds.maxZ);
|
||
}
|
||
}
|
||
if (!Number.isFinite(minX)) return;
|
||
mapView.centerX = (minX + maxX) / 2;
|
||
mapView.centerZ = (minZ + maxZ) / 2;
|
||
const rect = $('mapCanvas').getBoundingClientRect();
|
||
const scaleX = rect.width / Math.max(1, maxX - minX + 2);
|
||
const scaleZ = rect.height / Math.max(1, maxZ - minZ + 2);
|
||
mapView.scale = Math.min(24, Math.max(0.15, Math.min(scaleX, scaleZ)));
|
||
drawMap();
|
||
});
|
||
|
||
$('btnMapCenterBots').addEventListener('click', centerBigMapOnBots);
|
||
|
||
// 图幅切换:重新对焦并拉取对应范围的瓦片信息
|
||
$('bigMapRange').addEventListener('change', () => {
|
||
bigMapView.fitted = false;
|
||
fitBigMapToRegion();
|
||
drawMap();
|
||
loadBigMap(true);
|
||
});
|
||
|
||
// 爬取机器人周边:只爬当前图幅内的瓦片,而不是把整个世界拉下来
|
||
$('btnCrawlAround').addEventListener('click', async () => {
|
||
const focus = botFocusPosition();
|
||
const radius = selectedRangeBlocks();
|
||
if (!focus) {
|
||
toast('机器人还没有坐标,无法按周边爬取;请在配置里指定中心或先让机器人进入世界', true);
|
||
return;
|
||
}
|
||
try {
|
||
const res = await api('/api/bigmap/crawl', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
centerX: focus.x,
|
||
centerZ: focus.z,
|
||
rangeBlocks: radius,
|
||
bot: selectedBotId
|
||
})
|
||
});
|
||
toast(`正在爬取 ${Math.round(focus.x)},${Math.round(focus.z)} 周边 ±${radius} 格的瓦片`);
|
||
loadBigMap(true);
|
||
void res;
|
||
} catch (err) {
|
||
toast('爬取失败:' + err.message, true);
|
||
}
|
||
});
|
||
|
||
$('btnClearLog').addEventListener('click', () => { $('logView').innerHTML = ''; });
|
||
$('btnExportLog').addEventListener('click', async () => {
|
||
try {
|
||
const res = await api('/api/logs/export', { method: 'POST' });
|
||
toast('已导出到 ' + res.data.file);
|
||
} catch (err) {
|
||
toast('导出失败:' + err.message, true);
|
||
}
|
||
});
|
||
|
||
$('btnClearChat').addEventListener('click', () => {
|
||
chatEntries = [];
|
||
$('chatView').innerHTML = '';
|
||
});
|
||
$('chkChatAllBots').addEventListener('change', renderChat);
|
||
$('btnExportChat').addEventListener('click', async () => {
|
||
if (!selectedBotId) return;
|
||
try {
|
||
const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/chat/export', { method: 'POST' });
|
||
toast('已导出到 ' + res.data.file);
|
||
} catch (err) {
|
||
toast('导出失败:' + err.message, true);
|
||
}
|
||
});
|
||
|
||
$('btnExportExp').addEventListener('click', async () => {
|
||
try {
|
||
const res = await api('/api/expansion-log/export', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ botId: $('expFilterBot').value || undefined })
|
||
});
|
||
toast('已导出到 ' + res.data.file);
|
||
} catch (err) {
|
||
toast('导出失败:' + err.message, true);
|
||
}
|
||
});
|
||
$('btnClearExp').addEventListener('click', () => {
|
||
expEntries = [];
|
||
renderExp();
|
||
});
|
||
$('expFilterBot').addEventListener('change', renderExp);
|
||
|
||
$('btnSendCmd').addEventListener('click', sendConsole);
|
||
$('cmdInput').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') sendConsole();
|
||
});
|
||
// 快捷发送:把预设内容填进唯一的输入框并立即发送
|
||
for (const btn of document.querySelectorAll('[data-cmd]')) {
|
||
btn.addEventListener('click', () => {
|
||
$('cmdInput').value = btn.dataset.cmd;
|
||
sendConsole();
|
||
});
|
||
}
|
||
|
||
// 移动测试:绕开寻路器直接按方向键,用于区分「算不出路径」与「根本走不动」
|
||
for (const btn of document.querySelectorAll('[data-move]')) {
|
||
btn.addEventListener('click', async () => {
|
||
if (!selectedBotId) return;
|
||
btn.disabled = true;
|
||
toast('移动测试中…');
|
||
try {
|
||
const res = await api(`/api/bots/${encodeURIComponent(selectedBotId)}/move`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ direction: btn.dataset.move, seconds: 2 })
|
||
});
|
||
toast('移动测试:' + res.message);
|
||
} catch (err) {
|
||
toast('移动测试:' + err.message, true);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
$('btnLogin').addEventListener('click', doLogin);
|
||
$('loginPassword').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') doLogin();
|
||
});
|
||
$('btnLogout').addEventListener('click', doLogout);
|
||
|
||
/**
|
||
* 发送控制台内容。
|
||
*
|
||
* 聊天和指令不区分模式:**以 `/` 开头按游戏指令发送,否则按公屏消息发送** ——
|
||
* 服务端 `/api/bots/:id/chat` 就是这么分流的,前端不需要多一个下拉框。
|
||
*/
|
||
async function sendConsole() {
|
||
const text = $('cmdInput').value.trim();
|
||
if (!text || !selectedBotId) return;
|
||
try {
|
||
await api(`/api/bots/${encodeURIComponent(selectedBotId)}/chat`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ message: text })
|
||
});
|
||
$('cmdInput').value = '';
|
||
toast(text.startsWith('/') ? '指令已发送:' + text : '消息已发送');
|
||
} catch (err) {
|
||
toast('发送失败:' + err.message, true);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function startTask(botId, silent) {
|
||
if (!botId) {
|
||
toast('请先选择机器人', true);
|
||
return;
|
||
}
|
||
try {
|
||
// 勾选「仅对当前机器人生效」时把表单配置作为该机器人的专属配置下发;
|
||
// 否则不带 body,直接用它当前生效的配置(全局默认 + 已有覆盖)启动
|
||
const body = $('chkBotOverride').checked ? collectConfig() : null;
|
||
const res = await api('/api/bots/' + encodeURIComponent(botId) + '/task/start', {
|
||
method: 'POST',
|
||
body: JSON.stringify(body || {})
|
||
});
|
||
if (!silent) toast(res.message || '任务已启动');
|
||
if (res.data) renderStatus(res.data);
|
||
loadMap();
|
||
} catch (err) {
|
||
toast('启动失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
async function stopTask(botId) {
|
||
if (!botId) return;
|
||
try {
|
||
await api('/api/bots/' + encodeURIComponent(botId) + '/task/stop', { method: 'POST' });
|
||
toast('已请求停止');
|
||
} catch (err) {
|
||
toast('停止失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
async function reconnectBot(botId) {
|
||
if (!botId) return;
|
||
try {
|
||
await api('/api/bots/' + encodeURIComponent(botId) + '/reconnect', { method: 'POST' });
|
||
toast('正在重连');
|
||
} catch (err) {
|
||
toast('重连失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
/** 控制台新增一个玩家(机器人) */
|
||
async function addBot() {
|
||
const username = $('newBotName').value.trim();
|
||
if (!username) {
|
||
toast('请填写玩家名', true);
|
||
return;
|
||
}
|
||
try {
|
||
const res = await api('/api/bots', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
username,
|
||
password: $('newBotPass').value,
|
||
label: $('newBotLabel').value.trim()
|
||
})
|
||
});
|
||
$('newBotName').value = '';
|
||
$('newBotPass').value = '';
|
||
$('newBotLabel').value = '';
|
||
toast(res.message || '已添加');
|
||
if (res.data && res.data.bots) {
|
||
botListSignature = '';
|
||
renderStatus(Object.assign({}, lastStatus, { bots: res.data.bots }));
|
||
}
|
||
if (res.data && res.data.id) selectBot(res.data.id);
|
||
} catch (err) {
|
||
toast('添加失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
/** 移除控制台新增的玩家 */
|
||
async function removeBot(botId, username) {
|
||
if (!botId) return;
|
||
if (!window.confirm(`确定移除玩家「${username}」吗?该操作会断开它的连接并删除本地保存的凭据。`)) return;
|
||
try {
|
||
const res = await api('/api/bots/' + encodeURIComponent(botId), { method: 'DELETE' });
|
||
toast(res.message || '已移除');
|
||
if (selectedBotId === botId) selectedBotId = '';
|
||
if (res.data && res.data.bots) {
|
||
botListSignature = '';
|
||
renderStatus(Object.assign({}, lastStatus, { bots: res.data.bots }));
|
||
}
|
||
} catch (err) {
|
||
toast('移除失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
// ==================== 启动 ====================
|
||
|
||
async function startApp() {
|
||
if (appStarted) return;
|
||
appStarted = true;
|
||
wsEnabled = true;
|
||
initMap();
|
||
bindEvents();
|
||
bindMobileNav();
|
||
await loadConfig();
|
||
await loadMap();
|
||
await loadBigMap(true);
|
||
await loadExpansionLog();
|
||
connectWs();
|
||
// 领土地图数据每 20 秒刷新一次(服务端 markers 刷新间隔通常为 30 秒)
|
||
setInterval(() => { if (wsEnabled) loadMap(); }, 20000);
|
||
// 大地图元信息(含机器人坐标与爬取进度)每 10 秒刷新一次
|
||
setInterval(() => { if (wsEnabled) loadBigMap(); }, 10000);
|
||
// 相对时间("x 秒前")需要定期重绘
|
||
setInterval(() => { if (wsEnabled && lastStatus) renderStatus(lastStatus); }, 5000);
|
||
}
|
||
|
||
async function main() {
|
||
// 登录按钮必须最先绑定:未登录时 startApp() 不会执行,绑在里面就点不动
|
||
bindAuthEvents();
|
||
const ok = await checkSession();
|
||
if (ok) await startApp();
|
||
else $('loginPassword').focus();
|
||
}
|
||
|
||
main();
|