pELoad 新增功能模式(FUNC:MODE)远程切换与实时曲线页:切换带白名单校验/下发后立即回读确认(真实反馈驱动显示),模式名规范化兼容设备长格式回读(Fixed/LIST/BATTery/SOLar/CARProfile);新增 /trend 实时曲线页(canvas 绘制 V/I/P 三曲线、窗口 30s~10min、历史按时间范围回填),WebSocket 快照推送提至 5Hz
This commit is contained in:
@@ -104,6 +104,8 @@ td.l,th.l{text-align:left;}
|
||||
<span id="badgeLink" class="badge err">未连接</span>
|
||||
<span id="badgeOutp" class="badge err">输出关</span>
|
||||
<span id="badgeMode" class="badge">模式 -</span>
|
||||
<span style="flex:1"></span>
|
||||
<a class="btn" href="/trend" target="_blank">📈 实时曲线</a>
|
||||
</div>
|
||||
|
||||
<div class="steps" id="steps">
|
||||
@@ -152,6 +154,19 @@ td.l,th.l{text-align:left;}
|
||||
<div class="errline" id="measDiagErr" style="display:none"></div>
|
||||
<div class="row"><span class="k">环路优先(回读)</span><span class="v" id="devFunc">-</span></div>
|
||||
<div class="row"><span class="k">功能模式(回读)</span><span class="v" id="devFuncMode">-</span></div>
|
||||
<div class="row"><span class="k">切换结果</span><span class="v" id="funcModeFeed">-</span></div>
|
||||
<div class="sub">功能模式切换(FUNC:MODE)</div>
|
||||
<div class="inline">
|
||||
<select id="funcModeSel">
|
||||
<option value="FIX">FIX 固定输出(实时控制)</option>
|
||||
<option value="LIST">LIST 列表波形</option>
|
||||
<option value="BATT">BATT 电池测试</option>
|
||||
<option value="SOLAR">SOLAR 光伏曲线</option>
|
||||
<option value="CAR">CAR 汽车波形</option>
|
||||
</select>
|
||||
<button id="btnFuncMode" class="btn" onclick="switchFuncMode()">切换功能模式</button>
|
||||
</div>
|
||||
<div class="tip" id="funcModeTip">实时功率控制必须保持 <b>FIX</b>。切换到 LIST/BATT/SOLAR/CAR 后,输出将按该模式自主运行,脱离本程序的实时功率控制(页面会红色告警),需切回 FIX 恢复。</div>
|
||||
</div>
|
||||
|
||||
<!--======== 卡3:参数与输出控制 ========-->
|
||||
@@ -301,6 +316,23 @@ function sendFromInput(action, inputId, label, unit){
|
||||
if(isNaN(v)){ alert('请输入有效数值'); return; }
|
||||
sendCmd(action, v, '确认设定'+label+' = '+v+' '+unit+' ?');
|
||||
}
|
||||
// 功能模式切换(带确认 + 脱离实时控制警告)
|
||||
function switchFuncMode(){
|
||||
const sel = $('funcModeSel');
|
||||
const m = sel.value;
|
||||
if(m === 'FIX'){
|
||||
if(!confirm('确认切换为 FIX(固定输出)?\n将恢复本程序的实时功率控制。')) return;
|
||||
} else {
|
||||
if(!confirm('确认切换到 '+m+' 模式?\n\n⚠ 该模式会脱离本程序的实时功率控制,\n输出将按 '+m+' 的模式自主运行!\n请确保已理解风险后再确认。')) return;
|
||||
}
|
||||
fetch('/api/eload_cmd?action=func_mode&mode='+m)
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if(j.ok) statusEl.textContent = '已下发: func_mode '+m;
|
||||
else alert('下发失败: '+(j.error||'未知错误'));
|
||||
})
|
||||
.catch(() => alert('网络错误,指令未下发'));
|
||||
}
|
||||
|
||||
//------------------ 渲染(WebSocket 1Hz 快照,仅按 id 更新,不重建 DOM)------------------
|
||||
let lastSnap = null;
|
||||
@@ -382,8 +414,26 @@ function render(s){
|
||||
mde.textContent = dev.lastMeasErr;
|
||||
} else mde.style.display = 'none';
|
||||
$('devFunc').textContent = dev.func || '-';
|
||||
$('devFuncMode').innerHTML = (dev.funcMode || '-')
|
||||
+ (dev.funcModeFixWarn ? ' <span class="badge err">非FIX!</span>' : ' <span class="badge ok">FIX</span>');
|
||||
const fmName = (dev.funcMode || '').trim();
|
||||
$('devFuncMode').innerHTML = fmName
|
||||
? '<b style="font-size:15px">' + fmName + '</b>'
|
||||
+ (dev.funcModeFixWarn ? ' <span class="badge err">非FIX</span>' : ' <span class="badge ok">FIX</span>')
|
||||
: '<span class="dim">回读中...</span>';
|
||||
const fmf = $('funcModeFeed');
|
||||
if(dev.funcModeFeedValid){
|
||||
const ok = dev.funcModeFeedOk ? '已发送成功' : '发送失败(未连接或超时)';
|
||||
fmf.textContent = dev.funcModeFeed + ':' + ok + '('+ageText(now - dev.funcModeFeedTime)+')';
|
||||
fmf.style.color = dev.funcModeFeedOk ? 'var(--ok)' : 'var(--bad)';
|
||||
} else { fmf.textContent = '-'; fmf.style.color = ''; }
|
||||
setDisabled('btnFuncMode', !connected);
|
||||
setDisabled('funcModeSel', !connected);
|
||||
// 下拉框同步当前设备功能模式(仅当未聚焦时)
|
||||
const fmsel = $('funcModeSel');
|
||||
if(document.activeElement !== fmsel && dev.funcMode){
|
||||
const opts = ['FIX','LIST','BATT','SOLAR','CAR'];
|
||||
const idx = opts.indexOf(dev.funcMode);
|
||||
if(idx >= 0 && fmsel.selectedIndex !== idx) fmsel.selectedIndex = idx;
|
||||
}
|
||||
|
||||
//---- 卡3:控制 ----
|
||||
setDisabled('btnModeCC', !connected || cc);
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
#ifndef PELOAD_PAGE_TREND_H
|
||||
#define PELOAD_PAGE_TREND_H
|
||||
|
||||
#include <string>
|
||||
|
||||
//============================================================================
|
||||
// pELoad 实时曲线页(独立标签页)。
|
||||
//
|
||||
// - 通过 WebSocket /ws 接收 5Hz 快照,绘制电压/电流/功率三条实时曲线;
|
||||
// - 可从 /api/meas(100ms 高采样 meas_log)加载历史数据回填;
|
||||
// - 纯前端 canvas 绘制,无外部依赖。
|
||||
//============================================================================
|
||||
|
||||
namespace eload {
|
||||
const std::string TREND_HTML = R"HTML(<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pELoad 实时曲线</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#1a2130;--line:#2a3447;--fg:#e6edf3;--dim:#8b98ab;--blue:#58a6ff;--green:#3fb950;--orange:#d29922;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:var(--bg);color:var(--fg);font-family:'Segoe UI',system-ui,sans-serif;font-size:13px;padding:14px;}
|
||||
h1{font-size:17px;margin-bottom:10px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
|
||||
.badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--card);border:1px solid var(--line);}
|
||||
.badge.ok{color:var(--green);border-color:var(--green);}
|
||||
.badge.err{color:#f85149;border-color:#f85149;}
|
||||
.panel{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:10px;}
|
||||
.panel h2{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;}
|
||||
.panel h2 .now{float:right;font-size:15px;font-family:Consolas,monospace;color:var(--fg);text-transform:none;}
|
||||
canvas{width:100%;height:180px;display:block;background:#0b1018;border:1px solid #22304a;border-radius:4px;}
|
||||
.controls{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
|
||||
.controls select{background:#0f1420;border:1px solid var(--line);border-radius:6px;color:var(--fg);padding:4px 8px;}
|
||||
.btn{padding:5px 14px;border:1px solid var(--line);border-radius:6px;background:#222b3d;color:var(--fg);font-size:12px;cursor:pointer;}
|
||||
.btn:hover{border-color:#3b4a63;}
|
||||
#status{color:var(--dim);font-size:12px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>pELoad 实时曲线 <span id="status" class="badge">连接中...</span>
|
||||
<span style="flex:1"></span>
|
||||
<a class="btn" href="/" target="_blank">控制页面</a>
|
||||
</h1>
|
||||
|
||||
<div class="controls">
|
||||
<span style="color:var(--dim)">时间窗口</span>
|
||||
<select id="winSel">
|
||||
<option value="30">30 秒</option>
|
||||
<option value="60" selected>60 秒</option>
|
||||
<option value="120">120 秒</option>
|
||||
<option value="300">300 秒 (5分钟)</option>
|
||||
<option value="600">600 秒 (10分钟)</option>
|
||||
</select>
|
||||
<button class="btn" onclick="loadHistory()">加载历史数据 (最近窗口)</button>
|
||||
<span style="color:var(--dim)" id="info">采样: 0 点</span>
|
||||
</div>
|
||||
|
||||
<div class="panel"><h2>电压 V <span class="now" id="nowV" style="color:var(--blue)">-</span></h2><canvas id="cvV"></canvas></div>
|
||||
<div class="panel"><h2>电流 A <span class="now" id="nowI" style="color:var(--green)">-</span></h2><canvas id="cvI"></canvas></div>
|
||||
<div class="panel"><h2>功率 W <span class="now" id="nowP" style="color:var(--orange)">-</span></h2><canvas id="cvP"></canvas></div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const statusEl = $('status');
|
||||
const MAXPTS = 8000;
|
||||
// 数据缓冲:{t: ms时间戳, v, i, p}
|
||||
const data = [];
|
||||
let winSec = 60;
|
||||
|
||||
// 裁剪到时间窗口
|
||||
function trim(){
|
||||
const cutoff = Date.now() - winSec*1000;
|
||||
while(data.length && data[0].t < cutoff) data.shift();
|
||||
if(data.length > MAXPTS) data.splice(0, data.length - MAXPTS);
|
||||
$('info').textContent = '采样: ' + data.length + ' 点';
|
||||
}
|
||||
|
||||
function draw(canvas, key, color){
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width = canvas.clientWidth * (window.devicePixelRatio||1);
|
||||
const H = canvas.height = 180 * (window.devicePixelRatio||1);
|
||||
ctx.scale(window.devicePixelRatio||1, window.devicePixelRatio||1);
|
||||
const w = canvas.clientWidth, h = 180;
|
||||
ctx.fillStyle = '#0b1018'; ctx.fillRect(0,0,w,h);
|
||||
|
||||
// 纵向范围(动态,含 0)
|
||||
let min = 0, max = 0, has = false;
|
||||
for(const p of data){
|
||||
const v = p[key];
|
||||
if(!isFinite(v)) continue;
|
||||
if(!has){ min = max = v; has = true; }
|
||||
else { if(v<min)min=v; if(v>max)max=v; }
|
||||
}
|
||||
if(!has){ min = 0; max = 1; }
|
||||
const pad = (max-min)*0.15 || 1;
|
||||
min -= pad; max += pad;
|
||||
if(max <= min){ max = min+1; }
|
||||
|
||||
// 网格(横向 5 条 + 纵向约 10 格)
|
||||
ctx.strokeStyle = '#1c2740'; ctx.lineWidth = 1;
|
||||
for(let i=0;i<=5;i++){
|
||||
const y = (h-8) - (h-16) * (i/5) + 4;
|
||||
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
|
||||
}
|
||||
const tstep = winSec/10;
|
||||
for(let i=0;i<=10;i++){
|
||||
const x = (w-4) * (i/10) + 2;
|
||||
ctx.beginPath(); ctx.moveTo(x,4); ctx.lineTo(x,h-4); ctx.stroke();
|
||||
}
|
||||
|
||||
// 曲线
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 1.6;
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
const nowT = Date.now();
|
||||
for(const p of data){
|
||||
const x = 2 + (w-4) * (1 - (nowT - p.t)/ (winSec*1000));
|
||||
const y = 4 + (h-16) * (1 - (p[key]-min)/(max-min));
|
||||
if(x < -10) continue;
|
||||
if(!started){ ctx.moveTo(x,y); started = true; }
|
||||
else ctx.lineTo(x,y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Y 轴刻度(最大值/最小值标注)
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '11px Consolas,monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(max.toFixed(max>100?0:1), 4, 14);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(min.toFixed(min>100?0:1), w-4, h-6);
|
||||
}
|
||||
|
||||
function redraw(){
|
||||
trim();
|
||||
draw($('cvV'), 'v', '#58a6ff');
|
||||
draw($('cvI'), 'i', '#3fb950');
|
||||
draw($('cvP'), 'p', '#d29922');
|
||||
}
|
||||
|
||||
// 实时快照(WebSocket 5Hz)
|
||||
function onSnapshot(s){
|
||||
const d = s.dev || {};
|
||||
if(!d.measVValid && !d.measIValid && !d.measPValid) return;
|
||||
const t = Date.now();
|
||||
data.push({t, v:d.measV, i:d.measI, p:d.measP});
|
||||
$('nowV').textContent = d.measV.toFixed(2);
|
||||
$('nowI').textContent = d.measI.toFixed(3);
|
||||
$('nowP').textContent = d.measP.toFixed(1);
|
||||
redraw();
|
||||
}
|
||||
|
||||
// 历史数据加载(/api/meas 时间范围查询,覆盖整个窗口)
|
||||
function loadHistory(){
|
||||
const nowSec = Date.now()/1000;
|
||||
const start = nowSec - winSec;
|
||||
$('info').textContent = '加载历史...';
|
||||
const url = '/api/meas?start=' + start.toFixed(3) + '&end=' + nowSec.toFixed(3) + '&limit=20000';
|
||||
fetch(url).then(r=>r.json()).then(j => {
|
||||
const arr = j.rows || []; // 时间升序
|
||||
if(!arr.length){ $('info').textContent = '暂无历史数据'; return; }
|
||||
const nowMs = Date.now();
|
||||
data.length = 0;
|
||||
for(let i=0;i<arr.length;i++){
|
||||
const r = arr[i];
|
||||
data.push({t: nowMs - (nowSec - r.time)*1000, v:r.v, i:r.i, p:r.p});
|
||||
}
|
||||
const last = arr[arr.length-1];
|
||||
$('nowV').textContent = last.v.toFixed(2);
|
||||
$('nowI').textContent = last.i.toFixed(3);
|
||||
$('nowP').textContent = last.p.toFixed(1);
|
||||
redraw();
|
||||
$('info').textContent = '已加载 ' + arr.length + ' 条历史';
|
||||
}).catch(()=>{ $('info').textContent = '历史加载失败'; });
|
||||
}
|
||||
|
||||
$('winSel').addEventListener('change', function(){ winSec = parseInt(this.value,10); redraw(); });
|
||||
|
||||
// WebSocket 5Hz 快照
|
||||
let ws=null, wsTimer=null;
|
||||
function connectWs(){
|
||||
const proto = location.protocol==='https:'?'wss://':'ws://';
|
||||
ws = new WebSocket(proto + location.host + '/ws');
|
||||
ws.onopen = () => { statusEl.textContent='已连接'; statusEl.className='badge ok'; };
|
||||
ws.onmessage = e => { try{ onSnapshot(JSON.parse(e.data)); }catch(err){} };
|
||||
ws.onclose = () => { statusEl.textContent='连接断开, 重连中...'; statusEl.className='badge err';
|
||||
wsTimer = setTimeout(connectWs, 2000); };
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
connectWs();
|
||||
// 初始加载历史(有数据时立即有曲线)
|
||||
loadHistory();
|
||||
</script>
|
||||
</body>
|
||||
</html>)HTML";
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_PAGE_TREND_H
|
||||
Reference in New Issue
Block a user