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