以下为index.html代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>STM32 防冰除冰PID算法 串口调试</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1/dist/chartjs-plugin-annotation.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',sans-serif;background:#0f0f1a;color:#e0e0e0;display:flex;flex-direction:column;height:100vh;overflow:hidden}
header{background:#16213e;padding:7px 14px;display:flex;align-items:center;gap:10px;border-bottom:1px solid #0f3460;flex-shrink:0}
header h1{font-size:14px;color:#e94560;white-space:nowrap}
.dev-tag{font-size:10px;color:#555}
#status-dot{width:8px;height:8px;border-radius:50%;background:#555;flex-shrink:0}
#status-dot.on{background:#4caf50;box-shadow:0 0 5px #4caf50}
#status-dot.err{background:#e94560}
#status-text{font-size:11px;color:#aaa}
.port-bar{margin-left:auto;display:flex;gap:5px;align-items:center}
select,.hdr-btn{background:#0d0d1a;border:1px solid #0f3460;border-radius:4px;color:#e0e0e0;padding:3px 8px;font-size:11px;cursor:pointer;outline:none}
.hdr-btn.green{background:#1a4a2a;border-color:#4caf50}
.hdr-btn.red{background:#3a1a1a;border-color:#e94560}
.main{display:flex;flex:1;overflow:hidden}
.panel{width:195px;flex-shrink:0;background:#16213e;border-right:1px solid #0f3460;overflow-y:auto;padding:7px;display:flex;flex-direction:column;gap:7px}
.panel h3{font-size:10px;color:#555;text-transform:uppercase;letter-spacing:1px}
.card-group{background:#0d0d1a;border:1px solid #0f3460;border-radius:4px;padding:5px;display:flex;flex-direction:column;gap:4px}
.dc{display:flex;justify-content:space-between;align-items:center;padding:1px 3px}
.dc .lbl{font-size:10px;color:#666}
.dc .val{font-size:12px;font-weight:bold;color:#e94560}
.dc .val.green{color:#4caf50}
.dc .val.yellow{color:#ffc107}
.dc .val.blue{color:#4fc3f7}
.dc .val.orange{color:#ff9800}
#pid-badge{text-align:center;padding:3px;border-radius:3px;font-size:11px;font-weight:bold;background:#2a2a3a;color:#888;cursor:pointer;user-select:none}
#pid-badge.running{background:#1e5c2e;color:#7dff9a}
#pid-badge.stopped{background:#5c1e1e;color:#ff7d7d}
#sim-badge{text-align:center;padding:2px;border-radius:3px;font-size:10px;background:#1a1a2e;color:#666}
#sim-badge.running{color:#4fc3f7}
#sim-badge.paused{color:#ffc107}
.row{display:flex;align-items:center;gap:4px}
.row label{font-size:10px;color:#777;width:48px;flex-shrink:0}
.row input[type=number]{flex:1;background:#0f0f1a;border:1px solid #0f3460;border-radius:3px;color:#e0e0e0;padding:2px 4px;font-size:11px;outline:none;min-width:0}
.row input:focus{border-color:#e94560}
.row .unit{font-size:10px;color:#444;width:22px;flex-shrink:0}
.btn{width:100%;padding:4px;border:none;border-radius:3px;cursor:pointer;font-size:11px}
.btn:hover{filter:brightness(1.15)}
.btn-green{background:#1e5c2e;color:#7dff9a}
.btn-blue{background:#1e3a5c;color:#7db8ff}
.btn-yellow{background:#5c4a1e;color:#ffd07d}
.btn-gray{background:#2a2a3a;color:#aaa}
.btn-sm{padding:2px 5px;width:auto;font-size:10px}
.content{flex:1;display:flex;flex-direction:column;overflow:hidden}
.charts{flex:1;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:1px;background:#0a0a14;overflow:hidden}
.chart-wrap{background:#0f0f1a;padding:5px 8px;min-height:0}
.chart-wrap h4{font-size:10px;color:#555;margin-bottom:2px}
.log-bar{height:110px;flex-shrink:0;border-top:1px solid #0f3460;display:flex;flex-direction:column}
.log-hdr{background:#16213e;padding:2px 8px;font-size:10px;color:#555;display:flex;justify-content:space-between;align-items:center;gap:6px}
#log-paused-tip{color:#ffc107;font-size:10px;display:none}
#log{flex:1;overflow-y:auto;padding:3px 8px;font-family:monospace;font-size:10px;line-height:1.5;background:#0a0a14}
#log.paused{border-top:1px solid #ffc107}
.log-line.rx{color:#4fc3f7}
.log-line.tx{color:#a5d6a7}
.log-line.sys{color:#555;font-style:italic}
.log-line.err{color:#e94560}
.count{color:#ffb74d;font-size:9px}
.send-bar{display:flex;gap:5px;padding:4px 8px;background:#16213e;border-top:1px solid #0f3460;flex-shrink:0}
#cmd-input{flex:1;background:#0a0a14;border:1px solid #0f3460;border-radius:3px;color:#e0e0e0;padding:3px 7px;font-size:12px;outline:none}
#cmd-input:focus{border-color:#e94560}
.send-bar button{background:#e94560;color:#fff;border:none;border-radius:3px;padding:3px 10px;cursor:pointer;font-size:12px}
#btn-clear-log{background:#333}
#no-support{display:none;margin:14px;padding:10px;background:#3a1a1a;border:1px solid #e94560;border-radius:4px;color:#e94560;font-size:12px}
.perf-bar{display:flex;align-items:center;gap:10px;margin-left:12px;padding:0 10px;border-left:1px solid #0f3460;border-right:1px solid #0f3460}
.perf-item{display:flex;flex-direction:column;align-items:center;min-width:60px}
.perf-lbl{font-size:9px;color:#555;white-space:nowrap}
.perf-val{font-size:13px;font-weight:bold;color:#e0e0e0;white-space:nowrap}
.perf-val.yellow{color:#ffc107}
.perf-val.blue{color:#4fc3f7}
.perf-val.green{color:#4caf50}
.perf-val.orange{color:#ff9800}
</style>
</head>
<body>
<div id="no-support">请使用 Chrome 或 Edge 浏览器 (需要 Web Serial API)</div>
<header>
  <div id="status-dot"></div>
  <h1>STM32 防冰除冰PID算法 串口调试</h1>
  <span class="dev-tag">— 李鑫瑞</span>
  <span id="status-text">未连接</span>
  <div class="perf-bar">
    <span class="perf-item"><span class="perf-lbl">超调量</span><span class="perf-val yellow" id="d-overshoot">--</span></span>
    <span class="perf-item"><span class="perf-lbl">调节时间</span><span class="perf-val blue" id="d-settle">--</span></span>
    <span class="perf-item"><span class="perf-lbl">稳态误差</span><span class="perf-val green" id="d-steady">--</span></span>
    <span class="perf-item"><span class="perf-lbl">最大偏差</span><span class="perf-val orange" id="d-maxdev">--</span></span>
    <button class="hdr-btn" onclick="resetPerf()" style="font-size:10px;padding:2px 6px">重置</button>
  </div>
  <div class="port-bar">
    <select id="baud-select"><option value="115200" selected>115200</option><option value="9600">9600</option></select>
    <button class="hdr-btn green" id="btn-connect">连接串口</button>
    <button class="hdr-btn red" id="btn-disconnect" disabled>断开</button>
  </div>
</header>
<div class="main">
  <div class="panel">
    <div>
      <h3>PID 控制</h3>
      <div id="pid-badge" onclick="togglePid()">IDLE — 点击启动</div>
      <div style="height:3px"></div>
      <div class="card-group">
        <div class="row"><label>Kp</label><input type="number" id="in-kp" value="3.0" step="0.1"><button class="btn btn-blue btn-sm" onclick="sendCmd('PID:kp:'+document.getElementById('in-kp').value)">设</button></div>
        <div class="row"><label>Ki</label><input type="number" id="in-ki" value="0.5" step="0.1"><button class="btn btn-blue btn-sm" onclick="sendCmd('PID:ki:'+document.getElementById('in-ki').value)">设</button></div>
        <div class="row"><label>Kd</label><input type="number" id="in-kd" value="1.0" step="0.1"><button class="btn btn-blue btn-sm" onclick="sendCmd('PID:kd:'+document.getElementById('in-kd').value)">设</button></div>
        <button class="btn btn-gray" onclick="sendCmd('PID:reset')">↺ 重置积分</button>
      </div>
    </div>
    <div>
      <h3>实时数据</h3>
      <div class="card-group">
        <div class="dc"><span class="lbl">新风口表面温度</span><span class="val" id="d-tsurf">--</span></div>
        <div class="dc"><span class="lbl">环境温度</span><span class="val blue" id="d-tenv">--</span></div>
        <div class="dc"><span class="lbl">相对湿度</span><span class="val blue" id="d-humi">--</span></div>
        <div class="dc"><span class="lbl">合成风速</span><span class="val blue" id="d-wind">--</span></div>
        <div class="dc"><span class="lbl">结冰速率</span><span class="val yellow" id="d-mdot">--</span></div>
        <div class="dc"><span class="lbl">前馈功率</span><span class="val orange" id="d-pff">--</span></div>
        <div class="dc"><span class="lbl">动态功率</span><span class="val orange" id="d-pdyn">--</span></div>
        <div class="dc"><span class="lbl">PID输出功率</span><span class="val green" id="d-power">--</span></div>
        <div class="dc"><span class="lbl">温度误差</span><span class="val yellow" id="d-err">--</span></div>
        <div class="dc"><span class="lbl">Kp / Ki / Kd</span><span class="val" id="d-kpid" style="font-size:10px">--</span></div>
      </div>
    </div>
    <div>
      <h3>环境参数设置</h3>
      <div class="card-group">
        <div class="row"><label>环境温度</label><input type="number" id="in-tenv" value="-8" step="0.5"><span class="unit">°C</span><button class="btn btn-yellow btn-sm" onclick="sendCmd('SET Tenv:'+document.getElementById('in-tenv').value)">设</button></div>
        <div class="row"><label>相对湿度</label><input type="number" id="in-humi" value="75" step="1"><span class="unit">%</span><button class="btn btn-yellow btn-sm" onclick="sendCmd('SET H:'+document.getElementById('in-humi').value)">设</button></div>
        <div class="row"><label>合成风速</label><input type="number" id="in-wind" value="8" step="0.5"><span class="unit">m/s</span><button class="btn btn-yellow btn-sm" onclick="sendCmd('SET W:'+document.getElementById('in-wind').value)">设</button></div>
        <div class="row"><label>表面温度</label><input type="number" id="in-tsurf" value="2.0" step="0.5"><span class="unit">°C</span><button class="btn btn-blue btn-sm" onclick="sendCmd('SET Tsurf:'+document.getElementById('in-tsurf').value)">设</button></div>
      </div>
    </div>
    <div>
      <h3>模拟方案</h3>
      <div id="sim-badge">未运行</div>
      <div style="height:3px"></div>
      <div class="card-group">
        <button class="btn btn-blue" onclick="startSim(0)">方案0 轻度结冰</button>
        <button class="btn btn-yellow" onclick="startSim(1)">方案1 严重结冰</button>
        <button class="btn btn-green" onclick="startSim(2)">方案2 循环测试</button>
        <button class="btn btn-gray" onclick="stopSim()">停止方案</button>
        <button class="btn btn-gray" id="btn-pause-sim" onclick="pauseSim()">暂停方案</button>
      </div>
    </div>
    <button class="btn btn-gray" onclick="sendCmd('STATUS')">查询状态</button>
  </div>
  <div class="content">
    <div class="charts">
      <div class="chart-wrap"><h4>新风口表面温度变化 (°C) — 竖线标注环境突变点</h4><canvas id="chart-tsurf"></canvas></div>
      <div class="chart-wrap"><h4>环境参数变化 (左轴: 温度/风速  右轴: 湿度%)</h4><canvas id="chart-env"></canvas></div>
      <div class="chart-wrap"><h4>加热功率 — 前馈/动态/PID输出 (W/m2)</h4><canvas id="chart-power"></canvas></div>
      <div class="chart-wrap"><h4>模糊PID参数自整定 (左轴: Kp/Ki/Kd  右轴: 结冰速率)</h4><canvas id="chart-kpid"></canvas></div>
    </div>
    <div class="log-bar">
      <div class="log-hdr">
        <span>串口日志</span>
        <span id="log-paused-tip">已暂停滚动 (移开鼠标恢复)</span>
        <span id="log-count">0 条</span>
      </div>
      <div id="log"></div>
    </div>
    <div class="send-bar">
      <input id="cmd-input" type="text" placeholder="输入命令 (如: SET Tenv:-10  PID:start  SIM:start:1)" disabled/>
      <button onclick="sendFromInput()">发送</button>
      <button id="btn-clear-log" onclick="clearLog()">清空</button>
    </div>
  </div>
</div>
<script>
if(!('serial' in navigator))document.getElementById('no-support').style.display='block';
var dot=document.getElementById('status-dot'),statusTx=document.getElementById('status-text');
var btnConn=document.getElementById('btn-connect'),btnDisc=document.getElementById('btn-disconnect');
var port=null,writer=null,reader=null,reading=false;
btnConn.addEventListener('click',async function(){
  try{
    port=await navigator.serial.requestPort();
    await port.open({baudRate:parseInt(document.getElementById('baud-select').value)});
    writer=port.writable.getWriter();
    dot.className='on';statusTx.textContent='已连接';
    btnConn.disabled=true;btnDisc.disabled=false;
    document.getElementById('cmd-input').disabled=false;
    addLog('串口已连接');readLoop();
  }catch(e){if(e.name!=='NotFoundError'){addLog('连接失败: '+e.message,'err');dot.className='err';}}
});
btnDisc.addEventListener('click',async function(){
  reading=false;
  try{if(reader)await reader.cancel();}catch(e){}
  try{if(writer){writer.releaseLock();writer=null;}}catch(e){}
  try{if(port)await port.close();}catch(e){}
  port=null;dot.className='err';statusTx.textContent='已断开';
  btnConn.disabled=false;btnDisc.disabled=true;
  document.getElementById('cmd-input').disabled=true;
  addLog('串口已断开');
});
function LineBreakTransformer(){this.buf='';}
LineBreakTransformer.prototype.transform=function(chunk,ctrl){this.buf+=chunk;var lines=this.buf.split('\n');this.buf=lines.pop();lines.forEach(function(l){ctrl.enqueue(l);});};
LineBreakTransformer.prototype.flush=function(ctrl){if(this.buf)ctrl.enqueue(this.buf);};
async function readLoop(){
  var dec=new TextDecoderStream();
  port.readable.pipeTo(dec.writable);
  var lr=dec.readable.pipeThrough(new TransformStream(new LineBreakTransformer()));
  reader=lr.getReader();reading=true;
  try{while(reading){var r=await reader.read();if(r.done)break;if(r.value&&r.value.trim())onData(r.value.trim());}}
  catch(e){if(reading)addLog('读取错误: '+e.message,'err');}
  finally{reader.releaseLock();}
}
var pidRunning=false,simRunning=false,simPaused=false;
var PID_RE=/^PID state:(\d) Tenv:(-?[\d.]+) Tsurf:(-?[\d.]+) H:(-?[\d.]+) W:(-?[\d.]+) mdot:(-?[\d.eE+\-]+) Pff:(-?[\d.]+) Pdyn:(-?[\d.]+) e:(-?[\d.]+) I:(-?[\d.]+) D:(-?[\d.]+) Kp:(-?[\d.]+) Ki:(-?[\d.]+) Kd:(-?[\d.]+) P:(-?[\d.]+)/;
function safeNum(v,d){if(d===undefined)d=1;return isFinite(v)?v.toFixed(d):'--';}
function safe(v){return isFinite(v)?v:null;}
function setText(id,v){var el=document.getElementById(id);if(el)el.textContent=v;}
function onData(line){
  addLog(line,'rx');
  var pid=line.match(PID_RE);
  if(pid){
    var state=parseInt(pid[1]);
    var tenv=parseFloat(pid[2]),tsurf=parseFloat(pid[3]);
    var humi=parseFloat(pid[4]),wind=parseFloat(pid[5]);
    var mdot=parseFloat(pid[6]);
    var pff=parseFloat(pid[7]),pdyn=parseFloat(pid[8]);
    var err=parseFloat(pid[9]);
    var kp=parseFloat(pid[12]),ki=parseFloat(pid[13]),kd=parseFloat(pid[14]);
    var power=parseFloat(pid[15]);
    setText('d-tsurf',safeNum(tsurf,2)+' C');
    setText('d-tenv',safeNum(tenv,1)+' C');
    setText('d-humi',safeNum(humi,1)+' %');
    setText('d-wind',safeNum(wind,1)+' m/s');
    setText('d-mdot',isFinite(mdot)?(mdot*1e4).toFixed(3)+' x10-4':'--');
    setText('d-pff',safeNum(pff,1)+' W/m2');
    setText('d-pdyn',safeNum(pdyn,1)+' W/m2');
    setText('d-power',safeNum(power,2)+' W/m2');
    setText('d-err',safeNum(err,3)+' C');
    setText('d-kpid',safeNum(kp,2)+'/'+safeNum(ki,2)+'/'+safeNum(kd,2));
    pidRunning=(state===1);
    var badge=document.getElementById('pid-badge');
    if(state===0){badge.textContent='IDLE - 点击启动';badge.className='';}
    else if(state===1){badge.textContent='运行中 - 点击停止';badge.className='running';}
    else{badge.textContent='已停止 - 点击重启';badge.className='stopped';}
    var t=new Date().toLocaleTimeString();
    pushChart(chartTsurf,t,[safe(tsurf)]);
    pushChart(chartEnv,t,[safe(tenv),safe(wind),safe(humi)]);
    pushChart(chartPower,t,[safe(pff),safe(pdyn),safe(power)]);
    pushChart(chartKpid,t,[safe(kp),safe(ki),safe(kd),isFinite(mdot)?mdot*1e4:null]);
    updatePerf(tsurf, err);
    return;
  }
  if(line.indexOf('ACK SIM started')===0){
    simRunning=true;simPaused=false;updateSimBadge();
    // 环境突变竖线标注
    addEventLine('环境突变: '+line.replace('ACK SIM started ',''));
    resetPerf();
  }
  if(line==='ACK SIM stopped'){simRunning=false;simPaused=false;updateSimBadge();}
  if(line==='ACK SIM pause/resume'){simPaused=!simPaused;updateSimBadge();}
}
function updateSimBadge(){
  var b=document.getElementById('sim-badge'),pb=document.getElementById('btn-pause-sim');
  if(!simRunning){b.textContent='未运行';b.className='';pb.textContent='暂停方案';}
  else if(simPaused){b.textContent='已暂停';b.className='paused';pb.textContent='继续方案';}
  else{b.textContent='运行中';b.className='running';pb.textContent='暂停方案';}
}
async function sendCmd(cmd){if(!writer)return;await writer.write(new TextEncoder().encode(cmd+'\r\n'));addLog(cmd,'tx');}
function sendFromInput(){var el=document.getElementById('cmd-input');if(el.value.trim()){sendCmd(el.value.trim());el.value='';}}
document.getElementById('cmd-input').addEventListener('keydown',function(e){if(e.key==='Enter')sendFromInput();});
function togglePid(){if(pidRunning)sendCmd('PID:stop');else sendCmd('PID:start');}
function applyEnv(){sendCmd('SET Tenv:'+document.getElementById('in-tenv').value);sendCmd('SET H:'+document.getElementById('in-humi').value);sendCmd('SET W:'+document.getElementById('in-wind').value);}
function startSim(id){sendCmd('SIM:start:'+id);}
function stopSim(){sendCmd('SIM:stop');}
function pauseSim(){sendCmd('SIM:pause');}
var logEl=document.getElementById('log');
var logCount=0,lastLogText='',lastLogEl=null,lastLogN=1,logPaused=false;
logEl.addEventListener('mouseenter',function(){logPaused=true;logEl.classList.add('paused');document.getElementById('log-paused-tip').style.display='inline';});
logEl.addEventListener('mouseleave',function(){logPaused=false;logEl.classList.remove('paused');document.getElementById('log-paused-tip').style.display='none';logEl.scrollTop=logEl.scrollHeight;});
function addLog(text,type){
  type=type||'sys';
  var time=new Date().toLocaleTimeString();
  if(type==='rx'&&text===lastLogText&&lastLogEl){lastLogN++;lastLogEl.querySelector('.count').textContent=' x'+lastLogN;lastLogEl.querySelector('.time').textContent='['+time+']';if(!logPaused)logEl.scrollTop=logEl.scrollHeight;return;}
  var line=document.createElement('div');line.className='log-line '+type;
  var prefix=type==='rx'?'<- ':type==='tx'?'-> ':'   ';
  line.innerHTML='<span class="time">['+time+']</span> '+prefix+text+'<span class="count"></span>';
  logEl.appendChild(line);if(!logPaused)logEl.scrollTop=logEl.scrollHeight;
  while(logEl.children.length>500)logEl.removeChild(logEl.firstChild);
  if(type==='rx'){lastLogText=text;lastLogEl=line;lastLogN=1;}else{lastLogText='';lastLogEl=null;}
  document.getElementById('log-count').textContent=(++logCount)+' 条';
}
function clearLog(){logEl.innerHTML='';logCount=0;document.getElementById('log-count').textContent='0 条';}
// ---- 性能指标统计 ----
var perfData={startTime:null,maxTsurf:-999,minTsurf:999,settleTime:null,settled:false,overshoot:0,samples:[]};
function resetPerf(){
  perfData={startTime:Date.now(),maxTsurf:-999,minTsurf:999,settleTime:null,settled:false,overshoot:0,samples:[]};
  setText('d-overshoot','--');setText('d-settle','--');setText('d-steady','--');setText('d-maxdev','--');
}
function updatePerf(tsurf,err){
  if(!perfData.startTime)perfData.startTime=Date.now();  // 自动开始计时
  var target=2.0;
  if(isFinite(tsurf)){
    if(tsurf>perfData.maxTsurf)perfData.maxTsurf=tsurf;
    if(tsurf<perfData.minTsurf)perfData.minTsurf=tsurf;
    perfData.samples.push(tsurf);
    // 超调量:表面温度超过目标2°C的最大值(正值=超调,负值=欠调)
    var os=perfData.maxTsurf-target;
    setText('d-overshoot',(os>=0?'+':'')+os.toFixed(2)+' °C');
    // 最大偏差:离目标最远的距离
    var maxdev=Math.max(Math.abs(perfData.maxTsurf-target),Math.abs(perfData.minTsurf-target));
    setText('d-maxdev',maxdev.toFixed(2)+' °C');
    // 调节时间:误差持续<0.3°C超过8个采样点视为稳定
    if(!perfData.settled&&Math.abs(err)<0.3){
      perfData.stableCount=(perfData.stableCount||0)+1;
      if(perfData.stableCount>=8){
        perfData.settled=true;
        perfData.settleTime=(Date.now()-perfData.startTime)/1000;
        setText('d-settle',perfData.settleTime.toFixed(1)+' s');
      }
    }else{
      if(Math.abs(err)>=0.3)perfData.stableCount=0;
    }
    // 稳态误差:最近30个采样点的均方根误差(RMSE)
    if(perfData.samples.length>=10){
      var recent=perfData.samples.slice(-30);
      var mse=recent.reduce(function(s,v){return s+(v-target)*(v-target);},0)/recent.length;
      setText('d-steady',Math.sqrt(mse).toFixed(3)+' °C');
    }
  }
}
// ---- 环境突变竖线标注 ----
var eventLines=[];
function addEventLine(label){
  var t=new Date().toLocaleTimeString();
  eventLines.push({t:t,label:label});
  // 在4个图表上都加竖线annotation
  [chartTsurf,chartEnv,chartPower,chartKpid].forEach(function(chart){
    if(!chart.options.plugins.annotation)chart.options.plugins.annotation={annotations:{}};
    var key='evt'+eventLines.length;
    chart.options.plugins.annotation.annotations[key]={
      type:'line',scaleID:'x',value:t,
      borderColor:'#ffc107',borderWidth:2,borderDash:[6,3],
      label:{display:true,content:label,color:'#ffc107',font:{size:9},position:'start'}
    };
    chart.update('none');
  });
  addLog('[突变标注] '+label,'sys');
}
var MAX_PTS=120;
function makeChart(id,datasets){
  var ctx=document.getElementById(id).getContext('2d');
  return new Chart(ctx,{type:'line',data:{labels:[],datasets:datasets.map(function(d){return{label:d.label,data:[],borderColor:d.color,backgroundColor:d.color+'15',borderWidth:1.5,pointRadius:0,tension:0.3,fill:false,yAxisID:d.axis||'y'};})},options:{animation:false,responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:'#777',font:{size:10},boxWidth:10}}},scales:{x:{ticks:{color:'#444',maxTicksLimit:6,font:{size:9}},grid:{color:'#1a1a2e'}},y:{ticks:{color:'#aaa',font:{size:9}},grid:{color:'#1a1a2e'}},y2:{position:'right',ticks:{color:'#ff9800',font:{size:9}},grid:{display:false}}}}});
}
function pushChart(chart,label,vals){
  chart.data.labels.push(label);
  vals.forEach(function(v,i){if(i<chart.data.datasets.length)chart.data.datasets[i].data.push(v);});
  if(chart.data.labels.length>MAX_PTS){chart.data.labels.shift();chart.data.datasets.forEach(function(d){d.data.shift();});}
  chart.update('none');
}
var chartTsurf=makeChart('chart-tsurf',[{label:'新风口表面温度 (C)',color:'#e94560'}]);
chartTsurf.options.scales.y.min=-5;
chartTsurf.options.scales.y.max=8;
chartTsurf.update();
var chartEnv=makeChart('chart-env',[{label:'环境温度 (C)',color:'#4fc3f7'},{label:'风速 (m/s)',color:'#4caf50'},{label:'相对湿度 (%)',color:'#7986cb',axis:'y2'}]);
var chartPower=makeChart('chart-power',[{label:'前馈功率 Pff',color:'#7986cb'},{label:'动态功率 Pdyn',color:'#ef5350'},{label:'PID输出功率 P',color:'#4caf50'}]);
var chartKpid=makeChart('chart-kpid',[{label:'比例系数 Kp',color:'#e94560'},{label:'积分系数 Ki',color:'#4caf50'},{label:'微分系数 Kd',color:'#4fc3f7'},{label:'结冰速率 x10-4',color:'#ff9800',axis:'y2'}]);
</script>
</body>
</html>

以下为后端代码

/**
 * 静态文件服务器 - 提供 index.html
 * 串口通信由浏览器的 Web Serial API 直接处理,无需后端参与
 *
 * 使用方法: node server.js [端口]
 */

const http = require('http');
const fs   = require('fs');
const path = require('path');

const PORT = parseInt(process.argv[2]) || 8080;

http.createServer((req, res) => {
    const filePath = path.join(__dirname, 'index.html');
    fs.readFile(filePath, (err, data) => {
        if (err) { res.writeHead(404); res.end('Not found'); return; }
        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end(data);
    });
}).listen(PORT, () => {
    console.log(`[服务器] 已启动: http://localhost:${PORT}`);
    console.log('[提示] 使用 Chrome 或 Edge 浏览器访问,串口由浏览器直接连接');
});