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,全部通过。
这个提交包含在:
lyizai
2026-09-28 00:27:58 +08:00
父节点 fcd5fb2bd9
当前提交 a83dea9677
共修改 14 个文件,包含 1579 行新增和 343 行删除
+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()
});