Files
H100PowerManger/src/pELoad/web/pages/index.h
T

443 lines
26 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 → 控制模式/目标量 → 限值)
// ③ 开启输出(确认后下发使能序列)
// 所有下发的指令均带确认对话框;按钮按当前状态禁用并提示原因。
//
// 模式说明:
// 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