'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 = ''; 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} 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} `{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 = '
正在加载…
'; 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} 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 = '
正在加载…
'; 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 = '
正在加载…
'; 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 = '
正在查询…
'; 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 = '
正在查询…
'; 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();