1064 lines
36 KiB
JavaScript
1064 lines
36 KiB
JavaScript
/* ============================================================
|
||
* ESP32-S3-RLCD-4.2 Workbench — Canvas 像素级预览
|
||
*
|
||
* 架构:
|
||
* 1. RLCDSimulator - 400×300 1-bit 像素级 Canvas 渲染器
|
||
* 2. 位图字体 - 5×7 / 8×16 两套像素字体, hex 存储
|
||
* 3. renderDashboard - 仪表盘绘制(性能/Codex/智谱 三段)
|
||
* 4. 控制面板 - 字段编辑实时重绘 + 桥接 API 保留
|
||
*
|
||
* 这个文件是单文件应用, 不依赖外部库
|
||
* ============================================================ */
|
||
|
||
'use strict';
|
||
|
||
/* ============================================================
|
||
* Section 1: 位图字体 (5×7 和 8×16)
|
||
*
|
||
* 每个字符用 hex 数组表示, 每行 1 字节(8 像素)
|
||
* 5×7 字体: 每字符 7 字节, 有效位是低 5 位
|
||
* 8×16 字体: 每字符 16 字节, 全 8 位有效
|
||
*
|
||
* 只覆盖仪表盘用到的字符: 0-9 A-Z 标点 符号
|
||
* ============================================================ */
|
||
|
||
// 5×7 像素字体 (主用, 小字)
|
||
// 每字符 7 字节, bit0-bit4 是 5 列像素, bit0=最左列
|
||
// 来源: 经典 LCD 字体, 改造适配仪表盘
|
||
const FONT_5x7 = {
|
||
' ': [0x00,0x00,0x00,0x00,0x00,0x00,0x00],
|
||
'0': [0x0e,0x11,0x13,0x15,0x19,0x11,0x0e],
|
||
'1': [0x04,0x0c,0x04,0x04,0x04,0x04,0x0e],
|
||
'2': [0x0e,0x11,0x01,0x02,0x04,0x08,0x1f],
|
||
'3': [0x1f,0x02,0x04,0x02,0x01,0x11,0x0e],
|
||
'4': [0x02,0x06,0x0a,0x12,0x1f,0x02,0x02],
|
||
'5': [0x1f,0x10,0x1e,0x01,0x01,0x11,0x0e],
|
||
'6': [0x06,0x08,0x10,0x1e,0x11,0x11,0x0e],
|
||
'7': [0x1f,0x01,0x02,0x04,0x08,0x08,0x08],
|
||
'8': [0x0e,0x11,0x11,0x0e,0x11,0x11,0x0e],
|
||
'9': [0x0e,0x11,0x11,0x0f,0x01,0x02,0x0c],
|
||
'A': [0x0e,0x11,0x11,0x11,0x1f,0x11,0x11],
|
||
'B': [0x1e,0x11,0x11,0x1e,0x11,0x11,0x1e],
|
||
'C': [0x0e,0x11,0x10,0x10,0x10,0x11,0x0e],
|
||
'D': [0x1c,0x12,0x11,0x11,0x11,0x12,0x1c],
|
||
'E': [0x1f,0x10,0x10,0x1e,0x10,0x10,0x1f],
|
||
'F': [0x1f,0x10,0x10,0x1e,0x10,0x10,0x10],
|
||
'G': [0x0e,0x11,0x10,0x17,0x11,0x11,0x0f],
|
||
'H': [0x11,0x11,0x11,0x1f,0x11,0x11,0x11],
|
||
'I': [0x0e,0x04,0x04,0x04,0x04,0x04,0x0e],
|
||
'J': [0x01,0x01,0x01,0x01,0x11,0x11,0x0e],
|
||
'K': [0x11,0x12,0x14,0x18,0x14,0x12,0x11],
|
||
'L': [0x10,0x10,0x10,0x10,0x10,0x10,0x1f],
|
||
'M': [0x11,0x1b,0x15,0x15,0x11,0x11,0x11],
|
||
'N': [0x11,0x11,0x19,0x15,0x13,0x11,0x11],
|
||
'O': [0x0e,0x11,0x11,0x11,0x11,0x11,0x0e],
|
||
'P': [0x1e,0x11,0x11,0x1e,0x10,0x10,0x10],
|
||
'Q': [0x0e,0x11,0x11,0x11,0x15,0x12,0x0d],
|
||
'R': [0x1e,0x11,0x11,0x1e,0x14,0x12,0x11],
|
||
'S': [0x0f,0x10,0x10,0x0e,0x01,0x01,0x1e],
|
||
'T': [0x1f,0x04,0x04,0x04,0x04,0x04,0x04],
|
||
'U': [0x11,0x11,0x11,0x11,0x11,0x11,0x0e],
|
||
'V': [0x11,0x11,0x11,0x11,0x11,0x0a,0x04],
|
||
'W': [0x11,0x11,0x11,0x15,0x15,0x15,0x0a],
|
||
'X': [0x11,0x11,0x0a,0x04,0x0a,0x11,0x11],
|
||
'Y': [0x11,0x11,0x11,0x0a,0x04,0x04,0x04],
|
||
'Z': [0x1f,0x01,0x02,0x04,0x08,0x10,0x1f],
|
||
'%': [0x1c,0x22,0x02,0x04,0x08,0x10,0x3e], // 用 5x7 近似
|
||
'.': [0x00,0x00,0x00,0x00,0x00,0x0c,0x0c],
|
||
':': [0x00,0x0a,0x0a,0x00,0x0a,0x0a,0x00],
|
||
'/': [0x01,0x02,0x02,0x04,0x04,0x08,0x08],
|
||
'-': [0x00,0x00,0x00,0x1f,0x00,0x00,0x00],
|
||
'+': [0x00,0x04,0x04,0x1e,0x04,0x04,0x00],
|
||
'(': [0x02,0x04,0x08,0x08,0x08,0x04,0x02],
|
||
')': [0x08,0x04,0x02,0x02,0x02,0x04,0x08],
|
||
'*': [0x00,0x11,0x0a,0x1f,0x0a,0x11,0x00],
|
||
'!': [0x04,0x04,0x04,0x04,0x04,0x00,0x04],
|
||
'?': [0x0e,0x11,0x01,0x02,0x04,0x00,0x04],
|
||
'#': [0x0a,0x0a,0x1f,0x0a,0x1f,0x0a,0x0a],
|
||
'&': [0x0e,0x11,0x10,0x0a,0x14,0x12,0x0d],
|
||
'=': [0x00,0x00,0x1f,0x00,0x1f,0x00,0x00],
|
||
'|': [0x04,0x04,0x04,0x04,0x04,0x04,0x04],
|
||
};
|
||
|
||
// 8×16 像素字体 (大字用, 主数字)
|
||
// 每字符严格 16 字节, 8 列全部有效
|
||
// 来源: 经典 VGA 8x16 字符子集, 仪表盘用到的字符全覆盖
|
||
const FONT_8x16 = {
|
||
' ': [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||
'0': [0x00,0x7e,0xff,0xc3,0xc3,0xdb,0xdb,0xdb,0xdb,0xc3,0xc3,0xff,0x7e,0x00,0x00,0x00],
|
||
'1': [0x00,0x08,0x0c,0x0e,0xff,0xff,0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x00,0x00,0x00],
|
||
'2': [0x00,0x7e,0xff,0x03,0x03,0x07,0x0e,0x1c,0x38,0x70,0xe0,0xfe,0xfe,0x00,0x00,0x00],
|
||
'3': [0x00,0x7e,0xff,0x03,0x03,0x07,0x1e,0x1e,0x07,0x03,0x03,0xff,0x7e,0x00,0x00,0x00],
|
||
'4': [0x00,0x06,0x0e,0x1e,0x36,0x66,0xc6,0xff,0xff,0x06,0x06,0x06,0x06,0x00,0x00,0x00],
|
||
'5': [0x00,0xff,0xff,0xc0,0xc0,0xfe,0xff,0x03,0x03,0x03,0x03,0xff,0x7e,0x00,0x00,0x00],
|
||
'6': [0x00,0x07,0x0e,0x1c,0x38,0x70,0xfe,0xff,0xc3,0xc3,0xc3,0xff,0x7e,0x00,0x00,0x00],
|
||
'7': [0x00,0xff,0xff,0x03,0x03,0x06,0x06,0x0c,0x0c,0x18,0x18,0x30,0x30,0x00,0x00,0x00],
|
||
'8': [0x00,0x7e,0xff,0xc3,0xc3,0xff,0x7e,0x7e,0xff,0xc3,0xc3,0xff,0x7e,0x00,0x00,0x00],
|
||
'9': [0x00,0x7e,0xff,0xc3,0xc3,0xff,0x7f,0x0e,0x1c,0x38,0x70,0xe0,0xc0,0x00,0x00,0x00],
|
||
'%': [0x00,0x60,0x66,0x66,0x06,0x0c,0x18,0x30,0x60,0xc0,0x86,0x86,0x06,0x00,0x00,0x00],
|
||
'.': [0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x18,0x3c,0x18,0x00,0x00],
|
||
':': [0x00,0x00,0x00,0x00,0x18,0x3c,0x18,0x00,0x00,0x18,0x3c,0x18,0x00,0x00,0x00,0x00],
|
||
'/': [0x00,0x03,0x03,0x03,0x06,0x06,0x0c,0x0c,0x18,0x18,0x30,0x30,0x60,0x60,0x00,0x00],
|
||
'-': [0x00,0x00,0x00,0x00,0x00,0x00,0xff,0xff,0xff,0x00,0x00,0x00,0x00,0x00,0x00,0x00],
|
||
'+': [0x00,0x00,0x00,0x00,0x18,0x18,0x18,0xff,0xff,0x18,0x18,0x18,0x00,0x00,0x00,0x00],
|
||
'h': [0x00,0xc0,0xc0,0xc0,0xde,0xe7,0xc3,0xc3,0xc3,0xc3,0xc3,0xc3,0xc3,0x00,0x00,0x00],
|
||
'H': [0x00,0xc3,0xc3,0xc3,0xc3,0xc3,0xc3,0xff,0xff,0xc3,0xc3,0xc3,0xc3,0x00,0x00,0x00],
|
||
'z': [0x00,0x00,0x00,0x00,0xfe,0xfe,0x06,0x0c,0x18,0x30,0x60,0xfe,0xfe,0x00,0x00,0x00],
|
||
'Z': [0x00,0x7e,0xff,0x03,0x03,0x06,0x0c,0x18,0x30,0x60,0xc0,0xff,0x7e,0x00,0x00,0x00],
|
||
'G': [0x00,0x7e,0xff,0xc3,0xc0,0xc0,0xc0,0xdf,0xdf,0xc3,0xc3,0xff,0x7e,0x00,0x00,0x00],
|
||
'g': [0x00,0x00,0x00,0x7f,0xff,0xc3,0xc3,0xff,0x7f,0x03,0x03,0xff,0x7e,0x00,0x00,0x00],
|
||
'M': [0x00,0xc3,0xe7,0xff,0xdb,0xdb,0xc3,0xc3,0xc3,0xc3,0xc3,0xc3,0xc3,0x00,0x00,0x00],
|
||
'm': [0x00,0x00,0x00,0xde,0xe7,0xc3,0xc3,0xc3,0xc3,0xc3,0xc3,0xc3,0xc3,0x00,0x00,0x00],
|
||
};
|
||
|
||
/* ============================================================
|
||
* Section 2: RLCDSimulator - 400×300 1-bit Canvas 渲染器
|
||
* ============================================================ */
|
||
|
||
class RLCDSimulator {
|
||
constructor(canvas) {
|
||
this.W = 400;
|
||
this.H = 300;
|
||
this.canvas = canvas;
|
||
this.canvas.width = this.W;
|
||
this.canvas.height = this.H;
|
||
this.ctx = this.canvas.getContext('2d', { willReadFrequently: true });
|
||
this.ctx.imageSmoothingEnabled = false;
|
||
this.imageData = this.ctx.createImageData(this.W, this.H);
|
||
this.invert = false;
|
||
this.clear(true);
|
||
}
|
||
|
||
// 设置反色(白底黑字 ↔ 黑底白字)
|
||
setInvert(v) {
|
||
this.invert = !!v;
|
||
}
|
||
|
||
// 清屏 (white = 0=白, 1=黑; invert 会反转)
|
||
clear(white = true) {
|
||
const v = white ? 255 : 0;
|
||
const actual = this.invert ? (255 - v) : v;
|
||
const data = this.imageData.data;
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
data[i] = actual; data[i + 1] = actual; data[i + 2] = actual; data[i + 3] = 255;
|
||
}
|
||
}
|
||
|
||
// 写一个像素 (on=true=黑, false=白)
|
||
setPixel(x, y, on) {
|
||
x = x | 0; y = y | 0;
|
||
if (x < 0 || x >= this.W || y < 0 || y >= this.H) return;
|
||
const isOn = this.invert ? !on : on;
|
||
const v = isOn ? 0 : 255;
|
||
const idx = (y * this.W + x) * 4;
|
||
this.imageData.data[idx] = v;
|
||
this.imageData.data[idx + 1] = v;
|
||
this.imageData.data[idx + 2] = v;
|
||
this.imageData.data[idx + 3] = 255;
|
||
}
|
||
|
||
// 填充矩形
|
||
fillRect(x, y, w, h, on) {
|
||
for (let yy = y; yy < y + h; yy++) {
|
||
for (let xx = x; xx < x + w; xx++) {
|
||
this.setPixel(xx, yy, on);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 描边矩形 (1px 边框)
|
||
strokeRect(x, y, w, h, on) {
|
||
this.fillRect(x, y, w, 1, on);
|
||
this.fillRect(x, y + h - 1, w, 1, on);
|
||
this.fillRect(x, y, 1, h, on);
|
||
this.fillRect(x + w - 1, y, 1, h, on);
|
||
}
|
||
|
||
// 画线 (Bresenham)
|
||
line(x0, y0, x1, y1, on) {
|
||
x0 = x0 | 0; y0 = y0 | 0; x1 = x1 | 0; y1 = y1 | 0;
|
||
const dx = Math.abs(x1 - x0);
|
||
const dy = -Math.abs(y1 - y0);
|
||
const sx = x0 < x1 ? 1 : -1;
|
||
const sy = y0 < y1 ? 1 : -1;
|
||
let err = dx + dy;
|
||
while (true) {
|
||
this.setPixel(x0, y0, on);
|
||
if (x0 === x1 && y0 === y1) break;
|
||
const e2 = 2 * err;
|
||
if (e2 >= dy) { err += dy; x0 += sx; }
|
||
if (e2 <= dx) { err += dx; y0 += sy; }
|
||
}
|
||
}
|
||
|
||
// 画粗线 (多次 setPixel 偏移)
|
||
lineThick(x0, y0, x1, y1, on, thickness) {
|
||
if (thickness <= 1) { this.line(x0, y0, x1, y1, on); return; }
|
||
const half = (thickness - 1) / 2;
|
||
for (let oy = -Math.floor(half); oy <= Math.ceil(half); oy++) {
|
||
for (let ox = -Math.floor(half); ox <= Math.ceil(half); ox++) {
|
||
if (ox * ox + oy * oy <= half * half + 0.5) {
|
||
this.line(x0 + ox, y0 + oy, x1 + ox, y1 + oy, on);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 绘制文本 (5×7 字体)
|
||
text5x7(x, y, str, on) {
|
||
this.textSize(str, x, y, 8, on); // 改用真实 8pt(近似 5x7 视觉大小,但清晰得多)
|
||
}
|
||
|
||
// 绘制文本 (8×16 字体, 用于大数字)
|
||
text8x16(x, y, str, on) {
|
||
this.textSize(str, x, y, 16, on);
|
||
}
|
||
|
||
// 通用文本绘制:用 Canvas fillText 渲染,再阈值化合成到 imageData
|
||
// size 对应 LVGL montserrat 字号(14/20/28/48)
|
||
// 这样字号真实反映板子效果
|
||
textSize(str, x, y, size, on) {
|
||
str = String(str);
|
||
if (str.length === 0) return;
|
||
// 用一个离屏 canvas 渲染文字
|
||
const off = document.createElement('canvas');
|
||
// 估算文字尺寸(字符宽 ≈ size * 0.6,留 20% 余量)
|
||
const approxW = Math.ceil(str.length * size * 0.7) + 4;
|
||
const approxH = size + 4;
|
||
off.width = approxW;
|
||
off.height = approxH;
|
||
const octx = off.getContext('2d');
|
||
octx.imageSmoothingEnabled = false;
|
||
octx.fillStyle = '#fff';
|
||
octx.fillRect(0, 0, approxW, approxH);
|
||
octx.fillStyle = '#000';
|
||
octx.font = `700 ${size}px "Montserrat", "Segoe UI", -apple-system, sans-serif`;
|
||
octx.textBaseline = 'top';
|
||
octx.fillText(str, 1, 1);
|
||
// 读像素, 按亮度阈值化合成到主 imageData
|
||
// 注意: 背景是不透明白色, alpha 恒为 255, 必须检测 RGB 亮度
|
||
const src = octx.getImageData(0, 0, approxW, approxH).data;
|
||
for (let py = 0; py < approxH; py++) {
|
||
for (let px = 0; px < approxW; px++) {
|
||
const tx = x + px;
|
||
const ty = y + py;
|
||
if (tx < 0 || tx >= this.W || ty < 0 || ty >= this.H) continue;
|
||
const idx = (py * approxW + px) * 4;
|
||
// 取 R 通道(背景白=255, 字黑<128), AA 边缘是灰色(128-254)
|
||
const r = src[idx];
|
||
// r < 160 = 字的核心或较深 AA 边缘 = 黑像素
|
||
if (r < 160) {
|
||
this.setPixel(tx, ty, on);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 测量文本宽度(跟 textSize 配套)
|
||
textWidth(str, size) {
|
||
return Math.ceil(String(str).length * size * 0.62);
|
||
}
|
||
|
||
// 等宽字体文本 - 终端 CLI 风格核心
|
||
// 等宽字每个字符宽度固定, 用它画表格天然对齐
|
||
textMono(str, x, y, size, on, weight = '400') {
|
||
str = String(str);
|
||
if (str.length === 0) return;
|
||
const off = document.createElement('canvas');
|
||
// 等宽字字符宽 ≈ size * 0.6
|
||
const approxW = Math.ceil(str.length * size * 0.65) + 4;
|
||
const approxH = size + 6;
|
||
off.width = approxW;
|
||
off.height = approxH;
|
||
const octx = off.getContext('2d');
|
||
octx.imageSmoothingEnabled = false;
|
||
octx.fillStyle = '#fff';
|
||
octx.fillRect(0, 0, approxW, approxH);
|
||
octx.fillStyle = '#000';
|
||
// 优先用 Consolas(Windows) / Menlo(Mac) / Courier, 都是等宽
|
||
octx.font = `${weight} ${size}px "Consolas", "Menlo", "Courier New", monospace`;
|
||
octx.textBaseline = 'top';
|
||
octx.fillText(str, 2, 2);
|
||
const src = octx.getImageData(0, 0, approxW, approxH).data;
|
||
for (let py = 0; py < approxH; py++) {
|
||
for (let px = 0; px < approxW; px++) {
|
||
const tx = x + px;
|
||
const ty = y + py;
|
||
if (tx < 0 || tx >= this.W || ty < 0 || ty >= this.H) continue;
|
||
const r = src[(py * approxW + px) * 4];
|
||
if (r < 160) this.setPixel(tx, ty, on);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 等宽字体宽度(每字符固定宽)
|
||
textMonoWidth(str, size) {
|
||
return Math.ceil(String(str).length * size * 0.62);
|
||
}
|
||
|
||
// 进度条 (1px 边框 + 填充)
|
||
bar(x, y, w, h, percent, on) {
|
||
const p = Math.max(0, Math.min(100, percent));
|
||
this.strokeRect(x, y, w, h, on); // 边框同色(白底上画黑框)
|
||
const fillW = Math.max(0, Math.round((w - 2) * p / 100));
|
||
if (fillW > 0) this.fillRect(x + 1, y + 1, fillW, h - 2, on);
|
||
}
|
||
|
||
// 画水平细分隔线
|
||
hline(x, y, w, on) {
|
||
this.fillRect(x, y, w, 1, on);
|
||
}
|
||
|
||
// 提交到 Canvas
|
||
flush() {
|
||
this.ctx.putImageData(this.imageData, 0, 0);
|
||
}
|
||
|
||
// 测量文本宽度(向后兼容旧 API)
|
||
textWidth5x7(str) { return this.textWidth(str, 8); }
|
||
textWidth8x16(str) { return this.textWidth(str, 16); }
|
||
}
|
||
|
||
/* ============================================================
|
||
* Section 3: 仪表盘绘制
|
||
*
|
||
* 布局 (400×300 横屏):
|
||
* y=0~20 顶部状态条 (电量/连接/温湿度)
|
||
* y=22 分隔线
|
||
* y=24~86 CPU 区 (大字% + GHz + 曲线)
|
||
* y=88 分隔线
|
||
* y=90~110 MEM+DSK+NET 集成行
|
||
* y=112 分隔线
|
||
* y=114~170 Codex 区
|
||
* y=172 分隔线
|
||
* y=174~296 智谱区
|
||
* ============================================================ */
|
||
|
||
/* ============================================================
|
||
* Section 3: 仪表盘绘制 - 终端 CLI 风格 + 纵向左右二分构图
|
||
*
|
||
* 布局思路:
|
||
* - 左半屏(0~200):CPU 主角 - 大数字 + 频率 + 曲线
|
||
* - 右半屏(200~400):其它指标纵向堆叠 - MEM/DSK/NET/CODEX/智谱
|
||
* - 顶部状态条横跨全宽
|
||
* - 底部 token 进度条横跨全宽
|
||
*
|
||
* 这样横向不再拥挤, 纵向充分利用 300 高度
|
||
*
|
||
* 布局 (400×300 横屏):
|
||
* y=0~22 顶部状态栏(全宽)
|
||
* y=24 分隔线
|
||
* y=26~198 主区(左右二分)
|
||
* 左 (0~198): CPU 大字 + 频率 + 曲线
|
||
* 右 (200~400): MEM/DSK/NET 纵向堆叠
|
||
* y=200 分隔线
|
||
* y=204~238 CODEX 行
|
||
* y=240 分隔线
|
||
* y=244~278 智谱行
|
||
* y=280 分隔线
|
||
* y=284~296 TOKEN 进度条(全宽)
|
||
* ============================================================ */
|
||
|
||
function renderDashboard(sim, data) {
|
||
sim.clear(true);
|
||
const ON = true;
|
||
|
||
const mono = (str, x, y, size, weight) => sim.textMono(str, x, y, size, ON, weight);
|
||
const monoW = (str, size) => sim.textMonoWidth(str, size);
|
||
|
||
// ============ 4 宫格分隔线(细十字线) ============
|
||
// 竖中线 x=200,全高
|
||
sim.fillRect(200, 0, 1, 300, ON);
|
||
// 横中线 y=170(上半 170 高 / 下半 130 高)
|
||
sim.fillRect(0, 170, 400, 1, ON);
|
||
|
||
// ============ 左上格 - CPU (0~200, 0~170) ============
|
||
// 状态条融合在格顶 (y=4~18)
|
||
mono(`[SYS] ${data.battery}% ${data.connection}`, 4, 4, 14, '700');
|
||
mono(`${data.temp}C ${data.humidity}%`, 4, 20, 14);
|
||
// CPU 标签
|
||
mono('CPU', 8, 40, 14, '700');
|
||
// 大字 CPU% (36pt, 适配 200 宽格)
|
||
mono(`${data.cpuPercent}%`, 8, 56, 36, '700');
|
||
// 频率
|
||
mono(`${data.cpuGhz}GHz`, 8, 96, 20, '700');
|
||
// CPU 曲线(左上格内)
|
||
drawCpuCurve(sim, 8, 122, 184, 40, data.cpuHistory, ON);
|
||
|
||
// ============ 右上格 - MEM/DSK/NET (200~400, 0~170) ============
|
||
// 右上角时间
|
||
const clock = data.clock || '12:00';
|
||
mono(clock, 400 - monoW(clock, 14) - 4, 4, 14, '700');
|
||
|
||
// MEM
|
||
mono('MEM', 208, 24, 14, '700');
|
||
mono(`${data.memPercent}%`, 400 - monoW(`${data.memPercent}%`, 14) - 4, 24, 14, '700');
|
||
mono(`${data.memUsed}/${data.memTotal}`, 208, 42, 14);
|
||
// MEM 横向柱(格内宽 184)
|
||
sim.bar(208, 60, 184, 8, data.memPercent, ON);
|
||
|
||
// DSK
|
||
mono('DSK', 208, 76, 14, '700');
|
||
mono(`${data.diskText}`, 208, 92, 14);
|
||
mono(`${data.diskRW}`, 208, 108, 14);
|
||
|
||
// NET
|
||
mono('NET', 208, 124, 14, '700');
|
||
mono(`D ${data.netDown}`, 208, 140, 14);
|
||
mono(`U ${data.netUp}`, 400 - monoW(`U ${data.netUp}`, 14) - 4, 140, 14);
|
||
|
||
// ============ 左下格 - CODEX (0~200, 170~300) ============
|
||
drawUsageBars(sim, 6, 178, 188, {
|
||
title: 'CODEX',
|
||
subTitle: '用量',
|
||
topBar: {
|
||
used: data.codex5hUsed,
|
||
limit: data.codex5hLimit,
|
||
resetMs: data.codex5hResetMs,
|
||
unit: 'h',
|
||
},
|
||
botBar: {
|
||
used: data.codexWeekUsed,
|
||
limit: data.codexWeekLimit,
|
||
resetMs: data.codexWeekResetMs,
|
||
unit: 'h',
|
||
},
|
||
}, ON);
|
||
|
||
// ============ 右下格 - 智谱 (200~400, 170~300) ============
|
||
drawUsageBars(sim, 206, 178, 188, {
|
||
title: '智谱',
|
||
subTitle: 'token',
|
||
topBar: {
|
||
used: data.zhipu5hUsed,
|
||
limit: data.zhipu5hLimit,
|
||
resetMs: data.zhipu5hResetMs,
|
||
unit: 'M',
|
||
},
|
||
botBar: {
|
||
used: data.zhipuWeekUsed,
|
||
limit: data.zhipuWeekLimit,
|
||
resetMs: data.zhipuWeekResetMs,
|
||
unit: 'M',
|
||
},
|
||
}, ON);
|
||
}
|
||
|
||
// ASCII 进度条: [######### ] 50%
|
||
function asciiBar(percent, width) {
|
||
const p = Math.max(0, Math.min(100, percent));
|
||
const filled = Math.round(width * p / 100);
|
||
const empty = width - filled;
|
||
return '[' + '#'.repeat(filled) + ' '.repeat(empty) + ']';
|
||
}
|
||
|
||
// 智能日期格式化: 距离 ≤ 24h 显示时间, > 24h 显示日期
|
||
function formatResetTime(resetMs) {
|
||
const now = Date.now();
|
||
const diff = resetMs - now; // 正数=未来, 负数=过去
|
||
const absDiff = Math.abs(diff);
|
||
const date = new Date(resetMs);
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
if (absDiff <= 24 * 3600 * 1000) {
|
||
// 24 小时内: 显示时间 HH:MM
|
||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||
} else {
|
||
// 超过 24 小时: 显示日期 MM-DD
|
||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||
}
|
||
}
|
||
|
||
// 横向双柱状图(上下两条, 每条柱子水平延伸)
|
||
// 样式参考用户给的:
|
||
// CODEX token消耗
|
||
// [######### ] 64% 16:41
|
||
// [########## ] 75% 06-28
|
||
//
|
||
// 参数:
|
||
// sim, x, y, w, on
|
||
// title - 区段标题(如 'CODEX' / '智谱')
|
||
// subTitle - 副标题(如 'token消耗', 显示在标题右侧)
|
||
// topBar - { used, limit, resetMs, unit } 上柱(5h)
|
||
// botBar - { used, limit, resetMs, unit } 下柱(周)
|
||
function drawUsageBars(sim, x, y, w, opts, on) {
|
||
const mono = (s, xx, yy, sz, weight) => sim.textMono(s, xx, yy, sz, on, weight);
|
||
const monoW = (s, sz) => sim.textMonoWidth(s, sz);
|
||
|
||
const barH = 10; // 柱子高度
|
||
const gap = 6; // 柱子之间垂直间距
|
||
|
||
// 第一行:标题 + 副标题
|
||
mono(opts.title, x, y, 14, '700');
|
||
if (opts.subTitle) {
|
||
mono(opts.subTitle, x + w - monoW(opts.subTitle, 14), y, 14, '700');
|
||
}
|
||
|
||
// 上柱(5h)
|
||
const y2 = y + 18;
|
||
// 柱子宽度:总宽减去右侧"64% 16:41"的预留空间(约 90px)
|
||
const rightReserve = 90;
|
||
const barW = w - rightReserve;
|
||
const topPct = opts.topBar.used / Math.max(0.001, opts.topBar.limit) * 100;
|
||
const topPctInt = Math.round(topPct);
|
||
sim.bar(x, y2, barW, barH, Math.min(100, topPct), on);
|
||
// 右侧:百分比 + 倒计时
|
||
const topReset = formatResetTime(opts.topBar.resetMs);
|
||
const topRightStr = `${String(topPctInt).padStart(3)}% ${topReset}`;
|
||
mono(topRightStr, x + w - monoW(topRightStr, 14), y2 - 2, 14);
|
||
|
||
// 下柱(周)
|
||
const y3 = y2 + barH + gap;
|
||
const botPct = opts.botBar.used / Math.max(0.001, opts.botBar.limit) * 100;
|
||
const botPctInt = Math.round(botPct);
|
||
sim.bar(x, y3, barW, barH, Math.min(100, botPct), on);
|
||
const botReset = formatResetTime(opts.botBar.resetMs);
|
||
const botRightStr = `${String(botPctInt).padStart(3)}% ${botReset}`;
|
||
mono(botRightStr, x + w - monoW(botRightStr, 14), y3 - 2, 14);
|
||
}
|
||
|
||
// CPU 曲线绘制(终端风:粗折线)
|
||
function drawCpuCurve(sim, x, y, w, h, history, on) {
|
||
if (!history || history.length === 0) return;
|
||
const n = history.length;
|
||
for (let i = 0; i < n - 1; i++) {
|
||
const x0 = x + Math.round(i * w / (n - 1));
|
||
const x1 = x + Math.round((i + 1) * w / (n - 1));
|
||
const v0 = Math.max(0, Math.min(100, history[i]));
|
||
const v1 = Math.max(0, Math.min(100, history[i + 1]));
|
||
const y0 = y + h - Math.round(v0 * h / 100);
|
||
const y1 = y + h - Math.round(v1 * h / 100);
|
||
sim.lineThick(x0, y0, x1, y1, on, 2);
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* Section 4: 默认数据 & 状态
|
||
* ============================================================ */
|
||
|
||
const DEFAULT_DATA = {
|
||
// 顶部状态条
|
||
battery: 87,
|
||
connection: 'USB', // USB / BLE / WIFI / AP
|
||
temp: 24.3,
|
||
humidity: 58,
|
||
clock: '14:32',
|
||
|
||
// CPU
|
||
cpuPercent: 42,
|
||
cpuGhz: '3.61',
|
||
cpuHistory: generateDefaultCpuHistory(),
|
||
|
||
// 内存
|
||
memPercent: 68,
|
||
memUsed: '10.9G',
|
||
memTotal: '16.0G',
|
||
|
||
// 硬盘
|
||
diskText: 'C:62% D:46%',
|
||
diskRW: 'R:1.2M W:0.4M',
|
||
|
||
// 网络
|
||
netDown: '2.4M',
|
||
netUp: '0.3M',
|
||
|
||
// Codex
|
||
codexToday: 5.0,
|
||
codexWeek: 32,
|
||
codexResetIn: '04:12',
|
||
codexResetsAvail: 8,
|
||
codexResetsTotal: 10,
|
||
// 新增:柱状图用的限额数据
|
||
codex5hUsed: 3.2, // 5h 已用
|
||
codex5hLimit: 5.0, // 5h 限额
|
||
codexWeekUsed: 32, // 周已用
|
||
codexWeekLimit: 40, // 周限额
|
||
codex5hResetMs: Date.now() + 2 * 3600 * 1000, // 5h 下次重置(2h 后)
|
||
codexWeekResetMs: Date.now() + 3 * 24 * 3600 * 1000, // 周下次重置(3 天后)
|
||
|
||
// 智谱
|
||
zhipuStatus: 0, // 0=OK 1=SLOW 2=ERR
|
||
zhipuBalance: '4.82',
|
||
zhipuCallsToday: 289,
|
||
zhipuLatency: 420,
|
||
zhipuPlan: 'PRO',
|
||
zhipuTokensUsed: 1.2, // M
|
||
zhipuTokensTotal: 5.0, // M
|
||
// 新增:智谱限额柱状图(跟 Codex 同款样式)
|
||
zhipu5hUsed: 2.4,
|
||
zhipu5hLimit: 5.0,
|
||
zhipuWeekUsed: 18,
|
||
zhipuWeekLimit: 40,
|
||
zhipu5hResetMs: Date.now() + 5 * 3600 * 1000, // 5h 后重置
|
||
zhipuWeekResetMs: Date.now() + 5 * 24 * 3600 * 1000, // 5 天后重置
|
||
|
||
// 其他
|
||
model: 'mainline',
|
||
uptime: '04:12:35',
|
||
};
|
||
|
||
function generateDefaultCpuHistory() {
|
||
// 生成 32 个点的仿真 CPU 曲线
|
||
const arr = [];
|
||
let v = 35;
|
||
for (let i = 0; i < 32; i++) {
|
||
v += (Math.random() - 0.5) * 18;
|
||
v = Math.max(8, Math.min(95, v));
|
||
arr.push(Math.round(v));
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
// 当前状态
|
||
let data = structuredClone(DEFAULT_DATA);
|
||
let zoom = 2;
|
||
let showGrid = true;
|
||
let showGhost = false;
|
||
let invertColors = false;
|
||
let bridgeOnline = false;
|
||
|
||
let firmwares = [
|
||
{
|
||
id: 'factory-v1',
|
||
name: '微雪出厂固件 V1',
|
||
type: 'whole-image',
|
||
path: 'D:\\esp\\waveshare-rlcd-4.2\\03_Firmware\\01_Factory_V1.bin',
|
||
offset: '0x0',
|
||
ready: true,
|
||
description: '官方出厂整包',
|
||
},
|
||
{
|
||
id: 'custom-workbench',
|
||
name: '自制仪表盘固件',
|
||
type: 'idf-project',
|
||
projectPath: 'D:\\AI Code\\ESP-32',
|
||
artifact: 'build\\rlcd_clock.bin',
|
||
ready: false,
|
||
description: '当前预览配置编译产物',
|
||
},
|
||
];
|
||
let selectedFirmwareId = 'custom-workbench';
|
||
|
||
/* ============================================================
|
||
* Section 5: DOM 工具 & 渲染
|
||
* ============================================================ */
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const els = {
|
||
canvas: $('screen'),
|
||
canvasFrame: $('canvasFrame'),
|
||
bridgeState: $('bridgeState'),
|
||
zoomLabel: $('zoomLabel'),
|
||
dataBody: $('dataBody'),
|
||
firmwareList: $('firmwareList'),
|
||
portInput: $('portInput'),
|
||
logBox: $('logBox'),
|
||
flashModal: $('flashModal'),
|
||
flashSummary: $('flashSummary'),
|
||
};
|
||
|
||
const sim = new RLCDSimulator(els.canvas);
|
||
|
||
function setLog(msg) {
|
||
els.logBox.textContent = msg;
|
||
// 自动展开日志卡片
|
||
$('logCard').classList.remove('collapsed');
|
||
}
|
||
|
||
function updateBridgeState() {
|
||
els.bridgeState.classList.toggle('ok', bridgeOnline);
|
||
els.bridgeState.classList.toggle('bad', !bridgeOnline);
|
||
els.bridgeState.textContent = bridgeOnline ? '桥接已连接' : '桥接未连接';
|
||
}
|
||
|
||
function applyZoom() {
|
||
// 缩放整个设备外壳(包括 bezel + canvas),而不是只缩放 canvas
|
||
document.getElementById('deviceShell').style.transform = `scale(${zoom})`;
|
||
els.zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
|
||
}
|
||
|
||
function renderScreen() {
|
||
sim.setInvert(invertColors);
|
||
sim.clear(!invertColors);
|
||
renderDashboard(sim, data);
|
||
sim.flush();
|
||
|
||
els.canvasFrame.classList.toggle('show-grid', showGrid);
|
||
// 残影效果(简化:半透明叠加,这里只切 class)
|
||
els.canvasFrame.classList.toggle('ghost', showGhost);
|
||
}
|
||
|
||
/* ============================================================
|
||
* Section 6: 字段编辑面板(自动从 DEFAULT_DATA 生成)
|
||
* ============================================================ */
|
||
|
||
// 字段定义(分组, 每组有标题 + 字段列表)
|
||
const FIELD_GROUPS = [
|
||
{
|
||
title: '顶部状态条',
|
||
fields: [
|
||
{ key: 'battery', label: '电量 %', type: 'number', min: 0, max: 100 },
|
||
{ key: 'connection', label: '连接', type: 'select', options: ['USB', 'BLE', 'WIFI', 'AP'] },
|
||
{ key: 'temp', label: '温度 C', type: 'number', step: 0.1 },
|
||
{ key: 'humidity', label: '湿度 %', type: 'number', min: 0, max: 100 },
|
||
{ key: 'clock', label: '时间', type: 'text' },
|
||
],
|
||
},
|
||
{
|
||
title: 'CPU',
|
||
fields: [
|
||
{ key: 'cpuPercent', label: '使用率 %', type: 'range', min: 0, max: 100 },
|
||
{ key: 'cpuGhz', label: '频率 GHz', type: 'text' },
|
||
],
|
||
},
|
||
{
|
||
title: '内存 / 硬盘 / 网络',
|
||
fields: [
|
||
{ key: 'memPercent', label: '内存 %', type: 'range', min: 0, max: 100 },
|
||
{ key: 'memUsed', label: '已用', type: 'text' },
|
||
{ key: 'memTotal', label: '总量', type: 'text' },
|
||
{ key: 'diskText', label: '盘符', type: 'text' },
|
||
{ key: 'diskRW', label: '读写', type: 'text' },
|
||
{ key: 'netDown', label: '下行', type: 'text' },
|
||
{ key: 'netUp', label: '上行', type: 'text' },
|
||
],
|
||
},
|
||
{
|
||
title: 'Codex',
|
||
fields: [
|
||
{ key: 'codexToday', label: '今日 h', type: 'number', step: 0.1 },
|
||
{ key: 'codexWeek', label: '周 h', type: 'number' },
|
||
{ key: 'codexResetIn', label: '重置倒计时', type: 'text' },
|
||
{ key: 'codexResetsAvail', label: '可用次数', type: 'number', min: 0 },
|
||
{ key: 'codexResetsTotal', label: '总次数', type: 'number', min: 1 },
|
||
],
|
||
},
|
||
{
|
||
title: '智谱',
|
||
fields: [
|
||
{ key: 'zhipuStatus', label: '状态', type: 'select', options: [0, 1, 2], labels: ['正常', '降速', '异常'] },
|
||
{ key: 'zhipuBalance', label: '余额', type: 'text' },
|
||
{ key: 'zhipuCallsToday', label: '今日调用', type: 'number' },
|
||
{ key: 'zhipuLatency', label: '延迟 ms', type: 'number' },
|
||
{ key: 'zhipuPlan', label: '套餐', type: 'text' },
|
||
{ key: 'zhipuTokensUsed', label: '已用 M', type: 'number', step: 0.1 },
|
||
{ key: 'zhipuTokensTotal', label: '总额 M', type: 'number', step: 0.1 },
|
||
],
|
||
},
|
||
];
|
||
|
||
function renderDataPanel() {
|
||
els.dataBody.innerHTML = '';
|
||
for (const group of FIELD_GROUPS) {
|
||
const groupEl = document.createElement('div');
|
||
groupEl.className = 'data-group';
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'data-group-title';
|
||
title.textContent = group.title;
|
||
groupEl.appendChild(title);
|
||
|
||
for (const f of group.fields) {
|
||
const fieldEl = document.createElement('div');
|
||
fieldEl.className = 'field';
|
||
|
||
const label = document.createElement('label');
|
||
label.textContent = f.label;
|
||
label.htmlFor = `f-${f.key}`;
|
||
fieldEl.appendChild(label);
|
||
|
||
let input;
|
||
if (f.type === 'select') {
|
||
input = document.createElement('select');
|
||
for (let i = 0; i < f.options.length; i++) {
|
||
const opt = document.createElement('option');
|
||
opt.value = f.options[i];
|
||
opt.textContent = f.labels ? f.labels[i] : f.options[i];
|
||
input.appendChild(opt);
|
||
}
|
||
input.value = String(data[f.key]);
|
||
} else {
|
||
input = document.createElement('input');
|
||
input.type = f.type === 'range' ? 'range' : (f.type === 'number' ? 'number' : 'text');
|
||
if (f.min !== undefined) input.min = f.min;
|
||
if (f.max !== undefined) input.max = f.max;
|
||
if (f.step !== undefined) input.step = f.step;
|
||
input.value = data[f.key];
|
||
}
|
||
input.id = `f-${f.key}`;
|
||
input.addEventListener('input', () => {
|
||
let v = input.value;
|
||
if (f.type === 'number' || f.type === 'range') {
|
||
v = parseFloat(v);
|
||
if (isNaN(v)) v = 0;
|
||
}
|
||
data[f.key] = v;
|
||
renderScreen();
|
||
});
|
||
fieldEl.appendChild(input);
|
||
|
||
groupEl.appendChild(fieldEl);
|
||
}
|
||
|
||
els.dataBody.appendChild(groupEl);
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* Section 7: 固件列表渲染
|
||
* ============================================================ */
|
||
|
||
function renderFirmwares() {
|
||
els.firmwareList.innerHTML = '';
|
||
for (const fw of firmwares) {
|
||
const item = document.createElement('div');
|
||
item.className = 'fw-item' + (fw.id === selectedFirmwareId ? ' active' : '');
|
||
|
||
const name = document.createElement('div');
|
||
name.className = 'fw-name';
|
||
name.textContent = fw.name;
|
||
|
||
const badge = document.createElement('span');
|
||
badge.className = 'badge' + (fw.ready ? '' : ' warn');
|
||
badge.textContent = fw.ready ? '可用' : '需编译';
|
||
|
||
item.appendChild(name);
|
||
item.appendChild(badge);
|
||
|
||
item.addEventListener('click', () => {
|
||
selectedFirmwareId = fw.id;
|
||
renderFirmwares();
|
||
});
|
||
|
||
els.firmwareList.appendChild(item);
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* Section 8: 桥接 API 调用 (保留)
|
||
* ============================================================ */
|
||
|
||
const API_BASE = 'http://127.0.0.1:8788';
|
||
|
||
async function fetchJson(path, options) {
|
||
try {
|
||
const response = await fetch(`${API_BASE}${path}`, options);
|
||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||
bridgeOnline = true;
|
||
return await response.json();
|
||
} catch (err) {
|
||
bridgeOnline = false;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function refreshFirmwares() {
|
||
try {
|
||
const result = await fetchJson('/api/firmwares');
|
||
if (result.firmwares || Array.isArray(result)) {
|
||
firmwares = result.firmwares || result;
|
||
}
|
||
} catch {
|
||
// 静默失败, 用默认列表
|
||
}
|
||
updateBridgeState();
|
||
}
|
||
|
||
async function saveScreen() {
|
||
localStorage.setItem('rlcd-dashboard-data', JSON.stringify(data));
|
||
try {
|
||
await fetchJson('/api/screen', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ dashboard: data }),
|
||
});
|
||
setLog('配置已保存到 active-screen.json');
|
||
} catch {
|
||
setLog('桥接未连接, 已暂存到浏览器本地');
|
||
}
|
||
updateBridgeState();
|
||
}
|
||
|
||
async function generateCode() {
|
||
await saveScreen();
|
||
try {
|
||
const result = await fetchJson('/api/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ dashboard: data }),
|
||
});
|
||
setLog(result.message || '固件 UI 配置已生成');
|
||
} catch {
|
||
setLog('生成失败: 桥接服务不可用');
|
||
}
|
||
updateBridgeState();
|
||
}
|
||
|
||
async function buildCustomFirmware() {
|
||
await generateCode();
|
||
setLog('正在编译自制固件...');
|
||
try {
|
||
const result = await fetchJson('/api/build', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ firmwareId: 'custom-workbench' }),
|
||
});
|
||
setLog(result.output || result.message || '编译完成');
|
||
await refreshFirmwares();
|
||
renderFirmwares();
|
||
} catch {
|
||
setLog('编译失败: 桥接服务不可用');
|
||
}
|
||
updateBridgeState();
|
||
}
|
||
|
||
function openFlashModal() {
|
||
const fw = firmwares.find(f => f.id === selectedFirmwareId) || firmwares[0];
|
||
if (!fw) return;
|
||
const port = els.portInput.value.trim() || 'COM3';
|
||
els.flashSummary.textContent = `端口 ${port} · 固件 ${fw.name} · 类型 ${
|
||
fw.type === 'whole-image' ? '官方整包' : '自制工程'
|
||
}`;
|
||
els.flashModal.hidden = false;
|
||
}
|
||
|
||
async function flashSelectedFirmware() {
|
||
const fw = firmwares.find(f => f.id === selectedFirmwareId) || firmwares[0];
|
||
const port = els.portInput.value.trim() || 'COM3';
|
||
els.flashModal.hidden = true;
|
||
setLog('正在烧录, 请不要拔掉开发板...');
|
||
try {
|
||
const result = await fetchJson('/api/flash', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ firmwareId: fw.id, port, confirmed: true }),
|
||
});
|
||
setLog(result.output || result.message || '烧录完成');
|
||
} catch {
|
||
setLog('烧录失败: 桥接服务或端口不可用');
|
||
}
|
||
updateBridgeState();
|
||
}
|
||
|
||
/* ============================================================
|
||
* Section 9: 事件绑定
|
||
* ============================================================ */
|
||
|
||
// 缩放
|
||
$('zoomIn').addEventListener('click', () => {
|
||
zoom = Math.min(6, zoom + 0.5);
|
||
applyZoom();
|
||
});
|
||
$('zoomOut').addEventListener('click', () => {
|
||
zoom = Math.max(1, zoom - 0.5);
|
||
applyZoom();
|
||
});
|
||
|
||
// 滚轮缩放(在整个设备外壳上)
|
||
$('deviceShell').addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
if (e.deltaY < 0) zoom = Math.min(6, zoom + 0.25);
|
||
else zoom = Math.max(1, zoom - 0.25);
|
||
applyZoom();
|
||
}, { passive: false });
|
||
|
||
// 切换开关
|
||
function setupToggle(id, getter, setter) {
|
||
const btn = $(id);
|
||
const sync = () => btn.classList.toggle('on', getter());
|
||
sync();
|
||
btn.addEventListener('click', () => {
|
||
setter(!getter());
|
||
sync();
|
||
renderScreen();
|
||
});
|
||
}
|
||
setupToggle('gridToggle', () => showGrid, (v) => showGrid = v);
|
||
setupToggle('ghostToggle', () => showGhost, (v) => showGhost = v);
|
||
setupToggle('invertToggle', () => invertColors, (v) => invertColors = v);
|
||
|
||
// 卡片折叠
|
||
function setupCollapsible(cardId, headId) {
|
||
$(headId).addEventListener('click', () => {
|
||
$(cardId).classList.toggle('collapsed');
|
||
});
|
||
}
|
||
setupCollapsible('dataCard', 'dataCardHead');
|
||
setupCollapsible('opCard', 'opCardHead');
|
||
setupCollapsible('logCard', 'logCardHead');
|
||
|
||
// 按钮
|
||
$('saveButton').addEventListener('click', saveScreen);
|
||
$('resetButton').addEventListener('click', () => {
|
||
data = structuredClone(DEFAULT_DATA);
|
||
data.cpuHistory = generateDefaultCpuHistory();
|
||
localStorage.removeItem('rlcd-dashboard-data');
|
||
renderDataPanel();
|
||
renderScreen();
|
||
setLog('已重置为默认数据');
|
||
});
|
||
$('generateButton').addEventListener('click', generateCode);
|
||
$('buildButton').addEventListener('click', buildCustomFirmware);
|
||
$('flashButton').addEventListener('click', openFlashModal);
|
||
$('refreshFirmwareButton')?.addEventListener('click', async () => {
|
||
await refreshFirmwares();
|
||
renderFirmwares();
|
||
});
|
||
$('cancelFlashButton').addEventListener('click', () => {
|
||
els.flashModal.hidden = true;
|
||
});
|
||
$('confirmFlashButton').addEventListener('click', flashSelectedFirmware);
|
||
|
||
/* ============================================================
|
||
* Section 10: 模拟实时数据(可选, 让曲线动起来)
|
||
* ============================================================ */
|
||
|
||
let simulating = true;
|
||
function simulateTick() {
|
||
if (!simulating) return;
|
||
// CPU 曲线推进
|
||
if (data.cpuHistory.length >= 32) data.cpuHistory.shift();
|
||
const last = data.cpuHistory[data.cpuHistory.length - 1] || 40;
|
||
let next = last + (Math.random() - 0.5) * 20;
|
||
next = Math.max(5, Math.min(95, Math.round(next)));
|
||
data.cpuHistory.push(next);
|
||
data.cpuPercent = next;
|
||
|
||
// 同步面板上的 slider
|
||
const cpuInput = $('f-cpuPercent');
|
||
if (cpuInput) cpuInput.value = next;
|
||
|
||
renderScreen();
|
||
}
|
||
|
||
setInterval(simulateTick, 1500);
|
||
|
||
/* ============================================================
|
||
* Section 11: 启动
|
||
* ============================================================ */
|
||
|
||
async function init() {
|
||
// 尝试从 localStorage 恢复
|
||
try {
|
||
const saved = localStorage.getItem('rlcd-dashboard-data');
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved);
|
||
data = { ...structuredClone(DEFAULT_DATA), ...parsed };
|
||
if (!data.cpuHistory || data.cpuHistory.length === 0) {
|
||
data.cpuHistory = generateDefaultCpuHistory();
|
||
}
|
||
}
|
||
} catch { /* ignore */ }
|
||
|
||
renderDataPanel();
|
||
renderFirmwares();
|
||
applyZoom();
|
||
renderScreen();
|
||
updateBridgeState();
|
||
|
||
// 尝试连桥接(失败也无所谓)
|
||
try { await refreshFirmwares(); renderFirmwares(); } catch {}
|
||
}
|
||
|
||
init();
|