443 lines
26 KiB
C++
443 lines
26 KiB
C++
#ifndef PELOAD_PAGE_INDEX_H
|
||
#define PELOAD_PAGE_INDEX_H
|
||
|
||
#include <string>
|
||
|
||
//============================================================================
|
||
// pELoad 电子负载控制页面(纯前端,内嵌 HTML)。
|
||
//
|
||
// 操作顺序(防误触 + 步骤引导):
|
||
// ① 连接设备(链路绿 → 步骤2 可操作)
|
||
// ② 设置参数(选环路优先 CC/CV → 控制模式/目标量 → 限值)
|
||
// ③ 开启输出(确认后下发使能序列)
|
||
// 所有下发的指令均带确认对话框;按钮按当前状态禁用并提示原因。
|
||
//
|
||
// 模式说明:
|
||
// CC 优先(电流型负载模拟):控制量 = 电流,功率折算 I=P/U,可跟随 MOOS
|
||
// 功率 / 手动功率 / 手动电流;
|
||
// CV 优先(恒压吸收):控制量 = 电压(VOLT),功率由外部源决定。
|
||
// FUNC:MODE 必须保持 FIX(实时功率控制前提),非 FIX 时页面红色告警。
|
||
//
|
||
// 设备说明:docs/电子负载说明手册(IT6000C 双向可编程直流电源)
|
||
//============================================================================
|
||
|
||
namespace eload {
|
||
const std::string INDEX_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;--ok:#2ea043;--warn:#d29922;--bad:#f85149;--blue:#1f6feb;}
|
||
*{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:16px;padding-bottom:60px;}
|
||
h1{font-size:18px;margin-bottom:12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
|
||
h2{font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;border-bottom:1px solid var(--line);padding-bottom:6px;}
|
||
.badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--card);border:1px solid var(--line);white-space:nowrap;}
|
||
.badge.ok{color:var(--ok);border-color:var(--ok);}
|
||
.badge.warn{color:var(--warn);border-color:var(--warn);}
|
||
.badge.err{color:var(--bad);border-color:var(--bad);}
|
||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px;}
|
||
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:12px;}
|
||
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed #222c3d;gap:12px;}
|
||
.row:last-child{border-bottom:none;}
|
||
.row .k{color:var(--dim);flex-shrink:0;}
|
||
.row .v{font-family:Consolas,monospace;color:var(--fg);text-align:right;}
|
||
.sub{font-size:11px;color:#79c0ff;margin:10px 0 2px;border-bottom:1px solid #22304a;padding-bottom:2px;}
|
||
.btn{padding:6px 14px;margin:0 8px 8px 0;border:1px solid var(--line);border-radius:6px;background:#222b3d;color:var(--fg);font-size:12px;cursor:pointer;}
|
||
.btn:hover:not(:disabled){border-color:#3b4a63;}
|
||
.btn:disabled{opacity:.35;cursor:not-allowed;}
|
||
.btn.on{background:#1f6feb;border-color:#1f6feb;color:#fff;font-weight:600;}
|
||
.btn.off{background:#3d2226;border-color:#f85149;color:#f85149;}
|
||
.btn.warn{background:#3a2e12;border-color:var(--warn);color:var(--warn);}
|
||
.btn.big{padding:10px 22px;font-size:14px;}
|
||
.btn.sel{background:var(--blue);border-color:var(--blue);color:#fff;font-weight:600;}
|
||
input[type=number],input[type=text]{width:120px;background:#0f1420;border:1px solid var(--line);border-radius:6px;color:var(--fg);font-family:Consolas,monospace;padding:5px 8px;font-size:13px;}
|
||
input:focus{outline:none;border-color:var(--blue);}
|
||
input:disabled{opacity:.4;}
|
||
.inline{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;}
|
||
.inline label{color:var(--dim);}
|
||
.big{font-size:22px;font-family:Consolas,monospace;}
|
||
.dim{color:var(--dim);}
|
||
.errline{color:var(--bad);font-family:Consolas,monospace;font-size:12px;word-break:break-all;}
|
||
.okline{color:var(--ok);font-family:Consolas,monospace;font-size:12px;word-break:break-all;}
|
||
table{width:100%;border-collapse:collapse;font-size:11px;font-family:Consolas,monospace;}
|
||
th,td{padding:3px 6px;border-bottom:1px solid #222c3d;text-align:right;white-space:nowrap;}
|
||
th{color:var(--dim);font-weight:400;}
|
||
td.l,th.l{text-align:left;}
|
||
#status{color:var(--dim);font-size:12px;}
|
||
/* 顶部状态条 */
|
||
#topbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px 12px;margin-bottom:12px;}
|
||
#topbar .sep{width:1px;height:20px;background:var(--line);margin:0 6px;}
|
||
/* 操作顺序步骤条 */
|
||
.steps{display:flex;align-items:center;gap:6px;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-bottom:12px;flex-wrap:wrap;}
|
||
.step{display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:16px;border:1px solid var(--line);color:var(--dim);font-size:12px;}
|
||
.step .n{width:18px;height:18px;border-radius:50%;background:#2a3447;color:var(--fg);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;}
|
||
.step.done{border-color:var(--ok);color:var(--ok);}
|
||
.step.done .n{background:var(--ok);color:#fff;}
|
||
.step.active{border-color:var(--blue);color:#fff;font-weight:600;}
|
||
.step.active .n{background:var(--blue);color:#fff;}
|
||
.step.err{border-color:var(--bad);color:var(--bad);}
|
||
.step.err .n{background:var(--bad);color:#fff;}
|
||
.sarrow{color:var(--dim);}
|
||
.tip{font-size:11px;color:var(--dim);line-height:1.5;}
|
||
.tip b{color:#79c0ff;}
|
||
.bigstat{font-size:24px;font-weight:700;font-family:Consolas,monospace;text-align:center;padding:10px 0;}
|
||
.bigstat.on{color:var(--ok);}
|
||
.bigstat.off{color:var(--dim);}
|
||
.bigstat.wait{color:var(--warn);}
|
||
.meas{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;text-align:center;margin-bottom:8px;}
|
||
.meas .v{font-size:24px;font-family:Consolas,monospace;}
|
||
.meas .k{color:var(--dim);font-size:11px;}
|
||
.fixwarn{background:rgba(248,81,73,.15);border:1px solid var(--bad);color:var(--bad);border-radius:6px;padding:6px 10px;font-size:12px;margin-bottom:8px;}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="topbar">
|
||
<h1 style="margin:0">pELoad 电子负载控制</h1>
|
||
<span id="status" class="badge">加载中...</span>
|
||
<span class="sep"></span>
|
||
<span id="badgeLink" class="badge err">未连接</span>
|
||
<span id="badgeOutp" class="badge err">输出关</span>
|
||
<span id="badgeMode" class="badge">模式 -</span>
|
||
</div>
|
||
|
||
<div class="steps" id="steps">
|
||
<div class="step" id="step1"><span class="n">1</span>连接设备</div>
|
||
<span class="sarrow">▸</span>
|
||
<div class="step" id="step2"><span class="n">2</span>设置参数</div>
|
||
<span class="sarrow">▸</span>
|
||
<div class="step" id="step3"><span class="n">3</span>开启输出</div>
|
||
<span style="flex:1"></span>
|
||
<span class="tip" id="stepTip"></span>
|
||
</div>
|
||
|
||
<div class="grid" id="pages">加载中...</div>
|
||
|
||
<script>
|
||
const $ = id => document.getElementById(id);
|
||
const statusEl = $('status');
|
||
|
||
function row(k, v){ return '<div class="row"><span class="k">'+k+'</span><span class="v">'+v+'</span></div>'; }
|
||
function ageText(a){
|
||
if(typeof a !== 'number' || a < 0) return '从未';
|
||
if(a < 90) return a.toFixed(1)+' 秒前';
|
||
if(a < 3600) return Math.floor(a/60)+' 分 '+Math.floor(a%60)+' 秒前';
|
||
return Math.floor(a/3600)+' 时 '+Math.floor((a%3600)/60)+' 分前';
|
||
}
|
||
function ageCell(d, key){
|
||
if(!d || !d[key+'Valid']) return '<span class="dim">从未</span>';
|
||
const online = d[key+'Age'] < 3;
|
||
return (online ? '<span style="color:var(--ok)">' : '<span style="color:var(--bad)">')
|
||
+ ageText(d[key+'Age']) + (online ? ' (在线)' : ' (超时)') + '</span>';
|
||
}
|
||
function fmt(v, n){ return (typeof v === 'number') ? v.toFixed(n===undefined?2:n) : '-'; }
|
||
|
||
//------------------ 指令下发(全部带确认,防误触) ------------------
|
||
// 链路: 网页 fetch -> pELoad 入队 -> 控制线程(100ms) -> SCPI -> IT6000C
|
||
function sendCmd(action, value, confirmMsg){
|
||
if(confirmMsg && !confirm(confirmMsg)) return;
|
||
let url = '/api/eload_cmd?action='+action;
|
||
if(value !== undefined && value !== null) url += '&value='+value;
|
||
fetch(url)
|
||
.then(r => r.json())
|
||
.then(j => {
|
||
if(j.ok){ if(action!=='identify') statusEl.textContent = '已下发: '+action; }
|
||
else alert('下发失败: '+(j.error||'未知错误'));
|
||
})
|
||
.catch(() => alert('网络错误,指令未下发'));
|
||
}
|
||
function sendRaw(){
|
||
const cmd = $('rawCmd').value.trim();
|
||
if(!cmd){ alert('请输入 SCPI 命令'); return; }
|
||
if(!confirm('确认发送原始 SCPI 指令:\n'+cmd+'\n\n原始指令不做任何校验,请谨慎操作!')) return;
|
||
fetch('/api/eload_cmd?action=raw&cmd='+encodeURIComponent(cmd))
|
||
.then(r => r.json())
|
||
.then(j => {
|
||
$('rawResp').textContent = j.ok ? (j.response || '(无响应)') : ('错误: '+(j.error||''));
|
||
if(j.ok && j.response) loadScpiLog();
|
||
})
|
||
.catch(() => { $('rawResp').textContent = '网络错误'; });
|
||
}
|
||
// 从输入框取数并下发(带确认)
|
||
function sendFromInput(action, inputId, label, unit){
|
||
const v = parseFloat($(inputId).value);
|
||
if(isNaN(v)){ alert('请输入有效数值'); return; }
|
||
sendCmd(action, v, '确认设定'+label+' = '+v+' '+unit+' ?');
|
||
}
|
||
// disabled 属性辅助:条件为真时输出 " disabled",否则空串
|
||
// (HTML 中 disabled="false" 也会禁用按钮,因此必须用条件拼接)
|
||
function dis(cond){ return cond ? ' disabled' : ''; }
|
||
|
||
//------------------ 渲染(WebSocket 1Hz 快照) ------------------
|
||
let lastSnap = null;
|
||
function render(s){
|
||
lastSnap = s;
|
||
const link = s.link || {}, ctrl = s.ctrl || {}, dev = s.dev || {}, db = s.db || {};
|
||
const connected = !!link.connected;
|
||
const cc = !!ctrl.ccPriority;
|
||
const loadOn = !!ctrl.loadOn, outpApplied = !!ctrl.outpApplied;
|
||
const modeName = {follow:'跟随 MOOS 功率', manual:'手动功率', current:'手动电流', volt:'恒压吸收'};
|
||
const powerStale = ctrl.moosPowerValid && ctrl.moosPowerAge > (ctrl.staleSec||0);
|
||
// 预计算按钮禁用条件(供 dis() 使用,避免在字符串拼接中出现引号)
|
||
const dNotConn = !connected;
|
||
const dCC = !connected || cc;
|
||
const dCV = !connected || !cc;
|
||
const dFollow = !connected || ctrl.mode==='follow';
|
||
const dManual = !connected || ctrl.mode==='manual';
|
||
const dCurrent = !connected || ctrl.mode==='current';
|
||
const dOutpOn = !connected || loadOn;
|
||
const dOutpOff = !connected || !loadOn;
|
||
|
||
//---- 顶部状态条 ----
|
||
statusEl.textContent = connected ? '已连接 '+link.host+':'+link.port : '未连接';
|
||
statusEl.className = 'badge ' + (connected ? 'ok' : 'err');
|
||
$('badgeLink').textContent = '链路: ' + (connected ? '已连接' : '未连接');
|
||
$('badgeLink').className = 'badge ' + (connected ? 'ok' : 'err');
|
||
$('badgeOutp').textContent = '输出: ' + (outpApplied ? '开' : (loadOn ? '待开' : '关'));
|
||
$('badgeOutp').className = 'badge ' + (outpApplied ? 'ok' : (loadOn ? 'warn' : 'err'));
|
||
$('badgeMode').textContent = '模式: ' + (cc ? 'CC优先' : 'CV优先') + ' · ' + (modeName[ctrl.mode]||ctrl.mode);
|
||
$('badgeMode').className = 'badge' + (dev.funcModeFixWarn ? ' err' : '');
|
||
|
||
//---- 步骤条 ----
|
||
let curStep = 1, tipText = '';
|
||
if(!connected){ curStep = 1; tipText = '请先连接设备(步骤1)'; }
|
||
else if(!outpApplied){ curStep = 2; tipText = '设置环路优先/控制模式与限值(步骤2),然后开启输出(步骤3)'; }
|
||
else { curStep = 3; tipText = '输出已开启,正在进行实时功率控制'; }
|
||
if(dev.funcModeFixWarn) tipText = '!! FUNC:MODE 非 FIX,实时控制失效 !!';
|
||
$('step1').className = 'step ' + (connected ? 'done' : 'active');
|
||
$('step2').className = 'step ' + (outpApplied ? 'done' : (curStep===2 ? 'active' : ''));
|
||
$('step3').className = 'step ' + (outpApplied ? 'done active' : '');
|
||
$('stepTip').innerHTML = tipText;
|
||
|
||
let html = '';
|
||
|
||
//---- 卡1:以太网链路 ----
|
||
html += '<div class="card"><h2>① 连接设备 · 以太网链路</h2>'
|
||
+ row('设备地址', link.host+':'+link.port)
|
||
+ row('连接状态', connected
|
||
? '<span style="color:var(--ok)">已连接</span>' : '<span style="color:var(--bad)">未连接</span>')
|
||
+ row('自动重连', link.enabled ? '开启' : '关闭')
|
||
+ row('重连次数', link.reconnects)
|
||
+ row('收发统计', 'tx='+link.tx+' rx='+link.rx+' txErr='+link.txErr)
|
||
+ row('最近收帧', ageCell(link, 'lastRx'))
|
||
+ row('设备识别 *IDN?', link.idn || '<span class="dim">未识别</span>')
|
||
+ (link.lastError ? '<div class="errline">设备错误: '+link.lastError+'</div>' : '')
|
||
+ '<div class="sub">操作(指令均需确认)</div>'
|
||
+ '<button class="btn on"'+dis(connected)+' onclick="sendCmd(\'connect\',null,\'确认连接电子负载?\')">连接</button>'
|
||
+ '<button class="btn off"'+dis(dNotConn)+' onclick="sendCmd(\'disconnect\',null,\'确认断开以太网链路?\n断开前会先关闭输出。\')">断开</button>'
|
||
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendCmd(\'identify\')">识别 (*IDN?)</button>'
|
||
+ '<button class="btn warn"'+dis(dNotConn)+' onclick="sendCmd(\'clear_protect\',null,\'确认清除输出保护锁定?\')">清保护</button>'
|
||
+ '<button class="btn off"'+dis(dNotConn)+' onclick="sendCmd(\'reset_dev\',null,\'确认复位设备 (*RST)?\n所有设定将恢复默认!\')">复位设备</button>'
|
||
+ '</div>';
|
||
|
||
//---- 卡2:当前状态 + 实时测量 ----
|
||
const bigOutp = outpApplied ? '输出开' : (loadOn ? '待应用' : '输出关');
|
||
const bigCls = outpApplied ? 'on' : (loadOn ? 'wait' : 'off');
|
||
html += '<div class="card"><h2>当前状态 · 实时测量</h2>'
|
||
+ '<div class="bigstat '+bigCls+'">'+bigOutp+'</div>'
|
||
+ (dev.funcModeFixWarn
|
||
? '<div class="fixwarn">FUNC:MODE = '+(dev.funcMode||'?')+'(非 FIX)——实时功率控制已失效!请复位或手动恢复 FIX。</div>'
|
||
: '')
|
||
+ '<div class="meas">'
|
||
+ '<div><div class="v" style="color:#79c0ff">'+fmt(dev.measV,2)+'</div><div class="k">电压 V</div></div>'
|
||
+ '<div><div class="v" style="color:#3fb950">'+fmt(dev.measI,3)+'</div><div class="k">电流 A</div></div>'
|
||
+ '<div><div class="v" style="color:#d29922">'+fmt(dev.measP,1)+'</div><div class="k">功率 W</div></div>'
|
||
+ '</div>'
|
||
+ row('数据时效', 'V '+ageCell(dev,'measV')+' · I '+ageCell(dev,'measI')+' · P '+ageCell(dev,'measP'))
|
||
+ row('环路优先(回读)', dev.func || '-')
|
||
+ row('功能模式(回读)', (dev.funcMode || '-') + (dev.funcModeFixWarn ? ' <span class="badge err">非FIX!</span>' : ' <span class="badge ok">FIX</span>'))
|
||
+ '</div>';
|
||
|
||
//---- 卡3:控制(步骤2/3)----
|
||
html += '<div class="card"><h2>②③ 参数与输出控制</h2>';
|
||
|
||
// 环路优先选择
|
||
html += '<div class="sub">环路优先模式(决定控制量类型)</div>'
|
||
+ '<button class="btn sel"'+dis(dCC)+' onclick="sendCmd(\'mode_cc\',null,\'切换为 CC 优先(电流型负载,功率折算 I=P/U)?\')">CC 优先</button>'
|
||
+ '<button class="btn sel"'+dis(dCV)+' onclick="sendCmd(\'mode_cv\',null,\'切换为 CV 优先(恒压吸收,按电压控制)?\')">CV 优先</button>'
|
||
+ '<div class="tip">'+(cc ? '<b>CC 优先</b>:控制量 = 电流。目标功率/电流 → CURR 指令(负值=吸收电能)。'
|
||
: '<b>CV 优先</b>:控制量 = 电压。恒压吸收,功率由外部源决定。')+'</div>';
|
||
|
||
if(cc){
|
||
// CC:控制模式
|
||
html += '<div class="sub">控制模式(目标来源)</div>'
|
||
+ '<button class="btn"'+dis(dFollow)+' onclick="sendCmd(\'mode_follow\',null,\'切换为跟随 MOOS 功率消息(实时电机功率)?\')">跟随MOOS</button>'
|
||
+ '<button class="btn"'+dis(dManual)+' onclick="sendCmd(\'mode_manual\',null,\'切换为手动功率?\')">手动功率</button>'
|
||
+ '<button class="btn"'+dis(dCurrent)+' onclick="sendCmd(\'mode_current\',null,\'切换为手动电流?\')">手动电流</button>'
|
||
+ '<div class="tip">当前:<b>'+(modeName[ctrl.mode]||ctrl.mode)+'</b></div>'
|
||
+ '<div class="inline"><label>功率 (W)</label>'
|
||
+ '<input type="number" id="powerInput" step="1" value="'+(ctrl.manualPower||0)+'"'+dis(dNotConn)+'>'
|
||
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendFromInput(\'set_power\',\'powerInput\',\'手动目标功率\',\'W\')">设定功率</button></div>'
|
||
+ '<div class="inline"><label>电流 (A, 负=吸收)</label>'
|
||
+ '<input type="number" id="currInput" step="0.1" value="'+(ctrl.manualCurrent||0)+'"'+dis(dNotConn)+'>'
|
||
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendFromInput(\'set_current\',\'currInput\',\'手动电流\',\'A\')">设定电流</button></div>'
|
||
+ row('MOOS 功率消息', (ctrl.moosPowerValid
|
||
? fmt(ctrl.moosPower,1)+' W ('+ageText(ctrl.moosPowerAge)+')'
|
||
: '<span class="dim">未收到</span>')
|
||
+ (powerStale ? ' <span class="badge err">超时!</span>' : ''))
|
||
+ row('指令电流 CURR', fmt(ctrl.cmdCurrent,4)+' A')
|
||
+ row('符号约定', ctrl.sign==='negative' ? '负电流=吸收(负载模拟)' : '正电流=输出(电源模拟)');
|
||
} else {
|
||
// CV:电压设定
|
||
html += '<div class="inline"><label>目标电压 (V)</label>'
|
||
+ '<input type="number" id="voltInput" step="1" value="'+(ctrl.voltSet||0)+'"'+dis(dNotConn)+'>'
|
||
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendFromInput(\'set_volt\',\'voltInput\',\'CV 目标电压\',\'V\')">设定电压</button></div>'
|
||
+ row('指令电压 VOLT', fmt(ctrl.cmdVolt,2)+' V')
|
||
+ '<div class="tip">恒压吸收:设备维持该电压,吸收电流由外部源决定;功率大小请以实测为准。</div>';
|
||
}
|
||
|
||
// 输出开/关(大按钮)
|
||
html += '<div class="sub">输出控制</div>'
|
||
+ '<button class="btn big on"'+dis(dOutpOn)+' onclick="sendCmd(\'outp_on\',null,\'确认开启输出?\n电子负载将按当前设定开始吸收电能!\')">▶ 开启输出</button>'
|
||
+ '<button class="btn big off"'+dis(dOutpOff)+' onclick="sendCmd(\'outp_off\',null,\'确认关闭输出?\n电子负载停止吸收电能。\')">■ 关闭输出</button>'
|
||
+ '<div class="tip">'+(outpApplied
|
||
? '<b style="color:var(--ok)">输出已开启</b>,正在实时控制'
|
||
: (loadOn ? '<b style="color:var(--warn)">已请求开启,等待使能序列应用…</b>' : '输出未开启,开启前请完成步骤②参数设置'))+'</div>'
|
||
+ '</div>';
|
||
|
||
//---- 卡4:设备参数与限值 ----
|
||
html += '<div class="card"><h2>设备参数(限值下发)</h2>'
|
||
+ '<div class="tip">限值在输出开启时统一下发,修改后需“关闭输出→重新开启”生效(或在运行中直接下发,设备实时响应)。</div>'
|
||
+ '<div class="inline"><label>电压限值 VOLT:LIM (V)</label>'
|
||
+ '<input type="number" id="voltlimInput" step="1" value="'+(dev.voltLimit||0)+'"'+dis(dNotConn)+'>'
|
||
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendFromInput(\'set_voltlim\',\'voltlimInput\',\'电压限值\',\'V\')">下发</button></div>'
|
||
+ '<div class="inline"><label>电流限值 CURR:LIM (A)</label>'
|
||
+ '<input type="number" id="currlimInput" step="0.1" value="'+(dev.currLimit||0)+'"'+dis(dNotConn)+'>'
|
||
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendFromInput(\'set_currlimit\',\'currlimInput\',\'电流限值\',\'A\')">下发</button></div>'
|
||
+ '<div class="inline"><label>功率限值 POW:LIM (W)</label>'
|
||
+ '<input type="number" id="powlimInput" step="10" value="'+(dev.powLimit||0)+'"'+dis(dNotConn)+'>'
|
||
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendFromInput(\'set_powlimit\',\'powlimInput\',\'功率限值\',\'W\')">下发</button></div>'
|
||
+ '<div class="sub">远控 / 本控</div>'
|
||
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendCmd(\'remote\',null,\'确认进入远控模式 (SYST:REM)?\n面板按键将被锁定。\')">远控 SYST:REM</button>'
|
||
+ '<button class="btn warn"'+dis(dNotConn)+' onclick="sendCmd(\'local\',null,\'确认切回面板本控 (SYST:LOC)?\n输出将关闭且远控失效。\')">本控 SYST:LOC</button>'
|
||
+ '</div>';
|
||
|
||
//---- 卡5:SCPI 调试 ----
|
||
html += '<div class="card"><h2>SCPI 调试(高级)</h2>'
|
||
+ '<div class="inline"><input type="text" id="rawCmd" style="flex:1;min-width:180px" placeholder="例如: *IDN? 或 VOLT:LIM? MAX"'+dis(dNotConn)+' />'
|
||
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendRaw()">发送</button></div>'
|
||
+ '<div class="sub">最近响应</div>'
|
||
+ '<div class="okline" id="rawResp">-</div>'
|
||
+ '<div class="sub">非常规 SCPI 收发记录 (sqlite scpi_log)</div>'
|
||
+ '<div id="scpiLog" style="max-height:200px;overflow:auto"><span class="dim">点击“刷新记录”加载</span></div>'
|
||
+ '<button class="btn" onclick="loadScpiLog()">刷新记录</button>'
|
||
+ '</div>';
|
||
|
||
//---- 卡6:数据分析(高采样测量 meas_log)----
|
||
html += '<div class="card"><h2>数据分析 (高采样 meas_log)</h2>'
|
||
+ '<div class="row"><span class="k">测量记录条数</span><span class="v">'+db.measCount+'</span></div>'
|
||
+ '<div class="tip">实测电压/电流/功率按控制周期(默认 100ms)落库,'
|
||
+ '用于功率波形分析。可预览最近数据或导出 CSV 离线分析(Excel/Python)。</div>'
|
||
+ '<div class="sub">最近数据预览</div>'
|
||
+ '<div id="measPreview" style="max-height:220px;overflow:auto"><span class="dim">点击“加载预览”</span></div>'
|
||
+ '<button class="btn" onclick="loadMeas()">加载预览 (最近50条)</button>'
|
||
+ '<button class="btn" onclick="downloadMeasCsv()">导出 CSV (最近2000条)</button>'
|
||
+ '<div class="sub">按时间范围导出(unix 秒)</div>'
|
||
+ '<div class="inline"><label>起始</label><input type="number" id="csvStart" step="1" placeholder="起始 unix 秒">'
|
||
+ '<label>结束</label><input type="number" id="csvEnd" step="1" placeholder="结束 unix 秒">'
|
||
+ '<button class="btn" onclick="downloadMeasRange()">按范围导出</button></div>'
|
||
+ '</div>';
|
||
|
||
//---- 卡7:历史记录 ----
|
||
html += '<div class="card"><h2>历史记录 (sqlite eload_log, 1Hz)</h2>'
|
||
+ '<div class="row"><span class="k">记录总数</span><span class="v">'+db.statusCount+' / SCPI '+db.scpiCount+'</span></div>'
|
||
+ '<div id="history" style="max-height:240px;overflow:auto"><span class="dim">点击“查询”加载</span></div>'
|
||
+ '<button class="btn" onclick="loadHistory()">查询最近 60 条</button>'
|
||
+ '</div>';
|
||
|
||
$('pages').innerHTML = html;
|
||
}
|
||
|
||
function loadHistory(){
|
||
fetch('/api/history?limit=60')
|
||
.then(r => r.json())
|
||
.then(j => {
|
||
const rows = j.rows || [];
|
||
let html = '<table><tr><th>时间</th><th>连</th><th>输出</th><th class="l">模式</th>'
|
||
+ '<th>目标W</th><th>指令A</th><th>V</th><th>A</th><th>W</th></tr>';
|
||
for(let i=0;i<rows.length;i++){
|
||
const r = rows[i];
|
||
const t = new Date(r.time*1000).toLocaleTimeString();
|
||
html += '<tr><td class="l">'+t+'</td><td>'+(r.connected?1:0)+'</td><td>'+(r.outp?1:0)+'</td>'
|
||
+ '<td class="l">'+r.mode+'</td><td>'+fmt(r.targetPower,1)+'</td><td>'+fmt(r.cmdCurrent,3)+'</td>'
|
||
+ '<td>'+fmt(r.measVoltage,1)+'</td><td>'+fmt(r.measCurrent,3)+'</td><td>'+fmt(r.measPower,1)+'</td></tr>';
|
||
}
|
||
html += '</table>';
|
||
$('history').innerHTML = rows.length ? html : '<span class="dim">暂无记录</span>';
|
||
})
|
||
.catch(() => { $('history').innerHTML = '<span class="errline">查询失败</span>'; });
|
||
}
|
||
function loadScpiLog(){
|
||
fetch('/api/scpi_log?limit=50')
|
||
.then(r => r.json())
|
||
.then(j => {
|
||
const rows = j.rows || [];
|
||
let html = '<table><tr><th>时间</th><th class="l">收/发</th><th class="l">内容</th></tr>';
|
||
for(let i=0;i<rows.length;i++){
|
||
const r = rows[i];
|
||
const t = new Date(r.time*1000).toLocaleTimeString();
|
||
const dir = r.dir ? '<span style="color:#79c0ff">发</span>' : '<span style="color:#3fb950">收</span>';
|
||
html += '<tr><td class="l">'+t+'</td><td class="l">'+dir+'</td><td class="l">'+r.text+'</td></tr>';
|
||
}
|
||
html += '</table>';
|
||
$('scpiLog').innerHTML = rows.length ? html : '<span class="dim">暂无记录</span>';
|
||
})
|
||
.catch(() => { $('scpiLog').innerHTML = '<span class="errline">查询失败</span>'; });
|
||
}
|
||
|
||
//------------------ 数据分析(高采样测量 meas_log) ------------------
|
||
function loadMeas(){
|
||
fetch('/api/meas?limit=50')
|
||
.then(r => r.json())
|
||
.then(j => {
|
||
const rows = j.rows || [];
|
||
let html = '<table><tr><th>时间</th><th class="l">V</th><th class="l">I(A)</th>'
|
||
+ '<th class="l">P(W)</th><th class="l">指令I</th><th class="l">目标P</th><th>输出</th></tr>';
|
||
for(let i=0;i<rows.length;i++){
|
||
const r = rows[i];
|
||
const t = new Date(r.time*1000).toLocaleTimeString('zh-CN', {hour12:false}) + '.' + Math.floor((r.time%1)*1000);
|
||
html += '<tr><td class="l">'+t+'</td><td class="l">'+fmt(r.v,2)+'</td><td class="l">'+fmt(r.i,3)+'</td>'
|
||
+ '<td class="l">'+fmt(r.p,1)+'</td><td class="l">'+fmt(r.cmdI,3)+'</td><td class="l">'+fmt(r.targetP,1)+'</td>'
|
||
+ '<td>'+(r.outp?1:0)+'</td></tr>';
|
||
}
|
||
$('measPreview').innerHTML = rows.length ? html : '<span class="dim">暂无数据</span>';
|
||
})
|
||
.catch(() => { $('measPreview').innerHTML = '<span class="errline">查询失败</span>'; });
|
||
}
|
||
function downloadMeasCsv(){
|
||
if(!confirm('导出最近 2000 条测量记录 (CSV) 用于离线分析?')) return;
|
||
window.location.href = '/api/meas_csv?limit=2000';
|
||
}
|
||
function downloadMeasRange(){
|
||
const st = parseFloat($('csvStart').value), et = parseFloat($('csvEnd').value);
|
||
if(isNaN(st) || isNaN(et)){ alert('请输入有效的起始/结束 unix 秒'); return; }
|
||
if(et <= st){ alert('结束时间必须大于起始时间'); return; }
|
||
if(!confirm('导出时间范围 ['+st+' ~ '+et+'] 的测量记录 (CSV)?')) return;
|
||
window.location.href = '/api/meas_csv?start='+st+'&end='+et;
|
||
}
|
||
|
||
//------------------ WebSocket 1Hz 快照 ------------------
|
||
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 { render(JSON.parse(e.data)); } catch(err) {}
|
||
};
|
||
ws.onclose = () => {
|
||
statusEl.textContent = '连接断开, 重连中...';
|
||
statusEl.className = 'badge err';
|
||
wsTimer = setTimeout(connectWs, 2000);
|
||
};
|
||
ws.onerror = () => ws.close();
|
||
}
|
||
connectWs();
|
||
</script>
|
||
</body>
|
||
</html>)HTML";
|
||
|
||
} // namespace eload
|
||
#endif // PELOAD_PAGE_INDEX_H
|