'use strict'; /** * AutoStore Bot Web 控制台前端逻辑。 * * 三块职责: * 1. 通过 WebSocket 接收实时状态与日志,渲染左侧状态面板; * 2. 根据配置 schema 动态生成右侧配置表单,读写 `/api/config`; * 3. 用 Canvas 绘制领土地图(支持缩放 / 平移 / 图层开关)。 */ // ==================== 工具 ==================== const $ = (id) => document.getElementById(id); /** 取 URL 上的 token 参数,后续所有请求都带上 */ const TOKEN = new URLSearchParams(location.search).get('token') || ''; 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.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); } function fmtTime(ts) { if (!ts) return '-'; const d = new Date(ts); const pad = (n) => String(n).padStart(2, '0'); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(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); } // ==================== 配置 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' } ] } ], 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 } ] } ], 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' } ] } ], server: [ { title: '服务器交互', fields: [ { path: 'server.claimCommand', label: '开垦模式指令', type: 'text', hint: '不带斜杠;留空表示服务器自动开启' }, { path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_PASSWORD' }, { path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' }, { path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 }, { path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' }, { path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 } ] } ] }; // ==================== 配置读写 ==================== let currentConfig = null; 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; } let activeTab = 'strategy'; 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; 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 value = input.value; setByPath(result, path, value); } return result; } async function loadConfig() { try { const res = await api('/api/config'); currentConfig = res.data; renderConfig(); } catch (err) { toast('读取配置失败:' + err.message, true); } } async function saveConfig() { try { const config = collectConfig(); const res = await api('/api/config', { method: 'POST', body: JSON.stringify(config) }); currentConfig = res.data; renderConfig(); toast('配置已保存并应用'); } catch (err) { toast('保存配置失败:' + err.message, true); } } // ==================== 状态渲染 ==================== let lastStatus = null; function renderStatus(status) { lastStatus = status; const bot = status.bot || {}; const task = status.task || {}; const data = status.data || {}; // ===== 顶栏徽标 ===== const connBadge = $('connBadge'); const stateText = { online: '已连接', connecting: '连接中', offline: '已断开', idle: '未连接' }; connBadge.textContent = stateText[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.loggedIn ? '已登录 · ' : '') + (bot.riding ? '乘船中 · ' : '') + (bot.inWater ? '水中' : '陆地'); $('kReconnect').textContent = String(bot.reconnectAttempts || 0); // ===== 任务卡片 ===== $('kTaskState').textContent = task.state || 'IDLE'; $('kTaskStatus').textContent = task.status || '-'; $('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) + '%'; // ===== 数据源卡片 ===== $('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; // ===== Grim 卡片 ===== const grim = bot.grim; if (grim) { $('kGrimEnabled').textContent = grim.enabled ? '已启用' : '已关闭'; $('kGrimRot').textContent = `${grim.maxRotationPerTickDeg}°(实际 ${grim.effectiveStepDeg}°/tick)`; $('kGrimSettled').textContent = grim.rotationSettled ? '是' : '否'; $('kGrimReach').textContent = grim.attackReach + ' 格'; $('kGrimCooldown').textContent = grim.attackCooldownMs > 0 ? grim.attackCooldownMs + ' ms' : '就绪'; $('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); } } } // ==================== 日志 ==================== 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 mapView = { centerX: 0, centerZ: 0, scale: 2, dragging: false, lastX: 0, lastY: 0, data: null, /** 是否已经按玩家位置做过首次居中 */ centered: false }; function initMap() { const canvas = $('mapCanvas'); const ctx = canvas.getContext('2d'); 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)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); drawMap(); }; window.addEventListener('resize', resize); canvas.addEventListener('mousedown', (e) => { mapView.dragging = true; mapView.lastX = e.clientX; mapView.lastY = e.clientY; }); window.addEventListener('mouseup', () => { mapView.dragging = false; }); window.addEventListener('mousemove', (e) => { if (!mapView.dragging) return; 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(); }); canvas.addEventListener('wheel', (e) => { e.preventDefault(); 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); window.addEventListener('resize', resize); } function chunkToScreen(cx, cz, canvas) { const rect = canvas.getBoundingClientRect(); const w = rect.width; const h = rect.height; const scale = mapView.scale; return { x: w / 2 + (cx - mapView.centerX) * scale, y: h / 2 + (cz - mapView.centerZ) * scale, scale }; } function drawMap() { const canvas = $('mapCanvas'); const ctx = canvas.getContext('2d'); const rect = canvas.getBoundingClientRect(); 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], canvas); 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, canvas).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, canvas).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, canvas); 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, canvas); 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(); } // 机器人位置 if (data.playerChunk) { const p = chunkToScreen(data.playerChunk.x, data.playerChunk.z, canvas); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(p.x + scale / 2, p.y + scale / 2, Math.max(3, scale * 0.6), 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#0d1117'; ctx.lineWidth = 1.5; ctx.stroke(); } $('mapInfo').textContent = `缩放 ${scale.toFixed(2)} px/区块 · 中心 ${Math.round(mapView.centerX)}, ${Math.round(mapView.centerZ)}`; } 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})`; } let mapLoading = false; async function loadMap() { if (mapLoading) return; mapLoading = true; try { const full = $('chkFull').checked ? '1' : '0'; const res = await api('/api/map?full=' + full); mapView.data = res.data; if (!mapView.centered && res.data.playerChunk) { mapView.centerX = res.data.playerChunk.x; mapView.centerZ = res.data.playerChunk.z; mapView.centered = true; } drawMap(); } catch (err) { toast('读取地图失败:' + err.message, true); } finally { mapLoading = false; } } // ==================== WebSocket ==================== let ws = null; let wsRetry = 0; function connectWs() { 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); }; ws.onclose = () => { wsRetry++; const delay = Math.min(15000, 1000 * wsRetry); setTimeout(connectWs, delay); }; ws.onerror = () => { try { ws.close(); } catch { /* 忽略 */ } }; } // ==================== 事件绑定 ==================== function bindEvents() { $('btnStart').addEventListener('click', async () => { try { const res = await api('/api/task/start', { method: 'POST', body: JSON.stringify(collectConfig()) }); toast(res.message || '任务已启动'); renderStatus(res.data); loadMap(); } catch (err) { toast('启动失败:' + err.message, true); } }); $('btnStop').addEventListener('click', async () => { try { await api('/api/task/stop', { method: 'POST' }); toast('已请求停止'); } catch (err) { toast('停止失败:' + err.message, true); } }); $('btnReconnect').addEventListener('click', async () => { try { await api('/api/bot/reconnect', { method: 'POST' }); toast('正在重连'); } catch (err) { toast('重连失败:' + err.message, true); } }); $('btnRefreshMap').addEventListener('click', async () => { try { await api('/api/map/refresh', { method: 'POST' }); toast('已触发地图刷新'); } 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(); } catch (err) { toast('拉取失败:' + err.message, true); } }); $('btnSaveConfig').addEventListener('click', saveConfig); $('btnReloadConfig').addEventListener('click', 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(); }); $('chkFull').addEventListener('change', loadMap); $('chkFlying').addEventListener('change', drawMap); $('chkGrid').addEventListener('change', drawMap); $('btnMapCenter').addEventListener('click', () => { if (lastStatus && lastStatus.task && lastStatus.task.playerChunk) { mapView.centerX = lastStatus.task.playerChunk.x; mapView.centerZ = lastStatus.task.playerChunk.z; 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 canvas = $('mapCanvas'); const rect = canvas.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(); }); $('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); } }); $('btnSendCmd').addEventListener('click', sendCommand); $('cmdInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendCommand(); }); for (const btn of document.querySelectorAll('[data-cmd]')) { btn.addEventListener('click', () => { $('cmdInput').value = btn.dataset.cmd; sendCommand(); }); } async function sendCommand() { const command = $('cmdInput').value.trim(); if (!command) return; try { await api('/api/command', { method: 'POST', body: JSON.stringify({ command }) }); toast('已发送:' + command); } catch (err) { toast('发送失败:' + err.message, true); } } } // ==================== 启动 ==================== async function main() { initMap(); bindEvents(); await loadConfig(); await loadMap(); connectWs(); // 地图数据每 20 秒自动刷新一次(服务端 markers 刷新间隔通常为 30 秒) setInterval(loadMap, 20000); // 相对时间("x 秒前")需要定期重绘 setInterval(() => { if (lastStatus) renderStatus(lastStatus); }, 5000); } main();