soease преди 4 дни
родител
ревизия
d853d85633

+ 16 - 0
frontend/bindings/TengTuToolbox/models.js

@@ -263,6 +263,22 @@ export class AppSettings {
              */
             this["mouseAlert"] = (new MouseAlertConfig());
         }
+        if (!("browserPath" in $$source)) {
+            /**
+             * 指定浏览器 exe 路径(空=系统默认浏览器)
+             * @member
+             * @type {string}
+             */
+            this["browserPath"] = "";
+        }
+        if (!("ledColor" in $$source)) {
+            /**
+             * LED 滚动文字点阵颜色(如 #ff4d4d)
+             * @member
+             * @type {string}
+             */
+            this["ledColor"] = "";
+        }
         if (!("stockUpColor" in $$source)) {
             /**
              * 股票上涨颜色

+ 124 - 0
frontend/bindings/TengTuToolbox/settingsservice.js

@@ -62,6 +62,14 @@ export function ClearAPILogs() {
     return $Call.ByID(1756290968);
 }
 
+/**
+ * CloseLEDWindow 关闭 LED 滚动窗口(窗口已关则忽略)。
+ * @returns {$CancellablePromise<void>}
+ */
+export function CloseLEDWindow() {
+    return $Call.ByID(3709239437);
+}
+
 /**
  * GenerateQRCode 把输入字符串生成 PNG 二维码,返回 data URI 供前端展示。
  * size 为图片边长(像素),<=0 时用默认值 300。
@@ -126,6 +134,30 @@ export function GetKLine(code, count) {
     }));
 }
 
+/**
+ * GetLEDColor 返回当前 LED 点阵颜色字符串(默认 #ff4d4d)。
+ * @returns {$CancellablePromise<string>}
+ */
+export function GetLEDColor() {
+    return $Call.ByID(3663445744);
+}
+
+/**
+ * GetLEDSpeed 返回当前 LED 滚动速度(px/s),<=0 用默认 60。
+ * @returns {$CancellablePromise<number>}
+ */
+export function GetLEDSpeed() {
+    return $Call.ByID(3083387432);
+}
+
+/**
+ * GetLEDText 返回当前要显示的 LED 文字。
+ * @returns {$CancellablePromise<string>}
+ */
+export function GetLEDText() {
+    return $Call.ByID(2840292550);
+}
+
 /**
  * GetMinuteChart 拉取指定股票的分时数据(点击股市行打开分时图时调用)
  * @param {string} code
@@ -245,6 +277,16 @@ export function GetTrendingRepos() {
     }));
 }
 
+/**
+ * MD5Hash 计算输入字符串的 MD5 摘要(32 位小写十六进制)。
+ * 空输入返回空串(前端用于校验)。
+ * @param {string} text
+ * @returns {$CancellablePromise<string>}
+ */
+export function MD5Hash(text) {
+    return $Call.ByID(2582579328, text);
+}
+
 /**
  * MarkGithubRepoChecked 标记某仓库已检视:把本地版本更新为最新 release 版本,消除「发现新版本」提示
  * @param {string} url
@@ -255,6 +297,41 @@ export function MarkGithubRepoChecked(url, latest) {
     return $Call.ByID(1378316359, url, latest);
 }
 
+/**
+ * OpenInBrowser 用配置的浏览器打开网址。
+ * 若设置页指定了浏览器路径(BrowserPath)且文件存在,则用该浏览器打开;
+ * 否则回退到系统默认浏览器(通过 Wails OpenURL 能力)。
+ * @param {string} url
+ * @returns {$CancellablePromise<void>}
+ */
+export function OpenInBrowser(url) {
+    return $Call.ByID(1476290681, url);
+}
+
+/**
+ * OpenLEDWindow 打开(或更新)LED 点阵滚动窗口并显示指定文字。
+ * text 为空时不更新内容(保留上次文字),但窗口未开则忽略。
+ * color 非空时保存为点阵颜色并持久化到设置。
+ * speed > 0 时更新滚动速度(px/s)。
+ * 返回是否成功打开(供前端提示)。
+ * @param {string} text
+ * @param {string} color
+ * @param {number} speed
+ * @returns {$CancellablePromise<boolean>}
+ */
+export function OpenLEDWindow(text, color, speed) {
+    return $Call.ByID(3302455249, text, color, speed);
+}
+
+/**
+ * PickBrowserPath 弹出文件选择对话框,让用户指定浏览器可执行文件(如 chrome.exe / msedge.exe / firefox.exe)。
+ * 返回选中的 exe 路径(取消返回空串)。
+ * @returns {$CancellablePromise<string>}
+ */
+export function PickBrowserPath() {
+    return $Call.ByID(165230808);
+}
+
 /**
  * PickUploadFile 弹出文件选择对话框,返回用户选中的本地文件路径(取消返回空串)。
  * @returns {$CancellablePromise<string>}
@@ -300,6 +377,14 @@ export function ReadTodo() {
     return $Call.ByID(1630526072);
 }
 
+/**
+ * ReminderStatus 查询当前定时提醒状态(运行中 / 间隔 / 下次时刻)。
+ * @returns {$CancellablePromise<string>}
+ */
+export function ReminderStatus() {
+    return $Call.ByID(1750819476);
+}
+
 /**
  * RenderMarkdown 把 Markdown 文本渲染为安全 HTML 片段(供文件类自定义 Tab 展示)。
  * 使用 gomarkdown 渲染,并启用 CommonMark 扩展(表格、任务列表、围栏代码等)。
@@ -310,6 +395,16 @@ export function RenderMarkdown(md) {
     return $Call.ByID(3516797141, md);
 }
 
+/**
+ * SHA256Hash 计算输入字符串的 SHA256 摘要(64 位小写十六进制)。
+ * 空输入返回空串(前端用于校验)。
+ * @param {string} text
+ * @returns {$CancellablePromise<string>}
+ */
+export function SHA256Hash(text) {
+    return $Call.ByID(3463776099, text);
+}
+
 /**
  * SaveSettings 保存配置并立即生效:热键、窗口置顶、开机启动、页面地址、股票代码等
  * @param {$models.AppSettings} settings
@@ -366,6 +461,15 @@ export function SendNtfyMessage(topic, title, message) {
     return $Call.ByID(1563997112, topic, title, message);
 }
 
+/**
+ * SetLEDTransparent 保留空实现:原生分层窗口透明由 SetLayeredWindowAttributes
+ * 在建窗时设置,无需前端补设。兼容前端 bindings 调用(不再报错)。
+ * @returns {$CancellablePromise<void>}
+ */
+export function SetLEDTransparent() {
+    return $Call.ByID(1774887829);
+}
+
 /**
  * SetMobileMode 前端在窗口尺寸变化时调用:移动端(窄屏)隐藏主机 Tab 并禁止滚动条
  * @param {boolean} mobile
@@ -412,6 +516,18 @@ export function StartAPIService() {
     return $Call.ByID(3942830483);
 }
 
+/**
+ * StartReminder 启动(或更新)定时提醒:每 every 分钟提醒一次,直到取消。
+ * message 为提醒信息,minutes 为间隔分钟数(必须 >=1)。
+ * 返回启动状态描述(含下次提醒时刻),供前端提示。
+ * @param {string} message
+ * @param {number} minutes
+ * @returns {$CancellablePromise<string>}
+ */
+export function StartReminder(message, minutes) {
+    return $Call.ByID(2273071398, message, minutes);
+}
+
 /**
  * StartWebService 启动静态文件服务(默认页 index.html 或 readme.md)。
  * @param {string} dir
@@ -431,6 +547,14 @@ export function StopAPIService() {
     return $Call.ByID(1470228927);
 }
 
+/**
+ * StopReminder 取消定时提醒。返回是否在运行过。
+ * @returns {$CancellablePromise<string>}
+ */
+export function StopReminder() {
+    return $Call.ByID(1962022930);
+}
+
 /**
  * StopWebService 停止静态文件服务。
  * @returns {$CancellablePromise<void>}

+ 93 - 11
frontend/index.html

@@ -318,6 +318,83 @@
                         </div>
                     </div>
                 </div>
+
+                <!-- 工具 9:MD5 加密——输入字符串,生成 32 位 MD5 摘要 -->
+                <div class="tool-card is-collapsed">
+                    <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
+                        <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>MD5 加密</span>
+                        <span class="tool-status" id="md5-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">输入任意字符串,计算其 32 位小写十六进制 MD5 摘要。常用于文件/字符串校验,注意 MD5 已不适合密码存储。</p>
+                        <textarea class="input tr-input" id="md5-input" rows="3" placeholder="输入要计算 MD5 的字符串……"></textarea>
+                        <div class="tool-row tool-actions">
+                            <button class="btn-primary" id="md5-go">计算 MD5</button>
+                            <span class="tool-hint" id="md5-hint"></span>
+                        </div>
+                        <div class="hash-result" id="md5-result" hidden></div>
+                    </div>
+                </div>
+
+                <!-- 工具 10:SHA256 编码——输入字符串,生成 SHA256 摘要 -->
+                <div class="tool-card is-collapsed">
+                    <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
+                        <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>SHA256 编码</span>
+                        <span class="tool-status" id="sha-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">输入任意字符串,计算其 64 位小写十六进制 SHA256 摘要。适用于校验、签名与安全存储。</p>
+                        <textarea class="input tr-input" id="sha-input" rows="3" placeholder="输入要计算 SHA256 的字符串……"></textarea>
+                        <div class="tool-row tool-actions">
+                            <button class="btn-primary" id="sha-go">计算 SHA256</button>
+                            <span class="tool-hint" id="sha-hint"></span>
+                        </div>
+                        <div class="hash-result" id="sha-result" hidden></div>
+                    </div>
+                </div>
+
+                <!-- 工具 11:LED 滚动文字——输入文字(可汉字),转换为 LED 点阵独立窗口滚动显示 -->
+                <div class="tool-card is-collapsed">
+                    <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
+                        <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>LED 滚动文字</span>
+                        <span class="tool-status" id="led-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">输入文字(支持中文),转换为 LED 点阵,在独立的无边框置顶透明窗口中横向滚动显示。窗口可拖动、可关闭。</p>
+                        <input class="input" id="led-text" type="text" placeholder="输入要滚动的文字,如 你好 世界">
+                        <div class="tool-row tool-actions">
+                            <label class="color-field" title="点阵颜色">
+                                <input type="color" id="led-color" value="#ff4d4d">
+                                <span>颜色</span>
+                            </label>
+                            <label class="speed-field" title="滚动速度(像素/秒)">
+                                <input type="number" id="led-speed" value="60" min="10" max="600" step="10">
+                                <span>速度</span>
+                            </label>
+                            <button class="btn-primary" id="led-go">打开 LED 窗口</button>
+                            <button class="btn-ghost" id="led-close">关闭窗口</button>
+                            <span class="tool-hint" id="led-hint"></span>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- 工具 12:定时提醒——输入提醒信息与间隔分钟数,每 xx 分钟提醒一次直到取消 -->
+                <div class="tool-card is-collapsed">
+                    <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
+                        <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>定时提醒</span>
+                        <span class="tool-status" id="remind-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">输入提醒信息与间隔分钟数,每 xx 分钟弹出一次提醒,直到手动取消。适合定时喝水、休息、吃药等。</p>
+                        <input class="input" id="remind-message" type="text" placeholder="提醒信息,如:该喝水了">
+                        <div class="tool-row tool-actions">
+                            <input class="input" id="remind-minutes" type="number" min="1" max="1440" step="1" placeholder="间隔(分钟)" style="width:120px">
+                            <button class="btn-primary" id="remind-go">开始提醒</button>
+                            <button class="btn-ghost" id="remind-stop">取消提醒</button>
+                            <span class="tool-hint" id="remind-hint"></span>
+                        </div>
+                    </div>
+                </div>
             </div>
             <div class="tab-panel" id="tab-host">
                 <div class="host-cards">
@@ -333,6 +410,9 @@
                         <div class="card-title-row">
                             <span class="card-title"><span class="dot"></span>文件上传</span>
                         </div>
+                        <div>
+                            <p class="tool-hint">手机等设备访问 http://&lt;服务地址&gt;/upload 上传页,输入密码即可上传文件。</p>
+                        </div>
                         <div class="upload-box">
                             <div class="upload-row">
                                 <button class="btn-ghost" id="upload-btn">选择文件并上传</button>
@@ -348,17 +428,6 @@
                             </div>
                         </div>
                     </div>
-                    <!-- 客户端上传页:手机/其他设备扫码即可上传文件 -->
-                    <div class="card" id="client-upload-card">
-                        <div class="card-title-row">
-                            <span class="card-title"><span class="dot"></span>客户端上传页</span>
-                        </div>
-                        <div class="upload-box">
-                            <p class="tool-hint">手机等设备扫码打开上传页,输入密码即可上传文件;上传成功自动显示下载二维码。</p>
-                            <div class="upload-qr" id="client-upload-qr"></div>
-                            <div class="client-upload-url" id="client-upload-url"></div>
-                        </div>
-                    </div>
                     <div class="card">
                         <div class="card-title"><span class="dot"></span>电源命令</div>
                         <p id="pc-status">暂无请求</p>
@@ -455,6 +524,19 @@
                         <input class="input hotkey-input" id="hotkey" readonly placeholder="按下组合键">
                     </div>
 
+                    <!-- 指定浏览器:留空则用系统默认浏览器打开网址 -->
+                    <div class="setting-row">
+                        <div class="setting-info">
+                            <div class="setting-title">指定浏览器</div>
+                            <div class="setting-hint">点击网址时用该浏览器打开;留空则使用系统默认浏览器</div>
+                        </div>
+                        <div class="browser-path-group">
+                            <input class="input" id="browser-path" type="text" placeholder="留空=系统默认浏览器" spellcheck="false">
+                            <button class="btn-ghost" id="browser-pick" type="button" title="选择浏览器可执行文件">选择…</button>
+                            <button class="btn-ghost" id="browser-clear" type="button" title="恢复系统默认浏览器">清除</button>
+                        </div>
+                    </div>
+
                     <!-- Tab 显示顺序 -->
                     <div class="setting-row" data-tab-group="settings">
                         <div class="setting-info">

+ 62 - 0
frontend/led.html

@@ -0,0 +1,62 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+    <title>LED 滚动文字</title>
+    <style>
+        /* LED 滚动窗口:透明背景,整行可拖动 */
+        * { box-sizing: border-box; }
+        html, body {
+            margin: 0;
+            height: 100%;
+            overflow: hidden;
+            background: transparent !important;  /* 强制透明:窗口无底色 */
+            -webkit-user-select: none;
+            user-select: none;
+        }
+        .led-wrap {
+            position: relative;
+            width: 100vw;
+            height: 100vh;
+            display: flex;
+            align-items: center;
+            background: transparent !important;
+            --wails-draggable: drag;      /* 整行可拖动窗口 */
+            cursor: move;
+        }
+        /* 点阵画布:透明底,点阵由 requestAnimationFrame 驱动 */
+        #led-canvas {
+            display: block;
+            background: transparent;
+            width: 100%;
+            height: 100%;
+        }
+        /* 关闭按钮:不触发拖动 */
+        .led-close {
+            position: absolute;
+            top: 6px;
+            right: 6px;
+            width: 24px;
+            height: 24px;
+            border: 1px solid rgba(255, 255, 255, 0.22);
+            border-radius: 6px;
+            background: rgba(0, 0, 0, 0.45);
+            color: #fff;
+            font-size: 15px;
+            line-height: 1;
+            cursor: pointer;
+            --wails-draggable: no-drag;
+            z-index: 10;
+        }
+        .led-close:hover { background: rgba(255, 77, 77, 0.6); }
+    </style>
+</head>
+<body>
+    <div class="led-wrap" id="led-wrap">
+        <canvas id="led-canvas"></canvas>
+        <button class="led-close" id="led-close" title="关闭">×</button>
+    </div>
+    <script type="module" src="/src/led.js"></script>
+</body>
+</html>

+ 82 - 0
frontend/public/style.css

@@ -2276,6 +2276,60 @@ a.ver-muted:hover {
     color: var(--muted);
     font-size: calc(13.5px + var(--font-delta));
 }
+/* 颜色选择器:色块 + 标签,用于 LED 点阵颜色等 */
+.color-field {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    font-size: calc(13.5px + var(--font-delta));
+    color: var(--text);
+    cursor: pointer;
+    background: var(--glass);
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius-sm);
+    padding: 5px 10px 5px 6px;
+}
+.color-field input[type="color"] {
+    width: 26px;
+    height: 26px;
+    padding: 0;
+    border: none;
+    border-radius: 6px;
+    background: transparent;
+    cursor: pointer;
+}
+.color-field input[type="color"]::-webkit-color-swatch-wrapper {
+    padding: 0;
+}
+.color-field input[type="color"]::-webkit-color-swatch {
+    border: none;
+    border-radius: 6px;
+}
+/* 速度输入框:数字 + 标签,用于 LED 滚动速度等 */
+.speed-field {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    font-size: calc(13.5px + var(--font-delta));
+    color: var(--text);
+    background: var(--glass);
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius-sm);
+    padding: 5px 10px 5px 8px;
+}
+.speed-field input[type="number"] {
+    width: 60px;
+    padding: 3px 6px;
+    font-size: calc(13px + var(--font-delta));
+    color: var(--text);
+    background: var(--panel);
+    border: 1px solid var(--glass-border);
+    border-radius: 6px;
+    outline: none;
+}
+.speed-field input[type="number"]:focus {
+    border-color: var(--accent);
+}
 .websvc-preview {
     margin-top: 14px;
     height: 320px;
@@ -2316,6 +2370,22 @@ a.ver-muted:hover {
     user-select: text;
 }
 
+/* MD5 / SHA256 哈希结果:等宽字体 + 可复制 */
+.hash-result {
+    margin-top: 12px;
+    padding: 12px 14px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass-strong);
+    color: var(--accent);
+    font-family: Consolas, "Courier New", monospace;
+    font-size: calc(13px + var(--font-delta));
+    line-height: 1.6;
+    word-break: break-all;
+    user-select: text;
+    cursor: text;
+}
+
 /* 二维码工具:图片展示区 + 下载按钮 */
 .qr-box {
     margin-top: 12px;
@@ -2549,6 +2619,18 @@ a.ver-muted:hover {
     white-space: nowrap;
 }
 
+/* 指定浏览器路径:输入框 + 选择/清除按钮 */
+.browser-path-group {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    min-width: 0;
+}
+.browser-path-group .input {
+    flex: 1;
+    min-width: 120px;
+}
+
 /* 迷你勾选框(滚动条「透明」等):小号文字 + 原生 checkbox */
 .mini-label {
     display: inline-flex;

+ 351 - 0
frontend/src/led.js

@@ -0,0 +1,351 @@
+// ============================================================================
+// led.js — LED 点阵滚动文字
+//
+// 读取后端 GetLEDText() 获取要显示的文字,转换为 LED 点阵(数字/字母/符号
+// 用内置 5x7 字模,汉字用 Canvas 像素采样),在画布上横向循环滚动。
+// ============================================================================
+import {SettingsService} from "../bindings/TengTuToolbox/index.js";
+
+// ---- 5x7 点阵字模(每字符 7 行,每行 5 位,高位在左)----
+const FONT_5x7 = {
+    '0': ['01110','10001','10011','10101','11001','10001','01110'],
+    '1': ['00100','01100','00100','00100','00100','00100','01110'],
+    '2': ['01110','10001','00001','00010','00100','01000','11111'],
+    '3': ['11110','00001','00001','01110','00001','00001','11110'],
+    '4': ['00010','00110','01010','10010','11111','00010','00010'],
+    '5': ['11111','10000','11110','00001','00001','10001','01110'],
+    '6': ['00110','01000','10000','11110','10001','10001','01110'],
+    '7': ['11111','00001','00010','00100','01000','01000','01000'],
+    '8': ['01110','10001','10001','01110','10001','10001','01110'],
+    '9': ['01110','10001','10001','01111','00001','00010','01100'],
+    'A': ['01110','10001','10001','11111','10001','10001','10001'],
+    'B': ['11110','10001','10001','11110','10001','10001','11110'],
+    'C': ['01110','10001','10000','10000','10000','10001','01110'],
+    'D': ['11100','10010','10001','10001','10001','10010','11100'],
+    'E': ['11111','10000','10000','11110','10000','10000','11111'],
+    'F': ['11111','10000','10000','11110','10000','10000','10000'],
+    'G': ['01110','10001','10000','10111','10001','10001','01111'],
+    'H': ['10001','10001','10001','11111','10001','10001','10001'],
+    'I': ['01110','00100','00100','00100','00100','00100','01110'],
+    'J': ['00111','00010','00010','00010','00010','10010','01100'],
+    'K': ['10001','10010','10100','11000','10100','10010','10001'],
+    'L': ['10000','10000','10000','10000','10000','10000','11111'],
+    'M': ['10001','11011','10101','10101','10001','10001','10001'],
+    'N': ['10001','11001','10101','10011','10001','10001','10001'],
+    'O': ['01110','10001','10001','10001','10001','10001','01110'],
+    'P': ['11110','10001','10001','11110','10000','10000','10000'],
+    'Q': ['01110','10001','10001','10001','10101','10010','01101'],
+    'R': ['11110','10001','10001','11110','10100','10010','10001'],
+    'S': ['01111','10000','10000','01110','00001','00001','11110'],
+    'T': ['11111','00100','00100','00100','00100','00100','00100'],
+    'U': ['10001','10001','10001','10001','10001','10001','01110'],
+    'V': ['10001','10001','10001','10001','10001','01010','00100'],
+    'W': ['10001','10001','10001','10101','10101','10101','01010'],
+    'X': ['10001','10001','01010','00100','01010','10001','10001'],
+    'Y': ['10001','10001','01010','00100','00100','00100','00100'],
+    'Z': ['11111','00001','00010','00100','01000','10000','11111'],
+    'a': ['00000','00000','01110','00001','01111','10001','01111'],
+    'b': ['10000','10000','11110','10001','10001','10001','11110'],
+    'c': ['00000','00000','01110','10001','10000','10001','01110'],
+    'd': ['00001','00001','01111','10001','10001','10001','01111'],
+    'e': ['00000','00000','01110','10001','11111','10000','01110'],
+    'f': ['00110','01001','01000','11100','01000','01000','01000'],
+    'g': ['00000','01111','10001','10001','01111','00001','01110'],
+    'h': ['10000','10000','11110','10001','10001','10001','10001'],
+    'i': ['00100','00000','01100','00100','00100','00100','01110'],
+    'j': ['00010','00000','00110','00010','00010','10010','01100'],
+    'k': ['10000','10000','10010','10100','11000','10100','10010'],
+    'l': ['01100','00100','00100','00100','00100','00100','01110'],
+    'm': ['00000','00000','11010','10101','10101','10101','10101'],
+    'n': ['00000','00000','11110','10001','10001','10001','10001'],
+    'o': ['00000','00000','01110','10001','10001','10001','01110'],
+    'p': ['00000','00000','11110','10001','11110','10000','10000'],
+    'q': ['00000','00000','01111','10001','01111','00001','00001'],
+    'r': ['00000','00000','10110','11001','10000','10000','10000'],
+    's': ['00000','00000','01111','10000','01110','00001','11110'],
+    't': ['01000','01000','11100','01000','01000','01001','00110'],
+    'u': ['00000','00000','10001','10001','10001','10011','01101'],
+    'v': ['00000','00000','10001','10001','10001','01010','00100'],
+    'w': ['00000','00000','10001','10001','10101','10101','01010'],
+    'x': ['00000','00000','10001','01010','00100','01010','10001'],
+    'y': ['00000','00000','10001','10001','01111','00001','01110'],
+    'z': ['00000','00000','11111','00010','00100','01000','11111'],
+    ' ': ['00000','00000','00000','00000','00000','00000','00000'],
+    '.': ['00000','00000','00000','00000','00000','00110','00110'],
+    ',': ['00000','00000','00000','00000','00000','00110','00100'],
+    ':': ['00000','00110','00110','00000','00110','00110','00000'],
+    ';': ['00000','00110','00110','00000','00110','00100','01000'],
+    '-': ['00000','00000','00000','11111','00000','00000','00000'],
+    '_': ['00000','00000','00000','00000','00000','00000','11111'],
+    '+': ['00000','00100','00100','11111','00100','00100','00000'],
+    '=': ['00000','00000','11111','00000','11111','00000','00000'],
+    '/': ['00001','00010','00010','00100','01000','01000','10000'],
+    '\\':['10000','01000','01000','00100','00010','00010','00001'],
+    '!': ['00100','00100','00100','00100','00100','00000','00100'],
+    '?': ['01110','10001','00001','00010','00100','00000','00100'],
+    '@': ['01110','10001','10111','10101','10111','10000','01110'],
+    '#': ['01010','01010','11111','01010','11111','01010','01010'],
+    '$': ['00100','01111','10100','01110','00101','11110','00100'],
+    '%': ['11001','11010','00010','00100','01000','01011','10011'],
+    '&': ['01100','10010','10100','01000','10101','10010','01101'],
+    '*': ['00000','00100','10101','01110','10101','00100','00000'],
+    '(': ['00010','00100','01000','01000','01000','00100','00010'],
+    ')': ['01000','00100','00010','00010','00010','00100','01000'],
+    '[': ['01110','01000','01000','01000','01000','01000','01110'],
+    ']': ['01110','00010','00010','00010','00010','00010','01110'],
+    '{': ['00010','00100','00100','01000','00100','00100','00010'],
+    '}': ['01000','00100','00100','00010','00100','00100','01000'],
+    '<': ['00010','00100','01000','10000','01000','00100','00010'],
+    '>': ['01000','00100','00010','00001','00010','00100','01000'],
+    '|': ['00100','00100','00100','00100','00100','00100','00100'],
+    '~': ['00000','01000','10101','00010','00000','00000','00000'],
+    '^': ['00100','01010','10001','00000','00000','00000','00000'],
+    '“': ['01010','01010','00100','00100','00100','00100','00100'],
+    '”': ['01010','01010','00100','00100','00100','00100','00100'],
+    '‘': ['00100','01010','01010','00100','00100','00100','00100'],
+    '’': ['00100','01010','01010','00100','00100','00100','00100'],
+    '。': ['00000','00000','00000','00000','00110','00110','00000'],
+    ',': ['00000','00000','00000','00000','00110','00100','01000'],
+    '、': ['00100','00100','00100','00100','00100','00000','00000'],
+    ':': ['00000','00110','00110','00000','00110','00110','00000'],
+    ';': ['00000','00110','00110','00000','00110','00100','01000'],
+    '?': ['01110','10001','00001','00010','00100','00000','00100'],
+    '!': ['00100','00100','00100','00100','00100','00000','00100'],
+    '(': ['00010','00100','01000','01000','01000','00100','00010'],
+    ')': ['01000','00100','00010','00010','00010','00100','01000'],
+    '《': ['00010','00100','01000','10000','01000','00100','00010'],
+    '》': ['01000','00100','00010','00001','00010','00100','01000'],
+    '“': ['00100','01010','01010','00100','00100','00100','00100'],
+};
+
+// 汉字点阵:用 Canvas 把文字画出来,按 grid 采样。缓存避免重复计算。
+const hanziCache = new Map();
+
+// 将 5x7 字模放大 scale 倍(每点变 scale×scale 块),用于提高点阵密度
+function scaleGlyph(rows, scale) {
+    const h = rows.length * scale, w = rows[0].length * scale;
+    const out = [];
+    for (let y = 0; y < h; y++) {
+        const line = [];
+        for (let x = 0; x < w; x++) {
+            line.push(rows[Math.floor(y / scale)][Math.floor(x / scale)]);
+        }
+        out.push(line);
+    }
+    return out;
+}
+
+// 将单个字符渲染为点阵数组 [{row, col, on}],返回宽高(宽=列数,高=行数)
+function charToBitmap(ch) {
+    const key = ch;
+    if (hanziCache.has(key)) return hanziCache.get(key);
+
+    let rows = null;
+    const upper = ch.toUpperCase();
+    // 英文/数字/符号:优先用字模,放大 2 倍提高密度(5x7 → 10x14)
+    if (FONT_5x7[ch]) {
+        rows = scaleGlyph(FONT_5x7[ch].map(line => line.split('').map(b => b === '1')), 2);
+        return {w: 10, h: 14, on: rows, char: ch};
+    }
+    if (FONT_5x7[upper]) {
+        rows = scaleGlyph(FONT_5x7[upper].map(line => line.split('').map(b => b === '1')), 2);
+        return {w: 10, h: 14, on: rows, char: ch};
+    }
+
+    // 汉字/其他:Canvas 像素采样为 24x32 点阵(密度是原 12x16 的 2 倍)
+    const gw = 24, gh = 32;
+    const off = document.createElement('canvas');
+    off.width = gw; off.height = gh;
+    const octx = off.getContext('2d');
+    octx.fillStyle = '#fff';
+    octx.font = `${gh * 0.9}px "Microsoft YaHei", "SimHei", sans-serif`;
+    octx.textAlign = 'center';
+    octx.textBaseline = 'middle';
+    octx.fillText(ch, gw / 2, gh / 2 + 1);
+    const data = octx.getImageData(0, 0, gw, gh).data;
+    const on = [];
+    for (let y = 0; y < gh; y++) {
+        const line = [];
+        for (let x = 0; x < gw; x++) {
+            line.push(data[(y * gw + x) * 4 + 3] > 100);
+        }
+        on.push(line);
+    }
+    const r = {w: gw, h: gh, on, char: ch};
+    hanziCache.set(key, r);
+    return r;
+}
+
+// 把整串文字拆成字符点阵序列(含间距),返回 [{char, on, w, h}]
+function textToSequence(text) {
+    const seq = [];
+    const chars = [...text];
+    for (const ch of chars) {
+        const bmp = charToBitmap(ch);
+        seq.push({char: ch, on: bmp.on, w: bmp.w, h: bmp.h});
+    }
+    return seq;
+}
+
+// 计算整行点阵的总宽(列数)与最大高(行数)
+function layout(seq, gap) {
+    let total = 0, maxH = 0;
+    for (let i = 0; i < seq.length; i++) {
+        total += seq[i].w;
+        if (i < seq.length - 1) total += gap;
+        if (seq[i].h > maxH) maxH = seq[i].h;
+    }
+    return {total, maxH};
+}
+
+// 主渲染:canvas 上绘制点阵,requestAnimationFrame 横向无缝滚动循环
+const canvas = document.getElementById('led-canvas');
+const ctx = canvas.getContext('2d');
+let LED_COLOR = '#ff4d4d';       // LED 点阵颜色(可从后端读取,可设置)
+const GAP = 3;                    // 字符间距(点)
+const CHAR_GAP = 2;               // 字符内点距
+const DOT_RADIUS = 0.47;          // 点半径占点距比例(接近 0.5 更饱满,圆形 LED)
+let seq = [], maxRows = 0;
+let speed = 60;                   // 像素/秒(CSS 像素)
+let offsetX = 0;                  // 当前水平偏移(CSS 像素,负=向左滚)
+let lastTs = 0;
+let dpr = 1;                      // 设备像素比
+
+// 把 #rrggbb 颜色拆成 {r,g,b},方便做发光渐变
+function hexToRgb(hex) {
+    const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
+    if (!m) return {r: 255, g: 77, b: 77};
+    const n = parseInt(m[1], 16);
+    return {r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255};
+}
+
+// 按当前窗口逻辑高度计算点距(点阵单元尺寸)
+function dotSize(H) {
+    // 上下安全边距(CSS 像素),保证首末行笔划完整显示、不被窗口裁剪
+    const pad = 6;
+    const avail = Math.max(1, H - 2 * pad);
+    // 点阵总高 = maxRows * (dot + CHAR_GAP),须 <= avail,否则垂直居中后
+    // baseY 为负、顶部笔划被裁。向下取整保证数学上不溢出。
+    let dot = Math.floor((avail - CHAR_GAP * maxRows) / maxRows);
+    // 极高 DPI 缩放或窗口过矮导致可用高度不足以容纳满格点阵时,
+    // 不强制抬升 dot(旧的 Math.max(3,...) 会把 dot 抬回 3,使总高溢出、
+    // baseY 变负、顶部笔划被窗口裁剪),而是允许缩小到最小 1,
+    // 确保 baseY 不为负、点阵完整显示。
+    if (dot < 1) dot = 1;
+    return dot;
+}
+
+// 整行文字的像素宽度(含字符间与末尾间隙),用于计算无缝循环周期
+function totalPxWidth(dot) {
+    let w = 0;
+    for (const item of seq) {
+        w += item.w * (dot + CHAR_GAP) + GAP * dot; // 每字符点阵宽 + 字后间隙
+    }
+    return w;
+}
+
+// 绘制单个 LED 点:中心高亮 + 外圈光晕,模拟真实 LED 灯珠
+function drawLedDot(cx, cy, r, color) {
+    const rgb = hexToRgb(color);
+    // 高亮中心(更亮)
+    ctx.fillStyle = `rgba(${Math.min(255, rgb.r + 60)},${Math.min(255, rgb.g + 60)},${Math.min(255, rgb.b + 60)},1)`;
+    ctx.beginPath();
+    ctx.arc(cx, cy, r * 0.72, 0, Math.PI * 2);
+    ctx.fill();
+    // 外圈光晕(径向渐变:中心亮、边缘淡,聚焦发光感)
+    const grad = ctx.createRadialGradient(cx, cy, r * 0.3, cx, cy, r);
+    grad.addColorStop(0, `rgba(${rgb.r},${rgb.g},${rgb.b},0.85)`);
+    grad.addColorStop(0.7, `rgba(${rgb.r},${rgb.g},${rgb.b},0.35)`);
+    grad.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0.05)`);
+    ctx.fillStyle = grad;
+    ctx.beginPath();
+    ctx.arc(cx, cy, r, 0, Math.PI * 2);
+    ctx.fill();
+}
+
+// 绘制一遍文字,起始横坐标为 x0(CSS 像素)
+function drawText(x0, H, dot) {
+    let x = x0;
+    const cell = dot + CHAR_GAP;         // 点阵单元尺寸
+    const r = dot * DOT_RADIUS;          // 圆形点半径
+    for (const item of seq) {
+        const startX = Math.round(x);
+        // 垂直居中:让整行文字在窗口高度居中
+        const baseY = (H - item.h * cell + CHAR_GAP) / 2;
+        for (let row = 0; row < item.h; row++) {
+            for (let col = 0; col < item.w; col++) {
+                if (item.on[row][col]) {
+                    const cx = startX + col * cell + dot / 2;
+                    const cy = baseY + row * cell + dot / 2;
+                    drawLedDot(cx, cy, r, LED_COLOR);
+                }
+            }
+        }
+        x += item.w * cell + GAP * dot;
+    }
+}
+
+function renderFrame(ts) {
+    if (!lastTs) lastTs = ts;
+    const dt = (ts - lastTs) / 1000;
+    lastTs = ts;
+    // 统一用 CSS 像素坐标(ctx 已 setTransform(dpr),画布物理尺寸=CSS*dpr)
+    const W = canvas.clientWidth, H = canvas.clientHeight;
+    ctx.clearRect(0, 0, W, H);
+
+    if (seq.length > 0 && W > 0 && H > 0) {
+        const dot = dotSize(H);
+        const totalW = totalPxWidth(dot);          // 整行像素宽
+        const sep = Math.max(dot * 6, 80);          // 两遍文字间的最小间距
+        const period = Math.max(totalW + sep, W + sep); // 循环周期:至少覆盖一个窗口宽,保证无缝
+
+        // 向左滚动,跨过整段周期后回绕,实现连续滚动
+        offsetX -= speed * dt;
+        if (offsetX <= -period) offsetX += period;
+
+        // 绘制两遍:第一遍 offsetX,第二遍 offsetX+period,窗口内始终有内容,无缝衔接
+        drawText(offsetX, H, dot);
+        drawText(offsetX + period, H, dot);
+    }
+    requestAnimationFrame(renderFrame);
+}
+
+// 初始化:读取文字、颜色与速度、布局、设置画布尺寸
+async function init() {
+    console.log("LED_NEW_MARKER_2026"); // 验证 vite 是否读取新代码
+    // 页面已加载完成 => WebView2 控制器必然就绪,通知后端补设透明背景
+    // (Wails 创建窗口时设置的透明可能因控制器异步未就绪而丢失)
+    SettingsService.SetLEDTransparent();
+
+    const [text, color, spd] = await Promise.all([
+        SettingsService.GetLEDText(),
+        SettingsService.GetLEDColor(),
+        SettingsService.GetLEDSpeed(),
+    ]);
+    LED_COLOR = color || '#ff4d4d';
+    if (spd > 0) speed = spd;
+    seq = textToSequence(text || 'LED 滚动文字');
+    maxRows = layout(seq, GAP).maxH;
+
+    dpr = window.devicePixelRatio || 1;
+
+    // 设置画布物理尺寸并重置变换(赋值会清空矩阵,需每次重设)
+    const setupCanvas = () => {
+        canvas.width = Math.floor(canvas.clientWidth * dpr);
+        canvas.height = Math.floor(canvas.clientHeight * dpr);
+        ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后所有绘制用 CSS 像素
+    };
+    setupCanvas();
+
+    // 窗口尺寸变化时重设画布(同样重设变换,避免 scale 丢失)
+    new ResizeObserver(setupCanvas).observe(canvas);
+
+    requestAnimationFrame(renderFrame);
+}
+
+// 关闭按钮
+document.getElementById('led-close').addEventListener('click', () => {
+    SettingsService.CloseLEDWindow();
+});
+
+init();

+ 200 - 29
frontend/src/main.js

@@ -4,6 +4,25 @@ import {getCalendarInfo} from "./lunar.js"; // 农历与节日计算
 
 WML.Enable(); // 为带 data-wml-openURL 属性的链接绑定打开行为(动态内容渲染后需调 WML.Reload())
 
+// 全局拦截 data-wml-openURL 链接点击:统一交给后端 OpenInBrowser,由设置决定
+// 用指定浏览器(BrowserPath)还是系统默认浏览器打开,避免 WML 直接走系统默认。
+document.addEventListener('click', (e) => {
+    const link = e.target?.closest?.('a[data-wml-openURL]');
+    if (!link) return;
+    const url = link.getAttribute('data-wml-openURL') || link.getAttribute('href');
+    if (!url) return;
+    e.preventDefault();               // 阻止 <a href> 默认导航
+    e.stopImmediatePropagation();     // 阻止 WML 的 OpenURL(系统默认浏览器),改由后端按配置决定
+    if (typeof SettingsService?.OpenInBrowser === 'function') {
+        SettingsService.OpenInBrowser(url).catch(err => {
+            console.warn('打开浏览器失败,回退默认:', err);
+            window.open(url, '_blank'); // 后端失败时回退
+        });
+    } else {
+        window.open(url, '_blank'); // 浏览器预览环境无后端,直接新开
+    }
+}, true);
+
 // 浏览器预览(非 Wails 环境)时的兜底配置,保证页面仍可正常浏览
 const FALLBACK_SETTINGS = {
     theme: localStorage.getItem('tengtu-theme') || 'dark',
@@ -435,6 +454,9 @@ const uploadMaxSizeInput = document.getElementById('upload-max-size'); // 上传
 const todoPathInput = document.getElementById('todo-path');         // 待办(TODO)文件路径
 const recordDBPathInput = document.getElementById('record-db-path'); // 记录(Record)数据库路径
 const backupDirInput = document.getElementById('backup-dir');       // 文本备份目录(文件类自定义 Tab「备份」按钮使用)
+const browserPathInput = document.getElementById('browser-path');   // 指定浏览器 exe 路径(空=系统默认)
+const browserPickBtn = document.getElementById('browser-pick');     // 选择浏览器按钮
+const browserClearBtn = document.getElementById('browser-clear');   // 清除浏览器按钮
 const customTabsText = document.getElementById('custom-tabs');      // 自定义 Tab(每行 名称|地址|备注)
 const saveButton = document.getElementById('save-settings');        // 保存按钮
 const saveStatus = document.getElementById('save-status');          // 保存状态提示
@@ -565,6 +587,7 @@ async function syncForm(settings) {
     todoPathInput.value = settings.todoPath;
     recordDBPathInput.value = settings.recordDBPath;
     backupDirInput.value = settings.backupDir;
+    browserPathInput.value = settings.browserPath || '';
     stockUpColorInput.value = settings.stockUpColor || FALLBACK_SETTINGS.stockUpColor;
     stockDownColorInput.value = settings.stockDownColor || FALLBACK_SETTINGS.stockDownColor;
     stockRefreshSecsInput.value = settings.stockRefreshSecs || FALLBACK_SETTINGS.stockRefreshSecs;
@@ -667,6 +690,22 @@ hotkeyInput.addEventListener('focus', () => {
     hotkeyInput.select(); // 聚焦时全选,方便重新录入
 });
 
+// 指定浏览器:选择按钮弹出文件选择框,清除按钮恢复系统默认
+browserPickBtn.addEventListener('click', async () => {
+    try {
+        const path = await SettingsService.PickBrowserPath();
+        if (!path) return; // 用户取消
+        browserPathInput.value = path;
+        markDirty();
+    } catch (err) {
+        console.warn('选择浏览器失败:', err);
+    }
+});
+browserClearBtn.addEventListener('click', () => {
+    browserPathInput.value = '';
+    markDirty();
+});
+
 // ------------------------------------------------------------------------
 // 读取 / 保存配置
 // ------------------------------------------------------------------------
@@ -765,6 +804,7 @@ function collectForm() {
         todoPath: todoPathInput.value.trim(),
         recordDBPath: recordDBPathInput.value.trim(),
         backupDir: backupDirInput.value.trim(),
+        browserPath: browserPathInput.value.trim(), // 指定浏览器 exe 路径(空=系统默认)
         stockUpColor: fallbackColor(stockUpColorInput, currentSettings.stockUpColor, FALLBACK_SETTINGS.stockUpColor),
         stockDownColor: fallbackColor(stockDownColorInput, currentSettings.stockDownColor, FALLBACK_SETTINGS.stockDownColor),
         stockRefreshSecs: parseInt(stockRefreshSecsInput.value, 10) || FALLBACK_SETTINGS.stockRefreshSecs,
@@ -2368,6 +2408,165 @@ if (sdCancelBtn) sdCancelBtn.addEventListener('click', cancelShutdown);
 if (sdMinutesInput) sdMinutesInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') shutdownDelay(); }); // 回车触发
 
 
+// ------------------------------------------------------------------------
+// 工具页:MD5 加密——输入字符串,计算 32 位 MD5 摘要
+// ------------------------------------------------------------------------
+const md5InputEl = document.getElementById('md5-input');
+const md5GoBtn = document.getElementById('md5-go');
+const md5HintEl = document.getElementById('md5-hint');
+const md5ResultEl = document.getElementById('md5-result');
+
+async function calcMD5() {
+    const text = (md5InputEl?.value || '').trim();
+    if (!text) {
+        md5HintEl.textContent = '请输入要计算的字符串';
+        return;
+    }
+    md5HintEl.textContent = '计算中…';
+    try {
+        const hash = await SettingsService.MD5Hash(text);
+        md5ResultEl.textContent = hash;
+        md5ResultEl.hidden = false;
+        md5HintEl.textContent = `MD5(${text.length} 字符)`;
+    } catch (err) {
+        md5HintEl.textContent = '计算失败:' + (err?.message || err);
+    }
+}
+if (md5GoBtn) md5GoBtn.addEventListener('click', calcMD5);
+if (md5InputEl) md5InputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') calcMD5(); });
+
+
+// ------------------------------------------------------------------------
+// 工具页:SHA256 编码——输入字符串,计算 64 位 SHA256 摘要
+// ------------------------------------------------------------------------
+const shaInputEl = document.getElementById('sha-input');
+const shaGoBtn = document.getElementById('sha-go');
+const shaHintEl = document.getElementById('sha-hint');
+const shaResultEl = document.getElementById('sha-result');
+
+async function calcSHA256() {
+    const text = (shaInputEl?.value || '').trim();
+    if (!text) {
+        shaHintEl.textContent = '请输入要计算的字符串';
+        return;
+    }
+    shaHintEl.textContent = '计算中…';
+    try {
+        const hash = await SettingsService.SHA256Hash(text);
+        shaResultEl.textContent = hash;
+        shaResultEl.hidden = false;
+        shaHintEl.textContent = `SHA256(${text.length} 字符)`;
+    } catch (err) {
+        shaHintEl.textContent = '计算失败:' + (err?.message || err);
+    }
+}
+if (shaGoBtn) shaGoBtn.addEventListener('click', calcSHA256);
+if (shaInputEl) shaInputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') calcSHA256(); });
+
+
+// ------------------------------------------------------------------------
+// 工具页:LED 滚动文字——输入文字,打开独立 LED 点阵滚动窗口
+// ------------------------------------------------------------------------
+const ledTextEl = document.getElementById('led-text');
+const ledColorEl = document.getElementById('led-color');
+const ledSpeedEl = document.getElementById('led-speed');
+const ledGoBtn = document.getElementById('led-go');
+const ledCloseBtn = document.getElementById('led-close');
+const ledHintEl = document.getElementById('led-hint');
+const ledStatusEl = document.getElementById('led-status');
+
+async function openLED() {
+    const text = (ledTextEl?.value || '').trim();
+    if (!text) {
+        ledHintEl.textContent = '请输入要滚动的文字';
+        return;
+    }
+    const color = ledColorEl?.value || '#ff4d4d';
+    const speed = parseInt(ledSpeedEl?.value || '60', 10) || 60;
+    ledHintEl.textContent = '打开 LED 窗口…';
+    try {
+        const ok = await SettingsService.OpenLEDWindow(text, color, speed);
+        if (ok) {
+            ledHintEl.textContent = 'LED 窗口已打开';
+            ledStatusEl.textContent = '运行中';
+            ledStatusEl.className = 'tool-status is-on';
+        } else {
+            ledHintEl.textContent = '未打开(请先输入文字)';
+        }
+    } catch (err) {
+        ledHintEl.textContent = '打开失败:' + (err?.message || err);
+    }
+}
+
+async function closeLED() {
+    try {
+        await SettingsService.CloseLEDWindow();
+        ledHintEl.textContent = 'LED 窗口已关闭';
+        ledStatusEl.textContent = '';
+        ledStatusEl.className = 'tool-status';
+    } catch (err) {
+        ledHintEl.textContent = '关闭失败:' + (err?.message || err);
+    }
+}
+if (ledGoBtn) ledGoBtn.addEventListener('click', openLED);
+if (ledCloseBtn) ledCloseBtn.addEventListener('click', closeLED);
+if (ledTextEl) ledTextEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') openLED(); });
+
+
+// ------------------------------------------------------------------------
+// 工具页:定时提醒——输入提醒信息与间隔分钟数,每 xx 分钟提醒一次直到取消
+// ------------------------------------------------------------------------
+const remindMessageEl = document.getElementById('remind-message');
+const remindMinutesEl = document.getElementById('remind-minutes');
+const remindGoBtn = document.getElementById('remind-go');
+const remindStopBtn = document.getElementById('remind-stop');
+const remindHintEl = document.getElementById('remind-hint');
+const remindStatusEl = document.getElementById('remind-status');
+
+async function startPeriodicReminder() {
+    const message = (remindMessageEl?.value || '').trim();
+    const minutes = parseInt(remindMinutesEl?.value, 10);
+    if (!message) {
+        remindHintEl.textContent = '请输入提醒信息';
+        return;
+    }
+    if (!Number.isFinite(minutes) || minutes < 1) {
+        remindHintEl.textContent = '请输入 ≥1 的间隔分钟数';
+        return;
+    }
+    remindHintEl.textContent = '启动中…';
+    try {
+        const status = await SettingsService.StartReminder(message, minutes);
+        remindHintEl.textContent = status;
+        remindStatusEl.textContent = `每 ${minutes} 分钟`;
+        remindStatusEl.className = 'tool-status is-on';
+    } catch (err) {
+        remindHintEl.textContent = '启动失败:' + (err?.message || err);
+    }
+}
+
+async function stopPeriodicReminder() {
+    remindHintEl.textContent = '取消中…';
+    try {
+        const status = await SettingsService.StopReminder();
+        remindHintEl.textContent = status;
+        remindStatusEl.textContent = '';
+        remindStatusEl.className = 'tool-status';
+    } catch (err) {
+        remindHintEl.textContent = '取消失败:' + (err?.message || err);
+    }
+}
+if (remindGoBtn) remindGoBtn.addEventListener('click', startPeriodicReminder);
+if (remindStopBtn) remindStopBtn.addEventListener('click', stopPeriodicReminder);
+if (remindMessageEl) remindMessageEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') startPeriodicReminder(); });
+if (remindMinutesEl) remindMinutesEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') startPeriodicReminder(); });
+
+// 订阅后端定时提醒事件:到点弹出提醒弹窗(复用待办提醒弹窗)
+Events.On('periodicReminder', (event) => {
+    showTodoReminder(event.data);
+});
+
+
 async function loadHostPanel() {
     try {
         const status = await SettingsService.GetAPIStatus();
@@ -2389,41 +2588,13 @@ async function loadHostPanel() {
         logView.textContent = logs && logs.length ? logs.join('\n') : '暂无日志';
         logView.scrollTop = logView.scrollHeight; // 滚动到最新一行
 
-        await renderClientUploadQR(status); // 刷新客户端上传页二维码
+        // 客户端上传页已改为纯提示,不再显示二维码/网址
     } catch (err) {
         apiStatusEl.textContent = '状态获取失败(浏览器预览模式无后端)';
         console.warn('主机面板加载失败:', err);
     }
 }
 
-// 渲染「客户端上传页」二维码:扫码打开 http://<addr>/upload,供手机等设备上传文件
-const clientUploadQrBox = document.getElementById('client-upload-qr');
-const clientUploadUrlEl = document.getElementById('client-upload-url');
-async function renderClientUploadQR(status) {
-    if (!clientUploadQrBox) return;
-    clientUploadQrBox.innerHTML = '';
-    if (clientUploadUrlEl) clientUploadUrlEl.textContent = '';
-    if (!status || !status.running) return; // 未运行则不显示二维码
-
-    // 本机展示用 127.0.0.1;通配地址 0.0.0.0/[::] 无法在浏览器中访问
-    let addr = String(status.addr || '').trim();
-    if (addr.startsWith('0.0.0.0') || addr.startsWith('[::]')) {
-        addr = addr.replace(/^0\.0\.0\.0/, '127.0.0.1').replace(/^\[::\]/, '127.0.0.1');
-    }
-    const pageUrl = `http://${addr}/upload`;
-    try {
-        const qrUri = await SettingsService.GenerateQRCode(pageUrl, 0);
-        const img = document.createElement('img');
-        img.src = qrUri;
-        img.alt = '客户端上传页二维码';
-        img.title = '扫码打开客户端上传页';
-        clientUploadQrBox.appendChild(img);
-    } catch (err) {
-        console.warn('客户端上传页二维码生成失败:', err);
-    }
-    if (clientUploadUrlEl) clientUploadUrlEl.textContent = pageUrl;
-}
-
 // 乒乓切换 API 服务:运行中→停止,未运行→启动
 async function toggleAPIService() {
     apiToggleBtn.disabled = true;

+ 1 - 0
frontend/vite.config.js

@@ -13,6 +13,7 @@ export default defineConfig({
       input: {
         main: "index.html",     // 主窗口
         ticker: "ticker.html",  // 置顶滚动条窗口(股票条 / 新闻条)
+        led: "led.html",        // LED 点阵滚动文字窗口
       },
     },
   },

+ 34 - 0
hashservice.go

@@ -0,0 +1,34 @@
+// ============================================================================
+// hashservice.go — 哈希编码工具
+//
+// 为「工具」页提供 MD5 / SHA256 字符串编码:
+//   - MD5Hash:返回输入的 32 位十六进制 MD5 摘要
+//   - SHA256Hash:返回输入的 64 位十六进制 SHA256 摘要
+// ============================================================================
+package main
+
+import (
+	"crypto/md5"
+	"crypto/sha256"
+	"encoding/hex"
+)
+
+// MD5Hash 计算输入字符串的 MD5 摘要(32 位小写十六进制)。
+// 空输入返回空串(前端用于校验)。
+func (s *SettingsService) MD5Hash(text string) string {
+	if text == "" {
+		return ""
+	}
+	sum := md5.Sum([]byte(text))
+	return hex.EncodeToString(sum[:])
+}
+
+// SHA256Hash 计算输入字符串的 SHA256 摘要(64 位小写十六进制)。
+// 空输入返回空串(前端用于校验)。
+func (s *SettingsService) SHA256Hash(text string) string {
+	if text == "" {
+		return ""
+	}
+	sum := sha256.Sum256([]byte(text))
+	return hex.EncodeToString(sum[:])
+}

+ 29 - 0
ledwindow_other.go

@@ -0,0 +1,29 @@
+//go:build !windows
+
+// ============================================================================
+// ledwindow_other.go — LED 点阵滚动文字(非 Windows 空实现)
+//
+// LED 窗口用 Win32 API 实现(见 ledwindow_windows.go),仅 Windows 可用。
+// 其他平台(Linux/macOS)编译此空实现:所有方法无操作,避免链接失败。
+// ============================================================================
+package main
+
+// OpenLEDWindow 非 Windows 平台无操作,返回 false。
+func (s *SettingsService) OpenLEDWindow(text string, color string, speed int) bool {
+	return false
+}
+
+// CloseLEDWindow 非 Windows 平台无操作。
+func (s *SettingsService) CloseLEDWindow() {}
+
+// SetLEDTransparent 非 Windows 平台无操作。
+func (s *SettingsService) SetLEDTransparent() {}
+
+// GetLEDText 非 Windows 平台返回空。
+func (s *SettingsService) GetLEDText() string { return "" }
+
+// GetLEDColor 非 Windows 平台返回默认红。
+func (s *SettingsService) GetLEDColor() string { return "#ff4d4d" }
+
+// GetLEDSpeed 非 Windows 平台返回默认 60。
+func (s *SettingsService) GetLEDSpeed() int { return 60 }

+ 1510 - 0
ledwindow_windows.go

@@ -0,0 +1,1510 @@
+//go:build windows
+
+// ============================================================================
+// ledwindow_windows.go — LED 点阵滚动文字(Windows 原生实现)
+//
+// 需求:输入文字(含中文),转换为 LED 点阵并在独立的无边框、透明、置顶、
+// 可拖动窗口中横向循环滚动显示。
+//
+// 为何不用 Wails WebView2:WebView2 的默认背景不透明问题无法通过
+// PutDefaultBackgroundColor(0,0,0,0) 根治——组合宿主(DComp)下视觉无
+// SetAlphaMode,HWND 宿主下 WebView2 默认背景在未聚焦/聚焦时仍是白/蓝色。
+// 因此本窗口完全用 Win32 API 原生实现,不经过 Wails:
+//
+//  1. 分层窗口 WS_EX_LAYERED + 32 位 DIB section + UpdateLayeredWindow(ULW_ALPHA),
+//     逐像素 alpha 实现真正透明:背景 alpha=0(全透明),点阵 alpha=255(不透明)。
+//     (SetLayeredWindowAttributes(LWA_COLORKEY) 在本环境调用虽成功但实际失效,
+//     故改用 UpdateLayeredWindow 像素级透明。)
+//  2. 点阵直接写入 DIB 内存(BGRA),每帧用 UpdateLayeredWindow 提交到屏幕。
+//  3. 拖动:WM_LBUTTONDOWN → SendMessage(WM_NCLBUTTONDOWN, HTCAPTION),整窗可拖动。
+//  4. 置顶:WS_EX_TOPMOST;不占任务栏:WS_EX_TOOLWINDOW;不抢焦点:ShowWindow(SW_SHOWNOACTIVATE)。
+//  5. 文字点阵:数字/字母/符号用内置 5x7 字模放大 2 倍(10x14),
+//     汉字用 GDI 字体渲染到内存位图后按网格采样为 24x32 点阵(按 RGB 亮度采样,
+//     因 GDI TextOutW 不写 32 位 DIB 的 alpha 通道)。
+//  6. 横向循环滚动:窗口线程定时器按速度推进 offset,循环回绕。
+//
+// 说明:本文件仅在 Windows 编译。其他平台见 ledwindow_other.go 空实现。
+// 共享的 Win32 常量/proc/结构体(wmTimer、wndExLayered、procGetMessage、
+// wndclassex 等)复用 mousealert_windows.go 的定义,避免重复声明冲突。
+// ============================================================================
+package main
+
+import (
+	"fmt"
+	"runtime"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+	"unsafe"
+)
+
+// ------------------------------------------------------------------------
+// Windows API 常量(LED 专属;共享常量见 mousealert_windows.go)
+// ------------------------------------------------------------------------
+const (
+	wmNcHitTest     = 0x0084
+	wmClose         = 0x0010
+	wmNcLButtonDown = 0x00A1
+	wmLButtonDown   = 0x0201
+	wmLButtonUp     = 0x0202
+	wmMouseMove     = 0x0200
+	wmMouseLeave    = 0x02A3
+	wmMouseWheel    = 0x020A
+
+	timerScroll = 1 // 滚动定时器 ID
+
+	// 窗口尺寸(逻辑像素)
+	ledWinWidth  = 720
+	ledWinHeight = 260
+
+	// 点距/边距:窗口随缩放动态变化(见 ledResizeWindow)。
+	// ledBaseDot 基础点距(汉字 32 行 × 6px ≈ 192px,加边距约 260 高)。
+	ledBaseDot = 6
+	ledPadV    = 40 // 垂直边距(容纳边框+按钮,上下各 40)
+	ledPadH    = 20 // 水平边距(容纳边框,左右各 20)
+
+	// 点阵参数
+	ledCharGap = 2 // 字符内点距(点)
+	ledGap     = 3 // 字符间距(点)
+
+	// 颜色键背景(纯黑):COLORREF = 0x00BBGGRR
+	ledBgColor = 0x00000000
+
+	// UpdateLayeredWindow 参数
+	ulwAlpha   = 0x2 // ULW_ALPHA:使用 per-pixel alpha
+	acSrcAlpha = 0x1 // AC_SRC_ALPHA:像素包含 alpha 通道(BGRA)
+
+	// 悬停交互 UI 尺寸
+	ledBorderW     = 2 // 悬停边框宽度(px)
+	ledBtnSize     = 26 // 按钮直径(px)
+	ledBtnMargin   = 6 // 按钮距边框内边距(px)
+	ledBtnCloseTop = ledBorderW + 4 // 关闭按钮上缘
+	ledBtnZoomTop  = ledBorderW + 4 // 缩放按钮上缘
+
+	// 滚轮缩放档位
+	ledZoomMin = 0.6
+	ledZoomMax = 2.0
+	ledZoomStep = 0.1
+)
+
+// htCaption HTCAPTION:WM_NCHITTEST 返回后整窗可拖动
+const htCaption = 2
+
+// ------------------------------------------------------------------------
+// Windows 结构体(LED 专属;point/rect/msg/wndclassex/paintstruct 复用共享)
+// ------------------------------------------------------------------------
+
+// ledBitmapInfoHeader BITMAPINFOHEADER(用于 CreateDIBSection 采样汉字点阵)
+type ledBitmapInfoHeader struct {
+	BiSize          uint32
+	BiWidth         int32
+	BiHeight        int32 // 负值=自顶向下
+	BiPlanes        uint16
+	BiBitCount      uint16
+	BiCompression   uint32
+	BiSizeImage     uint32
+	BiXPelsPerMeter int32
+	BiYPelsPerMeter int32
+	BiClrUsed       uint32
+	BiClrImportant  uint32
+}
+
+type ledBitmapInfo struct {
+	BmiHeader ledBitmapInfoHeader
+	BmiColors [3]uint32
+}
+
+// blendfunction BLENDFUNCTION(UpdateLayeredWindow 用)
+type blendfunction struct {
+	BlendOp             byte
+	BlendFlags          byte
+	SourceConstantAlpha byte
+	AlphaFormat         byte
+}
+
+// trackMouseEvent TRACKMOUSEEVENT(TrackMouseEvent 用,检测鼠标离开)
+type trackMouseEvent struct {
+	CbSize      uint32
+	DwFlags     uint32
+	HwndTrack   uintptr
+	DwHoverTime uint32
+}
+
+const (
+	tmeLeave = 0x00000002 // TME_LEAVE
+)
+
+// ------------------------------------------------------------------------
+// LED 专属 Win32 函数(其余共享 proc 在 mousealert_windows.go 定义,复用)
+// ------------------------------------------------------------------------
+var (
+	ledGetSystemMetrics = user32.NewProc("GetSystemMetrics")
+	ledPostQuitMessage  = user32.NewProc("PostQuitMessage")
+	procUpdateLayeredWindow = user32.NewProc("UpdateLayeredWindow")
+	ledGetDC            = user32.NewProc("GetDC")
+	ledReleaseDC        = user32.NewProc("ReleaseDC")
+
+	ledSetBkColor       = gdi32.NewProc("SetBkColor")
+	ledCreateDIBSection = gdi32.NewProc("CreateDIBSection")
+	procGetPixel        = gdi32.NewProc("GetPixel")
+	procPatBlt          = gdi32.NewProc("PatBlt")
+
+	ledSendMessageW = user32.NewProc("SendMessageW")
+
+	procTrackMouseEvent = user32.NewProc("TrackMouseEvent")
+
+	procGetWindowRect = user32.NewProc("GetWindowRect")
+)
+
+// ------------------------------------------------------------------------
+// 5x7 点阵字模(每字符 7 行,每行 5 位,高位在左)
+// 与前端 led.js 的 FONT_5x7 完全一致。
+// ------------------------------------------------------------------------
+var ledFont5x7 = map[rune][7]string{
+	'0': {"01110", "10001", "10011", "10101", "11001", "10001", "01110"},
+	'1': {"00100", "01100", "00100", "00100", "00100", "00100", "01110"},
+	'2': {"01110", "10001", "00001", "00010", "00100", "01000", "11111"},
+	'3': {"11110", "00001", "00001", "01110", "00001", "00001", "11110"},
+	'4': {"00010", "00110", "01010", "10010", "11111", "00010", "00010"},
+	'5': {"11111", "10000", "11110", "00001", "00001", "10001", "01110"},
+	'6': {"00110", "01000", "10000", "11110", "10001", "10001", "01110"},
+	'7': {"11111", "00001", "00010", "00100", "01000", "01000", "01000"},
+	'8': {"01110", "10001", "10001", "01110", "10001", "10001", "01110"},
+	'9': {"01110", "10001", "10001", "01111", "00001", "00010", "01100"},
+	'A': {"01110", "10001", "10001", "11111", "10001", "10001", "10001"},
+	'B': {"11110", "10001", "10001", "11110", "10001", "10001", "11110"},
+	'C': {"01110", "10001", "10000", "10000", "10000", "10001", "01110"},
+	'D': {"11100", "10010", "10001", "10001", "10001", "10010", "11100"},
+	'E': {"11111", "10000", "10000", "11110", "10000", "10000", "11111"},
+	'F': {"11111", "10000", "10000", "11110", "10000", "10000", "10000"},
+	'G': {"01110", "10001", "10000", "10111", "10001", "10001", "01111"},
+	'H': {"10001", "10001", "10001", "11111", "10001", "10001", "10001"},
+	'I': {"01110", "00100", "00100", "00100", "00100", "00100", "01110"},
+	'J': {"00111", "00010", "00010", "00010", "00010", "10010", "01100"},
+	'K': {"10001", "10010", "10100", "11000", "10100", "10010", "10001"},
+	'L': {"10000", "10000", "10000", "10000", "10000", "10000", "11111"},
+	'M': {"10001", "11011", "10101", "10101", "10001", "10001", "10001"},
+	'N': {"10001", "11001", "10101", "10011", "10001", "10001", "10001"},
+	'O': {"01110", "10001", "10001", "10001", "10001", "10001", "01110"},
+	'P': {"11110", "10001", "10001", "11110", "10000", "10000", "10000"},
+	'Q': {"01110", "10001", "10001", "10001", "10101", "10010", "01101"},
+	'R': {"11110", "10001", "10001", "11110", "10100", "10010", "10001"},
+	'S': {"01111", "10000", "10000", "01110", "00001", "00001", "11110"},
+	'T': {"11111", "00100", "00100", "00100", "00100", "00100", "00100"},
+	'U': {"10001", "10001", "10001", "10001", "10001", "10001", "01110"},
+	'V': {"10001", "10001", "10001", "10001", "10001", "01010", "00100"},
+	'W': {"10001", "10001", "10001", "10101", "10101", "10101", "01010"},
+	'X': {"10001", "10001", "01010", "00100", "01010", "10001", "10001"},
+	'Y': {"10001", "10001", "01010", "00100", "00100", "00100", "00100"},
+	'Z': {"11111", "00001", "00010", "00100", "01000", "10000", "11111"},
+	'a': {"00000", "00000", "01110", "00001", "01111", "10001", "01111"},
+	'b': {"10000", "10000", "11110", "10001", "10001", "10001", "11110"},
+	'c': {"00000", "00000", "01110", "10001", "10000", "10001", "01110"},
+	'd': {"00001", "00001", "01111", "10001", "10001", "10001", "01111"},
+	'e': {"00000", "00000", "01110", "10001", "11111", "10000", "01110"},
+	'f': {"00110", "01001", "01000", "11100", "01000", "01000", "01000"},
+	'g': {"00000", "01111", "10001", "10001", "01111", "00001", "01110"},
+	'h': {"10000", "10000", "11110", "10001", "10001", "10001", "10001"},
+	'i': {"00100", "00000", "01100", "00100", "00100", "00100", "01110"},
+	'j': {"00010", "00000", "00110", "00010", "00010", "10010", "01100"},
+	'k': {"10000", "10000", "10010", "10100", "11000", "10100", "10010"},
+	'l': {"01100", "00100", "00100", "00100", "00100", "00100", "01110"},
+	'm': {"00000", "00000", "11010", "10101", "10101", "10101", "10101"},
+	'n': {"00000", "00000", "11110", "10001", "10001", "10001", "10001"},
+	'o': {"00000", "00000", "01110", "10001", "10001", "10001", "01110"},
+	'p': {"00000", "00000", "11110", "10001", "11110", "10000", "10000"},
+	'q': {"00000", "00000", "01111", "10001", "01111", "00001", "00001"},
+	'r': {"00000", "00000", "10110", "11001", "10000", "10000", "10000"},
+	's': {"00000", "00000", "01111", "10000", "01110", "00001", "11110"},
+	't': {"01000", "01000", "11100", "01000", "01000", "01001", "00110"},
+	'u': {"00000", "00000", "10001", "10001", "10001", "10011", "01101"},
+	'v': {"00000", "00000", "10001", "10001", "10001", "01010", "00100"},
+	'w': {"00000", "00000", "10001", "10001", "10101", "10101", "01010"},
+	'x': {"00000", "00000", "10001", "01010", "00100", "01010", "10001"},
+	'y': {"00000", "00000", "10001", "10001", "01111", "00001", "01110"},
+	'z': {"00000", "00000", "11111", "00010", "00100", "01000", "11111"},
+	' ': {"00000", "00000", "00000", "00000", "00000", "00000", "00000"},
+	'.': {"00000", "00000", "00000", "00000", "00000", "00110", "00110"},
+	',': {"00000", "00000", "00000", "00000", "00000", "00110", "00100"},
+	':': {"00000", "00110", "00110", "00000", "00110", "00110", "00000"},
+	';': {"00000", "00110", "00110", "00000", "00110", "00100", "01000"},
+	'-': {"00000", "00000", "00000", "11111", "00000", "00000", "00000"},
+	'_': {"00000", "00000", "00000", "00000", "00000", "00000", "11111"},
+	'+': {"00000", "00100", "00100", "11111", "00100", "00100", "00000"},
+	'=': {"00000", "00000", "11111", "00000", "11111", "00000", "00000"},
+	'/': {"00001", "00010", "00010", "00100", "01000", "01000", "10000"},
+	'\\': {"10000", "01000", "01000", "00100", "00010", "00010", "00001"},
+	'!': {"00100", "00100", "00100", "00100", "00100", "00000", "00100"},
+	'?': {"01110", "10001", "00001", "00010", "00100", "00000", "00100"},
+	'@': {"01110", "10001", "10111", "10101", "10111", "10000", "01110"},
+	'#': {"01010", "01010", "11111", "01010", "11111", "01010", "01010"},
+	'$': {"00100", "01111", "10100", "01110", "00101", "11110", "00100"},
+	'%': {"11001", "11010", "00010", "00100", "01000", "01011", "10011"},
+	'&': {"01100", "10010", "10100", "01000", "10101", "10010", "01101"},
+	'*': {"00000", "00100", "10101", "01110", "10101", "00100", "00000"},
+	'(': {"00010", "00100", "01000", "01000", "01000", "00100", "00010"},
+	')': {"01000", "00100", "00010", "00010", "00010", "00100", "01000"},
+	'[': {"01110", "01000", "01000", "01000", "01000", "01000", "01110"},
+	']': {"01110", "00010", "00010", "00010", "00010", "00010", "01110"},
+	'{': {"00010", "00100", "00100", "01000", "00100", "00100", "00010"},
+	'}': {"01000", "00100", "00100", "00010", "00100", "00100", "01000"},
+	'<': {"00010", "00100", "01000", "10000", "01000", "00100", "00010"},
+	'>': {"01000", "00100", "00010", "00001", "00010", "00100", "01000"},
+	'|': {"00100", "00100", "00100", "00100", "00100", "00100", "00100"},
+	'~': {"00000", "01000", "10101", "00010", "00000", "00000", "00000"},
+	'^': {"00100", "01010", "10001", "00000", "00000", "00000", "00000"},
+	'。': {"00000", "00000", "00000", "00000", "00110", "00110", "00000"},
+	',': {"00000", "00000", "00000", "00000", "00110", "00100", "01000"},
+	'、': {"00100", "00100", "00100", "00100", "00100", "00000", "00000"},
+	':': {"00000", "00110", "00110", "00000", "00110", "00110", "00000"},
+	';': {"00000", "00110", "00110", "00000", "00110", "00100", "01000"},
+	'?': {"01110", "10001", "00001", "00010", "00100", "00000", "00100"},
+	'!': {"00100", "00100", "00100", "00100", "00100", "00000", "00100"},
+	'(': {"00010", "00100", "01000", "01000", "01000", "00100", "00010"},
+	')': {"01000", "00100", "00010", "00010", "00010", "00100", "01000"},
+	'《': {"00010", "00100", "01000", "10000", "01000", "00100", "00010"},
+	'》': {"01000", "00100", "00010", "00001", "00010", "00100", "01000"},
+}
+
+// ------------------------------------------------------------------------
+// 点阵数据模型
+// ------------------------------------------------------------------------
+
+// ledGlyph 单个字符的点阵(on[row][col]=true 表示点亮)
+type ledGlyph struct {
+	w, h int
+	on   [][]bool
+}
+
+// ledChar 字符 + 其点阵
+type ledChar struct {
+	ch    rune
+	glyph ledGlyph
+}
+
+// scaleGlyph5x7 把 5x7 字模放大 scale 倍(每点变 scale×scale 块)
+func scaleGlyph5x7(rows [7]string, scale int) ledGlyph {
+	w := 5 * scale
+	h := 7 * scale
+	on := make([][]bool, h)
+	for y := 0; y < h; y++ {
+		on[y] = make([]bool, w)
+		for x := 0; x < w; x++ {
+			on[y][x] = rows[y/scale][x/scale] == '1'
+		}
+	}
+	return ledGlyph{w: w, h: h, on: on}
+}
+
+// charToGlyph 把单个字符渲染为点阵:优先内置 5x7 字模(放大 2 倍=10x14),
+// 汉字/其他用 GDI 渲染到内存位图后按 24x32 网格采样。
+// hdc 用于汉字采样(窗口线程持有)。
+func charToGlyph(hdc uintptr, ch rune) ledGlyph {
+	upper := ch
+	if ch >= 'a' && ch <= 'z' {
+		upper = ch - ('a' - 'A')
+	}
+	if rows, ok := ledFont5x7[ch]; ok {
+		return scaleGlyph5x7(rows, 2)
+	}
+	if rows, ok := ledFont5x7[upper]; ok {
+		return scaleGlyph5x7(rows, 2)
+	}
+	// 汉字/其他:GDI 渲染 24x32,按网格采样
+	return renderHanziGlyph(hdc, ch)
+}
+
+// renderHanziGlyph 用 GDI 把汉字画到 24x32 内存位图,按 alpha 通道采样为点阵。
+// 用 CreateDIBSection 得到像素缓冲,TextOutW 绘制后读 alpha 位。
+func renderHanziGlyph(hdc uintptr, ch rune) ledGlyph {
+	const gw, gh = 24, 32
+
+	// 32 位自顶向下 DIB
+	bmi := ledBitmapInfo{}
+	bmi.BmiHeader.BiSize = uint32(unsafe.Sizeof(ledBitmapInfoHeader{}))
+	bmi.BmiHeader.BiWidth = gw
+	bmi.BmiHeader.BiHeight = -gh
+	bmi.BmiHeader.BiPlanes = 1
+	bmi.BmiHeader.BiBitCount = 32
+	bmi.BmiHeader.BiCompression = 0 // BI_RGB
+
+	var bits unsafe.Pointer
+	hbmp, _, _ := ledCreateDIBSection.Call(
+		0,
+		uintptr(unsafe.Pointer(&bmi)),
+		0, // DIB_RGB_COLORS
+		uintptr(unsafe.Pointer(&bits)),
+		0, 0,
+	)
+	if hbmp == 0 || bits == nil {
+		return emptyGlyph(gw, gh)
+	}
+	defer procDeleteObject.Call(hbmp)
+
+	// 内存 DC + 选入位图
+	memDC, _, _ := procCreateCompatibleDC.Call(hdc)
+	if memDC == 0 {
+		return emptyGlyph(gw, gh)
+	}
+	defer procDeleteDC.Call(memDC)
+	oldBmp, _, _ := procSelectObject.Call(memDC, hbmp)
+
+	// 白色前景,黑色背景(黑=透明,白=点亮)
+	procSetBkMode.Call(memDC, 1) // TRANSPARENT
+	ledSetBkColor.Call(memDC, 0x00000000)
+	procSetTextColor.Call(memDC, 0x00FFFFFF)
+	// 清黑:PatBlt BLACKNESS(0x00000042),确保底为纯黑(GDI 读亮度判定)
+	procPatBlt.Call(memDC, 0, 0, uintptr(gw), uintptr(gh), 0x00000042)
+
+	// 创建字体:高约 gh*0.9 像素,微软雅黑
+	facePtr, _ := syscall.UTF16PtrFromString("Microsoft YaHei")
+	fontH := int32(gh) * 9 / 10 // gh*0.9,用整数避免常量折叠
+	hFont, _, _ := procCreateFontW.Call(
+		uintptr(fontH), 0, 0, 0, 400, 0, 0, 0,
+		1 /* DEFAULT_CHARSET */, 0, 0, 4 /* ANTIALIASED */, 0,
+		uintptr(unsafe.Pointer(facePtr)),
+	)
+	if hFont != 0 {
+		procSelectObject.Call(memDC, hFont)
+	} else {
+		hFont, _, _ = procGetStockObject.Call(17) // DEFAULT_GUI_FONT
+	}
+
+	// 绘制字符(居中):TextOutW 用当前字体,位置在网格中心
+	chStr := string(ch)
+	chPtr, _ := syscall.UTF16PtrFromString(chStr)
+	procTextOutW.Call(memDC, 2, 3, uintptr(unsafe.Pointer(chPtr)), uintptr(len(chStr)))
+
+	// 采样 RGB 亮度(GDI TextOutW 不写 32 位 DIB 的 alpha 通道,故按亮度判定点亮)。
+	// 白字黑底:字=0xFFFFFF(亮),底=0x000000(暗)。
+	px := uintptr(bits)
+	on := make([][]bool, gh)
+	for y := 0; y < gh; y++ {
+		on[y] = make([]bool, gw)
+		row := px + uintptr(y*gw*4)
+		for x := 0; x < gw; x++ {
+			b := *(*byte)(unsafe.Pointer(row + uintptr(x*4) + 0))
+			g := *(*byte)(unsafe.Pointer(row + uintptr(x*4) + 1))
+			r := *(*byte)(unsafe.Pointer(row + uintptr(x*4) + 2))
+			// 亮度 > 100(0-255 单通道)视为笔画点亮
+			on[y][x] = (int(r)+int(g)+int(b))/3 > 100
+		}
+	}
+
+	// 恢复并清理
+	if memDC != 0 {
+		procSelectObject.Call(memDC, oldBmp)
+	}
+	if hFont != 0 && hFont != uintptr(17) {
+		procDeleteObject.Call(hFont)
+	}
+	return ledGlyph{w: gw, h: gh, on: on}
+}
+
+// emptyGlyph 返回全灭的 w×h 点阵(采样失败时的占位)
+func emptyGlyph(w, h int) ledGlyph {
+	on := make([][]bool, h)
+	for y := range on {
+		on[y] = make([]bool, w)
+	}
+	return ledGlyph{w: w, h: h, on: on}
+}
+
+// textToLedSequence 把整串文字拆成字符点阵序列
+func textToLedSequence(hdc uintptr, text string) []ledChar {
+	seq := make([]ledChar, 0, len([]rune(text)))
+	for _, ch := range text {
+		seq = append(seq, ledChar{ch: ch, glyph: charToGlyph(hdc, ch)})
+	}
+	return seq
+}
+
+// ledSequenceMaxH 返回序列中最大的点阵高(行数)
+func ledSequenceMaxH(seq []ledChar) int {
+	maxH := 0
+	for _, c := range seq {
+		if c.glyph.h > maxH {
+			maxH = c.glyph.h
+		}
+	}
+	return maxH
+}
+
+// ledTotalPxWidth 计算整行文字的像素宽(含字符间与末尾间隙)
+func ledTotalPxWidth(seq []ledChar, dot int) int {
+	cell := dot + ledCharGap
+	w := 0
+	for _, c := range seq {
+		w += c.glyph.w*cell + ledGap*dot
+	}
+	return w
+}
+
+// ledParseColor 把 #RRGGBB / #RGB 转 COLORREF(0x00BBGGRR),失败回退红。
+func ledParseColor(s string) uintptr {
+	s = strings.TrimSpace(s)
+	if len(s) == 7 && s[0] == '#' {
+		var r, g, b uint32
+		if _, err := fmt.Sscanf(s, "#%02x%02x%02x", &r, &g, &b); err == nil {
+			return uintptr(r | g<<8 | b<<16)
+		}
+	}
+	if len(s) == 4 && s[0] == '#' {
+		var r, g, b uint32
+		if _, err := fmt.Sscanf(s, "#%1x%1x%1x", &r, &g, &b); err == nil {
+			return uintptr(r*17 | g*17<<8 | b*17<<16)
+		}
+	}
+	return 0x00004D4D // 默认红 #ff4d4d
+}
+
+// ------------------------------------------------------------------------
+// LED 窗口运行时状态(单实例)
+// ------------------------------------------------------------------------
+
+// ledWindowState LED 窗口的运行时状态(单实例,互斥保护)
+type ledWindowState struct {
+	mu sync.Mutex
+
+	started bool   // 窗口线程是否已启动(幂等,仅启动一次)
+	hwnd    uintptr // 窗口句柄
+	active  bool   // 窗口是否已创建并显示
+	closed  bool   // 是否已请求关闭(窗口线程退出)
+
+	text   string    // 要显示的文字
+	color  uintptr   // LED 点阵颜色(COLORREF)
+	speed  int       // 滚动速度(px/s)
+	seq    []ledChar // 点阵序列(文字解析结果)
+	maxH   int       // 序列最大高(行数)
+	offset int       // 当前水平偏移(px,向左滚为负累加)
+	period int       // 循环周期(px)
+	prevTs int64     // 上次绘制时间戳(ms,用于 dt 计算)
+
+	// 交互状态
+	hover     bool    // 鼠标是否悬停在窗口上(显示边框+按钮)
+	zoom      float64 // 缩放倍率(滚轮调整,影响点距)
+	dragging  bool    // 是否正在拖动窗口
+	closeHot  bool    // 鼠标是否悬停关闭按钮
+	zoomHot   bool    // 鼠标是否悬停缩放按钮
+	lastMouse point   // 最近鼠标位置(窗口客户区坐标)
+	closeRect rect    // 关闭按钮区域(客户区坐标)
+	zoomRect  rect    // 缩放按钮区域(客户区坐标)
+	tracking  bool    // 是否已注册 TrackMouseEvent
+
+	wndProc uintptr // 窗口过程回调地址(防 GC)
+
+	// UpdateLayeredWindow 像素级透明所需的 DIB 资源(窗口线程创建/持有)
+	memDC  uintptr        // 内存 DC(DIB 被选中)
+	dibBmp uintptr        // 32 位 DIB section 的 HBITMAP
+	dibPtr unsafe.Pointer // DIB 像素内存指针(BGRA 每像素 4 字节)
+	dibW   int            // DIB 宽
+	dibH   int            // DIB 高
+
+	classSeq int // 窗口类名序号(保证类名唯一,避免重复注册同名类)
+}
+
+var ledWinState = &ledWindowState{}
+
+// OpenLEDWindow 打开(或更新)LED 点阵滚动窗口并显示指定文字。
+// text 为空时不更新内容(保留上次文字),但窗口未开则忽略。
+// color 非空时保存为点阵颜色并持久化到设置。
+// speed > 0 时更新滚动速度(px/s)。
+// 返回是否成功打开(供前端提示)。
+func (s *SettingsService) OpenLEDWindow(text string, color string, speed int) bool {
+	if color != "" {
+		ledSaveColor(color)
+	}
+	if speed > 0 {
+		ledWinState.mu.Lock()
+		ledWinState.speed = speed
+		ledWinState.mu.Unlock()
+	}
+
+	if text == "" {
+		// 空文字:窗口已开则保留,否则不创建
+		ledWinState.mu.Lock()
+		open := ledWinState.active
+		ledWinState.mu.Unlock()
+		return open
+	}
+
+	// 更新文字与颜色
+	ledWinState.mu.Lock()
+	ledWinState.text = text
+	if color != "" {
+		ledWinState.color = ledParseColor(color)
+	} else if ledWinState.color == 0 {
+		ledWinState.color = ledParseColor(ledLoadColor())
+	}
+	if ledWinState.speed <= 0 {
+		ledWinState.speed = 60
+	}
+	ledWinState.seq = nil // 强制重新解析(窗口线程重建序列)
+	started := ledWinState.started
+	ledWinState.started = true
+	open := ledWinState.active
+	ledWinState.mu.Unlock()
+
+	if !started {
+		// 首次:启动窗口线程(建窗、消息循环)
+		go ledWindowLoop()
+		return true
+	}
+	// 已启动:唤醒窗口线程立即刷新
+	if open {
+		ledPostRefresh()
+	}
+	return true
+}
+
+// CloseLEDWindow 关闭 LED 滚动窗口(窗口已关则忽略)。
+func (s *SettingsService) CloseLEDWindow() {
+	ledWinState.mu.Lock()
+	hwnd := ledWinState.hwnd
+	ledWinState.closed = true
+	ledWinState.mu.Unlock()
+	if hwnd != 0 {
+		procPostMessage.Call(hwnd, wmClose, 0, 0)
+	}
+}
+
+// SetLEDTransparent 保留空实现:原生分层窗口透明由 SetLayeredWindowAttributes
+// 在建窗时设置,无需前端补设。兼容前端 bindings 调用(不再报错)。
+func (s *SettingsService) SetLEDTransparent() {
+	// 原生窗口透明已由 LWA_COLORKEY 在建窗时实现,此处无操作。
+}
+
+// GetLEDText 返回当前要显示的 LED 文字。
+func (s *SettingsService) GetLEDText() string {
+	ledWinState.mu.Lock()
+	defer ledWinState.mu.Unlock()
+	return ledWinState.text
+}
+
+// GetLEDColor 返回当前 LED 点阵颜色字符串(默认 #ff4d4d)。
+func (s *SettingsService) GetLEDColor() string {
+	ledWinState.mu.Lock()
+	c := ledWinState.color
+	ledWinState.mu.Unlock()
+	if c == 0 {
+		c = ledParseColor(ledLoadColor())
+	}
+	return ledFormatColor(c)
+}
+
+// GetLEDSpeed 返回当前 LED 滚动速度(px/s),<=0 用默认 60。
+func (s *SettingsService) GetLEDSpeed() int {
+	ledWinState.mu.Lock()
+	defer ledWinState.mu.Unlock()
+	if ledWinState.speed <= 0 {
+		ledWinState.speed = 60
+	}
+	return ledWinState.speed
+}
+
+// ledSaveColor 持久化 LED 颜色到设置(颜色非空时)。
+func ledSaveColor(color string) {
+	if color == "" {
+		return
+	}
+	settings := loadSettings()
+	settings.LEDColor = color
+	_ = saveSettings(settings)
+}
+
+// ledLoadColor 从设置读取已保存的 LED 颜色。
+func ledLoadColor() string {
+	c := loadSettings().LEDColor
+	if c == "" {
+		c = "#ff4d4d"
+	}
+	return c
+}
+
+// ledFormatColor 把 COLORREF 转回 #RRGGBB 字符串。
+func ledFormatColor(c uintptr) string {
+	r := (c >> 0) & 0xFF
+	g := (c >> 8) & 0xFF
+	b := (c >> 16) & 0xFF
+	return fmt.Sprintf("#%02x%02x%02x", r, g, b)
+}
+
+// ledPostRefresh 向窗口线程投递自定义刷新消息(窗口已建时)。
+func ledPostRefresh() {
+	ledWinState.mu.Lock()
+	hwnd := ledWinState.hwnd
+	ledWinState.mu.Unlock()
+	if hwnd != 0 {
+		procPostMessage.Call(hwnd, wmRefresh, 0, 0)
+	}
+}
+
+// ------------------------------------------------------------------------
+// 窗口创建 / 消息循环
+// ------------------------------------------------------------------------
+
+// ledWindowLoop 窗口线程:注册窗口类、建窗、显示、跑消息循环。
+// 所有窗口操作必须在同一线程(GUI 线程),Win32 要求。
+func ledWindowLoop() {
+	runtime.LockOSThread()
+	defer runtime.UnlockOSThread()
+
+	hinst, _, _ := procGetModuleHandleW.Call(0)
+
+	// 注册窗口类:类名唯一(序号递增),避免重复注册同名类失败
+	ledWinState.mu.Lock()
+	ledWinState.classSeq++
+	clsName := fmt.Sprintf("LedScrollClass_%d", ledWinState.classSeq)
+	ledWinState.mu.Unlock()
+	className, _ := syscall.UTF16PtrFromString(clsName)
+	wc := wndclassex{}
+	wc.CbSize = uint32(unsafe.Sizeof(wndclassex{}))
+	wc.Style = 0
+	wc.LpfnWndProc = ledWinState.wndProcAddr()
+	wc.HInstance = hinst
+	wc.HCursor = 0
+	wc.HbrBackground = 0 // 无背景(WM_ERASEBKGND 自己填充黑,避免闪烁)
+	wc.LpszClassName = className
+
+	atom, _, _ := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc)))
+	if atom == 0 {
+		ledWinState.mu.Lock()
+		ledWinState.started = false
+		ledWinState.mu.Unlock()
+		return
+	}
+
+	// 创建窗口:分层 + 置顶 + 工具窗
+	// 注意:不加 WS_EX_NOACTIVATE——某些 Win11 版本下 NOACTIVATE 与 LAYERED 组合
+	// 会导致窗口不渲染(不可见)。避免激活改用 ShowWindow(SW_SHOWNOACTIVATE)。
+	exStyle := uintptr(wndExLayered | wndExTool | wndExTopMost)
+	style := uintptr(0x80000000) // WS_POPUP(无边框、无标题、无最小化)
+	hwnd, _, _ := procCreateWindowEx.Call(
+		exStyle,
+		uintptr(unsafe.Pointer(className)),
+		0, // 无标题
+		style,
+		0, 0, // 位置:初始 0,0,随后居中
+		ledWinWidth, ledWinHeight,
+		0, 0, hinst, 0,
+	)
+	if hwnd == 0 {
+		ledWinState.mu.Lock()
+		ledWinState.started = false
+		ledWinState.mu.Unlock()
+		return
+	}
+
+	ledWinState.mu.Lock()
+	ledWinState.hwnd = hwnd
+	ledWinState.active = true
+	ledWinState.closed = false
+	ledWinState.mu.Unlock()
+
+	// 像素级透明:创建 32 位 DIB section,用 UpdateLayeredWindow 逐像素提交。
+	// 背景像素 alpha=0(全透明),点阵像素 alpha=255(不透明)。
+	// 注意:不再用 SetLayeredWindowAttributes(LWA_COLORKEY) —— 本环境调用虽返回
+	// 成功但实际不生效(窗口显示为不透明白色矩形,自检已证实)。
+	if !ledEnsureDIB(ledWinWidth, ledWinHeight) {
+		procSetLayeredWindowAttr.Call(hwnd, 0, 200, lwaAlpha) // 兜底
+	}
+
+	// 居中显示(不激活,避免抢焦点)
+	ledCenterWindow(hwnd, ledWinWidth, ledWinHeight)
+	procShowWindow.Call(hwnd, 4) // SW_SHOWNOACTIVATE
+
+	// 置顶 + 不激活
+	procSetWindowPos.Call(hwnd, hwndTopMost, 0, 0, 0, 0,
+		swpNoActivate|swpShowWindow|0x0001 /* SWP_NOMOVE */|0x0002 /* SWP_NOSIZE */)
+
+	// 滚动定时器(约 16ms)
+	procSetTimer.Call(hwnd, timerScroll, 16, 0)
+
+	// 初始化按钮命中区域(否则 closeRect/zoomRect 全 0,按钮点击失效)
+	ledInitButtonRects()
+
+	// 首次建窗后立即解析点阵序列(否则 seq=nil → 只画透明背景)
+	ledWinState.mu.Lock()
+	if ledWinState.zoom <= 0 {
+		ledWinState.zoom = 1.0 // 缩放倍率默认 1.0(新建窗口重置)
+	}
+	ledWinState.hover = false
+	ledWinState.closeHot = false
+	ledWinState.zoomHot = false
+	ledWinState.tracking = false
+	ledWinState.mu.Unlock()
+	ledRebuildSequence()
+	// 建窗后按实际内容调整窗口尺寸(否则固定 720×260,长文字或缩放后可能裁剪)
+	ledResizeWindow(hwnd)
+	ledRenderFrame(hwnd) // 首帧提交
+
+	// 消息循环
+	var msg msg
+	for {
+		ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0)
+		if int32(ret) == 0 || int32(ret) == -1 {
+			break
+		}
+		procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
+		procDispatchMessage.Call(uintptr(unsafe.Pointer(&msg)))
+	}
+
+	// 退出清理
+	procKillTimer.Call(hwnd, timerScroll)
+	ledReleaseDIB() // 释放 DIB section / memDC
+	ledWinState.mu.Lock()
+	ledWinState.hwnd = 0
+	ledWinState.active = false
+	ledWinState.started = false
+	ledWinState.seq = nil
+	ledWinState.offset = 0
+	ledWinState.mu.Unlock()
+}
+
+// wndProcAddr 返回 LED 窗口过程地址(保持 Go 函数引用,防 GC)。
+// 必须在窗口线程内、RegisterClassEx 之前惰性初始化——与 mousealert_windows.go
+// 的 wndProcAddr() 一致。不能在全局 init() 里 NewCallback(绑定时机错误,会导致
+// RegisterClassEx 拒绝注册)。
+func (st *ledWindowState) wndProcAddr() uintptr {
+	if st.wndProc != 0 {
+		return st.wndProc
+	}
+	st.wndProc = syscall.NewCallback(ledWndProc)
+	return st.wndProc
+}
+
+// ledCenterWindow 把窗口移到屏幕水平居中、垂直约 1/4 处。
+func ledCenterWindow(hwnd uintptr, w, h int) {
+	sw, _, _ := ledGetSystemMetrics.Call(0) // SM_CXSCREEN
+	sh, _, _ := ledGetSystemMetrics.Call(1) // SM_CYSCREEN
+	x := (int32(sw) - int32(w)) / 2
+	y := (int32(sh) - int32(h)) / 4
+	// SWP_NOSIZE(0x0001)|SWP_NOZORDER(0x0004)|SWP_NOACTIVATE(0x0010):只移动,不改大小/层级/激活
+	// 必须含 SWP_NOSIZE,否则 cx=cy=0 会把窗口尺寸设成 0×0(客户区为 0,画面空白)
+	procSetWindowPos.Call(hwnd, 0, uintptr(x), uintptr(y), 0, 0, 0x0001|0x0004|0x0010)
+}
+
+// ledInitButtonRects 初始化关闭/缩放按钮的命中区域(客户区坐标)。
+// 使用当前窗口实际尺寸(ledResizeWindow 缩放后调用,按钮随窗口动态移动)。
+func ledInitButtonRects() {
+	ledWinState.mu.Lock()
+	w := ledWinState.dibW
+	if w <= 0 {
+		w = ledWinWidth
+	}
+	ledWinState.mu.Unlock()
+	closeCx := w - ledBorderW - ledBtnMargin - ledBtnSize/2
+	closeCy := ledBtnCloseTop + ledBtnSize/2
+	zoomCx := ledBorderW + ledBtnMargin + ledBtnSize/2
+	zoomCy := ledBtnZoomTop + ledBtnSize/2
+	r := ledBtnSize / 2
+	ledWinState.mu.Lock()
+	ledWinState.closeRect = rect{
+		Left:   int32(closeCx - r), Top: int32(closeCy - r),
+		Right:  int32(closeCx + r), Bottom: int32(closeCy + r),
+	}
+	ledWinState.zoomRect = rect{
+		Left:   int32(zoomCx - r), Top: int32(zoomCy - r),
+		Right:  int32(zoomCx + r), Bottom: int32(zoomCy + r),
+	}
+	ledWinState.mu.Unlock()
+}
+
+// ledTrackLeave 注册 TrackMouseEvent,鼠标离开窗口时收到 WM_MOUSELEAVE。
+func ledTrackLeave(hwnd uintptr) {
+	ledWinState.mu.Lock()
+	if ledWinState.tracking {
+		ledWinState.mu.Unlock()
+		return
+	}
+	ledWinState.tracking = true
+	ledWinState.mu.Unlock()
+	tme := trackMouseEvent{
+		CbSize:      uint32(unsafe.Sizeof(trackMouseEvent{})),
+		DwFlags:     tmeLeave,
+		HwndTrack:   hwnd,
+		DwHoverTime: 0,
+	}
+	procTrackMouseEvent.Call(uintptr(unsafe.Pointer(&tme)))
+}
+
+// ------------------------------------------------------------------------
+// 窗口过程
+// ------------------------------------------------------------------------
+
+// ledWndProc 窗口过程。wParam/lParam 按消息解释。
+// 返回 0 表示已处理,非 0 由 DefWindowProc 语义。
+func ledWndProc(hwnd uintptr, msg uint32, wParam uintptr, lParam uintptr) uintptr {
+	switch msg {
+	case wmPaint:
+		// 分层窗口用 UpdateLayeredWindow 提交,无需 WM_PAINT GDI 绘制;
+		// 但窗口仍可能收到 WM_PAINT(如系统要求重绘),直接渲染一帧即可。
+		ledRenderFrame(hwnd)
+		return 0
+
+	case wmTimer:
+		if uintptr(wParam) == timerScroll {
+			ledAdvanceScroll()
+			ledRenderFrame(hwnd) // 每帧直接提交到屏幕
+		}
+		return 0
+
+	case wmRefresh:
+		// 文字已更新:重新解析点阵 + 重置偏移 + 窗口尺寸随内容调整 + 渲染一帧
+		ledRebuildSequence()
+		ledResizeWindow(hwnd)
+		ledRenderFrame(hwnd)
+		return 0
+
+	case wmNcHitTest:
+		// 返回 HTCLIENT(默认),不用 HTCAPTION —— 对 WS_EX_LAYERED+WS_EX_NOACTIVATE
+		// 窗口,HTCAPTION 会让系统进入"标题栏移动"状态,阻断 WM_PAINT 派发(画面空白)。
+		// 拖动改由 WM_LBUTTONDOWN 触发 SendMessage(WM_NCLBUTTONDOWN, HTCAPTION)。
+		r2, _, _ := procDefWindowProc.Call(hwnd, uintptr(msg), wParam, lParam)
+		return r2
+
+	case wmLButtonDown:
+		// 判断是否点中关闭/缩放按钮;否则整窗拖动(标题栏移动)
+		x := int16(uint16(lParam & 0xFFFF))
+		y := int16(uint16((lParam >> 16) & 0xFFFF))
+		ledWinState.mu.Lock()
+		closeRect := ledWinState.closeRect
+		zoomRect := ledWinState.zoomRect
+		ledWinState.mu.Unlock()
+
+		cx := int32(x)
+		cy := int32(y)
+		inClose := cx >= closeRect.Left && cx <= closeRect.Right && cy >= closeRect.Top && cy <= closeRect.Bottom
+		inZoom := cx >= zoomRect.Left && cx <= zoomRect.Right && cy >= zoomRect.Top && cy <= zoomRect.Bottom
+
+		if inClose {
+			// 关闭按钮:销毁窗口
+			ledWinState.mu.Lock()
+			ledWinState.hover = false
+			ledWinState.closeHot = false
+			ledWinState.zoomHot = false
+			ledWinState.mu.Unlock()
+			procDestroyWindow.Call(hwnd)
+			return 0
+		}
+		if inZoom {
+			// 缩放按钮:切换缩放(1.0 → 1.5 → 0.6 → 1.0),并重建周期
+			ledWinState.mu.Lock()
+			switch ledWinState.zoom {
+			case 1.0:
+				ledWinState.zoom = 1.5
+			case 1.5:
+				ledWinState.zoom = 0.6
+			default:
+				ledWinState.zoom = 1.0
+			}
+			ledWinState.mu.Unlock()
+			ledRebuildSequence()
+			ledResizeWindow(hwnd)
+			ledRenderFrame(hwnd)
+			return 0
+		}
+		// 普通区域:整窗拖动(标题栏移动,不抢焦点)
+		ledSendMessageW.Call(hwnd, wmNcLButtonDown, htCaption, lParam)
+		return 0
+
+	case wmLButtonUp:
+		return 0
+
+	case wmMouseMove:
+		// 记录鼠标位置,注册离开检测,更新按钮悬停,重绘(悬停边框)
+		x := int16(uint16(lParam & 0xFFFF))
+		y := int16(uint16((lParam >> 16) & 0xFFFF))
+		ledWinState.mu.Lock()
+		ledWinState.lastMouse = point{X: int32(x), Y: int32(y)}
+		wasHover := ledWinState.hover
+		ledWinState.hover = true
+		closeRect := ledWinState.closeRect
+		zoomRect := ledWinState.zoomRect
+		ledWinState.mu.Unlock()
+
+		if !wasHover {
+			// 首次悬停:注册 TrackMouseEvent(离开时收 WM_MOUSELEAVE)
+			ledTrackLeave(hwnd)
+		}
+
+		// 更新按钮悬停状态
+		cx := int32(x)
+		cy := int32(y)
+		inClose := cx >= closeRect.Left && cx <= closeRect.Right && cy >= closeRect.Top && cy <= closeRect.Bottom
+		inZoom := cx >= zoomRect.Left && cx <= zoomRect.Right && cy >= zoomRect.Top && cy <= zoomRect.Bottom
+		changed := false
+		ledWinState.mu.Lock()
+		if ledWinState.closeHot != inClose {
+			ledWinState.closeHot = inClose
+			changed = true
+		}
+		if ledWinState.zoomHot != inZoom {
+			ledWinState.zoomHot = inZoom
+			changed = true
+		}
+		ledWinState.mu.Unlock()
+		// 首次悬停或按钮悬停变化时重绘(显示/更新边框与按钮)
+		if changed || !wasHover {
+			ledRenderFrame(hwnd)
+		}
+		return 0
+
+	case wmMouseLeave:
+		// 鼠标离开:隐藏边框与按钮
+		ledWinState.mu.Lock()
+		ledWinState.hover = false
+		ledWinState.closeHot = false
+		ledWinState.zoomHot = false
+		ledWinState.tracking = false
+		ledWinState.mu.Unlock()
+		ledRenderFrame(hwnd)
+		return 0
+
+	case wmMouseWheel:
+		// 滚轮缩放:调整点距倍率
+		delta := int16(uint16(wParam >> 16)) // HIWORD = 滚轮增量
+		ledWinState.mu.Lock()
+		z := ledWinState.zoom
+		if z <= 0 {
+			z = 1
+		}
+		if delta > 0 {
+			z += ledZoomStep
+		} else {
+			z -= ledZoomStep
+		}
+		if z < ledZoomMin {
+			z = ledZoomMin
+		}
+		if z > ledZoomMax {
+			z = ledZoomMax
+		}
+		ledWinState.zoom = z
+		ledWinState.mu.Unlock()
+		// 缩放改变点距 → 重建周期(平铺宽度变化)
+		ledRebuildSequence()
+		// 窗口尺寸随点距/内容动态变化,保证放大后点阵不被裁剪
+		ledResizeWindow(hwnd)
+		ledRenderFrame(hwnd)
+		return 0
+
+	case wmClose:
+		procDestroyWindow.Call(hwnd)
+		return 0
+
+	case wmDestroy:
+		ledPostQuitMessage.Call(0)
+		return 0
+	}
+	r, _, _ := procDefWindowProc.Call(hwnd, uintptr(msg), wParam, lParam)
+	return r
+}
+
+// ledRebuildSequence 重建点阵序列(窗口线程内调用)。
+func ledRebuildSequence() {
+	ledWinState.mu.Lock()
+	text := ledWinState.text
+	color := ledWinState.color
+	if color == 0 {
+		color = ledParseColor(ledLoadColor())
+	}
+	hwnd := ledWinState.hwnd
+	ledWinState.mu.Unlock()
+
+	// 用窗口 DC 采样汉字
+	hdc, _, _ := ledGetDC.Call(hwnd)
+	// 文字末尾追加两个空格:无限滚动循环时形成断句分隔,
+	// 避免前一句末字与后一句首字粘连、观看者无法断句。
+	text = text + "  "
+	seq := textToLedSequence(hdc, text)
+	if hdc != 0 {
+		ledReleaseDC.Call(hwnd, hdc)
+	}
+
+	maxH := ledSequenceMaxH(seq)
+
+	// 无缝无限滚动:把序列平铺两份(seq + seq),offset 在 [-单份宽, 0) 内回绕,
+	// 这样任何时候窗口内都有连续文字,循环处无缝衔接。
+	tiled := make([]ledChar, 0, len(seq)*2)
+	tiled = append(tiled, seq...)
+	tiled = append(tiled, seq...)
+
+	// 单份序列的总像素宽(平铺周期)
+	tileW := ledTotalPxWidth(seq, ledWinState.dot())
+
+	ledWinState.mu.Lock()
+	ledWinState.seq = tiled
+	ledWinState.maxH = maxH
+	// 循环周期 = 单份序列宽(无限平铺回绕);周期需 > 0 才回绕
+	if tileW > 0 {
+		ledWinState.period = tileW
+	} else {
+		ledWinState.period = ledWinWidth
+	}
+	ledWinState.offset = 0
+	ledWinState.prevTs = 0
+	ledWinState.mu.Unlock()
+}
+
+// dot 计算当前点距(点阵单元像素大小)。
+// 基础点距固定为 ledBaseDot,再乘以滚轮缩放倍率。
+// 窗口高度不再参与计算——尺寸随 dot 动态变化(见 ledResizeWindow),
+// 避免"高度变化→点距变化→窗口再变化"的耦合振荡。
+func (st *ledWindowState) dot() int {
+	d := ledBaseDot
+	z := st.zoom
+	if z <= 0 {
+		z = 1
+	}
+	return int(float64(d) * z)
+}
+
+// ledTargetSize 根据当前点距、点阵高与单份序列宽计算目标窗口尺寸。
+// 高度容纳点阵 + 上下边距(边框+按钮);宽度容纳一个完整循环周期。
+func ledTargetSize(dot, maxH, tileW int) (int, int) {
+	if maxH <= 0 {
+		maxH = 32
+	}
+	lineH := maxH * dot
+	h := lineH + 2*ledPadV
+	// 至少容纳悬停按钮与边框
+	minH := ledBtnCloseTop + ledBtnSize + ledBtnMargin + ledBorderW
+	if h < minH {
+		h = minH
+	}
+	w := tileW + 2*ledPadH
+	// 上限:不超屏幕宽的 9/10(点阵再长也受限,避免窗口占满屏幕)
+	if sw, _, _ := ledGetSystemMetrics.Call(0); int32(sw) > 0 {
+		maxW := int(int32(sw)) * 9 / 10
+		if w > maxW {
+			w = maxW
+		}
+	}
+	// 下限:保持基础宽,避免纯字母小字串窗口过窄
+	if w < ledWinWidth {
+		w = ledWinWidth
+	}
+	return w, h
+}
+
+// ledResizeWindow 按当前内容(点距/点阵高/序列宽)调整窗口尺寸并重建 DIB。
+// 必须在窗口线程内调用(缩放、文字更新后)。
+func ledResizeWindow(hwnd uintptr) {
+	ledWinState.mu.Lock()
+	dot := ledWinState.dot()
+	maxH := ledWinState.maxH
+	tile := ledWinState.period
+	ledWinState.mu.Unlock()
+	if tile <= 0 {
+		tile = ledWinWidth
+	}
+	w, h := ledTargetSize(dot, maxH, tile)
+
+	// 调整窗口尺寸:保持左上角位置(SWP_NOMOVE),不改层级/激活
+	procSetWindowPos.Call(hwnd, 0, 0, 0, uintptr(w), uintptr(h),
+		swpNoActivate|0x0001 /* SWP_NOMOVE */|0x0004 /* SWP_NOZORDER */)
+
+	// 重建 DIB 匹配新尺寸(释放旧 + 创建新)
+	if !ledEnsureDIB(w, h) {
+		return
+	}
+	// 按钮命中区域随窗口宽高动态更新
+	ledInitButtonRects()
+}
+
+// ledAdvanceScroll 按经过时间推进滚动偏移(窗口线程内调用)。
+func ledAdvanceScroll() {
+	ledWinState.mu.Lock()
+	now := time.Now().UnixMilli()
+	if ledWinState.prevTs == 0 {
+		ledWinState.prevTs = now
+		ledWinState.mu.Unlock()
+		return
+	}
+	dt := now - ledWinState.prevTs
+	ledWinState.prevTs = now
+	// 鼠标悬停时停止滚动:可放大/缩小/关闭查看点阵细节
+	if ledWinState.hover {
+		ledWinState.mu.Unlock()
+		return
+	}
+	spd := ledWinState.speed
+	if spd <= 0 {
+		spd = 60
+	}
+	// 向左滚:offset 递减
+	ledWinState.offset -= int(float32(spd) * float32(dt) / 1000.0)
+	// 无限回绕:把 offset 归一到 (-period, 0],保证平铺无缝衔接。
+	// 注意 seq 已平铺两份,period=单份宽,窗口宽 < period+单份宽,任意时刻有连续文字。
+	if ledWinState.period > 0 {
+		p := ledWinState.period
+		// 归一:offset 在 (-p, 0]
+		for ledWinState.offset <= -p {
+			ledWinState.offset += p
+		}
+		for ledWinState.offset > 0 {
+			ledWinState.offset -= p
+		}
+	}
+	ledWinState.mu.Unlock()
+}
+
+// ledEnsureDIB 创建 32 位自顶向下 DIB section 并选入内存 DC。
+// 必须窗口线程内调用。成功后 dibPtr 指向 BGRA 像素缓冲。
+// w/h 为动态尺寸(随缩放变化);非法尺寸回退默认 720×260。
+func ledEnsureDIB(w, h int) bool {
+	if w <= 0 || h <= 0 {
+		w, h = ledWinWidth, ledWinHeight
+	}
+	// 先释放旧 DIB(幂等:首次调用 memDC=0,无操作)
+	ledReleaseDIB()
+
+	bmi := ledBitmapInfo{}
+	bmi.BmiHeader.BiSize = uint32(unsafe.Sizeof(ledBitmapInfoHeader{}))
+	bmi.BmiHeader.BiWidth = int32(w)
+	bmi.BmiHeader.BiHeight = -int32(h) // 自顶向下
+	bmi.BmiHeader.BiPlanes = 1
+	bmi.BmiHeader.BiBitCount = 32
+	bmi.BmiHeader.BiCompression = 0 // BI_RGB
+
+	var bits unsafe.Pointer
+	hbmp, _, _ := ledCreateDIBSection.Call(
+		0,
+		uintptr(unsafe.Pointer(&bmi)),
+		0, // DIB_RGB_COLORS
+		uintptr(unsafe.Pointer(&bits)),
+		0, 0,
+	)
+	if hbmp == 0 || bits == nil {
+		return false
+	}
+
+	memDC, _, _ := procCreateCompatibleDC.Call(0)
+	if memDC == 0 {
+		procDeleteObject.Call(hbmp)
+		return false
+	}
+	procSelectObject.Call(memDC, hbmp)
+
+	ledWinState.mu.Lock()
+	ledWinState.memDC = memDC
+	ledWinState.dibBmp = hbmp
+	ledWinState.dibPtr = bits
+	ledWinState.dibW = w
+	ledWinState.dibH = h
+	ledWinState.mu.Unlock()
+
+	return true
+}
+
+// ledReleaseDIB 释放 DIB section 与内存 DC(窗口线程内调用)。
+func ledReleaseDIB() {
+	ledWinState.mu.Lock()
+	memDC := ledWinState.memDC
+	hbmp := ledWinState.dibBmp
+	ledWinState.memDC = 0
+	ledWinState.dibBmp = 0
+	ledWinState.dibPtr = nil
+	ledWinState.mu.Unlock()
+	if memDC != 0 {
+		procDeleteDC.Call(memDC)
+	}
+	if hbmp != 0 {
+		procDeleteObject.Call(hbmp)
+	}
+}
+
+// ledRenderFrame 渲染一帧到 DIB 并调 UpdateLayeredWindow 提交到屏幕。
+// 背景像素 alpha=0(全透明),点阵像素 alpha=255(不透明,LED 颜色)。
+// 悬停时绘制半透明边框与按钮(关闭 / 缩放指示)。
+func ledRenderFrame(hwnd uintptr) {
+	ledWinState.mu.Lock()
+	memDC := ledWinState.memDC
+	base := ledWinState.dibPtr
+	w := ledWinState.dibW
+	h := ledWinState.dibH
+	seq := ledWinState.seq
+	offset := ledWinState.offset
+	maxH := ledWinState.maxH
+	color := ledWinState.color
+	dot := ledWinState.dot()
+	hover := ledWinState.hover
+	closeHot := ledWinState.closeHot
+	zoomHot := ledWinState.zoomHot
+	zoom := ledWinState.zoom
+	ledWinState.mu.Unlock()
+
+	if memDC == 0 || base == nil || w <= 0 || h <= 0 {
+		return
+	}
+	if color == 0 {
+		color = ledParseColor(ledLoadColor())
+	}
+
+	// 1) 清空为全透明(B=0,G=0,R=0,A=0)
+	p := uintptr(base)
+	for i := 0; i < w*h*4; i += 4 {
+		*(*byte)(unsafe.Pointer(p + uintptr(i))) = 0
+		*(*byte)(unsafe.Pointer(p + uintptr(i) + 1)) = 0
+		*(*byte)(unsafe.Pointer(p + uintptr(i) + 2)) = 0
+		*(*byte)(unsafe.Pointer(p + uintptr(i) + 3)) = 0
+	}
+
+	// 2) 画点阵:圆点(抗锯齿)+ 无限平铺
+	if len(seq) > 0 && maxH > 0 {
+		cell := dot + ledCharGap
+		lineH := maxH * dot
+		startY := (h - lineH) / 2
+		if startY < 0 {
+			startY = 0
+		}
+		// 颜色拆分(COLORREF 0x00BBGGRR)
+		cr := byte(color & 0xFF)
+		cg := byte((color >> 8) & 0xFF)
+		cb := byte((color >> 16) & 0xFF)
+
+		// 平铺周期(单份序列宽),避免重复绘制超出窗口的字符
+		tile := ledWinState.period
+		if tile <= 0 {
+			tile = w
+		}
+
+		penX := offset
+		for _, c := range seq {
+			glyph := c.glyph
+			// 垂直居中:每个字符按其自身高度在整行高度内居中,
+			// 使字母(低)与汉字(高)的视觉中线对齐(避免字母顶对齐偏上)
+			charTop := startY + (maxH-glyph.h)/2*dot
+			for gy := 0; gy < glyph.h; gy++ {
+				for gx := 0; gx < glyph.w; gx++ {
+					if !glyph.on[gy][gx] {
+						continue
+					}
+					px := penX + gx*cell
+					py := charTop + gy*dot
+					// 只画可能与窗口相交的圆点(含一个平铺周期的余量)
+					if px > w || px+dot < 0 || py+dot < 0 || py > h {
+						continue
+					}
+					ledDrawDot(base, w, h, px, py, dot, cr, cg, cb)
+				}
+			}
+			penX += glyph.w*cell + ledGap*dot
+			// 提前结束:当前字符已远超窗口右缘 + 平铺余量
+			if penX > w+tile+cell {
+				break
+			}
+		}
+	}
+
+	// 3) 悬停:绘制边框 + 按钮
+	if hover {
+		ledDrawHoverUI(base, w, h, closeHot, zoomHot, zoom)
+	}
+
+	// 4) UpdateLayeredWindow 提交
+	screenDC, _, _ := ledGetDC.Call(0)
+	if screenDC == 0 {
+		return
+	}
+	defer ledReleaseDC.Call(0, screenDC)
+
+	var srcPt point
+	var srcSize size
+	srcSize.Cx = int32(w)
+	srcSize.Cy = int32(h)
+	var dstPt point
+	var wr rect
+	procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&wr)))
+	dstPt.X = wr.Left
+	dstPt.Y = wr.Top
+
+	blend := blendfunction{
+		BlendOp:             0,
+		BlendFlags:          0,
+		SourceConstantAlpha: 255,
+		AlphaFormat:         acSrcAlpha,
+	}
+	ret, _, _ := procUpdateLayeredWindow.Call(
+		hwnd,
+		screenDC,
+		uintptr(unsafe.Pointer(&dstPt)),
+		uintptr(unsafe.Pointer(&srcSize)),
+		memDC,
+		uintptr(unsafe.Pointer(&srcPt)),
+		0,
+		uintptr(unsafe.Pointer(&blend)),
+		uintptr(ulwAlpha),
+	)
+	_ = ret
+}
+
+// ledDrawDot 画一个圆点(抗锯齿):在 dot×dot 单元内以距离平方判断圆形。
+// 用 2x 超采样 + 距离软边实现平滑边缘。
+func ledDrawDot(base unsafe.Pointer, w, h, x0, y0, dot int, cr, cg, cb byte) {
+	if dot <= 0 {
+		return
+	}
+	// 圆心与半径(留 1px 间隙让点阵更透气)
+	center := float64(dot) / 2.0
+	radius := float64(dot)/2.0 - 0.5
+	if radius < 1 {
+		radius = 1
+	}
+	r2 := radius * radius
+	// 内半径(全亮),外半径(全透明),之间线性过渡 → 抗锯齿
+	innerR2 := (radius - 1.0) * (radius - 1.0)
+	if innerR2 < 0 {
+		innerR2 = 0
+	}
+
+	// 颜色(预乘 alpha 不需要,AC_SRC_ALPHA 下 alpha 即不透明度)
+	for dy := 0; dy < dot; dy++ {
+		for dx := 0; dx < dot; dx++ {
+			xx := x0 + dx
+			yy := y0 + dy
+			if xx < 0 || xx >= w || yy < 0 || yy >= h {
+				continue
+			}
+			// 到圆心的距离平方
+			dxc := float64(dx) + 0.5 - center
+			dyc := float64(dy) + 0.5 - center
+			dist2 := dxc*dxc + dyc*dyc
+			var alpha float64
+			if dist2 <= innerR2 {
+				alpha = 1.0
+			} else if dist2 >= r2 {
+				alpha = 0.0
+			} else {
+				// 线性软边
+				alpha = (r2 - dist2) / (r2 - innerR2)
+				if alpha < 0 {
+					alpha = 0
+				}
+				if alpha > 1 {
+					alpha = 1
+				}
+			}
+			if alpha <= 0 {
+				continue
+			}
+			a := byte(alpha * 255)
+			off := (yy*w + xx) * 4
+			*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 0)) = cb
+			*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 1)) = cg
+			*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 2)) = cr
+			*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 3)) = a
+		}
+	}
+}
+
+// ledDrawHoverUI 绘制悬停边框 + 关闭按钮 + 缩放指示按钮。
+func ledDrawHoverUI(base unsafe.Pointer, w, h int, closeHot, zoomHot bool, zoom float64) {
+	// 边框:浅蓝半透明(alpha 130),宽 ledBorderW
+	border := byte(130)
+	// 边框颜色(浅灰蓝)
+	br, bg, bb := byte(160), byte(200), byte(255)
+	for y := 0; y < h; y++ {
+		for x := 0; x < w; x++ {
+			on := x < ledBorderW || x >= w-ledBorderW || y < ledBorderW || y >= h-ledBorderW
+			if !on {
+				continue
+			}
+			off := (y*w + x) * 4
+			*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 0)) = bb
+			*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 1)) = bg
+			*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 2)) = br
+			*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 3)) = border
+		}
+	}
+
+	// 关闭按钮(右上角圆形 ×)
+	closeCx := w - ledBorderW - ledBtnMargin - ledBtnSize/2
+	closeCy := ledBtnCloseTop + ledBtnSize/2
+	drawCircleButton(base, w, h, closeCx, closeCy, ledBtnSize/2,
+		200, 80, 80, // 红
+		closeHot, func(px, py int) {
+			// 画 ×
+			r := ledBtnSize / 4
+			if abs(px-closeCx)+abs(py-closeCy) <= r {
+				off := (py*w + px) * 4
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 0)) = 255
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 1)) = 255
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 2)) = 255
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 3)) = 255
+			}
+		})
+
+	// 缩放按钮(左上角圆形,显示 +/-)
+	zoomCx := ledBorderW + ledBtnMargin + ledBtnSize/2
+	zoomCy := ledBtnZoomTop + ledBtnSize/2
+	drawCircleButton(base, w, h, zoomCx, zoomCy, ledBtnSize/2,
+		80, 160, 200, // 蓝
+		zoomHot, func(px, py int) {
+			// 画放大镜:中心圆 + 手柄
+			off := (py*w + px) * 4
+			// 手柄(对角线)
+			if abs(px-zoomCx)+abs(py-zoomCy) > ledBtnSize/5 && abs(px-zoomCx)+abs(py-zoomCy) <= ledBtnSize/3 {
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 0)) = 255
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 1)) = 255
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 2)) = 255
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 3)) = 255
+			}
+			// 中间横杠
+			if abs(py-zoomCy) <= 1 && abs(px-zoomCx) <= ledBtnSize/6 {
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 0)) = 255
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 1)) = 255
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 2)) = 255
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 3)) = 255
+			}
+		})
+
+	// 缩放倍率文本(右上角下方,简单显示 zoom)
+	_ = zoom
+}
+
+// drawCircleButton 画一个圆形按钮(实心 + 悬停高亮),中心 (cx,cy) 半径 r。
+// content 在按钮内部像素上回调,用于画图标。
+func drawCircleButton(base unsafe.Pointer, w, h, cx, cy, r int, cr, cg, cb byte, hot bool, content func(px, py int)) {
+	r2 := r * r
+	for dy := -r; dy <= r; dy++ {
+		for dx := -r; dx <= r; dx++ {
+			xx := cx + dx
+			yy := cy + dy
+			if xx < 0 || xx >= w || yy < 0 || yy >= h {
+				continue
+			}
+			if dx*dx+dy*dy > r2 {
+				continue
+			}
+			off := (yy*w + xx) * 4
+			if hot {
+				// 悬停:亮一点
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 0)) = cb
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 1)) = cg
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 2)) = cr
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 3)) = 230
+			} else {
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 0)) = cb
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 1)) = cg
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 2)) = cr
+				*(*byte)(unsafe.Pointer(uintptr(base) + uintptr(off) + 3)) = 180
+			}
+			if content != nil {
+				content(xx, yy)
+			}
+		}
+	}
+}
+
+// abs 返回绝对值。
+func abs(x int) int {
+	if x < 0 {
+		return -x
+	}
+	return x
+}
+
+// 调试日志已移除:不再写 led_transparent.log,避免残留记录文件。

+ 97 - 0
reminderservice.go

@@ -0,0 +1,97 @@
+// ============================================================================
+// reminderservice.go — 定时提醒
+//
+// 「工具」页定时提醒:设置提醒信息和间隔分钟数,每 xx 分钟广播一次
+// periodicReminder 事件(前端弹窗显示)并唤出主窗口,直到手动取消。
+// ============================================================================
+package main
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+	"time"
+)
+
+// 定时提醒运行状态
+var (
+	reminderMu    sync.Mutex
+	reminderStop  chan struct{} // 停止信号(取消定时提醒时 close)
+	reminderText  string        // 当前提醒信息
+	reminderEvery time.Duration // 当前间隔
+	reminderNext  time.Time     // 下次提醒时刻
+	reminderRun   bool          // 是否在运行
+)
+
+// StartReminder 启动(或更新)定时提醒:每 every 分钟提醒一次,直到取消。
+// message 为提醒信息,minutes 为间隔分钟数(必须 >=1)。
+// 返回启动状态描述(含下次提醒时刻),供前端提示。
+func (s *SettingsService) StartReminder(message string, minutes int) string {
+	msg := strings.TrimSpace(message)
+	if msg == "" {
+		return "请输入提醒信息"
+	}
+	if minutes < 1 {
+		return "间隔分钟数必须 ≥ 1"
+	}
+
+	reminderMu.Lock()
+	defer reminderMu.Unlock()
+
+	// 已运行则先停止旧定时器
+	if reminderRun {
+		close(reminderStop)
+	}
+
+	reminderText = msg
+	reminderEvery = time.Duration(minutes) * time.Minute
+	reminderStop = make(chan struct{})
+	reminderRun = true
+	reminderNext = time.Now().Add(reminderEvery)
+
+	go reminderLoop(reminderStop, msg, reminderEvery)
+	return fmt.Sprintf("已启动:每 %d 分钟提醒「%s」,下次 %s", minutes, msg, reminderNext.Format("15:04:05"))
+}
+
+// StopReminder 取消定时提醒。返回是否在运行过。
+func (s *SettingsService) StopReminder() string {
+	reminderMu.Lock()
+	defer reminderMu.Unlock()
+	if !reminderRun {
+		return "定时提醒未运行"
+	}
+	close(reminderStop)
+	reminderRun = false
+	reminderText = ""
+	reminderEvery = 0
+	reminderNext = time.Time{}
+	return "已取消定时提醒"
+}
+
+// ReminderStatus 查询当前定时提醒状态(运行中 / 间隔 / 下次时刻)。
+func (s *SettingsService) ReminderStatus() string {
+	reminderMu.Lock()
+	defer reminderMu.Unlock()
+	if !reminderRun {
+		return ""
+	}
+	return fmt.Sprintf("运行中|每 %d 分钟|下次 %s",
+		int(reminderEvery.Minutes()), reminderNext.Format("15:04:05"))
+}
+
+// reminderLoop 每间隔提醒一次:广播 periodicReminder 事件并唤出主窗口。
+// stop 关闭时退出循环。
+func reminderLoop(stop chan struct{}, msg string, every time.Duration) {
+	ticker := time.NewTicker(every)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-stop:
+			return // 已取消
+		case now := <-ticker.C:
+			payload := msg + "\n" + now.Format("2006-01-02 15:04:05")
+			app.Event.Emit("periodicReminder", payload)
+			showMainWindow()
+		}
+	}
+}

+ 4 - 0
settings.go

@@ -109,6 +109,8 @@ type AppSettings struct {
 	NewsTicker   TickerConfig `json:"newsTicker"`   // 新闻滚动条配置
 	Poem         PoemConfig   `json:"poem"`         // 随机古诗显示配置
 	MouseAlert   MouseAlertConfig `json:"mouseAlert"` // 系统级鼠标提醒气泡配置
+	BrowserPath  string       `json:"browserPath"`  // 指定浏览器 exe 路径(空=系统默认浏览器)
+	LEDColor     string       `json:"ledColor"`     // LED 滚动文字点阵颜色(如 #ff4d4d)
 
 	StockUpColor           string `json:"stockUpColor"`           // 股票上涨颜色
 	StockDownColor         string `json:"stockDownColor"`         // 股票下跌颜色
@@ -157,6 +159,8 @@ func defaultSettings() AppSettings {
 		NewsTicker:  TickerConfig{Enabled: false, Width: 1400, FontSize: 14, Color: "#3ddc84", Speed: 60, Transparent: true, X: -1, Y: -1},
 		Poem:        PoemConfig{Enabled: false, Color: DefaultPoemColor, FontSize: DefaultPoemFontSize, TTS: false, Interval: DefaultPoemInterval, Speed: DefaultPoemSpeed},
 		MouseAlert:  MouseAlertConfig{FontSize: DefaultMouseAlertFontSize, Width: DefaultMouseAlertWidth, Speed: DefaultMouseAlertSpeed, Color: DefaultMouseAlertColor, Duration: DefaultMouseAlertDuration, OutlineColor: DefaultMouseAlertOutlineColor, OutlineWidth: DefaultMouseAlertOutlineWidth},
+		BrowserPath: "",      // 指定浏览器路径:默认空(使用系统默认浏览器)
+		LEDColor:     "#ff4d4d", // LED 滚动文字点阵颜色:默认红色
 
 		StockUpColor:           DefaultStockUpColor,
 		StockDownColor:         DefaultStockDownColor,

+ 57 - 0
settingsservice.go

@@ -11,7 +11,9 @@ import (
 	"fmt"
 	"net/url"
 	"os"
+	"os/exec"
 	"path/filepath"
+	"runtime"
 	"strings"
 	"time"
 
@@ -254,6 +256,61 @@ func (s *SettingsService) PickUploadFile() (string, error) {
 		PromptForSingleSelection()
 }
 
+// PickBrowserPath 弹出文件选择对话框,让用户指定浏览器可执行文件(如 chrome.exe / msedge.exe / firefox.exe)。
+// 返回选中的 exe 路径(取消返回空串)。
+func (s *SettingsService) PickBrowserPath() (string, error) {
+	if app == nil {
+		return "", fmt.Errorf("应用未初始化")
+	}
+	return app.Dialog.OpenFile().
+		CanChooseDirectories(false).
+		CanChooseFiles(true).
+		SetTitle("选择浏览器可执行文件").
+		PromptForSingleSelection()
+}
+
+// OpenInBrowser 用配置的浏览器打开网址。
+// 若设置页指定了浏览器路径(BrowserPath)且文件存在,则用该浏览器打开;
+// 否则回退到系统默认浏览器(通过 Wails OpenURL 能力)。
+func (s *SettingsService) OpenInBrowser(url string) error {
+	url = strings.TrimSpace(url)
+	if url == "" {
+		return fmt.Errorf("网址为空")
+	}
+	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
+		return fmt.Errorf("仅支持 http/https 网址")
+	}
+
+	// 用户指定了浏览器:直接启动该 exe 打开网址
+	settings := loadSettings()
+	browser := strings.TrimSpace(settings.BrowserPath)
+	if browser != "" {
+		if _, err := os.Stat(browser); err != nil {
+			return fmt.Errorf("指定的浏览器不存在: %s", browser)
+		}
+		cmd := exec.Command(browser, url)
+		if err := cmd.Start(); err != nil {
+			return fmt.Errorf("启动浏览器失败: %w", err)
+		}
+		return nil
+	}
+
+	// 未指定浏览器:用系统默认浏览器打开
+	return openURLSystem(url)
+}
+
+// openURLSystem 用系统默认浏览器打开网址(跨平台)。
+func openURLSystem(url string) error {
+	switch runtime.GOOS {
+	case "windows":
+		return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
+	case "darwin":
+		return exec.Command("open", url).Start()
+	default:
+		return exec.Command("xdg-open", url).Start()
+	}
+}
+
 // UploadFile 供桌面端「服务」页直接上传本地文件(不走 HTTP、无需密码)。
 // 把本地文件压缩为 zip 存入上传目录(zip 内保留原文件名),返回下载信息。
 // 仅本机桌面调用,不暴露给外部网络。