feat: 1.7.0 落地 TODO 全部待办(地面修正 / 手动执行器 / 自救 / 回城 / 租约 / 通知)
按 TODO.md 的优先级把待办全部落地为代码,详见 CHANGELOG 的 [1.7.0]。 - 「走不动」攻关:地面状态修正(syncGroundToPhysics / trustSetbackOnGround)、 出站包类型分布、备选执行器 manualMover(只借 A* 规划、自己按键执行) - 原点自救分两轮并统计成败;回城前评估「原地是否更差」并记录前后坐标 - 新增目标区块租约表(targetLease)避免多机器人撞同一块 - 定时消息支持 cron 与「期望回复」确认;新增人工介入 Webhook 通知(notifier) - 大地图后台预热与视野裁剪;控制台日志按帧批量写入、配置面板支持搜索 顺带修掉两个影响部署的细节: - 部署脚本(deploy.js / ssh-helper.js)改为也能从 .env 读 SSH 凭据 —— 在命令行传密码会留在 shell 历史与进程列表里,写进已被忽略的 .env 更安全; 显式环境变量仍然优先。.env.example 补上占位符并说明这些只在本机用。 - package.json / package-lock.json 版本对齐到 1.7.0(此前停留在 1.1.0, 与 CHANGELOG 的语义化版本声明脱节)。 测试:368 → 436 个用例,`npm test` 与 `npm run check` 全绿。
这个提交包含在:
+200
-11
@@ -160,6 +160,18 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'regionCenterZ', label: '中心 Z(区块)', type: 'number' },
|
||||
{ path: 'regionRadius', label: '半径(区块)', type: 'number', min: 1 }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '多机器人协调',
|
||||
fields: [
|
||||
{
|
||||
path: 'coordination.targetLeaseTtlSeconds',
|
||||
label: '目标区块租约时长(秒)',
|
||||
type: 'number',
|
||||
min: 30,
|
||||
hint: '多个机器人同时扩地时用租约避免撞在同一区块上;持有者持续续租,掉线 / 卡死后租约到期自动释放'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
timing: [
|
||||
@@ -218,7 +230,12 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 },
|
||||
{ path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' },
|
||||
{ path: 'pathing.maxSegmentBlocks', label: '单段寻路最长距离(格)', type: 'number', min: 32, hint: '寻路器只能规划已加载区块范围(本服约 130 格),长距离会自动拆段;此项只是单段上限' },
|
||||
{ path: 'pathing.segmentMarginBlocks', label: '分段安全边距(格)', type: 'number', min: 0, hint: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' }
|
||||
{ path: 'pathing.segmentMarginBlocks', label: '分段安全边距(格)', type: 'number', min: 0, hint: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' },
|
||||
{ path: 'pathing.executor', label: '寻路执行器', type: 'select', options: [
|
||||
{ value: 'pathfinder', label: 'pathfinder(默认)' },
|
||||
{ value: 'manual', label: '手动执行(备选方案)' }
|
||||
], hint: '手动执行:只用 A* 算路径,执行改为自己按键(与手动移动测试一致的时序);仅在实测确认寻路器执行层被服务端回弹时才切换' },
|
||||
{ path: 'pathing.manualTimeoutMs', label: '手动执行超时(毫秒)', type: 'number', min: 10000, hint: '单段手动执行的最长时间,防止卡死时无限按键' }
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -282,7 +299,12 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'bigMap.maxZoom', label: '最大可浏览层级', type: 'number', min: 0, max: 8, hint: '本站 squaremap 为 3(该层级 1 方块 = 1 像素)' },
|
||||
{ path: 'bigMap.rangeBlocks', label: '覆盖半径(方块)', type: 'number', min: 256, hint: '默认 20480,与服务器世界边界一致' },
|
||||
{ path: 'bigMap.crawlOnStart', label: '启动时自动预爬取', type: 'checkbox' },
|
||||
{ path: 'bigMap.concurrency', label: '瓦片下载并发数', type: 'number', min: 1, max: 16 }
|
||||
{ path: 'bigMap.concurrency', label: '瓦片下载并发数', type: 'number', min: 1, max: 16 },
|
||||
{ path: 'bigMap.prewarm', label: '后台预热高层级瓦片', type: 'checkbox', hint: '机器人移动到新区域时低优先级拉取周边高层级瓦片,避免首次缩放时干等;已缓存的不重复下载' },
|
||||
{ path: 'bigMap.prewarmRangeBlocks', label: '预热半径(方块)', type: 'number', min: 256 },
|
||||
{ path: 'bigMap.prewarmLevels', label: '预热层级数', type: 'number', min: 0, max: 8, hint: '从「启动预爬取层级」往上数几层(受最大可浏览层级限制)' },
|
||||
{ path: 'bigMap.prewarmMinMoveBlocks', label: '移动多少格后重新预热', type: 'number', min: 0 },
|
||||
{ path: 'bigMap.prewarmIntervalMs', label: '预热最小间隔(毫秒)', type: 'number', min: 60000, hint: '即便没怎么移动,超过这个时间也会再预热一次' }
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -301,6 +323,9 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
|
||||
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' },
|
||||
{ path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' },
|
||||
{ path: 'grim.syncGroundToPhysics', label: '物理引擎 onGround 对齐(方案 A)', type: 'checkbox', hint: '每个物理 tick 用世界数据复核脚下是否有方块,是则把物理引擎的 onGround 同步为 true,让移动走地面分支(TODO 问题 1 的疑似根因)' },
|
||||
{ path: 'grim.trustSetbackOnGround', label: '不采纳例行同步的 onGround(方案 B)', type: 'checkbox', hint: '服务端下发的位置与本地几乎一致时,只同步坐标、不采纳它的「在空中」,避免物理引擎的地面状态被反复打断' },
|
||||
{ path: 'grim.setbackSyncEpsilon', label: '方案 B 判定阈值(格)', type: 'number', step: 0.05, min: 0, max: 5, hint: '位置差不超过它时视为例行同步;超过则按真正的传送处理。先用默认值实测,再看状态卡里的「最近同步差」调整' },
|
||||
{ path: 'grim.sendPlayerInput', label: '每 tick 补发 player_input', type: 'checkbox', hint: '1.21.2+ 移动预测依赖它;mineflayer 地面移动时完全不发,导致反作弊判定「无输入却位移」并把机器人每 tick 拉回(实测本服必需)' },
|
||||
{ path: 'grim.sendTickEnd', label: '每 tick 补发 tick_end', type: 'checkbox', hint: '原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界' }
|
||||
]
|
||||
@@ -322,6 +347,15 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 },
|
||||
{ path: 'server.reconnectMaxDelayMs', label: '重连间隔上限(毫秒)', type: 'number', min: 1000, hint: '连续重连失败时按指数退避,最多退到这个间隔' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '通知提醒(Webhook)',
|
||||
fields: [
|
||||
{ path: 'notify.enabled', label: '启用通知', type: 'checkbox', hint: '需要人工介入时(等待验证码 / 原点自救失败)推送到飞书等自定义机器人,不用一直盯控制台' },
|
||||
{ path: 'notify.webhookUrl', label: 'Webhook 地址', type: 'password', hint: '飞书 / Lark 自定义机器人的 Webhook;留空回退环境变量 NOTIFY_WEBHOOK_URL' },
|
||||
{ path: 'notify.onVerification', label: '等待验证码时提醒', type: 'checkbox', hint: '验证码只有 120 秒时限,错过要等下次重登' },
|
||||
{ path: 'notify.onOriginRescueFailed', label: '原点自救失败时提醒', type: 'checkbox', hint: '自救失败多半说明被切到了登录服,需要人工发验证码' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -442,11 +476,41 @@ function renderConfig() {
|
||||
body.appendChild(buildField(field));
|
||||
}
|
||||
}
|
||||
filterConfigFields($('configSearch') ? $('configSearch').value : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词过滤配置项(8 个标签页、字段很多,逐个找很费劲)。
|
||||
*
|
||||
* 只隐藏不删除:隐藏的输入仍然留在 DOM 里,保存时 `collectConfig` 依旧能读到它们的值。
|
||||
*
|
||||
* @param {string} query 搜索词(名称 / 字段名 / 说明,大小写不敏感)
|
||||
*/
|
||||
function filterConfigFields(query) {
|
||||
const body = $('configBody');
|
||||
if (!body) return;
|
||||
const keyword = String(query || '').trim().toLowerCase();
|
||||
let lastTitle = null;
|
||||
let titleHasVisible = false;
|
||||
for (const child of Array.from(body.children)) {
|
||||
if (child.classList.contains('group-title')) {
|
||||
if (lastTitle) lastTitle.classList.toggle('hidden', !titleHasVisible);
|
||||
lastTitle = child;
|
||||
titleHasVisible = false;
|
||||
continue;
|
||||
}
|
||||
const match = !keyword || (child.dataset.search || '').includes(keyword);
|
||||
child.classList.toggle('hidden', !match);
|
||||
if (match) titleHasVisible = true;
|
||||
}
|
||||
if (lastTitle) lastTitle.classList.toggle('hidden', !titleHasVisible);
|
||||
}
|
||||
|
||||
function buildField(field) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'field';
|
||||
// 供配置搜索使用:名称 + 字段路径 + 说明一起匹配,输入「回城」「returnOnTimeout」都能找到
|
||||
wrap.dataset.search = `${field.label} ${field.path} ${field.hint || ''}`.toLowerCase();
|
||||
const label = document.createElement('label');
|
||||
label.textContent = field.label;
|
||||
wrap.appendChild(label);
|
||||
@@ -751,6 +815,18 @@ function renderStatus(status) {
|
||||
loginEl.title = '';
|
||||
}
|
||||
$('kReconnect').textContent = String(bot.reconnectAttempts || 0);
|
||||
// 原点自救统计:成功率与最近一次结果(失败说明可能其实是登录服,需要人工发验证码)
|
||||
const rescue = bot.originRescue || {};
|
||||
const rescueEl = $('kOriginRescue');
|
||||
if ((rescue.attempts || 0) > 0) {
|
||||
const last = rescue.lastResult;
|
||||
rescueEl.textContent = `成功 ${rescue.successes || 0}/${rescue.attempts}`
|
||||
+ (last ? `(最近${last.success ? '成功' : '失败'},第 ${last.rounds} 轮)` : '');
|
||||
rescueEl.style.color = last && last.success ? 'var(--green)' : 'var(--yellow)';
|
||||
} else {
|
||||
rescueEl.textContent = '未触发';
|
||||
rescueEl.style.color = '';
|
||||
}
|
||||
|
||||
// 上线 / 下线按钮:在线时是「下线」,离线时是「上线」
|
||||
const offlineBtn = $('btnOffline');
|
||||
@@ -789,6 +865,21 @@ function renderStatus(status) {
|
||||
$('kRetry').textContent = String(task.retryCount || 0);
|
||||
$('kProgress').textContent = (task.lastProgress || 0) + '%';
|
||||
$('kTimeout').textContent = Math.round((task.consecutiveTimeoutMs || 0) / 1000) + 's';
|
||||
// 最近一次回城把机器人从哪挪到了哪(回城是破坏性操作,必须能看出是否值得)
|
||||
const returnEl = $('kLastReturn');
|
||||
const lastReturn = task.lastReturn;
|
||||
if (lastReturn && lastReturn.from && lastReturn.to) {
|
||||
returnEl.textContent = `(${lastReturn.from.x}, ${lastReturn.from.z}) → `
|
||||
+ `(${lastReturn.to.x}, ${lastReturn.to.z}),直线 ${lastReturn.distance} 格`;
|
||||
returnEl.title = lastReturn.reason || '';
|
||||
returnEl.style.color = lastReturn.distance >= 8 ? 'var(--green)' : 'var(--yellow)';
|
||||
} else if ((task.returnSkippedNotStuck || 0) > 0) {
|
||||
returnEl.textContent = `未回城(原地还能走,已放弃 ${task.returnSkippedNotStuck} 次)`;
|
||||
returnEl.style.color = 'var(--green)';
|
||||
} else {
|
||||
returnEl.textContent = '-';
|
||||
returnEl.style.color = '';
|
||||
}
|
||||
$('kClaimTime').textContent = task.withinClaimTime ? '时段内' : '时段外';
|
||||
$('progressBar').style.width = (task.lastProgress || 0) + '%';
|
||||
|
||||
@@ -850,6 +941,13 @@ function renderStatus(status) {
|
||||
$('kGrimPackets').style.color = parts.length > 0 ? 'var(--green)' : 'var(--yellow)';
|
||||
}
|
||||
$('kGrimBlocked').textContent = String(grim.blockedActions || 0);
|
||||
// 地面状态修正(TODO 问题 1):方案 A 对齐次数 / 方案 B 保住次数 / 最近一次同步差
|
||||
const stats = bot.packetStats || {};
|
||||
const groundParts = [`物理对齐 ${stats.physicsGroundSync || 0}`, `保住 ${stats.setbackOnGroundKept || 0}`];
|
||||
if (stats.lastSetbackDelta) groundParts.push(`最近同步差 ${stats.lastSetbackDelta.distance} 格`);
|
||||
$('kGrimGround').textContent = groundParts.join(' · ');
|
||||
$('kGrimGround').style.color = (stats.physicsGroundSync || stats.setbackOnGroundKept)
|
||||
? 'var(--green)' : 'var(--yellow)';
|
||||
$('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常';
|
||||
$('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)';
|
||||
const issues = $('grimIssues');
|
||||
@@ -878,7 +976,10 @@ 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(':'))
|
||||
.map((t) => [
|
||||
t.id, t.enabled, t.content, t.mode, t.cron, t.intervalSeconds, t.nextInSeconds,
|
||||
t.expectPattern, t.lastResult ? `${t.lastResult.ok ? 1 : 0}@${t.lastResult.at}` : ''
|
||||
].join(':'))
|
||||
.join('|');
|
||||
if (signature === scheduleSignature) return;
|
||||
scheduleSignature = signature;
|
||||
@@ -887,7 +988,7 @@ function renderSchedules(schedules) {
|
||||
if (!schedules || schedules.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'schedule-empty';
|
||||
empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间重复发送同一条消息或指令。';
|
||||
empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间(或按 cron 表达式)重复发送同一条消息或指令。';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -911,8 +1012,22 @@ function renderSchedules(schedules) {
|
||||
content.appendChild(code);
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'sub';
|
||||
sub.textContent = `${task.kind === 'command' ? '指令' : '公屏'} · 每 ${task.intervalSeconds} 秒`
|
||||
+ (task.label ? ` · ${task.label}` : '');
|
||||
// 调度描述:cron 任务显示表达式,间隔任务显示秒数;无效表达式会带出错误说明
|
||||
const scheduleText = task.mode === 'cron'
|
||||
? `cron ${task.cron}`
|
||||
: `每 ${task.intervalSeconds} 秒`;
|
||||
const parts = [task.kind === 'command' ? '指令' : '公屏', scheduleText];
|
||||
if (task.label) parts.push(task.label);
|
||||
// 发送结果确认:成功显示绿色对勾,超时未确认显示黄色感叹号
|
||||
if (task.expectPattern) {
|
||||
if (task.lastResult && task.lastResult.ok) parts.push('✓ 已确认');
|
||||
else if (task.lastResult) parts.push('⚠ 未确认');
|
||||
else parts.push('待确认');
|
||||
}
|
||||
if (task.cronError) parts.push(task.cronError);
|
||||
sub.textContent = parts.join(' · ');
|
||||
if (task.cronError) sub.style.color = 'var(--red)';
|
||||
else if (task.lastResult && task.lastResult.ok === false) sub.style.color = 'var(--yellow)';
|
||||
content.appendChild(sub);
|
||||
item.appendChild(content);
|
||||
|
||||
@@ -949,14 +1064,18 @@ async function saveSchedules(schedules) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 取当前列表的原始定义(去掉服务端附加的剩余秒数字段) */
|
||||
/** 取当前列表的原始定义(去掉服务端附加的剩余秒数 / 发送结果等运行时字段) */
|
||||
function currentSchedules() {
|
||||
const bot = selectedBot(lastStatus || {});
|
||||
return ((bot && bot.schedules) || []).map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
content: t.content,
|
||||
mode: t.mode === 'cron' ? 'cron' : 'interval',
|
||||
cron: t.cron || '',
|
||||
intervalSeconds: t.intervalSeconds,
|
||||
expectPattern: t.expectPattern || '',
|
||||
confirmTimeoutSeconds: t.confirmTimeoutSeconds,
|
||||
enabled: t.enabled
|
||||
}));
|
||||
}
|
||||
@@ -1629,9 +1748,46 @@ async function renderSimpV2(body, force) {
|
||||
// ==================== 运行日志 ====================
|
||||
|
||||
const MAX_LOG_LINES = 800;
|
||||
/**
|
||||
* 待渲染的日志队列。
|
||||
*
|
||||
* 服务端是「一条日志一次推送」,而机器人卡住时日志会成串到达(例如「被服务器传送」
|
||||
* 的爆发),逐条改 DOM + 逐条写 scrollTop 会让日志区明显卡顿(手机上尤其明显)。
|
||||
* 这里把同一帧内到达的日志合并成一次 DOM 更新。
|
||||
*/
|
||||
let pendingLogs = [];
|
||||
let logFlushScheduled = false;
|
||||
|
||||
function appendLog(entry) {
|
||||
pendingLogs.push(entry);
|
||||
// 后台标签页里 rAF 不触发,这里做个上限,避免队列无限增长
|
||||
if (pendingLogs.length > MAX_LOG_LINES * 2) pendingLogs.splice(0, pendingLogs.length - MAX_LOG_LINES * 2);
|
||||
scheduleLogFlush();
|
||||
}
|
||||
|
||||
function scheduleLogFlush() {
|
||||
if (logFlushScheduled) return;
|
||||
logFlushScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
logFlushScheduled = false;
|
||||
flushLogs();
|
||||
});
|
||||
}
|
||||
|
||||
/** 把队列里的日志一次性写入 DOM(一次布局、一次滚动) */
|
||||
function flushLogs() {
|
||||
if (pendingLogs.length === 0) return;
|
||||
const view = $('logView');
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const entry of pendingLogs) fragment.appendChild(buildLogLine(entry));
|
||||
pendingLogs = [];
|
||||
view.appendChild(fragment);
|
||||
while (view.childNodes.length > MAX_LOG_LINES) view.removeChild(view.firstChild);
|
||||
if ($('chkAutoScroll').checked) view.scrollTop = view.scrollHeight;
|
||||
}
|
||||
|
||||
/** 构造一行日志的 DOM */
|
||||
function buildLogLine(entry) {
|
||||
const line = document.createElement('span');
|
||||
line.className = 'log-line log-' + (entry.level || 'info');
|
||||
const time = document.createElement('span');
|
||||
@@ -1639,14 +1795,14 @@ function appendLog(entry) {
|
||||
time.textContent = '[' + entry.time + ']';
|
||||
line.appendChild(time);
|
||||
line.appendChild(document.createTextNode(entry.message));
|
||||
view.appendChild(line);
|
||||
while (view.childNodes.length > MAX_LOG_LINES) view.removeChild(view.firstChild);
|
||||
if ($('chkAutoScroll').checked) view.scrollTop = view.scrollHeight;
|
||||
return line;
|
||||
}
|
||||
|
||||
function renderLogs(entries) {
|
||||
pendingLogs = [];
|
||||
$('logView').innerHTML = '';
|
||||
for (const entry of entries) appendLog(entry);
|
||||
for (const entry of entries) pendingLogs.push(entry);
|
||||
flushLogs();
|
||||
}
|
||||
|
||||
// ==================== 公屏聊天 ====================
|
||||
@@ -1991,6 +2147,9 @@ function drawBigMap() {
|
||||
ctx.lineWidth = 1 / px;
|
||||
for (const state of bigMapView.markers.states) {
|
||||
if (!state.path) continue;
|
||||
// 按视野裁剪:全世界视角下邦国多边形很多,视野外的直接跳过(超大地图防掉帧)
|
||||
if (state.bounds && (state.bounds.maxX < minBlockX || state.bounds.minX > maxBlockX
|
||||
|| state.bounds.maxZ < minBlockZ || state.bounds.minZ > maxBlockZ)) continue;
|
||||
ctx.fillStyle = hexToRgba(state.color, 0.26);
|
||||
ctx.fill(state.path, 'evenodd');
|
||||
ctx.strokeStyle = hexToRgba(state.color, 0.85);
|
||||
@@ -2304,14 +2463,26 @@ function setBigMapMarkers(markers) {
|
||||
for (const state of states) {
|
||||
const path = new Path2D();
|
||||
let has = false;
|
||||
// 方块坐标包围盒:全世界视角下用来按视野裁剪多边形(视野外的不必绘制,避免掉帧)
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minZ = Infinity;
|
||||
let maxZ = -Infinity;
|
||||
for (const flat of state.polygons || []) {
|
||||
if (flat.length < 6) continue;
|
||||
path.moveTo(flat[0], flat[1]);
|
||||
for (let i = 2; i < flat.length; i += 2) path.lineTo(flat[i], flat[i + 1]);
|
||||
path.closePath();
|
||||
for (let i = 0; i < flat.length; i += 2) {
|
||||
if (flat[i] < minX) minX = flat[i];
|
||||
if (flat[i] > maxX) maxX = flat[i];
|
||||
if (flat[i + 1] < minZ) minZ = flat[i + 1];
|
||||
if (flat[i + 1] > maxZ) maxZ = flat[i + 1];
|
||||
}
|
||||
has = true;
|
||||
}
|
||||
state.path = has ? path : null;
|
||||
state.bounds = Number.isFinite(minX) ? { minX, maxX, minZ, maxZ } : null;
|
||||
}
|
||||
bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states };
|
||||
}
|
||||
@@ -2495,11 +2666,21 @@ function bindEvents() {
|
||||
toast('请填写要发送的内容', true);
|
||||
return;
|
||||
}
|
||||
const mode = $('schedMode').value === 'cron' ? 'cron' : 'interval';
|
||||
const cron = $('schedCron').value.trim();
|
||||
if (mode === 'cron' && !cron) {
|
||||
toast('cron 模式下必须填写表达式,例如 0 9 * * *', true);
|
||||
return;
|
||||
}
|
||||
const next = currentSchedules();
|
||||
next.push({
|
||||
id: 'task-' + Date.now().toString(36),
|
||||
content,
|
||||
mode,
|
||||
cron: mode === 'cron' ? cron : '',
|
||||
intervalSeconds: Number($('schedInterval').value) || 300,
|
||||
expectPattern: $('schedExpect').value.trim(),
|
||||
confirmTimeoutSeconds: Number($('schedConfirm').value) || 10,
|
||||
label: $('schedLabel').value.trim(),
|
||||
enabled: true
|
||||
});
|
||||
@@ -2507,6 +2688,11 @@ function bindEvents() {
|
||||
await saveSchedules(next);
|
||||
});
|
||||
|
||||
// 调度方式切换:选 cron 时显示表达式输入框(固定间隔输入框保留,切回来不用重填)
|
||||
$('schedMode').addEventListener('change', () => {
|
||||
$('schedCronField').classList.toggle('hidden', $('schedMode').value !== 'cron');
|
||||
});
|
||||
|
||||
$('btnRefreshMap').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/map/refresh', { method: 'POST' });
|
||||
@@ -2555,6 +2741,9 @@ function bindEvents() {
|
||||
renderConfig();
|
||||
});
|
||||
|
||||
// 配置搜索:只隐藏不删除,保存时仍能读到隐藏字段的值
|
||||
$('configSearch').addEventListener('input', (e) => filterConfigFields(e.target.value));
|
||||
|
||||
// ===== 地图视图切换 =====
|
||||
$('mapTabs').addEventListener('click', (e) => {
|
||||
const tab = e.target.closest('.tab');
|
||||
|
||||
+31
-3
@@ -81,6 +81,7 @@
|
||||
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
|
||||
<dt>状态</dt><dd id="kBotState">-</dd>
|
||||
<dt>登录验证</dt><dd id="kLogin">-</dd>
|
||||
<dt>原点自救</dt><dd id="kOriginRescue">-</dd>
|
||||
<dt>重连次数</dt><dd id="kReconnect">0</dd>
|
||||
</dl>
|
||||
<div class="row">
|
||||
@@ -102,6 +103,7 @@
|
||||
<dt>重试</dt><dd id="kRetry">0</dd>
|
||||
<dt>开垦进度</dt><dd id="kProgress">0%</dd>
|
||||
<dt>连续寻路超时</dt><dd id="kTimeout">0s</dd>
|
||||
<dt>最近回城</dt><dd id="kLastReturn">-</dd>
|
||||
<dt>开垦时段</dt><dd id="kClaimTime">-</dd>
|
||||
</dl>
|
||||
<div class="progress"><div id="progressBar" class="progress-fill"></div></div>
|
||||
@@ -119,20 +121,42 @@
|
||||
<input id="schedContent" class="input" type="text" placeholder="/u restore confirm">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>调度方式</label>
|
||||
<select id="schedMode" class="input">
|
||||
<option value="interval">固定间隔</option>
|
||||
<option value="cron">cron 表达式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>间隔(秒)</label>
|
||||
<input id="schedInterval" class="input" type="number" min="5" value="300">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field hidden" id="schedCronField">
|
||||
<label>cron 表达式(分 时 日 月 周,本地时间)</label>
|
||||
<input id="schedCron" class="input" type="text" placeholder="0 9 * * *">
|
||||
<div class="hint">例:<code>*/30 * * * *</code> 每 30 分钟 · <code>0 9 * * *</code> 每天 09:00 · <code>0 12 * * 1-5</code> 工作日 12:00</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>备注</label>
|
||||
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
|
||||
<label>期望回复(正则,可留空)</label>
|
||||
<input id="schedExpect" class="input" type="text" placeholder="民心.*恢复">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>确认超时(秒)</label>
|
||||
<input id="schedConfirm" class="input" type="number" min="1" max="120" value="10">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>备注</label>
|
||||
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
|
||||
</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>
|
||||
<p class="hint">只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。填了「期望回复」时,发出后会等待服务器回复,超时未回复会在日志里给出警告。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +188,7 @@
|
||||
<dt>攻击距离</dt><dd id="kGrimReach">-</dd>
|
||||
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
|
||||
<dt>协议补包</dt><dd id="kGrimPackets">-</dd>
|
||||
<dt>地面修正</dt><dd id="kGrimGround">-</dd>
|
||||
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
|
||||
<dt>物理自检</dt><dd id="kGrimAudit">-</dd>
|
||||
</dl>
|
||||
@@ -323,6 +348,9 @@
|
||||
<button class="tab" data-tab="grim">Grim</button>
|
||||
<button class="tab" data-tab="server">服务器</button>
|
||||
</div>
|
||||
<div class="field config-search">
|
||||
<input id="configSearch" class="input" type="search" placeholder="搜索配置项(名称 / 字段名 / 说明)" autocomplete="off">
|
||||
</div>
|
||||
<div id="configBody" class="config-body"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -314,6 +314,17 @@ html, body {
|
||||
.log-error { color: var(--red); }
|
||||
.log-success { color: var(--green); }
|
||||
|
||||
/*
|
||||
* 日志 / 公屏行很多时(移动端尤其明显):让浏览器跳过「视口外」行的渲染与布局。
|
||||
* 相当于浏览器自带的虚拟列表 —— 不改变内容与滚动位置,只是不再为看不见的行做绘制。
|
||||
* 不支持的浏览器会忽略这两行,行为与原来一致。
|
||||
*/
|
||||
.log-line,
|
||||
.chat-line {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 20px;
|
||||
}
|
||||
|
||||
.chat-line { display: block; }
|
||||
.chat-time { color: #55606f; margin-right: 6px; }
|
||||
.chat-bot { color: var(--accent-2); margin-right: 4px; }
|
||||
|
||||
@@ -11,6 +11,13 @@ const { defaultConfig, normalizeConfig } = require('../config/expansionConfig');
|
||||
|
||||
/** 状态推送间隔(毫秒) */
|
||||
const STATUS_PUSH_INTERVAL_MS = 1000;
|
||||
/**
|
||||
* 大地图预热检查间隔(毫秒)。
|
||||
*
|
||||
* 这里只是「低频问一句」,真正的节流(移动够远 / 距上次够久)由
|
||||
* BigMapService.prewarmAround 自己判断,所以这个间隔可以设得很保守。
|
||||
*/
|
||||
const MAP_PREWARM_CHECK_INTERVAL_MS = 60000;
|
||||
/** 登录接口路径(免令牌,否则无法登录) */
|
||||
const LOGIN_PATH = '/api/login';
|
||||
/** 会话探测接口路径(免令牌) */
|
||||
@@ -52,6 +59,8 @@ class WebConsole {
|
||||
this.expansionLog = deps.expansionLog || null;
|
||||
this.chatLog = deps.chatLog || null;
|
||||
this.bigMap = deps.bigMap || null;
|
||||
/** @type {import('../core/notifier').Notifier|null} 人工介入提醒(状态只暴露域名,不含完整地址) */
|
||||
this.notifier = deps.notifier || null;
|
||||
this.getConfig = deps.getConfig;
|
||||
this.applyConfig = deps.applyConfig;
|
||||
this.exportDir = opts.exportDir || path.join(__dirname, '..', '..', 'data');
|
||||
@@ -120,6 +129,10 @@ class WebConsole {
|
||||
}, STATUS_PUSH_INTERVAL_MS);
|
||||
if (this._pushTimer.unref) this._pushTimer.unref();
|
||||
|
||||
// 大地图后台预热:低频检查机器人位置,移动够远就顺带拉取周边高层级瓦片(低优先级)
|
||||
this._mapPrewarmTimer = setInterval(() => this._maybePrewarmMap(), MAP_PREWARM_CHECK_INTERVAL_MS);
|
||||
if (this._mapPrewarmTimer.unref) this._mapPrewarmTimer.unref();
|
||||
|
||||
// 新日志 / 新公屏消息 / 新开拓记录实时推送
|
||||
this.logger.onEntry = (entry) => this._broadcast('log', entry);
|
||||
if (this.chatLog) this.chatLog.onEntry = (entry) => this._broadcast('chat', entry);
|
||||
@@ -148,6 +161,7 @@ class WebConsole {
|
||||
/** 停止服务 */
|
||||
async stop() {
|
||||
if (this._pushTimer) clearInterval(this._pushTimer);
|
||||
if (this._mapPrewarmTimer) clearInterval(this._mapPrewarmTimer);
|
||||
this.logger.onEntry = null;
|
||||
if (this.chatLog) this.chatLog.onEntry = null;
|
||||
if (this.expansionLog) this.expansionLog.onEntry = null;
|
||||
@@ -949,6 +963,8 @@ class WebConsole {
|
||||
expansionLog: this.expansionLog ? this.expansionLog.stats() : null,
|
||||
chatLog: this.chatLog ? this.chatLog.snapshot() : null,
|
||||
bigMap: this.bigMap ? this.bigMapPayload() : null,
|
||||
/** 通知提醒状态:是否启用 / 是否配好地址 / 已发与失败条数 */
|
||||
notify: this.notifier ? this.notifier.snapshot() : null,
|
||||
serverTime: Date.now(),
|
||||
claimTime: {
|
||||
start: this.getConfig().claimStartHour,
|
||||
@@ -1053,6 +1069,26 @@ class WebConsole {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 大地图后台预热(低频调用):以「第一个有坐标的机器人」为中心,
|
||||
* 交给 BigMapService 判断是否值得预热(移动够远 / 距上次够久)。
|
||||
*
|
||||
* 预热是低优先级任务:失败只记一条日志,不影响任何业务功能。
|
||||
*/
|
||||
_maybePrewarmMap() {
|
||||
if (!this.bigMap) return;
|
||||
let target = null;
|
||||
try {
|
||||
target = this.pool.positions().find((bot) => bot.position) || null;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!target) return;
|
||||
this.bigMap.prewarmAround(target.position.x, target.position.z).catch((err) => {
|
||||
this.logger.log('大地图预热失败:' + (err && err.message ? err.message : String(err)), 'warn');
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {object} 大地图上的领土标记(多边形 + 王城) */
|
||||
bigMapMarkersPayload() {
|
||||
const model = this.dataService.getModel();
|
||||
|
||||
在新工单中引用
屏蔽一个用户