Files
H100PowerManger/src/pELoad/web/pages/index.h
T
zjk c610190c54 pELoad 连接后从设备回读真实配置同步状态
程序重启/刷新后页面状态与设备实际状态错配,导致按页面流程下发指令反馈错误。
新增 syncDeviceConfigOnce:连接建立进入远控后同步查询设备 OUTP/FUNC/FUNC:MODE
及各限值/设定值,以设备真实回读为准覆盖内存状态;设备输出仍为开时置
loadOn=true 并由 applyEnable 重新下发完整使能序列(接管恢复控制)。

- IT6000C 新增 7 个配置回读查询封装(CURR?/VOLT?/VOLT:LIM?/CURR:LIM?*2/POW:LIM?*2)
- ELoad 实现 syncDeviceConfigOnce 并接入 doControlCycle(连接后自动,覆盖重连/复位/本控→远控)
- 抽出 normFunc 辅助函数复用于 pollError;快照新增 cfgSynced/cfgSyncTime
- 页面卡2 增加设备配置同步状态显示
2026-09-07 13:03:08 +08:00

605 lines
34 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#ifndef PELOAD_PAGE_INDEX_H
#define PELOAD_PAGE_INDEX_H
#include <string>
//============================================================================
// pELoad 电子负载控制页面(纯前端,内嵌 HTML)。
//
// 操作顺序(防误触 + 步骤引导):
// ① 连接设备(链路绿 → 步骤2 可操作)
// ② 设置参数(选环路优先 CC/CV → 控制模式/目标量 → 限值)
// ③ 开启输出(确认后下发使能序列)
// 所有下发的指令均带确认对话框;按钮按当前状态禁用。
//
// 关键设计:页面为静态骨架(输入框/按钮是稳定 DOM,永不重建),
// render() 只按 id 更新动态数值/状态/按钮禁用 —— 输入框聚焦编辑时
// 不受 1Hz 快照刷新影响,光标稳定、输入流畅(setInput 聚焦时不覆盖)。
//
// 模式说明:
// 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);}
.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>
<span style="flex:1"></span>
<a class="btn" href="/trend" target="_blank">📈 实时曲线</a>
</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">
<!--======== 卡1:以太网链路 ========-->
<div class="card">
<h2>① 连接设备 · 以太网链路</h2>
<div class="row"><span class="k">设备地址</span><span class="v" id="linkAddr">-</span></div>
<div class="row"><span class="k">连接状态</span><span class="v" id="linkConn">未连接</span></div>
<div class="row"><span class="k">自动重连</span><span class="v" id="linkAuto">-</span></div>
<div class="row"><span class="k">重连次数</span><span class="v" id="linkReconn">-</span></div>
<div class="row"><span class="k">收发统计</span><span class="v" id="linkStats">-</span></div>
<div class="row"><span class="k">最近收帧</span><span class="v" id="linkLastRx">-</span></div>
<div class="row"><span class="k">设备识别 *IDN?</span><span class="v" id="linkIdn">未识别</span></div>
<div class="errline" id="linkError" style="display:none"></div>
<div class="sub">操作(指令均需确认)</div>
<button id="btnConnect" class="btn on" onclick="sendCmd('connect',null,'确认连接电子负载?')">连接</button>
<button id="btnDisconnect" class="btn off" onclick="sendCmd('disconnect',null,'确认断开以太网链路?\n断开前会先关闭输出。')">断开</button>
<button id="btnIdentify" class="btn" onclick="sendCmd('identify')">识别 (*IDN?)</button>
<button id="btnClearProt" class="btn warn" onclick="sendCmd('clear_protect',null,'确认清除输出保护锁定?')">清保护</button>
<button id="btnReset" class="btn off" onclick="sendCmd('reset_dev',null,'确认复位设备 (*RST)?\n所有设定将恢复默认!')">复位设备</button>
</div>
<!--======== 卡2:当前状态 + 实时测量 ========-->
<div class="card">
<h2>当前状态 · 实时测量</h2>
<div class="bigstat off" id="bigOutp">输出关</div>
<div class="fixwarn" id="fixWarn" style="display:none"></div>
<div class="meas">
<div><div class="v" style="color:#79c0ff" id="measV">-</div><div class="k">电压 V</div></div>
<div><div class="v" style="color:#3fb950" id="measI">-</div><div class="k">电流 A</div></div>
<div><div class="v" style="color:#d29922" id="measP">-</div><div class="k">功率 W</div></div>
</div>
<div class="row"><span class="k">数据时效</span><span class="v" id="measAge">-</span></div>
<div class="row"><span class="k">测量查询</span><span class="v" id="measDiag">-</span></div>
<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="cfgSync">-</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:参数与输出控制 ========-->
<div class="card">
<h2>②③ 参数与输出控制</h2>
<div class="sub">环路优先模式(决定控制量类型)</div>
<button id="btnModeCC" class="btn sel" onclick="sendCmd('mode_cc',null,'确认切换为 CC 优先(电流型负载,功率折算 I=P/U)?')">CC 优先</button>
<button id="btnModeCV" class="btn sel" onclick="sendCmd('mode_cv',null,'确认切换为 CV 优先(恒压吸收,按电压控制)?')">CV 优先</button>
<div class="tip" id="ctrlTip"></div>
<div id="ccArea">
<div class="sub">控制模式(目标来源)</div>
<button id="btnModeFollow" class="btn" onclick="sendCmd('mode_follow',null,'确认切换为跟随 MOOS 功率消息(实时电机功率)?')">跟随MOOS</button>
<button id="btnModeManual" class="btn" onclick="sendCmd('mode_manual',null,'确认切换为手动功率?')">手动功率</button>
<button id="btnModeCurrent" class="btn" onclick="sendCmd('mode_current',null,'确认切换为手动电流?')">手动电流</button>
<div class="tip" id="curModeTip"></div>
<div class="inline"><label>功率 (W)</label>
<input type="number" id="powerInput" step="1" value="0">
<button id="btnSetPower" class="btn" onclick="sendFromInput('set_power','powerInput','手动目标功率','W')">设定功率</button></div>
<div class="inline"><label>电流 (A, 负=吸收)</label>
<input type="number" id="currInput" step="0.1" value="0">
<button id="btnSetCurr" class="btn" onclick="sendFromInput('set_current','currInput','手动电流','A')">设定电流</button></div>
<div class="row"><span class="k">MOOS 功率消息</span><span class="v" id="moosPower">-</span></div>
<div class="row"><span class="k">指令电流 CURR</span><span class="v" id="cmdCurrent">-</span></div>
<div class="row"><span class="k">符号约定</span><span class="v" id="signTip">-</span></div>
</div>
<div id="cvArea" style="display:none">
<div class="inline"><label>目标电压 (V)</label>
<input type="number" id="voltInput" step="1" value="0">
<button id="btnSetVolt" class="btn" onclick="sendFromInput('set_volt','voltInput','CV 目标电压','V')">设定电压</button></div>
<div class="row"><span class="k">指令电压 VOLT</span><span class="v" id="cmdVolt">-</span></div>
<div class="tip">恒压吸收:设备维持该电压,吸收电流由外部源决定;功率大小请以实测为准。</div>
</div>
<div class="sub">输出控制</div>
<button id="btnOutpOn" class="btn big on" onclick="sendCmd('outp_on',null,'确认开启输出?\n电子负载将按当前设定开始吸收电能!')">▶ 开启输出</button>
<button id="btnOutpOff" class="btn big off" onclick="sendCmd('outp_off',null,'确认关闭输出?\n电子负载停止吸收电能。')">■ 关闭输出</button>
<div class="tip" id="outpTip"></div>
</div>
<!--======== 卡4:设备参数与限值 ========-->
<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="0">
<button id="btnSetVoltlim" class="btn" 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="0">
<button id="btnSetCurrlim" class="btn" onclick="sendFromInput('set_currlimit','currlimInput','电流限值','A')">下发</button></div>
<div class="inline"><label>功率限值 POW:LIM (W)</label>
<input type="number" id="powlimInput" step="10" value="0">
<button id="btnSetPowlim" class="btn" onclick="sendFromInput('set_powlimit','powlimInput','功率限值','W')">下发</button></div>
<div class="sub">远控 / 本控</div>
<div class="row"><span class="k">当前控制端</span><span class="v" id="remoteState">-</span></div>
<div class="row"><span class="k">最近指令结果</span><span class="v" id="remoteFeed">-</span></div>
<div class="tip">IT6000C 无远控状态查询命令,此状态按最近一次远控/本控指令的发送结果推断;若面板被手动按回本控,需重新发送“远控”。</div>
<button id="btnRemote" class="btn" onclick="sendCmd('remote',null,'确认进入远控模式 (SYST:REM)?\n面板按键将被锁定。')">远控 SYST:REM</button>
<button id="btnLocal" class="btn warn" onclick="sendCmd('local',null,'确认切回面板本控 (SYST:LOC)?\n输出将关闭且远控失效。')">本控 SYST:LOC</button>
</div>
<!--======== 卡5:SCPI 调试 ========-->
<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" />
<button id="btnRawSend" class="btn" 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:数据分析 ========-->
<div class="card">
<h2>数据分析 (高采样 meas_log)</h2>
<div class="row"><span class="k">测量记录条数</span><span class="v" id="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:历史记录 ========-->
<div class="card">
<h2>历史记录 (sqlite eload_log, 1Hz)</h2>
<div class="row"><span class="k">记录总数</span><span class="v" id="statusCount">-</span></div>
<div id="history" style="max-height:240px;overflow:auto"><span class="dim">点击“查询”加载</span></div>
<button class="btn" onclick="loadHistory()">查询最近 60 条</button>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const statusEl = $('status');
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+' ?');
}
// 功能模式切换(带确认 + 脱离实时控制警告)
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;
// 设置输入框值:用户聚焦编辑中不覆盖(保证输入流畅、光标稳定)
function setInput(id, v){
const el = $(id);
if(!el) return;
if(document.activeElement === el) return;
const str = (v === undefined || v === null) ? '' : String(v);
if(el.value !== str) el.value = str;
}
function setDisabled(id, cond){ const el = $(id); if(el) el.disabled = !!cond; }
function render(s){
lastSnap = s;
const link = s.link || {}, ctrl = s.ctrl || {}, dev = s.dev || {}, db = s.db || {};
const connected = !!link.connected, 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);
const now = Date.now() / 1000;
//---- 顶部状态条 ----
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;
//---- 卡1:链路 ----
$('linkAddr').textContent = link.host + ':' + link.port;
$('linkConn').innerHTML = connected
? '<span style="color:var(--ok)">已连接</span>'
: '<span style="color:var(--bad)">未连接</span>';
$('linkAuto').textContent = link.enabled ? '开启' : '关闭';
$('linkReconn').textContent = link.reconnects;
$('linkStats').textContent = 'tx='+link.tx+' rx='+link.rx+' txErr='+link.txErr;
$('linkLastRx').innerHTML = ageCell(link, 'lastRx');
$('linkIdn').innerHTML = link.idn || '<span class="dim">未识别</span>';
$('linkError').style.display = link.lastError ? '' : 'none';
$('linkError').textContent = link.lastError || '';
setDisabled('btnConnect', connected);
setDisabled('btnDisconnect', !connected);
setDisabled('btnIdentify', !connected);
setDisabled('btnClearProt', !connected);
setDisabled('btnReset', !connected);
//---- 卡2:状态 + 测量 ----
const bo = $('bigOutp');
bo.textContent = outpApplied ? '输出开' : (loadOn ? '待应用' : '输出关');
bo.className = 'bigstat ' + (outpApplied ? 'on' : (loadOn ? 'wait' : 'off'));
const fw = $('fixWarn');
if(dev.funcModeFixWarn){
fw.style.display = '';
fw.innerHTML = 'FUNC:MODE = '+(dev.funcMode||'?')+'(非 FIX)——实时功率控制已失效!请复位或手动恢复 FIX。';
} else fw.style.display = 'none';
$('measV').textContent = fmt(dev.measV, 2);
$('measI').textContent = fmt(dev.measI, 3);
$('measP').textContent = fmt(dev.measP, 1);
$('measAge').innerHTML = 'V '+ageCell(dev,'measV')+' · I '+ageCell(dev,'measI')+' · P '+ageCell(dev,'measP');
$('measDiag').innerHTML = '成功 <span style="color:var(--ok)">'+dev.measOk+'</span> · 失败 <span style="color:'+(dev.measFail>0?'var(--bad)':'var(--dim)')+'">'+dev.measFail+'</span>';
const mde = $('measDiagErr');
if(dev.lastMeasErr){
mde.style.display = '';
mde.textContent = dev.lastMeasErr;
} else mde.style.display = 'none';
$('devFunc').textContent = dev.func || '-';
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 cfs = $('cfgSync');
if(dev.cfgSynced){
cfs.innerHTML = '<span style="color:var(--ok)">已同步</span>('+ageText(now - dev.cfgSyncTime)+')';
} else {
cfs.innerHTML = '<span style="color:var(--warn)">同步中…</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);
setDisabled('btnModeCV', !connected || !cc);
$('ctrlTip').innerHTML = cc
? '<b>CC 优先</b>:控制量 = 电流。目标功率/电流 → CURR 指令(负值=吸收电能)。'
: '<b>CV 优先</b>:控制量 = 电压。恒压吸收,功率由外部源决定。';
$('ccArea').style.display = cc ? '' : 'none';
$('cvArea').style.display = cc ? 'none' : '';
setDisabled('btnModeFollow', !connected || ctrl.mode==='follow');
setDisabled('btnModeManual', !connected || ctrl.mode==='manual');
setDisabled('btnModeCurrent', !connected || ctrl.mode==='current');
$('curModeTip').innerHTML = '当前:<b>'+(modeName[ctrl.mode]||ctrl.mode)+'</b>';
setInput('powerInput', ctrl.manualPower || 0);
setInput('currInput', ctrl.manualCurrent || 0);
setInput('voltInput', ctrl.voltSet || 0);
setDisabled('btnSetPower', !connected);
setDisabled('btnSetCurr', !connected);
setDisabled('btnSetVolt', !connected);
setDisabled('powerInput', !connected);
setDisabled('currInput', !connected);
setDisabled('voltInput', !connected);
$('moosPower').innerHTML = (ctrl.moosPowerValid
? fmt(ctrl.moosPower,1)+' W ('+ageText(ctrl.moosPowerAge)+')'
: '<span class="dim">未收到</span>')
+ (powerStale ? ' <span class="badge err">超时!</span>' : '');
$('cmdCurrent').textContent = fmt(ctrl.cmdCurrent,4) + ' A';
$('signTip').textContent = ctrl.sign==='negative' ? '负电流=吸收(负载模拟)' : '正电流=输出(电源模拟)';
$('cmdVolt').textContent = fmt(ctrl.cmdVolt,2) + ' V';
setDisabled('btnOutpOn', !connected || loadOn);
setDisabled('btnOutpOff', !connected || !loadOn);
$('outpTip').innerHTML = outpApplied
? '<b style="color:var(--ok)">输出已开启</b>,正在实时控制'
: (loadOn ? '<b style="color:var(--warn)">已请求开启,等待使能序列应用…</b>'
: '输出未开启,开启前请完成步骤②参数设置');
//---- 卡4:参数 ----
setInput('voltlimInput', dev.voltLimit || 0);
setInput('currlimInput', dev.currLimit || 0);
setInput('powlimInput', dev.powLimit || 0);
setDisabled('voltlimInput', !connected);
setDisabled('currlimInput', !connected);
setDisabled('powlimInput', !connected);
setDisabled('btnSetVoltlim', !connected);
setDisabled('btnSetCurrlim', !connected);
setDisabled('btnSetPowlim', !connected);
setDisabled('btnRemote', !connected);
setDisabled('btnLocal', !connected);
const rs = $('remoteState');
rs.textContent = dev.remoteState==='remote' ? '远控(SCPI 控制中)'
: dev.remoteState==='local' ? '本控(面板控制)' : '未知';
rs.style.color = dev.remoteState==='remote' ? 'var(--ok)'
: dev.remoteState==='local' ? 'var(--warn)' : 'var(--dim)';
const rf = $('remoteFeed');
rf.textContent = dev.remoteFeedValid
? dev.remoteFeed + '('+ageText(now - dev.remoteFeedTime)+')'
: '-';
const feedFail = (dev.remoteFeed||'').indexOf('失败') >= 0;
rf.style.color = feedFail ? 'var(--bad)' : '';
rf.style.fontFamily = feedFail ? 'Consolas,monospace' : '';
//---- 卡5:SCPI ----
setDisabled('btnRawSend', !connected);
setDisabled('rawCmd', !connected);
//---- 卡6/7:数据 ----
$('measCount').textContent = db.measCount;
$('statusCount').textContent = db.statusCount + ' / SCPI ' + db.scpiCount;
}
//------------------ 数据分析(高采样测量 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">指令V</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.cmdV,1)+'</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;
}
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>目标V</th><th>指令A</th><th>指令V</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.voltSet,0)+'</td>'
+ '<td>'+fmt(r.cmdCurrent,3)+'</td><td>'+fmt(r.cmdVolt,1)+'</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>'; });
}
//------------------ 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