pMotor 网页修复快照刷新导致输入光标跳动:页面改为加载时一次性构建静态骨架,WebSocket 快照到达后仅就地更新各数值叶节点(setH),不再整块 innerHTML 重建;输入框/按钮常驻 DOM,聚焦与光标不再被每秒推送打断,移除原 __lastSpeed 保值 hack
build-test-deploy / ci (push) Failing after 1h31m36s

This commit is contained in:
zjk
2026-09-09 20:50:31 +08:00
parent c610190c54
commit e59f7e3d2e
+148 -110
View File
@@ -12,6 +12,9 @@
// (fetch /api/motor_cmd -> pMotor 入队 -> MOOS CAN_TX -> pCanBridge -> CAN1)
// - 展示:反馈转速/母线电压、温度、支路一/支路二故障与报警(按位解析中文)
// - 协议定义:docs/推进电机通信协议20260321.docx
//
// 重要:页面 DOM 只在加载时构建一次,收到快照后仅就地更新各数值叶节点,
// 绝不再整块重建 pages(重建会销毁正被聚焦/输入的控件导致光标跳动)。
//============================================================================
namespace motor {
@@ -56,7 +59,7 @@ input[type=number]:focus{outline:none;border-color:#1f6feb;}
</head>
<body>
<h1>pMotor 推进电机控制 <span id="status" class="badge">连接中...</span></h1>
<div class="grid" id="pages">加载中...</div>
<div class="grid" id="pages"></div>
<script>
const $ = id => document.getElementById(id);
@@ -65,7 +68,6 @@ const statusEl = $('status');
function row(k, v){ return '<div class="row"><span class="k">'+k+'</span><span class="v">'+v+'</span></div>'; }
function sub(t){ return '<div class="sub">'+t+'</div>'; }
function hex(v){ return '0x' + (v & 0xFF).toString(16).toUpperCase().padStart(2,'0'); }
function hex16(v){ return '0x' + (v & 0xFFFF).toString(16).toUpperCase().padStart(4,'0'); }
function card(title, html){ return '<div class="card"><h2>'+title+'</h2>'+html+'</div>'; }
function ageText(a){
@@ -80,6 +82,8 @@ function ageCell(d){
return (online ? '<span class="fc-ok">' : '<span class="fc-bad">')
+ ageText(d.age) + (online ? ' (在线)' : ' (超时)') + '</span>';
}
// 就地更新:只替换叶节点内容,绝不重建含输入控件的父级 DOM
function setH(id, html){ const el = $(id); if(el) el.innerHTML = html; }
//------------------ 控制指令(/api/motor_cmd) ------------------
// 链路: 网页 fetch -> pMotor 入队 -> Iterate 周期编码 0x18EF2010
@@ -117,135 +121,172 @@ function motorSetSpeed(){
sendCmd('speed', v, '确认设定期望转速 '+v+' rpm?\n(对应指令帧 Byte2/3,使能状态保持不变)');
}
//------------------ 控制卡片 ------------------
function cmdCard(s){
const c = s || {};
const cmd = c.cmd || {};
const en = cmd.enable;
let h = '';
h += row('使能状态', en
? '<span class="fc-ok">已使能(运行)</span>'
: '<span style="color:var(--dim)">未使能(停机)</span>');
h += row('期望转速', (cmd.speed||0) + ' rpm');
h += row('复位命令', cmd.resetActive ? '<span class="fc-warn">生效中(保持数秒)</span>' : '无效');
if(cmd.lastCmdValid){
const names = {enable:'使能命令=1', disable:'使能命令=0(停机)', reset:'复位命令=1', speed:'期望转速'};
h += row('最近指令', (names[cmd.lastCmdAction]||cmd.lastCmdAction)
+ (cmd.lastCmdAction==='speed' ? ' ('+(cmd.lastCmdSpeed||0)+' rpm)' : '')
+ ' · ' + ageText(cmd.lastCmdAge));
} else {
h += row('最近指令', '<span style="color:var(--dim)">无</span>');
}
h += '<div class="sub">使能命令 (Byte0 bit0)</div>' +
//------------------ 页面骨架(只在加载时构建一次) ------------------
// 所有可被聚焦的控件(输入框/按钮)都在这份骨架里,之后绝不重建。
function buildUI(){
let html = '';
// 控制卡片(含输入框,永不重建)
let c = '';
c += row('使能状态', '<span id="cEnable">…</span>');
c += row('期望转速', '<span id="cSpeed">…</span> rpm');
c += row('复位命令', '<span id="cReset">…</span>');
c += row('最近指令', '<span id="cLast">…</span>');
c += sub('使能命令 (Byte0 bit0)') +
'<div>' +
'<button class="btn on" onclick="motorEnable()">使能 (1)</button>' +
'<button class="btn off" onclick="motorStop()">去使能 (0)</button></div>' +
'<div class="sub">复位命令 (Byte0 bit1,仅故障状态下有效)</div>' +
'<div>' +
'<button class="btn warn" onclick="motorReset()">复位</button></div>' +
'<div class="sub">期望转速 (Byte2/3,s16,单位 rpm)</div>' +
'<button class="btn off" onclick="motorStop()">去使能 (0)</button></div>';
c += sub('复位命令 (Byte0 bit1,仅故障状态下有效)') +
'<div><button class="btn warn" onclick="motorReset()">复位</button></div>';
c += sub('期望转速 (Byte2/3,s16,单位 rpm)') +
'<div>' +
'<input type="number" id="speedInput" value="1000" min="-32767" max="32767" step="100" title="期望转速 (rpm)">' +
'<span style="color:var(--dim);margin-left:6px;">rpm</span> ' +
'<button class="btn" onclick="motorSetSpeed()">设定</button></div>' +
'<div class="sub" style="margin-top:8px;">指令帧 0x18EF2010 周期 ' + ((c.link&&c.link.periodMs)||500) +
'ms 持续发送(协议要求);停机=使能0且转速清0(协议示例 00 00 00 00 00 00 00 00)</div>';
return card('电机指令 (下行 0x18EF2010)', h);
'<div class="sub" style="margin-top:8px;">指令帧 0x18EF2010 周期 <span id="cPeriod">…</span>' +
' ms 持续发送(协议要求);停机=使能0且转速清0(协议示例 00 00 00 00 00 00 00 00)</div>';
html += card('电机指令 (下行 0x18EF2010)', c);
// 运行状态(状态帧3:转速 / 母线电压)
let s3 = '';
s3 += row('反馈转速', '<span id="s3speed" class="state-big">…</span> rpm');
s3 += row('母线电压', '<span id="s3volt">…</span>');
s3 += row('最后更新', '<span id="s3age">…</span>');
s3 += sub('来源 0x18EF1220(100ms,冗余 0x18EF1221)');
html += card('运行状态', s3);
// 温度(状态帧4)
let s4 = '';
s4 += row('A相绕组温度', '<span id="tA">…</span>');
s4 += row('B相绕组温度', '<span id="tB">…</span>');
s4 += row('C相绕组温度', '<span id="tC">…</span>');
s4 += row('逆变组件底板温度', '<span id="tInv">…</span>');
s4 += row('输出滤波器温度', '<span id="tFilter">…</span>');
s4 += row('筒内空气温度', '<span id="tAir">…</span>');
s4 += row('最后更新', '<span id="tAge">…</span>');
s4 += sub('来源 0x18EF1320(100ms,冗余 0x18EF1321,Byte0~5 各 1 字节 s8)');
html += card('温度', s4);
// 支路一 / 支路二 故障报警卡片
function branchCard(title, pfx){
let b = '';
b += '<div class="row"><span class="k" id="'+pfx+'fK">故障</span>' +
'<span class="v"><span id="'+pfx+'faults">…</span></span></div>';
b += '<div class="row"><span class="k" id="'+pfx+'aK">报警</span>' +
'<span class="v"><span id="'+pfx+'alarms">…</span></span></div>';
b += row('故障字原始值', '<span id="'+pfx+'words">…</span>');
b += row('最后更新', '<span id="'+pfx+'age">…</span>');
return card(title, b);
}
html += branchCard('支路一故障/报警 (0x18EF1020)', 'f1');
html += branchCard('支路二故障/报警 (0x18EF1120)', 'f2');
// 通信链路卡片
let l = '';
l += row('下行通道 (CAN1)', '<span id="lch">…</span>');
l += row('冗余通道', '<span id="lred">…</span>');
l += row('指令帧周期', '<span id="lperiod">…</span> ms');
l += row('指令帧累计', '<span id="ltx">…</span>');
l += row('最近下发', '<span id="llast">…</span>');
l += row('电机报文接收', '<span id="lrx">…</span> 帧');
l += sub('链路: 电机 <-> CAN1 <-> CANET <-> pCanBridge <-> MOOS <-> pMotor');
html += card('通信链路 (pCanBridge)', l);
// 协议参考卡片(纯静态)
let p = '';
p += row('0x18EF2010', '主控指令帧(下行 500ms,冗余 0x18EF2011)');
p += row('0x18EF2110', '主控限制帧(下行,全预留,暂不使用)');
p += row('0x18EF1020', '支路一故障报警状态帧1(上行 200ms,冗余 0x18EF1021)');
p += row('0x18EF1120', '支路二故障报警状态帧2(上行 200ms,冗余 0x18EF1121)');
p += row('0x18EF1220', '状态帧3:反馈转速/母线电压(上行 100ms,冗余 0x18EF1221)');
p += row('0x18EF1320', '状态帧4:温度(上行 100ms,冗余 0x18EF1321)');
p += row('帧格式', 'CAN2.0B 扩展帧 29 位 ID,数据域 8 字节,Intel 字节序');
p += row('波特率', '250 kbps(默认,按客户可定制 125~1000)');
html += card('报文定义 (推进电机通信协议 20260321)', p);
pagesEl.innerHTML = html;
}
//------------------ 状态帧3:转速 / 母线电压 ------------------
function state3Card(d){
if(!d) return '';
let h = '';
h += row('反馈转速', '<span class="state-big">'+(d.valid?(d.speed||0):'—')+'</span> rpm');
h += row('母线电压', (d.valid?(d.voltage||0):'—') + (d.valid?' V':''));
h += row('最后更新', ageCell(d));
h += sub('来源 0x18EF1220(100ms,冗余 0x18EF1221)');
return card('运行状态', h);
//------------------ 快照就地更新(不触碰输入控件) ------------------
function updateCmd(c){
c = c || {};
setH('cEnable', c.enable
? '<span class="fc-ok">已使能(运行)</span>'
: '<span style="color:var(--dim)">未使能(停机)</span>');
setH('cSpeed', String(c.speed || 0));
setH('cReset', c.resetActive ? '<span class="fc-warn">生效中(保持数秒)</span>' : '无效');
if(c.lastCmdValid){
const names = {enable:'使能命令=1', disable:'使能命令=0(停机)', reset:'复位命令=1', speed:'期望转速'};
setH('cLast', (names[c.lastCmdAction] || c.lastCmdAction)
+ (c.lastCmdAction==='speed' ? ' ('+(c.lastCmdSpeed||0)+' rpm)' : '')
+ ' · ' + ageText(c.lastCmdAge));
} else {
setH('cLast', '<span style="color:var(--dim)">无</span>');
}
}
//------------------ 状态帧4:温度 ------------------
function state4Card(d){
if(!d) return '';
const v = d.valid;
let h = '';
function updateState3(d){
if(!d) return;
setH('s3speed', d.valid ? String(d.speed || 0) : '—');
setH('s3volt', d.valid ? ((d.voltage||0) + ' V') : '—');
setH('s3age', ageCell(d));
}
function updateState4(d){
if(!d) return;
const temps = [
['A相绕组温度', d.tA], ['B相绕组温度', d.tB], ['C相绕组温度', d.tC],
['逆变组件底板温度', d.tInv], ['输出滤波器温度', d.tFilter], ['筒内空气温度', d.tAir],
['tA', d.tA], ['tB', d.tB], ['tC', d.tC],
['tInv', d.tInv], ['tFilter', d.tFilter], ['tAir', d.tAir],
];
for(const t of temps){
h += row(t[0], v ? (t[1]|0) + ' ℃' : '—');
setH(t[0], d.valid ? ((t[1]|0) + ' ℃') : '—');
}
h += row('最后更新', ageCell(d));
h += sub('来源 0x18EF1320(100ms,冗余 0x18EF1321,Byte0~5 各 1 字节 s8)');
return card('温度', h);
setH('tAge', ageCell(d));
}
//------------------ 支路故障/报警卡片 ------------------
function branchCard(title, d){
if(!d) return '';
const words = d.words || [];
let h = '';
function updateBranch(pfx, d){
if(!d) return;
const faults = d.faults || [];
const alarms = d.alarms || [];
if(faults.length){
h += row('故障(' + faults.length + '条)', faults.map(function(f){ return '<span class="fc-bad">'+f+'</span>'; }).join(''));
} else {
h += row('故障', d.valid ? '<span class="fc-ok">无</span>' : '—');
}
if(alarms.length){
h += row('报警(' + alarms.length + '条)', alarms.map(function(a){ return '<span class="fc-warn">'+a+'</span>'; }).join(''));
} else {
h += row('报警', d.valid ? '<span class="fc-ok">无</span>' : '—');
}
h += row('故障字原始值', words.map(hex16).join(' '));
h += row('最后更新', ageCell(d));
return card(title, h);
setH(pfx+'fK', '故障' + (faults.length ? '(' + faults.length + '条)' : ''));
setH(pfx+'aK', '报警' + (alarms.length ? '(' + alarms.length + '条)' : ''));
setH(pfx+'faults', faults.length
? faults.map(function(f){ return '<span class="fc-bad">'+f+'</span>'; }).join('')
: (d.valid ? '<span class="fc-ok">无</span>' : '—'));
setH(pfx+'alarms', alarms.length
? alarms.map(function(a){ return '<span class="fc-warn">'+a+'</span>'; }).join('')
: (d.valid ? '<span class="fc-ok">无</span>' : '—'));
setH(pfx+'words', (d.words || []).map(hex16).join(' '));
setH(pfx+'age', ageCell(d));
}
//------------------ 通信链路卡片 ------------------
function linkCard(d){
const l = (d && d.link) || {};
let h = '';
h += row('下行通道 (CAN1)', l.channel || 'CAN1');
h += row('冗余通道', l.redChannel ? l.redChannel : '<span style="color:var(--dim)">未配置</span>');
h += row('指令帧周期', (l.periodMs||0) + ' ms');
h += row('指令帧累计', (l.txCount||0) + ' 成功 / ' + (l.txErr||0) + ' 失败');
h += row('最近下发', ageCell({valid:!!l.lastTxValid, age:l.lastTxAge}));
h += row('电机报文接收', (l.rxCount||0) + ' 帧');
h += sub('链路: 电机 <-> CAN1 <-> CANET <-> pCanBridge <-> MOOS <-> pMotor');
return card('通信链路 (pCanBridge)', h);
}
//------------------ 协议参考卡片 ------------------
function protoCard(){
let h = '';
h += row('0x18EF2010', '主控指令帧(下行 500ms,冗余 0x18EF2011)');
h += row('0x18EF2110', '主控限制帧(下行,全预留,暂不使用)');
h += row('0x18EF1020', '支路一故障报警状态帧1(上行 200ms,冗余 0x18EF1021)');
h += row('0x18EF1120', '支路二故障报警状态帧2(上行 200ms,冗余 0x18EF1121)');
h += row('0x18EF1220', '状态帧3:反馈转速/母线电压(上行 100ms,冗余 0x18EF1221)');
h += row('0x18EF1320', '状态帧4:温度(上行 100ms,冗余 0x18EF1321)');
h += row('帧格式', 'CAN2.0B 扩展帧 29 位 ID,数据域 8 字节,Intel 字节序');
h += row('波特率', '250 kbps(默认,按客户可定制 125~1000)');
return card('报文定义 (推进电机通信协议 20260321)', h);
function updateLink(l){
l = l || {};
setH('lch', l.channel || 'CAN1');
setH('lred', l.redChannel ? l.redChannel : '<span style="color:var(--dim)">未配置</span>');
setH('lperiod', String(l.periodMs || 0));
setH('ltx', (l.txCount||0) + ' 成功 / ' + (l.txErr||0) + ' 失败');
setH('llast', ageCell({valid:!!l.lastTxValid, age:l.lastTxAge}));
setH('lrx', String(l.rxCount || 0));
}
function render(snap){
let html = '';
html += cmdCard(snap);
html += state3Card(snap.state3);
html += state4Card(snap.state4);
html += branchCard('支路一故障/报警 (0x18EF1020)', snap.fault1);
html += branchCard('支路二故障/报警 (0x18EF1120)', snap.fault2);
html += linkCard(snap);
html += protoCard();
pagesEl.innerHTML = html;
// 保留转速输入值(render 会重建 DOM)
const prev = window.__lastSpeed;
if(prev !== undefined){ const el = $('speedInput'); if(el) el.value = prev; }
snap = snap || {};
const link = snap.link || {};
updateCmd(snap.cmd);
updateState3(snap.state3);
updateState4(snap.state4);
updateBranch('f1', snap.fault1);
updateBranch('f2', snap.fault2);
updateLink(link);
setH('cPeriod', String(link.periodMs || 500));
}
// 骨架构建一次后即保持稳定;render 只做叶节点就地更新,
// 输入框不再被销毁重建,光标与焦点因此不受快照刷新影响。
buildUI();
render({});
function connect(){
const proto = location.protocol==='https:'?'wss':'ws';
const ws = new WebSocket(proto+'://'+location.host+'/ws');
@@ -254,10 +295,7 @@ function connect(){
ws.onerror = ()=>{ ws.close(); };
ws.onmessage = (ev)=>{
try{
const snap = JSON.parse(ev.data);
const el = $('speedInput');
if(el && el === document.activeElement) window.__lastSpeed = el.value;
render(snap);
render(JSON.parse(ev.data));
}catch(e){}
};
}