feat: 大地图跟随机器人并支持可调图幅,新增定时消息,控制台界面重做并适配移动端
大地图跟随机器人 + 可调图幅 - 爬取与显示区域改为以机器人当前位置为中心(bigMap.followBot 默认开启), 不再傻傻地爬世界原点周边 - 界面新增「图幅」下拉(±512/1024/2048/4096/8192/全世界)与「爬取周边」按钮, 只爬当前图幅内的瓦片:层级 0 每张覆盖 4096 格,±4096 只需 9 张,全世界要 100 张 - POST /api/bigmap/crawl 支持 centerX/centerZ/rangeBlocks;GET /api/bigmap 支持 rangeBlocks/bot 查询参数;启动预爬取会先等机器人进入世界再以其位置为中心 领土地图以机器人所在邦国为中心 - 之前初始视图用的是控制器的 playerChunk,而任务启动前它是 (0,0), 导致地图永远停在世界原点附近。现在按「机器人所在区块 → 包含它的邦国 → 该邦国王城/外接矩形」定位 定时重复发送消息(src/bot/scheduledMessenger.js) - 每个机器人可配多条定时任务,按固定间隔重复发送同一内容,如每 300 秒 /u restore confirm - 以 / 开头按游戏指令发送,否则按公屏消息发送 - 只在已连接且通过登录验证时发送;未就绪的周期顺延且不补发,避免重连后一次性连发 - 与指令节流队列共用,间隔下限 5 秒、单机器人最多 20 条 - 控制台新增「定时消息」卡片(新增/删除/启停 + 剩余秒数倒计时) 控制台界面重做 + 移动端适配 - 重新设计的深色主题:统一圆角、阴影、焦点态与悬停态,卡片式分层 - 响应式三档:≥1200px 三栏;≤1200px 两栏;≤820px 单栏 + 底部导航 - 移动端:触控目标 ≥36px、地图/日志高度按视口自适应、避让底部安全区、 横屏压矮地图、尊重「减少动效」偏好、Toast 全宽 - 「添加玩家」折叠进 details;顶栏窄屏自动换行且按钮等分 修复 - POST /api/bigmap/crawl 响应把中心与图幅字段传错(实际爬取正确,返回值显示成原点) - 定时任务保存后接口返回原始列表而非归一化结果,前端拿不到剩余秒数 测试 308 -> 325,全部通过。
这个提交包含在:
+35
-4
@@ -3,6 +3,7 @@
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const { BotManager } = require('./botManager');
|
||||
const { ScheduledMessenger } = require('./scheduledMessenger');
|
||||
const { ExpansionController } = require('../controller/expansionController');
|
||||
const { normalizeConfig } = require('../config/expansionConfig');
|
||||
const { sanitizeId } = require('../config/botsConfig');
|
||||
@@ -130,11 +131,16 @@ class BotPool extends EventEmitter {
|
||||
manager.on('spawn', () => this.emit('botSpawn', id));
|
||||
manager.on('end', () => this.emit('botEnd', id));
|
||||
|
||||
// 定时消息器:按固定间隔重复发送同一条内容(如每 300 秒 /u restore confirm)
|
||||
const messenger = new ScheduledMessenger(manager, this.logger);
|
||||
messenger.setTasks(setting.schedules || []);
|
||||
|
||||
this.entries.set(id, {
|
||||
id,
|
||||
connection,
|
||||
manager,
|
||||
controller,
|
||||
messenger,
|
||||
/** 账号来源:env(.env 配置)/ store(控制台新增) */
|
||||
source: connection.source || 'env',
|
||||
/** 该机器人实际生效的配置(全局 + 覆盖) */
|
||||
@@ -190,7 +196,10 @@ class BotPool extends EventEmitter {
|
||||
const entry = this._addBot(connection);
|
||||
if (!this.defaultId) this.defaultId = id;
|
||||
this._log(`已添加玩家 ${username}(密码保存在服务器本地凭据文件,权限 0600)`);
|
||||
if (opts.connect !== false && this._settingOf(id).enabled) entry.manager.connect();
|
||||
if (opts.connect !== false && this._settingOf(id).enabled) {
|
||||
entry.messenger.start();
|
||||
entry.manager.connect();
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -209,6 +218,7 @@ class BotPool extends EventEmitter {
|
||||
throw badRequest(`${entry.connection.username} 是在 .env 的 MC_BOTS 里配置的,`
|
||||
+ '请修改 .env 后重启;控制台只能移除在这里添加的玩家');
|
||||
}
|
||||
entry.messenger.stop();
|
||||
entry.manager.disconnect();
|
||||
this.entries.delete(id);
|
||||
delete this.settings[id];
|
||||
@@ -234,6 +244,7 @@ class BotPool extends EventEmitter {
|
||||
this._log(`${entry.connection.username} 已在控制台禁用,跳过连接`);
|
||||
continue;
|
||||
}
|
||||
entry.messenger.start();
|
||||
entry.manager.connect();
|
||||
}
|
||||
}
|
||||
@@ -246,6 +257,7 @@ class BotPool extends EventEmitter {
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
entry.messenger.stop();
|
||||
entry.manager.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -301,13 +313,14 @@ class BotPool extends EventEmitter {
|
||||
|
||||
// ==================== 配置 ====================
|
||||
|
||||
/** @returns {object} 机器人的非敏感设置(启用 / 备注 / 自动开始 / 配置覆盖) */
|
||||
/** @returns {object} 机器人的非敏感设置(启用 / 备注 / 自动开始 / 定时任务 / 配置覆盖) */
|
||||
_settingOf(id) {
|
||||
const raw = this.settings[id] || {};
|
||||
return {
|
||||
enabled: raw.enabled !== false,
|
||||
autoStart: raw.autoStart === true,
|
||||
label: raw.label || '',
|
||||
schedules: Array.isArray(raw.schedules) ? raw.schedules : [],
|
||||
config: raw.config && typeof raw.config === 'object' ? raw.config : {}
|
||||
};
|
||||
}
|
||||
@@ -356,6 +369,13 @@ class BotPool extends EventEmitter {
|
||||
entry.config = this.getConfigFor(id);
|
||||
entry.manager.applyConfig(entry.config);
|
||||
entry.controller.config = entry.config;
|
||||
// 定时任务列表整体替换:把归一化结果写回设置,
|
||||
// 保证「接口返回值 / 落盘内容 / 运行时状态」三者一致
|
||||
if (patch && patch.schedules !== undefined) {
|
||||
const normalized = entry.messenger.setTasks(this._settingOf(id).schedules);
|
||||
this.settings[id] = Object.assign({}, this.settings[id], { schedules: normalized });
|
||||
this._persistSettings();
|
||||
}
|
||||
return this._settingOf(id);
|
||||
}
|
||||
|
||||
@@ -368,10 +388,20 @@ class BotPool extends EventEmitter {
|
||||
enabled: setting.enabled,
|
||||
autoStart: setting.autoStart,
|
||||
hasOverrides: Object.keys(setting.config).length > 0,
|
||||
config: this.getConfigFor(id)
|
||||
config: this.getConfigFor(id),
|
||||
schedules: this.entry(id) ? this.entry(id).messenger.snapshot() : []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取运行时条目(不存在时返回 null,不抛异常)。
|
||||
* @param {string} id
|
||||
* @returns {object|null}
|
||||
*/
|
||||
entry(id) {
|
||||
return this.entries.get(id) || null;
|
||||
}
|
||||
|
||||
_persistSettings() {
|
||||
if (!this.botStore) return;
|
||||
this.botStore.save({ bots: this.settings });
|
||||
@@ -486,7 +516,8 @@ class BotPool extends EventEmitter {
|
||||
enabled: setting.enabled,
|
||||
autoStart: setting.autoStart,
|
||||
source: entry.source,
|
||||
task: entry.controller.snapshot()
|
||||
task: entry.controller.snapshot(),
|
||||
schedules: entry.messenger.snapshot()
|
||||
}));
|
||||
}
|
||||
return bots;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
'use strict';
|
||||
|
||||
/** 单个任务的间隔下限(秒):再短就近似刷屏,容易被服务器或反作弊盯上 */
|
||||
const MIN_INTERVAL_SECONDS = 5;
|
||||
/** 单个机器人最多允许的定时任务数 */
|
||||
const MAX_TASKS = 20;
|
||||
/** 调度器的心跳间隔(毫秒):只做「到点了吗」的判断,开销可忽略 */
|
||||
const TICK_INTERVAL_MS = 1000;
|
||||
|
||||
/**
|
||||
* 定时消息器:按固定间隔重复发送同一条内容(公屏消息或游戏指令)。
|
||||
*
|
||||
* 典型用法是服务器要求周期性执行的指令,例如每 300 秒发一次 `/u restore confirm`
|
||||
* 来维持疆土民心。这类「心跳式」指令是挂机机器人的刚需,
|
||||
* 但绝不能简单地 setInterval —— 断线、未登录、正在回城时都不该发,
|
||||
* 否则要么白发(服务端丢弃),要么在错误的时机打断状态机。
|
||||
*
|
||||
* 因此这里做成:
|
||||
* - 每个任务独立计时,按「上次成功发送时间 + 间隔」判断是否到点;
|
||||
* - 只在 `canOperate()`(已连接 + 已通过登录验证)时才发;
|
||||
* - 未就绪时跳过本次并顺延,不补发(避免重连后一次性连发多条);
|
||||
* - 与机器人共用指令节流队列,不会绕过 `server.commandCooldownMs` 刷屏。
|
||||
*/
|
||||
class ScheduledMessenger {
|
||||
/**
|
||||
* @param {import('./botManager').BotManager} manager
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
*/
|
||||
constructor(manager, logger) {
|
||||
this.manager = manager;
|
||||
this.logger = logger || null;
|
||||
/** @type {Array<object>} 定时任务列表 */
|
||||
this.tasks = [];
|
||||
/** @type {Map<string, number>} 任务 id → 下次应发送的时间戳 */
|
||||
this._nextDueAt = new Map();
|
||||
this._timer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务列表(整体替换),并重置各任务的计时。
|
||||
*
|
||||
* @param {Array<object>} tasks 原始任务定义
|
||||
* @param {number} [now] 当前时间戳(默认取系统时间;测试里注入固定值即可不必真等)
|
||||
* @returns {Array<object>} 归一化后的任务列表
|
||||
*/
|
||||
setTasks(tasks, now) {
|
||||
this.tasks = normalizeTasks(tasks);
|
||||
this._nextDueAt.clear();
|
||||
const current = Number.isFinite(now) ? now : Date.now();
|
||||
for (const task of this.tasks) {
|
||||
// 首次不立即发送:给机器人留出进入世界与登录的时间
|
||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||
}
|
||||
return this.tasks;
|
||||
}
|
||||
|
||||
/** 启动调度心跳 */
|
||||
start() {
|
||||
if (this._timer) return;
|
||||
this._timer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
|
||||
if (this._timer.unref) this._timer.unref();
|
||||
}
|
||||
|
||||
/** 停止调度心跳 */
|
||||
stop() {
|
||||
if (this._timer) clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳:把所有到点的任务发出去。
|
||||
*
|
||||
* 抽成公开方法是为了可测 —— 测试里直接调 `tick()` 并注入当前时间,
|
||||
* 不必真的等 300 秒。
|
||||
*
|
||||
* @param {number} [now] 当前时间戳(默认取系统时间)
|
||||
* @returns {number} 本次实际发出的条数
|
||||
*/
|
||||
tick(now) {
|
||||
const current = Number.isFinite(now) ? now : Date.now();
|
||||
let sent = 0;
|
||||
for (const task of this.tasks) {
|
||||
if (!task.enabled) continue;
|
||||
const due = this._nextDueAt.get(task.id);
|
||||
if (due === undefined) {
|
||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||
continue;
|
||||
}
|
||||
if (current < due) continue;
|
||||
|
||||
// 未就绪就顺延到下一个周期,不补发
|
||||
if (!this.manager.canSendChat()) {
|
||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||
continue;
|
||||
}
|
||||
const ok = this.manager.sendChat(task.content);
|
||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||
if (ok) {
|
||||
sent++;
|
||||
this._log(`定时任务「${task.label || task.id}」已发送:${task.content}`);
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** @returns {object} 供控制台展示的状态快照 */
|
||||
snapshot() {
|
||||
const now = Date.now();
|
||||
return this.tasks.map((task) => {
|
||||
const due = this._nextDueAt.get(task.id);
|
||||
return Object.assign({}, task, {
|
||||
nextDueAt: due || 0,
|
||||
nextInSeconds: due ? Math.max(0, Math.round((due - now) / 1000)) : null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_log(message) {
|
||||
if (this.logger) this.logger.log(`[${this.manager.connection.username}] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化任务列表:丢弃非法项、补齐默认值、限制数量。
|
||||
*
|
||||
* @param {Array<object>} tasks
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
function normalizeTasks(tasks) {
|
||||
if (!Array.isArray(tasks)) return [];
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < tasks.length && result.length < MAX_TASKS; i++) {
|
||||
const raw = tasks[i];
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const content = String(raw.content === undefined || raw.content === null ? '' : raw.content).trim();
|
||||
if (!content) continue;
|
||||
let id = String(raw.id || '').trim();
|
||||
if (!id || seen.has(id)) id = `task-${i + 1}`;
|
||||
while (seen.has(id)) id += '_';
|
||||
seen.add(id);
|
||||
result.push({
|
||||
id,
|
||||
label: String(raw.label || '').trim(),
|
||||
content,
|
||||
enabled: raw.enabled !== false,
|
||||
intervalSeconds: clampInterval(raw.intervalSeconds),
|
||||
/** 以 `/` 开头的内容会被 sendChat 自动转成指令 */
|
||||
kind: content.startsWith('/') ? 'command' : 'chat'
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把间隔钳制到合理范围。
|
||||
* @param {*} value
|
||||
* @returns {number}
|
||||
*/
|
||||
function clampInterval(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return 300;
|
||||
return Math.min(86400, Math.max(MIN_INTERVAL_SECONDS, Math.trunc(n)));
|
||||
}
|
||||
|
||||
module.exports = { ScheduledMessenger, normalizeTasks, clampInterval, MIN_INTERVAL_SECONDS, MAX_TASKS };
|
||||
@@ -231,8 +231,20 @@ function defaultConfig() {
|
||||
zoom: 0,
|
||||
/** 允许浏览的最大层级(本站 squaremap 的 zoom.max 为 3) */
|
||||
maxZoom: 3,
|
||||
/** 以原点为中心的覆盖半径(方块),默认与服务器世界边界 ±20480 一致 */
|
||||
rangeBlocks: 20480,
|
||||
/**
|
||||
* 爬取半径(方块),**以机器人为中心**(见 followBot)。
|
||||
*
|
||||
* 层级 0 每张瓦片覆盖 4096 格,所以:
|
||||
* 1024 格半径 = 1 张瓦片,4096 格 = 4 张,8192 格 = 9 张,20480 格 = 100 张。
|
||||
* 只关心机器人周边时把半径调小,爬取和传输开销会差一个数量级。
|
||||
*/
|
||||
rangeBlocks: 4096,
|
||||
/**
|
||||
* 是否以机器人当前位置为中心爬取与显示大地图(默认开启)。
|
||||
*
|
||||
* 关闭时以世界原点 (0,0) 为中心 —— 只有在想看整张世界地图时才需要。
|
||||
*/
|
||||
followBot: true,
|
||||
/** 启动时是否自动预爬取 */
|
||||
crawlOnStart: true,
|
||||
/** 瓦片下载并发数(对源站友好,默认 4) */
|
||||
@@ -390,9 +402,10 @@ function validateConfig(config) {
|
||||
config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3);
|
||||
// 预爬取层级不可能超过可浏览的最大层级,否则会爬到源站不存在的瓦片
|
||||
if (config.bigMap.zoom > config.bigMap.maxZoom) config.bigMap.zoom = config.bigMap.maxZoom;
|
||||
config.bigMap.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 20480);
|
||||
config.bigMap.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 4096);
|
||||
config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4);
|
||||
if (typeof config.bigMap.world !== 'string') config.bigMap.world = '';
|
||||
if (typeof config.bigMap.followBot !== 'boolean') config.bigMap.followBot = true;
|
||||
|
||||
config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
|
||||
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000);
|
||||
|
||||
+47
-17
@@ -86,22 +86,40 @@ class BigMapService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算覆盖 `[-rangeBlocks, rangeBlocks)` 区间所需的瓦片下标范围(闭区间)。
|
||||
* 计算覆盖以 `center` 为中心、半径 `rangeBlocks` 的方形区域所需的瓦片下标范围(闭区间)。
|
||||
*
|
||||
* 默认中心是原点(全世界),但实际使用时通常只关心**机器人所在位置周边**的图幅 ——
|
||||
* 全世界层级 0 是 100 张瓦片,而 1024 格半径只需 1 张、4096 格半径只需 4 张,
|
||||
* 爬取和传输的开销差一个数量级。
|
||||
*
|
||||
* @param {number} zoom 层级
|
||||
* @param {number} [rangeBlocks] 覆盖半径(方块),缺省取配置
|
||||
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number, count: number}}
|
||||
* @param {number} [centerBlockX] 中心方块 X,缺省 0
|
||||
* @param {number} [centerBlockZ] 中心方块 Z,缺省 0
|
||||
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number, count: number,
|
||||
* centerBlockX: number, centerBlockZ: number, rangeBlocks: number}}
|
||||
*/
|
||||
tileRange(zoom, rangeBlocks) {
|
||||
tileRange(zoom, rangeBlocks, centerBlockX, centerBlockZ) {
|
||||
const radius = Number.isFinite(rangeBlocks)
|
||||
? Math.abs(rangeBlocks)
|
||||
: (this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS);
|
||||
const centerX = Number.isFinite(centerBlockX) ? centerBlockX : 0;
|
||||
const centerZ = Number.isFinite(centerBlockZ) ? centerBlockZ : 0;
|
||||
const perTile = this.blocksPerTile(zoom);
|
||||
const minX = Math.floor(-radius / perTile);
|
||||
const maxX = Math.ceil(radius / perTile) - 1;
|
||||
const minZ = minX;
|
||||
const maxZ = maxX;
|
||||
return { minX, maxX, minZ, maxZ, count: (maxX - minX + 1) * (maxZ - minZ + 1) };
|
||||
const minX = Math.floor((centerX - radius) / perTile);
|
||||
const maxX = Math.ceil((centerX + radius) / perTile) - 1;
|
||||
const minZ = Math.floor((centerZ - radius) / perTile);
|
||||
const maxZ = Math.ceil((centerZ + radius) / perTile) - 1;
|
||||
return {
|
||||
minX,
|
||||
maxX,
|
||||
minZ,
|
||||
maxZ,
|
||||
count: (maxX - minX + 1) * (maxZ - minZ + 1),
|
||||
centerBlockX: centerX,
|
||||
centerBlockZ: centerZ,
|
||||
rangeBlocks: radius
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,19 +228,22 @@ class BigMapService {
|
||||
// ==================== 批量爬取 ====================
|
||||
|
||||
/**
|
||||
* 批量爬取某一层级覆盖范围内的全部瓦片(已缓存则跳过)。
|
||||
* 批量爬取某一层级、某一区域内的全部瓦片(已缓存则跳过)。
|
||||
*
|
||||
* 区域默认以原点为中心;实际使用时通常传机器人所在坐标,只爬它周边那几平方公里,
|
||||
* 这样图幅可以自由调整而不用把整个世界都拉下来。
|
||||
*
|
||||
* 重复调用时若已有爬取在途则直接返回当前进度,不会并发起多轮。
|
||||
*
|
||||
* @param {number} [zoom] 层级,缺省取配置里的 bigMap.zoom
|
||||
* @param {{force?: boolean}} [options] force=true 时忽略磁盘缓存重新下载
|
||||
* @param {{force?: boolean, centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options]
|
||||
* @returns {Promise<object>} 爬取结果统计
|
||||
*/
|
||||
async crawlAll(zoom, options) {
|
||||
if (this.crawl.running) return this.snapshot().crawl;
|
||||
const z = clampZoom(Number.isFinite(zoom) ? zoom : (this.config.bigMap.zoom || 0), this.maxZoom);
|
||||
const opts = options || {};
|
||||
const range = this.tileRange(z);
|
||||
const z = clampZoom(Number.isFinite(zoom) ? zoom : (this.config.bigMap.zoom || 0), this.maxZoom);
|
||||
const range = this.tileRange(z, opts.rangeBlocks, opts.centerBlockX, opts.centerBlockZ);
|
||||
this._crawlAbort = false;
|
||||
this.crawl = {
|
||||
running: true,
|
||||
@@ -232,10 +253,14 @@ class BigMapService {
|
||||
failed: 0,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: 0,
|
||||
lastError: ''
|
||||
lastError: '',
|
||||
centerBlockX: range.centerBlockX,
|
||||
centerBlockZ: range.centerBlockZ,
|
||||
rangeBlocks: range.rangeBlocks
|
||||
};
|
||||
if (this.logger) {
|
||||
this.logger.log(`开始爬取大地图瓦片:层级 ${z},范围 ${range.minX}..${range.maxX} × ${range.minZ}..${range.maxZ},共 ${range.count} 张`);
|
||||
this.logger.log(`开始爬取大地图瓦片:层级 ${z},中心 ${range.centerBlockX},${range.centerBlockZ},`
|
||||
+ `半径 ${range.rangeBlocks} 格,瓦片 ${range.minX}..${range.maxX} × ${range.minZ}..${range.maxZ},共 ${range.count} 张`);
|
||||
}
|
||||
|
||||
const tasks = [];
|
||||
@@ -285,11 +310,15 @@ class BigMapService {
|
||||
// ==================== 状态 ====================
|
||||
|
||||
/**
|
||||
* @param {{centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options]
|
||||
* @returns {object} 大地图状态快照(供控制台与前端渲染使用)
|
||||
*/
|
||||
snapshot() {
|
||||
snapshot(options) {
|
||||
const opts = options || {};
|
||||
const zoom = this.config && this.config.bigMap ? this.config.bigMap.zoom : 0;
|
||||
const range = this.tileRange(zoom);
|
||||
const centerX = Number.isFinite(opts.centerBlockX) ? opts.centerBlockX : 0;
|
||||
const centerZ = Number.isFinite(opts.centerBlockZ) ? opts.centerBlockZ : 0;
|
||||
const range = this.tileRange(zoom, opts.rangeBlocks, centerX, centerZ);
|
||||
return {
|
||||
enabled: !!(this.config && this.config.bigMap && this.config.bigMap.enabled),
|
||||
baseUrl: this.baseUrl,
|
||||
@@ -298,7 +327,8 @@ class BigMapService {
|
||||
maxZoom: this.maxZoom,
|
||||
zoom,
|
||||
blocksPerTile: this.blocksPerTile(zoom),
|
||||
rangeBlocks: this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS,
|
||||
rangeBlocks: range.rangeBlocks,
|
||||
followBot: !!(this.config && this.config.bigMap && this.config.bigMap.followBot),
|
||||
tiles: range,
|
||||
cached: this.countCached(zoom),
|
||||
source: {
|
||||
|
||||
+39
-7
@@ -20,6 +20,8 @@ const { WebConsole } = require('./web/server');
|
||||
|
||||
/** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */
|
||||
const TICK_INTERVAL_MS = 50;
|
||||
/** 启动时等待机器人进入世界的上限(毫秒),超时后大地图退回以世界原点为中心 */
|
||||
const WAIT_FOR_BOT_MS = 60000;
|
||||
|
||||
async function main() {
|
||||
const logger = new Logger({ echo: true });
|
||||
@@ -116,13 +118,10 @@ async function main() {
|
||||
dataService.start(config.refreshIntervalSeconds);
|
||||
pool.start();
|
||||
|
||||
// 大地图:启动时预爬取低层级瓦片(全世界一张大图),保证控制台打开就能看到
|
||||
// 大地图:启动时预爬取「机器人所在位置周边」的瓦片。
|
||||
// 机器人还没进世界时先等一会儿 —— 以世界原点为中心爬是没意义的。
|
||||
if (config.bigMap.enabled && config.bigMap.crawlOnStart) {
|
||||
setTimeout(() => {
|
||||
bigMap.crawlAll(config.bigMap.zoom).catch((err) => {
|
||||
logger.log('大地图预爬取失败:' + (err && err.message ? err.message : String(err)), 'warn');
|
||||
});
|
||||
}, 3000).unref?.();
|
||||
scheduleInitialCrawl(pool, bigMap, config, logger);
|
||||
}
|
||||
|
||||
const tickTimer = setInterval(() => {
|
||||
@@ -161,6 +160,39 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时预爬取大地图瓦片:优先等机器人进入世界,以它的位置为中心。
|
||||
*
|
||||
* 大地图的用途是看自己机器人周边,所以「以世界原点为中心爬」几乎总是爬错地方。
|
||||
* 这里最多等 `WAIT_FOR_BOT_MS`,等到任意一个机器人有坐标就用它当中心;
|
||||
* 等不到(比如机器人被禁用)再退回世界原点,保证控制台至少不是空白。
|
||||
*
|
||||
* @param {import('./bot/botPool').BotPool} pool
|
||||
* @param {import('./core/bigMapService').BigMapService} bigMap
|
||||
* @param {object} config
|
||||
* @param {import('./util/logger').Logger} logger
|
||||
*/
|
||||
function scheduleInitialCrawl(pool, bigMap, config, logger) {
|
||||
const deadline = Date.now() + WAIT_FOR_BOT_MS;
|
||||
const attempt = () => {
|
||||
const position = pool.positions().find((bot) => bot.position) || null;
|
||||
if (!position && Date.now() < deadline) {
|
||||
setTimeout(attempt, 3000).unref?.();
|
||||
return;
|
||||
}
|
||||
const center = position
|
||||
? { centerBlockX: position.position.x, centerBlockZ: position.position.z }
|
||||
: { centerBlockX: 0, centerBlockZ: 0 };
|
||||
if (!position) {
|
||||
logger.log('等待机器人进入世界超时,大地图改以世界原点为中心预爬取', 'warn');
|
||||
}
|
||||
bigMap.crawlAll(config.bigMap.zoom, center).catch((err) => {
|
||||
logger.log('大地图预爬取失败:' + (err && err.message ? err.message : String(err)), 'warn');
|
||||
});
|
||||
};
|
||||
setTimeout(attempt, 3000).unref?.();
|
||||
}
|
||||
|
||||
// 仅在作为入口运行时启动(被 require 时只导出工厂,便于测试与语法自检)
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
@@ -169,4 +201,4 @@ if (require.main === module) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
module.exports = { main, scheduleInitialCrawl };
|
||||
|
||||
+304
-32
@@ -19,6 +19,28 @@ 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 {
|
||||
@@ -628,6 +650,9 @@ function selectBot(id, silent) {
|
||||
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')) {
|
||||
@@ -724,11 +749,14 @@ function renderStatus(status) {
|
||||
// ===== 大地图爬取进度 =====
|
||||
if (status.bigMap && status.bigMap.crawl) {
|
||||
const crawl = status.bigMap.crawl;
|
||||
$('btnCrawlMap').textContent = crawl.running
|
||||
? `爬取中 ${crawl.done}/${crawl.total}`
|
||||
: '爬取地图';
|
||||
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) {
|
||||
@@ -737,6 +765,19 @@ function renderStatus(status) {
|
||||
$('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)';
|
||||
@@ -752,6 +793,130 @@ function renderStatus(status) {
|
||||
drawMap();
|
||||
}
|
||||
|
||||
// ==================== 定时消息 ====================
|
||||
|
||||
/** 上次渲染定时列表用的签名,避免每秒推送都重建 DOM */
|
||||
let scheduleSignature = '';
|
||||
|
||||
/**
|
||||
* 渲染当前机器人的定时发送列表。
|
||||
*
|
||||
* @param {Array<object>} schedules 服务端返回的任务快照(含剩余秒数)
|
||||
*/
|
||||
function renderSchedules(schedules) {
|
||||
const list = $('scheduleList');
|
||||
if (!list) return;
|
||||
const signature = (schedules || [])
|
||||
.map((t) => [t.id, t.enabled, t.content, t.intervalSeconds, t.nextInSeconds].join(':'))
|
||||
.join('|');
|
||||
if (signature === scheduleSignature) return;
|
||||
scheduleSignature = signature;
|
||||
|
||||
list.innerHTML = '';
|
||||
if (!schedules || schedules.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'schedule-empty';
|
||||
empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间重复发送同一条消息或指令。';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const task of schedules) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'schedule-item' + (task.enabled ? '' : ' off');
|
||||
|
||||
const toggle = document.createElement('input');
|
||||
toggle.type = 'checkbox';
|
||||
toggle.checked = task.enabled;
|
||||
toggle.title = '启用 / 停用';
|
||||
toggle.addEventListener('change', () => toggleSchedule(task, toggle.checked));
|
||||
item.appendChild(toggle);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'content';
|
||||
const code = document.createElement('code');
|
||||
code.textContent = task.content;
|
||||
code.title = task.content;
|
||||
content.appendChild(code);
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'sub';
|
||||
sub.textContent = `${task.kind === 'command' ? '指令' : '公屏'} · 每 ${task.intervalSeconds} 秒`
|
||||
+ (task.label ? ` · ${task.label}` : '');
|
||||
content.appendChild(sub);
|
||||
item.appendChild(content);
|
||||
|
||||
const next = document.createElement('div');
|
||||
next.className = 'sub next';
|
||||
next.textContent = task.enabled && task.nextInSeconds !== null && task.nextInSeconds !== undefined
|
||||
? `${task.nextInSeconds}s 后`
|
||||
: '已停用';
|
||||
item.appendChild(next);
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.className = 'remove';
|
||||
remove.textContent = '×';
|
||||
remove.title = '删除该定时任务';
|
||||
remove.addEventListener('click', () => removeSchedule(task));
|
||||
item.appendChild(remove);
|
||||
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存整份定时任务列表 */
|
||||
async function saveSchedules(schedules) {
|
||||
if (!selectedBotId) return;
|
||||
try {
|
||||
await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/schedules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ schedules })
|
||||
});
|
||||
scheduleSignature = '';
|
||||
toast('定时任务已保存');
|
||||
} catch (err) {
|
||||
toast('保存定时任务失败:' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取当前列表的原始定义(去掉服务端附加的剩余秒数字段) */
|
||||
function currentSchedules() {
|
||||
const bot = selectedBot(lastStatus || {});
|
||||
return ((bot && bot.schedules) || []).map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
content: t.content,
|
||||
intervalSeconds: t.intervalSeconds,
|
||||
enabled: t.enabled
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleSchedule(task, enabled) {
|
||||
const next = currentSchedules().map((t) => (t.id === task.id ? Object.assign({}, t, { enabled }) : t));
|
||||
saveSchedules(next);
|
||||
}
|
||||
|
||||
function removeSchedule(task) {
|
||||
const next = currentSchedules().filter((t) => t.id !== task.id);
|
||||
saveSchedules(next);
|
||||
}
|
||||
|
||||
// ==================== 移动端导航 ====================
|
||||
|
||||
/** 小屏底部导航:点击滚动到对应区块 */
|
||||
function bindMobileNav() {
|
||||
const nav = $('mobileNav');
|
||||
if (!nav) return;
|
||||
nav.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.mobile-nav-item');
|
||||
if (!item) return;
|
||||
for (const el of nav.querySelectorAll('.mobile-nav-item')) {
|
||||
el.classList.toggle('active', el === item);
|
||||
}
|
||||
const target = $(item.dataset.scroll);
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 运行日志 ====================
|
||||
|
||||
const MAX_LOG_LINES = 800;
|
||||
@@ -1339,11 +1504,7 @@ async function loadMap(forceFull) {
|
||||
if (needFull) lastFullFetchAt = Date.now();
|
||||
else if (cached && cached.otherChunks) next.otherChunks = cached.otherChunks;
|
||||
mapView.data = next;
|
||||
if (!mapView.centered && next.playerChunk) {
|
||||
mapView.centerX = next.playerChunk.x;
|
||||
mapView.centerZ = next.playerChunk.z;
|
||||
mapView.centered = true;
|
||||
}
|
||||
if (!mapView.centered) focusTerritoryOnBotCountry(next);
|
||||
drawMap();
|
||||
} catch (err) {
|
||||
toast('读取地图失败:' + err.message, true);
|
||||
@@ -1352,6 +1513,51 @@ async function loadMap(forceFull) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把领土地图对准「机器人所在的邦国」。
|
||||
*
|
||||
* 之前这里用的是控制器的 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;
|
||||
|
||||
@@ -1360,7 +1566,10 @@ async function loadBigMap(force) {
|
||||
if (bigMapLoading) return;
|
||||
bigMapLoading = true;
|
||||
try {
|
||||
const res = await api('/api/bigmap');
|
||||
// 带上图幅与关注的机器人:服务端据此算出要爬/要用的瓦片范围
|
||||
const query = '?rangeBlocks=' + selectedRangeBlocks()
|
||||
+ (selectedBotId ? '&bot=' + encodeURIComponent(selectedBotId) : '');
|
||||
const res = await api('/api/bigmap' + query);
|
||||
const data = res.data;
|
||||
bigMapView.data = data;
|
||||
// 领土标记是 160KB 级别的数据,按需刷新(默认 5 分钟一次)
|
||||
@@ -1371,9 +1580,7 @@ async function loadBigMap(force) {
|
||||
// 会话新占领的区块:复用领土地图的数据
|
||||
if (mapView.data) bigMapView.sessionChunks = [mapView.data.session || []];
|
||||
|
||||
if (!bigMapView.fitted && data.rangeBlocks) {
|
||||
fitBigMapToWorld();
|
||||
}
|
||||
if (!bigMapView.fitted) fitBigMapToRegion();
|
||||
drawMap();
|
||||
} catch (err) {
|
||||
toast('读取大地图失败:' + err.message, true);
|
||||
@@ -1400,21 +1607,30 @@ function setBigMapMarkers(markers) {
|
||||
bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states };
|
||||
}
|
||||
|
||||
/** 让全世界刚好铺满画布 */
|
||||
function fitBigMapToWorld() {
|
||||
const data = bigMapView.data;
|
||||
/**
|
||||
* 让「选定图幅」刚好铺满画布,并以机器人所在位置为中心。
|
||||
*
|
||||
* 图幅由界面上的「图幅」下拉框决定(±512 ~ 全世界),
|
||||
* 这样既能看到机器人周边细节,也能一键切到全局视角。
|
||||
*/
|
||||
function fitBigMapToRegion() {
|
||||
const rect = $('mapCanvas').getBoundingClientRect();
|
||||
const radius = (data && data.rangeBlocks) || 20480;
|
||||
bigMapView.centerX = 0;
|
||||
bigMapView.centerZ = 0;
|
||||
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) return;
|
||||
if (bots.length === 0) {
|
||||
toast('机器人还没有坐标(未进入世界)', true);
|
||||
return;
|
||||
}
|
||||
let x = 0;
|
||||
let z = 0;
|
||||
for (const bot of bots) {
|
||||
@@ -1540,6 +1756,35 @@ function bindEvents() {
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 定时消息编辑器 =====
|
||||
$('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' });
|
||||
@@ -1616,12 +1861,18 @@ function bindEvents() {
|
||||
$('chkBotLabels').addEventListener('change', drawMap);
|
||||
|
||||
$('btnMapCenter').addEventListener('click', () => {
|
||||
const data = mapView.data;
|
||||
if (data && data.playerChunk) {
|
||||
mapView.centerX = data.playerChunk.x;
|
||||
mapView.centerZ = data.playerChunk.z;
|
||||
drawMap();
|
||||
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', () => {
|
||||
@@ -1657,17 +1908,37 @@ function bindEvents() {
|
||||
drawMap();
|
||||
});
|
||||
|
||||
$('btnMapFitWorld').addEventListener('click', () => {
|
||||
fitBigMapToWorld();
|
||||
drawMap();
|
||||
});
|
||||
$('btnMapCenterBots').addEventListener('click', centerBigMapOnBots);
|
||||
|
||||
$('btnCrawlMap').addEventListener('click', async () => {
|
||||
// 图幅切换:重新对焦并拉取对应范围的瓦片信息
|
||||
$('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({}) });
|
||||
toast(res.message);
|
||||
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);
|
||||
}
|
||||
@@ -1886,6 +2157,7 @@ async function startApp() {
|
||||
wsEnabled = true;
|
||||
initMap();
|
||||
bindEvents();
|
||||
bindMobileNav();
|
||||
await loadConfig();
|
||||
await loadMap();
|
||||
await loadBigMap(true);
|
||||
|
||||
+137
-79
@@ -2,7 +2,8 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0d1117">
|
||||
<title>AutoStore Bot 控制台</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
@@ -10,46 +11,62 @@
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="logo">AS</span>
|
||||
<div>
|
||||
<div class="brand-text">
|
||||
<h1>AutoStore Bot</h1>
|
||||
<p>多机器人自动扩地 · mineflayer · Grim 合规 · 大地图</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<label class="inline-field">
|
||||
当前机器人
|
||||
<label class="field-inline">
|
||||
<span class="field-label">当前机器人</span>
|
||||
<select id="botSelect" class="input input-sm"></select>
|
||||
</label>
|
||||
<span id="connBadge" class="badge badge-offline">未连接</span>
|
||||
<span id="taskBadge" class="badge badge-idle">空闲</span>
|
||||
<span id="authBadge" class="badge badge-idle" title="控制台登录状态">未登录</span>
|
||||
<button id="btnStart" class="btn btn-primary">开始扩地</button>
|
||||
<button id="btnStop" class="btn btn-danger">停止</button>
|
||||
<button id="btnReconnect" class="btn">重连</button>
|
||||
<div class="badges">
|
||||
<span id="connBadge" class="badge badge-offline">未连接</span>
|
||||
<span id="taskBadge" class="badge badge-idle">空闲</span>
|
||||
<span id="authBadge" class="badge badge-idle" title="控制台登录状态">未登录</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button id="btnStart" class="btn btn-primary">开始扩地</button>
|
||||
<button id="btnStop" class="btn btn-danger">停止</button>
|
||||
<button id="btnReconnect" class="btn">重连</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="mobile-nav" id="mobileNav">
|
||||
<button class="mobile-nav-item active" data-scroll="panelStatus">状态</button>
|
||||
<button class="mobile-nav-item" data-scroll="panelMap">地图</button>
|
||||
<button class="mobile-nav-item" data-scroll="panelLogs">日志</button>
|
||||
<button class="mobile-nav-item" data-scroll="panelConfig">配置</button>
|
||||
</nav>
|
||||
|
||||
<main class="layout">
|
||||
<!-- ==================== 左栏:状态面板 ==================== -->
|
||||
<section class="column col-left">
|
||||
<section class="column col-left" id="panelStatus">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>机器人(<span id="botCount">0</span>)</h2>
|
||||
<div class="row">
|
||||
<div class="row row-compact">
|
||||
<button id="btnStartAll" class="btn btn-sm">全部开始</button>
|
||||
<button id="btnStopAll" class="btn btn-sm">全部停止</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="botList" class="bot-list"></div>
|
||||
<div class="add-bot">
|
||||
<div class="row">
|
||||
<input id="newBotName" class="input input-sm" type="text" placeholder="玩家名" maxlength="16">
|
||||
<input id="newBotPass" class="input input-sm" type="password" placeholder="服务器密码(可留空)">
|
||||
<input id="newBotLabel" class="input input-sm" type="text" placeholder="备注(可留空)">
|
||||
<button id="btnAddBot" class="btn btn-sm btn-primary">添加玩家</button>
|
||||
<details class="add-bot">
|
||||
<summary>+ 添加玩家</summary>
|
||||
<div class="field">
|
||||
<input id="newBotName" class="input" type="text" placeholder="玩家名(1-16 位字母/数字/下划线)" maxlength="16">
|
||||
</div>
|
||||
<div class="field">
|
||||
<input id="newBotPass" class="input" type="password" placeholder="服务器密码(可留空)" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<input id="newBotLabel" class="input" type="text" placeholder="备注(可留空)">
|
||||
</div>
|
||||
<button id="btnAddBot" class="btn btn-sm btn-primary btn-block">添加</button>
|
||||
<p class="hint">密码只保存在服务器本地凭据文件(权限 0600),不会进仓库、也不会出现在任何接口响应里。.env 里配置的账号需改 .env 才能移除。</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -89,6 +106,35 @@
|
||||
<div class="progress"><div id="progressBar" class="progress-fill"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>定时消息</h2>
|
||||
<button id="btnAddSchedule" class="btn btn-sm">+ 新增</button>
|
||||
</div>
|
||||
<div id="scheduleList" class="schedule-list"></div>
|
||||
<div id="scheduleEditor" class="schedule-editor hidden">
|
||||
<div class="field">
|
||||
<label>内容(以 / 开头按游戏指令发送)</label>
|
||||
<input id="schedContent" class="input" type="text" placeholder="/u restore confirm">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>间隔(秒)</label>
|
||||
<input id="schedInterval" class="input" type="number" min="5" value="300">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>备注</label>
|
||||
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-compact">
|
||||
<button id="btnSaveSchedule" class="btn btn-sm btn-primary">保存</button>
|
||||
<button id="btnCancelSchedule" class="btn btn-sm">取消</button>
|
||||
</div>
|
||||
<p class="hint">只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>领土数据源</h2>
|
||||
<dl class="kv">
|
||||
@@ -101,7 +147,7 @@
|
||||
<dt>己方飞地</dt><dd id="kFlying">0</dd>
|
||||
<dt>SimpCore API</dt><dd id="kApi">未启用</dd>
|
||||
</dl>
|
||||
<div class="row">
|
||||
<div class="row row-compact">
|
||||
<button id="btnRefreshMap" class="btn btn-sm">刷新地图</button>
|
||||
<button id="btnFetchAll" class="btn btn-sm">拉取全服领土</button>
|
||||
</div>
|
||||
@@ -115,6 +161,7 @@
|
||||
<dt>朝向已同步</dt><dd id="kGrimSettled">-</dd>
|
||||
<dt>攻击距离</dt><dd id="kGrimReach">-</dd>
|
||||
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
|
||||
<dt>协议补包</dt><dd id="kGrimPackets">-</dd>
|
||||
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
|
||||
<dt>物理自检</dt><dd id="kGrimAudit">-</dd>
|
||||
</dl>
|
||||
@@ -123,26 +170,36 @@
|
||||
</section>
|
||||
|
||||
<!-- ==================== 中栏:地图与日志 ==================== -->
|
||||
<section class="column col-center">
|
||||
<section class="column col-center" id="panelMap">
|
||||
<div class="card card-map">
|
||||
<div class="card-head">
|
||||
<div class="card-head card-head-wrap">
|
||||
<div class="tabs tabs-inline" id="mapTabs">
|
||||
<button class="tab active" data-view="big">大地图</button>
|
||||
<button class="tab" data-view="territory">领土地图</button>
|
||||
</div>
|
||||
<div class="row" id="bigMapControls">
|
||||
<div class="row row-compact" id="bigMapControls">
|
||||
<label class="field-inline">
|
||||
<span class="field-label">图幅</span>
|
||||
<select id="bigMapRange" class="input input-sm">
|
||||
<option value="512">±512 格</option>
|
||||
<option value="1024">±1024 格</option>
|
||||
<option value="2048">±2048 格</option>
|
||||
<option value="4096" selected>±4096 格</option>
|
||||
<option value="8192">±8192 格</option>
|
||||
<option value="20480">全世界</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="chk"><input type="checkbox" id="chkTerritory" checked> 领土标记</label>
|
||||
<label class="chk"><input type="checkbox" id="chkBotLabels" checked> 机器人名字</label>
|
||||
<button id="btnMapFitWorld" class="btn btn-sm">全世界</button>
|
||||
<label class="chk"><input type="checkbox" id="chkBotLabels" checked> 名字</label>
|
||||
<button id="btnCrawlAround" class="btn btn-sm btn-primary">爬取周边</button>
|
||||
<button id="btnMapCenterBots" class="btn btn-sm">回到机器人</button>
|
||||
<button id="btnCrawlMap" class="btn btn-sm">爬取地图</button>
|
||||
</div>
|
||||
<div class="row hidden" id="territoryMapControls">
|
||||
<label class="chk"><input type="checkbox" id="chkFull" checked> 显示全部邦国</label>
|
||||
<label class="chk"><input type="checkbox" id="chkFlying" checked> 标出飞地</label>
|
||||
<div class="row row-compact hidden" id="territoryMapControls">
|
||||
<label class="chk"><input type="checkbox" id="chkFull" checked> 全部邦国</label>
|
||||
<label class="chk"><input type="checkbox" id="chkFlying" checked> 飞地</label>
|
||||
<label class="chk"><input type="checkbox" id="chkGrid" checked> 网格</label>
|
||||
<button id="btnMapCenter" class="btn btn-sm">回到玩家</button>
|
||||
<button id="btnMapFit" class="btn btn-sm">全图</button>
|
||||
<button id="btnMapCenter" class="btn btn-sm">回到机器人</button>
|
||||
<button id="btnMapFit" class="btn btn-sm">看己方全境</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="mapCanvas"></canvas>
|
||||
@@ -165,26 +222,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-log">
|
||||
<div class="card-head">
|
||||
<div class="card card-log" id="panelLogs">
|
||||
<div class="card-head card-head-wrap">
|
||||
<div class="tabs tabs-inline" id="logTabs">
|
||||
<button class="tab active" data-pane="log">运行日志</button>
|
||||
<button class="tab" data-pane="chat">公屏聊天</button>
|
||||
<button class="tab" data-pane="exp">开拓日志</button>
|
||||
</div>
|
||||
<div class="row" id="logControls">
|
||||
<div class="row row-compact" id="logControls">
|
||||
<label class="chk"><input type="checkbox" id="chkAutoScroll" checked> 自动滚动</label>
|
||||
<button id="btnClearLog" class="btn btn-sm">清屏</button>
|
||||
<button id="btnExportLog" class="btn btn-sm">导出日志</button>
|
||||
<button id="btnExportLog" class="btn btn-sm">导出</button>
|
||||
</div>
|
||||
<div class="row hidden" id="chatControls">
|
||||
<div class="row row-compact hidden" id="chatControls">
|
||||
<label class="chk"><input type="checkbox" id="chkChatAutoScroll" checked> 自动滚动</label>
|
||||
<label class="chk"><input type="checkbox" id="chkChatAllBots"> 全部机器人</label>
|
||||
<button id="btnClearChat" class="btn btn-sm">清屏</button>
|
||||
<button id="btnExportChat" class="btn btn-sm">导出公屏日志</button>
|
||||
<button id="btnExportChat" class="btn btn-sm">导出</button>
|
||||
</div>
|
||||
<div class="row hidden" id="expControls">
|
||||
<label class="chk">机器人
|
||||
<div class="row row-compact hidden" id="expControls">
|
||||
<label class="field-inline">
|
||||
<span class="field-label">机器人</span>
|
||||
<select id="expFilterBot" class="input input-sm"><option value="">全部</option></select>
|
||||
</label>
|
||||
<label class="chk"><input type="checkbox" id="chkExpAutoScroll" checked> 自动滚动</label>
|
||||
@@ -194,7 +252,7 @@
|
||||
</div>
|
||||
<div id="logView" class="log-view"></div>
|
||||
<div id="chatView" class="log-view hidden"></div>
|
||||
<div id="expView" class="log-view hidden">
|
||||
<div id="expView" class="log-view log-table hidden">
|
||||
<table class="exp-table">
|
||||
<thead>
|
||||
<tr><th>时间</th><th>机器人</th><th>邦国</th><th>区块</th><th>方块坐标</th><th>来源</th></tr>
|
||||
@@ -206,12 +264,46 @@
|
||||
</section>
|
||||
|
||||
<!-- ==================== 右栏:配置与控制 ==================== -->
|
||||
<section class="column col-right">
|
||||
<section class="column col-right" id="panelConfig">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>控制台 / 聊天框</h2>
|
||||
<span class="mono small" id="consoleTarget">-</span>
|
||||
</div>
|
||||
<p class="hint" id="consoleHint">登录验证通过后可向该机器人发送指令或公屏消息。</p>
|
||||
<div class="row row-compact">
|
||||
<select id="sendMode" class="input input-sm">
|
||||
<option value="chat">公屏聊天</option>
|
||||
<option value="command">游戏指令</option>
|
||||
</select>
|
||||
<input id="cmdInput" class="input input-grow" type="text" placeholder="输入内容后回车发送">
|
||||
<button id="btnSendCmd" class="btn btn-sm btn-primary">发送</button>
|
||||
</div>
|
||||
<div class="row row-compact">
|
||||
<button class="btn btn-sm" data-cmd="u return">/u return</button>
|
||||
<button class="btn btn-sm" data-cmd="u claimmode">/u claimmode</button>
|
||||
<button class="btn btn-sm" data-cmd="spawn">/spawn</button>
|
||||
<button class="btn btn-sm" data-cmd="list">/list</button>
|
||||
</div>
|
||||
<div class="row row-compact">
|
||||
<input id="quickChatInput" class="input input-grow" type="text" placeholder="快捷公屏消息">
|
||||
<button id="btnQuickChat" class="btn btn-sm">公屏发送</button>
|
||||
</div>
|
||||
<div class="row row-compact">
|
||||
<span class="field-label">移动测试</span>
|
||||
<button class="btn btn-sm" data-move="forward">前</button>
|
||||
<button class="btn btn-sm" data-move="back">后</button>
|
||||
<button class="btn btn-sm" data-move="left">左</button>
|
||||
<button class="btn btn-sm" data-move="right">右</button>
|
||||
<button class="btn btn-sm" data-move="jump">跳</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-config">
|
||||
<div class="card-head">
|
||||
<h2>配置</h2>
|
||||
<div class="row">
|
||||
<button id="btnSaveConfig" class="btn btn-sm btn-primary">保存配置</button>
|
||||
<div class="row row-compact">
|
||||
<button id="btnSaveConfig" class="btn btn-sm btn-primary">保存</button>
|
||||
<button id="btnReloadConfig" class="btn btn-sm">重新载入</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,40 +323,6 @@
|
||||
</div>
|
||||
<div id="configBody" class="config-body"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>控制台 / 聊天框</h2>
|
||||
<span class="mono small" id="consoleTarget">-</span>
|
||||
</div>
|
||||
<p class="hint" id="consoleHint">登录验证通过后可向该机器人发送指令或公屏消息。</p>
|
||||
<div class="row">
|
||||
<select id="sendMode" class="input input-sm">
|
||||
<option value="chat">公屏聊天</option>
|
||||
<option value="command">游戏指令</option>
|
||||
</select>
|
||||
<input id="cmdInput" class="input" type="text" placeholder="输入内容后回车发送">
|
||||
<button id="btnSendCmd" class="btn btn-sm">发送</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn btn-sm" data-cmd="u return">/u return</button>
|
||||
<button class="btn btn-sm" data-cmd="u claimmode">/u claimmode</button>
|
||||
<button class="btn btn-sm" data-cmd="spawn">/spawn</button>
|
||||
<button class="btn btn-sm" data-cmd="list">/list</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="quickChatInput" class="input" type="text" placeholder="快捷公屏消息(如:大家好)">
|
||||
<button id="btnQuickChat" class="btn btn-sm">公屏发送</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="hint">移动测试:</span>
|
||||
<button class="btn btn-sm" data-move="forward">前</button>
|
||||
<button class="btn btn-sm" data-move="back">后</button>
|
||||
<button class="btn btn-sm" data-move="left">左</button>
|
||||
<button class="btn btn-sm" data-move="right">右</button>
|
||||
<button class="btn btn-sm" data-move="jump">跳</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -275,8 +333,8 @@
|
||||
<p class="hint">验证通过后才允许操作各机器人的控制台、聊天框与扩地任务。</p>
|
||||
<input id="loginPassword" class="input" type="password" placeholder="控制台密码 / 令牌" autocomplete="current-password">
|
||||
<div class="row">
|
||||
<button id="btnLogin" class="btn btn-primary">登录</button>
|
||||
<button id="btnLogout" class="btn btn-sm">退出登录</button>
|
||||
<button id="btnLogin" class="btn btn-primary btn-grow">登录</button>
|
||||
<button id="btnLogout" class="btn">退出登录</button>
|
||||
</div>
|
||||
<p id="loginError" class="hint error-text"></p>
|
||||
</div>
|
||||
|
||||
+360
-180
@@ -1,16 +1,38 @@
|
||||
/* ==========================================================================
|
||||
AutoStore Bot 控制台样式
|
||||
设计要点:
|
||||
- 深色主题,卡片式布局,三层栅格(左状态 / 中地图日志 / 右配置)
|
||||
- 响应式:≤1200px 收成两栏,≤820px 收成单栏并启用底部导航
|
||||
- 移动端:触控目标 ≥ 36px、地图自适应高度、底部安全区避让
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--panel-2: #1c2330;
|
||||
--border: #30363d;
|
||||
--text: #c9d1d9;
|
||||
--muted: #8b949e;
|
||||
--bg: #0b0f16;
|
||||
--bg-soft: #10151d;
|
||||
--panel: #151b25;
|
||||
--panel-2: #1b2230;
|
||||
--panel-3: #212a3a;
|
||||
--border: #2b3444;
|
||||
--border-soft: #222a37;
|
||||
--text: #d7dee8;
|
||||
--text-dim: #9aa7b8;
|
||||
--muted: #6b7787;
|
||||
|
||||
--accent: #e94560;
|
||||
--accent-2: #2f81f7;
|
||||
--accent-hover: #f2586f;
|
||||
--accent-2: #3b8ef7;
|
||||
--accent-2-hover: #5aa0f9;
|
||||
--green: #3fb950;
|
||||
--yellow: #d29922;
|
||||
--yellow: #d6a01f;
|
||||
--red: #f85149;
|
||||
--purple: #bc8cff;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 8px 24px -12px rgba(0, 0, 0, .5);
|
||||
--gap: 12px;
|
||||
--topbar-h: 58px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -18,114 +40,141 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
min-height: 100%;
|
||||
background: radial-gradient(1200px 600px at 20% -10%, #16202e 0%, var(--bg) 55%);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.mono { font-family: Consolas, "Cascadia Mono", monospace; }
|
||||
.mono { font-family: Consolas, "Cascadia Mono", ui-monospace, monospace; }
|
||||
.small { font-size: 12px; }
|
||||
.hidden { display: none !important; }
|
||||
.error-text { color: var(--red); }
|
||||
|
||||
/* ==================== 顶栏 ==================== */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 18px;
|
||||
background: linear-gradient(90deg, #161b22, #1b2230);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 16px;
|
||||
background: rgba(16, 21, 29, .92);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 11px; min-width: 0; }
|
||||
.logo {
|
||||
width: 38px; height: 38px;
|
||||
width: 36px; height: 36px;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 9px;
|
||||
background: var(--accent);
|
||||
color: #fff; font-weight: 700; letter-spacing: 1px;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, var(--accent), #b5324a);
|
||||
color: #fff; font-weight: 700; letter-spacing: .5px;
|
||||
box-shadow: 0 4px 14px -6px rgba(233, 69, 96, .9);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.brand h1 { font-size: 16px; margin: 0; }
|
||||
.brand p { font-size: 12px; margin: 2px 0 0; color: var(--muted); }
|
||||
.topbar-right { display: flex; align-items: center; gap: 8px; }
|
||||
.brand-text { min-width: 0; }
|
||||
.brand h1 { font-size: 15px; margin: 0; white-space: nowrap; }
|
||||
.brand p { font-size: 11.5px; margin: 1px 0 0; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.topbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.badges { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.topbar-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.badge {
|
||||
padding: 4px 10px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-size: 11.5px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-online { color: var(--green); border-color: #1f6f34; }
|
||||
.badge-online { color: var(--green); border-color: #1f6f34; background: rgba(63, 185, 80, .1); }
|
||||
.badge-offline { color: var(--muted); }
|
||||
.badge-running { color: var(--accent-2); border-color: #1f4f8f; }
|
||||
.badge-running { color: var(--accent-2); border-color: #1f4f8f; background: rgba(59, 142, 247, .12); }
|
||||
.badge-idle { color: var(--muted); }
|
||||
.badge-error { color: var(--red); border-color: #8f2b26; }
|
||||
|
||||
/* ==================== 按钮 ==================== */
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background .15s, border-color .15s;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background .15s, border-color .15s, transform .06s, opacity .15s;
|
||||
}
|
||||
.btn:hover { background: #232b39; border-color: #48505e; }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: #d63851; border-color: #d63851; }
|
||||
.btn-danger { background: #3a1d22; border-color: #7a2b34; color: #ff8b98; }
|
||||
.btn-danger:hover { background: #4a2329; }
|
||||
.btn:hover:not(:disabled) { background: var(--panel-3); border-color: #3c4759; }
|
||||
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--accent-2); border-color: var(--accent-2); color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--accent-2-hover); border-color: var(--accent-2-hover); }
|
||||
.btn-danger { background: rgba(248, 81, 73, .12); border-color: #7a2b34; color: #ff9aa2; }
|
||||
.btn-danger:hover:not(:disabled) { background: rgba(248, 81, 73, .2); }
|
||||
.btn-sm { padding: 4px 9px; font-size: 12px; }
|
||||
.btn-block { width: 100%; }
|
||||
.btn-grow { flex: 1 1 auto; }
|
||||
|
||||
/* ==================== 布局 ==================== */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(460px, 1fr) 380px;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
grid-template-columns: 320px minmax(420px, 1fr) 380px;
|
||||
gap: var(--gap);
|
||||
padding: var(--gap);
|
||||
align-items: start;
|
||||
}
|
||||
.column { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.column { display: flex; flex-direction: column; gap: var(--gap); min-width: 0; }
|
||||
|
||||
/* ==================== 卡片 ==================== */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-soft) 100%);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 13px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 13px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 0 0 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .6px;
|
||||
letter-spacing: .7px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.card-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 8px; margin-bottom: 8px;
|
||||
gap: 8px; margin-bottom: 9px;
|
||||
}
|
||||
.card-head h2 { margin: 0; }
|
||||
.card-head-wrap { flex-wrap: wrap; }
|
||||
.row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
|
||||
.row-compact { margin-top: 6px; }
|
||||
|
||||
/* ==================== 键值表 ==================== */
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 88px 1fr;
|
||||
grid-template-columns: 92px 1fr;
|
||||
gap: 4px 8px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.kv dt { color: var(--muted); }
|
||||
.kv dd { margin: 0; word-break: break-all; }
|
||||
.kv dd { margin: 0; word-break: break-word; }
|
||||
|
||||
.progress {
|
||||
margin-top: 10px; height: 6px; border-radius: 3px;
|
||||
background: #0b0f14; overflow: hidden;
|
||||
background: #080b11; overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%; width: 0%;
|
||||
@@ -136,194 +185,325 @@ html, body {
|
||||
.issues { margin: 8px 0 0; padding-left: 18px; color: var(--yellow); font-size: 12px; }
|
||||
.issues li { margin-bottom: 3px; }
|
||||
|
||||
/* ==================== 机器人列表 ==================== */
|
||||
.bot-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bot-item {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr) auto;
|
||||
gap: 9px; align-items: center;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-2);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.bot-item:hover { border-color: #3c4759; }
|
||||
.bot-item.active { border-color: var(--accent-2); background: rgba(59, 142, 247, .08); }
|
||||
.bot-item .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
|
||||
.bot-item .dot.online { background: var(--green); box-shadow: 0 0 8px rgba(63, 185, 80, .7); }
|
||||
.bot-item .dot.connecting { background: var(--yellow); }
|
||||
.bot-item .dot.offline { background: var(--red); }
|
||||
.bot-item .dot.disabled { background: #454e5e; }
|
||||
.bot-item .name { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.bot-item .sub { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.bot-item .meta { font-size: 11px; color: var(--muted); text-align: right; white-space: nowrap; }
|
||||
.bot-item .remove {
|
||||
margin-left: 6px; padding: 0 6px;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
background: transparent; color: var(--muted); cursor: pointer;
|
||||
font-size: 13px; line-height: 18px;
|
||||
}
|
||||
.bot-item .remove:hover { color: var(--red); border-color: #7a2b34; }
|
||||
|
||||
.add-bot { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); }
|
||||
.add-bot summary {
|
||||
cursor: pointer; font-size: 12.5px; color: var(--accent-2);
|
||||
list-style: none; user-select: none;
|
||||
}
|
||||
.add-bot summary::-webkit-details-marker { display: none; }
|
||||
.add-bot summary::before { content: '▸ '; }
|
||||
.add-bot[open] summary::before { content: '▾ '; }
|
||||
.add-bot .field { margin-top: 8px; }
|
||||
.add-bot .hint { font-size: 11px; color: var(--muted); margin: 8px 0 0; }
|
||||
|
||||
/* ==================== 定时消息 ==================== */
|
||||
.schedule-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.schedule-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
gap: 8px; align-items: center;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.schedule-item.off { opacity: .55; }
|
||||
.schedule-item .content { min-width: 0; }
|
||||
.schedule-item .content code {
|
||||
display: block;
|
||||
font-family: Consolas, ui-monospace, monospace;
|
||||
color: var(--text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.schedule-item .sub { font-size: 11px; color: var(--muted); }
|
||||
.schedule-item .remove {
|
||||
padding: 0 6px; border: 1px solid var(--border); border-radius: 4px;
|
||||
background: transparent; color: var(--muted); cursor: pointer; line-height: 18px;
|
||||
}
|
||||
.schedule-item .remove:hover { color: var(--red); border-color: #7a2b34; }
|
||||
.schedule-empty { font-size: 12px; color: var(--muted); padding: 4px 0; }
|
||||
.schedule-editor {
|
||||
margin-top: 10px; padding-top: 10px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.schedule-editor .hint { font-size: 11px; color: var(--muted); margin: 6px 0 0; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
|
||||
/* ==================== 地图 ==================== */
|
||||
.card-map { display: flex; flex-direction: column; }
|
||||
#mapCanvas {
|
||||
width: 100%;
|
||||
height: 440px;
|
||||
height: clamp(300px, 46vh, 520px);
|
||||
background: #05070a;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
display: block;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
#mapCanvas:active { cursor: grabbing; }
|
||||
.map-legend {
|
||||
display: flex; gap: 14px; flex-wrap: wrap;
|
||||
margin-top: 8px; font-size: 12px; color: var(--muted);
|
||||
display: flex; gap: 12px; flex-wrap: wrap;
|
||||
margin-top: 8px; font-size: 11.5px; color: var(--muted);
|
||||
align-items: center;
|
||||
}
|
||||
.sw {
|
||||
display: inline-block; width: 10px; height: 10px;
|
||||
border-radius: 2px; margin-right: 4px; vertical-align: -1px;
|
||||
}
|
||||
.sw-self { background: #2f81f7; }
|
||||
.sw-self { background: #3b8ef7; }
|
||||
.sw-session { background: #3fb950; }
|
||||
.sw-flying { background: #f85149; }
|
||||
.sw-target { background: #d29922; }
|
||||
.sw-target { background: #d6a01f; }
|
||||
.sw-player { background: #ffffff; }
|
||||
.sw-capital { background: #bc8cff; }
|
||||
.sw-bot { background: #ffd33d; }
|
||||
|
||||
.chk { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; }
|
||||
.hidden { display: none !important; }
|
||||
.inline-field { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.input-sm { width: auto; min-width: 110px; padding: 4px 8px; font-size: 12px; }
|
||||
.tabs-inline { margin-bottom: 0; }
|
||||
.error-text { color: var(--red); }
|
||||
|
||||
/* ==================== 机器人列表 ==================== */
|
||||
.bot-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bot-item {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 1fr auto;
|
||||
gap: 8px; align-items: center;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bot-item:hover { border-color: #48505e; }
|
||||
.bot-item.active { border-color: var(--accent-2); box-shadow: 0 0 0 1px rgba(47,129,247,.35); }
|
||||
.bot-item .dot {
|
||||
width: 9px; height: 9px; border-radius: 50%; background: var(--muted);
|
||||
}
|
||||
.bot-item .dot.online { background: var(--green); }
|
||||
.bot-item .dot.connecting { background: var(--yellow); }
|
||||
.bot-item .dot.offline { background: var(--red); }
|
||||
.bot-item .dot.disabled { background: #484f58; }
|
||||
.bot-item .name { font-size: 13px; }
|
||||
.bot-item .sub { font-size: 11px; color: var(--muted); }
|
||||
.bot-item .meta { font-size: 11px; color: var(--muted); text-align: right; white-space: nowrap; }
|
||||
.bot-item .remove {
|
||||
margin-left: 6px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.bot-item .remove:hover { color: var(--red); border-color: #7a2b34; }
|
||||
|
||||
.add-bot { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); }
|
||||
.add-bot .row { margin-top: 0; }
|
||||
.add-bot .input-sm { flex: 1 1 60px; min-width: 0; }
|
||||
.add-bot .hint { font-size: 11px; color: #5d6672; margin: 6px 0 0; }
|
||||
|
||||
/* ==================== 公屏聊天 ==================== */
|
||||
.chat-line { display: block; }
|
||||
.chat-time { color: #586069; margin-right: 6px; }
|
||||
.chat-bot { color: var(--accent-2); margin-right: 4px; }
|
||||
.chat-user { color: var(--purple); margin-right: 4px; }
|
||||
.chat-out { color: var(--green); }
|
||||
.chat-command { color: var(--yellow); }
|
||||
.chat-system { color: var(--muted); }
|
||||
.chat-action { color: #6e7681; }
|
||||
|
||||
/* ==================== 开拓日志 ==================== */
|
||||
#expView { white-space: normal; padding: 0; }
|
||||
.exp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.exp-table th {
|
||||
position: sticky; top: 0;
|
||||
text-align: left; color: var(--muted); font-weight: 500;
|
||||
background: #05070a; padding: 4px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.exp-table td { padding: 3px 6px; border-bottom: 1px solid #12161d; white-space: nowrap; }
|
||||
.exp-table tbody tr:hover { background: #0e131a; }
|
||||
.exp-empty { color: var(--muted); }
|
||||
|
||||
.config-scope { margin-bottom: 8px; }
|
||||
|
||||
/* ==================== 登录遮罩 ==================== */
|
||||
.overlay {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
display: grid; place-items: center;
|
||||
background: rgba(5, 7, 10, .78);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.overlay-box {
|
||||
width: min(360px, 92vw);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
.overlay-box h2 { font-size: 15px; margin: 0 0 6px; }
|
||||
.overlay-box .input { margin: 10px 0; }
|
||||
|
||||
/* ==================== 日志 ==================== */
|
||||
/* ==================== 日志 / 聊天 / 表格 ==================== */
|
||||
.card-log { display: flex; flex-direction: column; }
|
||||
.log-view {
|
||||
height: 300px;
|
||||
height: clamp(220px, 34vh, 380px);
|
||||
overflow-y: auto;
|
||||
background: #05070a;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 10px;
|
||||
font-family: Consolas, "Cascadia Mono", ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.log-line { display: block; }
|
||||
.log-time { color: #586069; margin-right: 6px; }
|
||||
.log-time { color: #55606f; margin-right: 6px; }
|
||||
.log-info { color: var(--text); }
|
||||
.log-warn { color: var(--yellow); }
|
||||
.log-error { color: var(--red); }
|
||||
.log-success { color: var(--green); }
|
||||
|
||||
/* ==================== 配置 ==================== */
|
||||
.chat-line { display: block; }
|
||||
.chat-time { color: #55606f; margin-right: 6px; }
|
||||
.chat-bot { color: var(--accent-2); margin-right: 4px; }
|
||||
.chat-user { color: var(--purple); margin-right: 4px; }
|
||||
.chat-out { color: var(--green); }
|
||||
.chat-command { color: var(--yellow); }
|
||||
.chat-system { color: var(--text-dim); }
|
||||
.chat-action { color: var(--muted); }
|
||||
|
||||
.log-table { white-space: normal; padding: 0; }
|
||||
.exp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.exp-table th {
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
text-align: left; color: var(--text-dim); font-weight: 600;
|
||||
background: #05070a; padding: 5px 7px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.exp-table td { padding: 3px 7px; border-bottom: 1px solid #10151d; white-space: nowrap; }
|
||||
.exp-table tbody tr:hover { background: #0d1219; }
|
||||
.exp-empty { color: var(--muted); padding: 8px; }
|
||||
|
||||
/* ==================== 表单 ==================== */
|
||||
.card-config { display: flex; flex-direction: column; }
|
||||
.tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.config-scope { margin-bottom: 8px; }
|
||||
.tabs { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 9px; }
|
||||
.tabs-inline { margin-bottom: 0; }
|
||||
.tab {
|
||||
padding: 4px 10px; font-size: 12px; cursor: pointer;
|
||||
background: var(--panel-2); border: 1px solid var(--border);
|
||||
border-radius: 6px; color: var(--muted);
|
||||
background: var(--panel-2); border: 1px solid var(--border-soft);
|
||||
border-radius: 999px; color: var(--muted);
|
||||
font-family: inherit;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: #fff; background: var(--accent-2); border-color: var(--accent-2); }
|
||||
.config-body { max-height: 520px; overflow-y: auto; }
|
||||
.config-body { max-height: min(58vh, 560px); overflow-y: auto; padding-right: 2px; }
|
||||
|
||||
.field { margin-bottom: 9px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; }
|
||||
.field .hint { font-size: 11px; color: #5d6672; margin-top: 2px; }
|
||||
.field .hint { font-size: 11px; color: var(--muted); margin-top: 3px; line-height: 1.45; }
|
||||
.input, select.input {
|
||||
width: 100%; padding: 5px 8px;
|
||||
background: #0b0f14; border: 1px solid var(--border);
|
||||
border-radius: 6px; color: var(--text); font-size: 13px;
|
||||
width: 100%; padding: 6px 9px;
|
||||
background: #080b11; border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); color: var(--text); font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.input:focus { border-color: var(--accent-2); }
|
||||
.field-inline { display: flex; align-items: center; gap: 8px; }
|
||||
.field-inline input[type=checkbox] { width: 15px; height: 15px; }
|
||||
.input:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(59, 142, 247, .15); }
|
||||
.field-inline { display: flex; align-items: center; gap: 6px; }
|
||||
.field-inline input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent-2); }
|
||||
.field-label { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
||||
.input-sm { width: auto; min-width: 104px; padding: 4px 8px; font-size: 12px; }
|
||||
.input-grow { flex: 1 1 120px; min-width: 0; width: auto; }
|
||||
.group-title {
|
||||
font-size: 12px; color: var(--accent-2);
|
||||
margin: 12px 0 6px; padding-bottom: 4px;
|
||||
margin: 13px 0 7px; padding-bottom: 4px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
.group-title:first-child { margin-top: 0; }
|
||||
|
||||
.chk { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
||||
.chk input[type=checkbox] { accent-color: var(--accent-2); }
|
||||
|
||||
/* ==================== 提示 ==================== */
|
||||
.toast {
|
||||
position: fixed; right: 18px; bottom: 18px;
|
||||
background: var(--panel-2); border: 1px solid var(--border);
|
||||
position: fixed; right: 16px; bottom: 16px;
|
||||
background: var(--panel-3); border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent-2);
|
||||
padding: 10px 14px; border-radius: 8px;
|
||||
font-size: 13px; max-width: 380px;
|
||||
padding: 10px 14px; border-radius: var(--radius);
|
||||
font-size: 13px; max-width: min(380px, calc(100vw - 32px));
|
||||
opacity: 0; transform: translateY(8px);
|
||||
transition: opacity .2s, transform .2s;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.error { border-left-color: var(--red); }
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.config-body { max-height: none; }
|
||||
/* ==================== 登录遮罩 ==================== */
|
||||
.overlay {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
display: grid; place-items: center;
|
||||
padding: 16px;
|
||||
background: rgba(5, 7, 10, .8);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.overlay-box {
|
||||
width: min(380px, 100%);
|
||||
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-soft) 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.overlay-box h2 { font-size: 16px; margin: 0 0 6px; color: var(--text); text-transform: none; letter-spacing: 0; }
|
||||
.overlay-box .input { margin: 12px 0; }
|
||||
|
||||
/* ==================== 移动端底部导航 ==================== */
|
||||
.mobile-nav { display: none; }
|
||||
|
||||
/* ==================== 响应式 ==================== */
|
||||
|
||||
/* 中等屏幕:配置栏移到下方,两栏布局 */
|
||||
@media (max-width: 1200px) {
|
||||
.layout { grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); }
|
||||
.col-right { grid-column: 1 / -1; }
|
||||
.col-right { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); align-items: start; }
|
||||
.config-body { max-height: min(46vh, 460px); }
|
||||
}
|
||||
|
||||
/* 平板 / 手机:单栏 + 底部导航 */
|
||||
@media (max-width: 820px) {
|
||||
:root { --gap: 10px; }
|
||||
|
||||
.topbar {
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.brand p { display: none; }
|
||||
.topbar-right { width: 100%; justify-content: space-between; }
|
||||
.topbar-right .field-inline { flex: 1 1 100%; }
|
||||
.topbar-right .field-inline select { flex: 1 1 auto; width: auto; }
|
||||
.topbar-actions { width: 100%; }
|
||||
.topbar-actions .btn { flex: 1 1 0; text-align: center; }
|
||||
.badges { width: 100%; }
|
||||
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 10px 10px calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
.col-right { grid-column: auto; display: flex; }
|
||||
|
||||
/* 底部导航:把「状态 / 地图 / 日志 / 配置」四块一键定位 */
|
||||
.mobile-nav {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
z-index: 40;
|
||||
background: rgba(16, 21, 29, .96);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
.mobile-nav-item {
|
||||
flex: 1 1 0;
|
||||
padding: 11px 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border-top: 2px solid transparent;
|
||||
}
|
||||
.mobile-nav-item.active { color: var(--accent-2); border-top-color: var(--accent-2); }
|
||||
|
||||
/* 触控目标放大 */
|
||||
.btn { padding: 8px 13px; font-size: 13px; }
|
||||
.btn-sm { padding: 6px 11px; font-size: 12.5px; }
|
||||
.input, select.input { padding: 8px 10px; font-size: 14px; }
|
||||
.input-sm { padding: 6px 9px; font-size: 12.5px; }
|
||||
.tab { padding: 6px 12px; font-size: 12.5px; }
|
||||
|
||||
#mapCanvas { height: clamp(260px, 52vh, 420px); }
|
||||
.log-view { height: clamp(240px, 46vh, 400px); font-size: 12.5px; }
|
||||
.config-body { max-height: none; }
|
||||
|
||||
.card { padding: 12px; }
|
||||
.kv { grid-template-columns: 84px 1fr; }
|
||||
.toast { left: 12px; right: 12px; bottom: calc(64px + env(safe-area-inset-bottom, 0px)); max-width: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.brand h1 { font-size: 14px; }
|
||||
.schedule-item { grid-template-columns: auto minmax(0, 1fr) auto; }
|
||||
.schedule-item .next { display: none; }
|
||||
.field-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* 横屏手机:地图压矮一点,避免看不到下面的日志 */
|
||||
@media (max-height: 520px) and (orientation: landscape) {
|
||||
#mapCanvas { height: 46vh; }
|
||||
.log-view { height: 40vh; }
|
||||
}
|
||||
|
||||
/* 尊重「减少动效」偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; animation: none !important; }
|
||||
}
|
||||
|
||||
+74
-6
@@ -313,12 +313,34 @@ class WebConsole {
|
||||
if (body.enabled !== undefined) patch.enabled = body.enabled === true;
|
||||
if (body.autoStart !== undefined) patch.autoStart = body.autoStart === true;
|
||||
if (body.config !== undefined) patch.config = body.config;
|
||||
if (body.schedules !== undefined) patch.schedules = body.schedules;
|
||||
const saved = this._setBotSetting(entry, patch);
|
||||
res.json({ ok: true, data: saved });
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
// 定时消息:整体替换某机器人的定时发送列表
|
||||
app.post('/api/bots/:id/schedules', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const schedules = (req.body && req.body.schedules) || [];
|
||||
this._setBotSetting(entry, { schedules });
|
||||
// 返回带「剩余秒数」的快照,前端可直接渲染倒计时
|
||||
res.json({
|
||||
ok: true,
|
||||
data: this._settingSnapshot(entry).schedules || [],
|
||||
message: '定时任务已保存'
|
||||
});
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/bots/:id/schedules', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
res.json({ ok: true, data: this._settingSnapshot(entry).schedules || [] });
|
||||
return null;
|
||||
});
|
||||
});
|
||||
app.post('/api/bots/:id/task/start', async (req, res) => {
|
||||
try {
|
||||
const entry = this.pool.require(req.params.id);
|
||||
@@ -464,7 +486,7 @@ class WebConsole {
|
||||
res.json({ ok: true, data: { enabled: false } });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, data: this.bigMapPayload() });
|
||||
res.json({ ok: true, data: this.bigMapPayload(req.query || {}) });
|
||||
});
|
||||
|
||||
app.get('/api/bigmap/markers', (req, res) => {
|
||||
@@ -498,6 +520,7 @@ class WebConsole {
|
||||
}
|
||||
});
|
||||
|
||||
// 爬取地图瓦片:默认爬「机器人所在位置周边」的图幅,也可显式指定中心与半径
|
||||
app.post('/api/bigmap/crawl', async (req, res) => {
|
||||
if (!this.bigMap) {
|
||||
res.status(500).json({ ok: false, message: '未启用大地图' });
|
||||
@@ -505,11 +528,22 @@ class WebConsole {
|
||||
}
|
||||
const body = req.body || {};
|
||||
const zoom = Number.isFinite(Number(body.zoom)) ? Number(body.zoom) : undefined;
|
||||
const center = this._bigMapCenter({ query: { bot: body.bot } }, body);
|
||||
const rangeBlocks = Number.isFinite(Number(body.rangeBlocks)) ? Number(body.rangeBlocks) : undefined;
|
||||
// 爬取可能持续数十秒:立刻返回,进度通过 /api/bigmap 轮询
|
||||
this.bigMap.crawlAll(zoom, { force: body.force === true }).catch((err) => {
|
||||
this.bigMap.crawlAll(zoom, {
|
||||
force: body.force === true,
|
||||
centerBlockX: center.x,
|
||||
centerBlockZ: center.z,
|
||||
rangeBlocks
|
||||
}).catch((err) => {
|
||||
this.logger.log('大地图爬取失败:' + (err && err.message ? err.message : String(err)), 'error');
|
||||
});
|
||||
res.json({ ok: true, message: '已开始爬取地图瓦片', data: this.bigMap.snapshot() });
|
||||
res.json({
|
||||
ok: true,
|
||||
message: `已开始爬取 ${Math.round(center.x)},${Math.round(center.z)} 周边的瓦片`,
|
||||
data: this.bigMap.snapshot({ centerBlockX: center.x, centerBlockZ: center.z, rangeBlocks })
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bigmap/cache/clear', (req, res) => {
|
||||
@@ -687,7 +721,7 @@ class WebConsole {
|
||||
defaultBotId: this.pool.defaultId || '',
|
||||
expansionLog: this.expansionLog ? this.expansionLog.stats() : null,
|
||||
chatLog: this.chatLog ? this.chatLog.snapshot() : null,
|
||||
bigMap: this.bigMap ? this.bigMap.snapshot() : null,
|
||||
bigMap: this.bigMap ? this.bigMapPayload() : null,
|
||||
serverTime: Date.now(),
|
||||
claimTime: {
|
||||
start: this.getConfig().claimStartHour,
|
||||
@@ -749,10 +783,44 @@ class WebConsole {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算大地图的关注中心。
|
||||
*
|
||||
* 默认以「当前选中的机器人」所在位置为中心 —— 大地图的意义就是看自己机器人周边,
|
||||
* 而不是永远盯着世界原点 (0,0)。没有可用坐标时回退到原点。
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {{centerX?: number, centerZ?: number}} [override] 显式指定的中心优先
|
||||
* @returns {{x: number, z: number, source: string}}
|
||||
*/
|
||||
_bigMapCenter(req, override) {
|
||||
const opts = override || {};
|
||||
if (Number.isFinite(Number(opts.centerX)) && Number.isFinite(Number(opts.centerZ))) {
|
||||
return { x: Number(opts.centerX), z: Number(opts.centerZ), source: 'explicit' };
|
||||
}
|
||||
const follow = !this.bigMap || !this.bigMap.config || !this.bigMap.config.bigMap
|
||||
|| this.bigMap.config.bigMap.followBot !== false;
|
||||
if (follow) {
|
||||
const requested = req && req.query ? req.query.bot : undefined;
|
||||
const entry = this.pool.get(requested) || this.defaultEntry;
|
||||
const position = entry ? entry.manager.snapshot().position : null;
|
||||
if (position) return { x: position.x, z: position.z, source: 'bot' };
|
||||
}
|
||||
return { x: 0, z: 0, source: 'origin' };
|
||||
}
|
||||
|
||||
/** @returns {object} 大地图元信息(坐标系、瓦片范围、缓存进度、机器人与领土) */
|
||||
bigMapPayload() {
|
||||
const snapshot = this.bigMap.snapshot();
|
||||
bigMapPayload(query) {
|
||||
const params = query || {};
|
||||
const center = this._bigMapCenter({ query: params });
|
||||
const rangeBlocks = Number.isFinite(Number(params.rangeBlocks)) ? Number(params.rangeBlocks) : undefined;
|
||||
const snapshot = this.bigMap.snapshot({
|
||||
centerBlockX: center.x,
|
||||
centerBlockZ: center.z,
|
||||
rangeBlocks
|
||||
});
|
||||
return Object.assign(snapshot, {
|
||||
center: { x: center.x, z: center.z, source: center.source },
|
||||
bots: this.pool.positions(),
|
||||
markers: this.bigMapMarkersPayload()
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户