2026-06-23 20:38:23 +08:00

1064 lines
36 KiB
JavaScript
Raw Permalink 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.

/* ============================================================
* 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();