'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 } ] } ], 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} 条`; } // ===== 大地图爬取进度 ===== 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' }); }); } // ==================== 运行日志 ==================== 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); } }); // ===== 定时消息编辑器 ===== $('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(); }); $('btnQuickChat').addEventListener('click', sendQuickChat); $('quickChatInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendQuickChat(); }); for (const btn of document.querySelectorAll('[data-cmd]')) { btn.addEventListener('click', () => { $('sendMode').value = 'command'; $('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); async function sendConsole() { const text = $('cmdInput').value.trim(); if (!text || !selectedBotId) return; const mode = $('sendMode').value; const path = mode === 'command' ? `/api/bots/${encodeURIComponent(selectedBotId)}/command` : `/api/bots/${encodeURIComponent(selectedBotId)}/chat`; const body = mode === 'command' ? { command: text } : { message: text }; try { await api(path, { method: 'POST', body: JSON.stringify(body) }); $('cmdInput').value = ''; toast('已发送'); } catch (err) { toast('发送失败:' + err.message, true); } } async function sendQuickChat() { const text = $('quickChatInput').value.trim(); if (!text || !selectedBotId) return; try { await api(`/api/bots/${encodeURIComponent(selectedBotId)}/chat`, { method: 'POST', body: JSON.stringify({ message: text }) }); $('quickChatInput').value = ''; toast('公屏消息已发送'); } 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();