Bladeren bron

添加鼠标滚动文字提示功能

soease 1 week geleden
bovenliggende
commit
f62072a876

+ 1 - 1
README.md

@@ -56,7 +56,7 @@ wails3 build
 
 ### 鼠标提醒
 
-通过内置 API 触发系统级鼠标提醒:平时鼠标为正常样式;触发后鼠标图标闪烁(白↔红,约 250ms 周期),光标下方浮出半透明提示文字,可覆盖任意窗口(系统级),默认 8 秒自动消退。详见 [mousealert_windows.go](mousealert_windows.go)。
+通过内置 API 触发系统级鼠标提醒:平时鼠标为正常样式;触发后鼠标图标闪烁(白↔红,约 250ms 周期),光标下方浮出半透明提示文字,可覆盖任意窗口(系统级),默认 8 秒自动消退。文字默认带黑色描边(可在设置中调整描边颜色与宽度,0 关闭),避免在亮/暗不同背景下看不清。详见 [mousealert_windows.go](mousealert_windows.go)。
 
 ## 项目结构
 

+ 1 - 0
frontend/bindings/TengTuToolbox/index.js

@@ -19,6 +19,7 @@ export {
     LinkItem,
     MinuteChart,
     MinutePoint,
+    MouseAlertConfig,
     NewsItem,
     PCStatus,
     PoemConfig,

+ 110 - 9
frontend/bindings/TengTuToolbox/models.js

@@ -223,6 +223,14 @@ export class AppSettings {
              */
             this["poem"] = (new PoemConfig());
         }
+        if (!("mouseAlert" in $$source)) {
+            /**
+             * 系统级鼠标提醒气泡配置
+             * @member
+             * @type {MouseAlertConfig}
+             */
+            this["mouseAlert"] = (new MouseAlertConfig());
+        }
         if (!("stockUpColor" in $$source)) {
             /**
              * 股票上涨颜色
@@ -305,6 +313,7 @@ export class AppSettings {
         const $$createField16_0 = $$createType7;
         const $$createField17_0 = $$createType7;
         const $$createField18_0 = $$createType8;
+        const $$createField19_0 = $$createType9;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("tabOrder" in $$parsedSource) {
             $$parsedSource["tabOrder"] = $$createField4_0($$parsedSource["tabOrder"]);
@@ -330,6 +339,9 @@ export class AppSettings {
         if ("poem" in $$parsedSource) {
             $$parsedSource["poem"] = $$createField18_0($$parsedSource["poem"]);
         }
+        if ("mouseAlert" in $$parsedSource) {
+            $$parsedSource["mouseAlert"] = $$createField19_0($$parsedSource["mouseAlert"]);
+        }
         return new AppSettings(/** @type {Partial<AppSettings>} */($$parsedSource));
     }
 }
@@ -641,7 +653,7 @@ export class MinuteChart {
      * @returns {MinuteChart}
      */
     static createFrom($$source = {}) {
-        const $$createField2_0 = $$createType10;
+        const $$createField2_0 = $$createType11;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("points" in $$parsedSource) {
             $$parsedSource["points"] = $$createField2_0($$parsedSource["points"]);
@@ -698,6 +710,94 @@ export class MinutePoint {
     }
 }
 
+/**
+ * MouseAlertConfig 系统级鼠标提醒气泡的显示配置
+ */
+export class MouseAlertConfig {
+    /**
+     * Creates a new MouseAlertConfig instance.
+     * @param {Partial<MouseAlertConfig>} [$$source = {}] - The source object to create the MouseAlertConfig.
+     */
+    constructor($$source = {}) {
+        if (!("fontSize" in $$source)) {
+            /**
+             * 字号(px)
+             * @member
+             * @type {number}
+             */
+            this["fontSize"] = 0;
+        }
+        if (!("width" in $$source)) {
+            /**
+             * 气泡窗口宽度(px)
+             * @member
+             * @type {number}
+             */
+            this["width"] = 0;
+        }
+        if (!("speed" in $$source)) {
+            /**
+             * 超长文字滚动速度(px/s)
+             * @member
+             * @type {number}
+             */
+            this["speed"] = 0;
+        }
+        if (!("color" in $$source)) {
+            /**
+             * 文字颜色
+             * @member
+             * @type {string}
+             */
+            this["color"] = "";
+        }
+        if (!("background" in $$source)) {
+            /**
+             * 气泡背景色(COLORKEY 抠成透明,仅作底色参考)
+             * @member
+             * @type {string}
+             */
+            this["background"] = "";
+        }
+        if (!("duration" in $$source)) {
+            /**
+             * 显示时长(秒)
+             * @member
+             * @type {number}
+             */
+            this["duration"] = 0;
+        }
+        if (!("outlineColor" in $$source)) {
+            /**
+             * 文字描边颜色(白边/黑边,与文字形成反差)
+             * @member
+             * @type {string}
+             */
+            this["outlineColor"] = "";
+        }
+        if (!("outlineWidth" in $$source)) {
+            /**
+             * 文字描边宽度(px),0 表示关闭描边
+             * @member
+             * @type {number}
+             */
+            this["outlineWidth"] = 0;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new MouseAlertConfig instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {MouseAlertConfig}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new MouseAlertConfig(/** @type {Partial<MouseAlertConfig>} */($$parsedSource));
+    }
+}
+
 /**
  * NewsItem 一条新闻标题,供「新闻」页与新闻滚动条使用
  */
@@ -1201,8 +1301,8 @@ export class RepoFeed {
      * @returns {RepoFeed}
      */
     static createFrom($$source = {}) {
-        const $$createField7_0 = $$createType12;
-        const $$createField8_0 = $$createType14;
+        const $$createField7_0 = $$createType13;
+        const $$createField8_0 = $$createType15;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("newer" in $$parsedSource) {
             $$parsedSource["newer"] = $$createField7_0($$parsedSource["newer"]);
@@ -1664,9 +1764,10 @@ const $$createType5 = CustomTab.createFrom;
 const $$createType6 = $Create.Array($$createType5);
 const $$createType7 = TickerConfig.createFrom;
 const $$createType8 = PoemConfig.createFrom;
-const $$createType9 = MinutePoint.createFrom;
-const $$createType10 = $Create.Array($$createType9);
-const $$createType11 = ReleaseItem.createFrom;
-const $$createType12 = $Create.Array($$createType11);
-const $$createType13 = CommitItem.createFrom;
-const $$createType14 = $Create.Array($$createType13);
+const $$createType9 = MouseAlertConfig.createFrom;
+const $$createType10 = MinutePoint.createFrom;
+const $$createType11 = $Create.Array($$createType10);
+const $$createType12 = ReleaseItem.createFrom;
+const $$createType13 = $Create.Array($$createType12);
+const $$createType14 = CommitItem.createFrom;
+const $$createType15 = $Create.Array($$createType14);

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

@@ -36,6 +36,14 @@ export function BackupFile(path) {
     return $Call.ByID(122452644, path);
 }
 
+/**
+ * CancelShutdown 取消延时关机(工具页「取消」按钮调用)。
+ * @returns {$CancellablePromise<void>}
+ */
+export function CancelShutdown() {
+    return $Call.ByID(1266387008);
+}
+
 /**
  * CaptureScreen 截取屏幕并返回 PNG 数据 URI,供「主机」页预览。
  * display 为显示器索引,-1 表示全部屏幕。
@@ -378,6 +386,16 @@ export function ShowWindow() {
     return $Call.ByID(3712560909);
 }
 
+/**
+ * ShutdownDelay 在指定分钟后执行关机。
+ * minutes 必须 > 0;返回计划执行时间(HH:MM:SS)与提示信息。
+ * @param {number} minutes
+ * @returns {$CancellablePromise<string>}
+ */
+export function ShutdownDelay(minutes) {
+    return $Call.ByID(3801676825, minutes);
+}
+
 /**
  * StartAPIService 启动 / 重启内置 API 服务(服务页手动启动)
  * @returns {$CancellablePromise<void>}

+ 34 - 0
frontend/index.html

@@ -293,6 +293,23 @@
                         </div>
                     </div>
                 </div>
+
+                <!-- 工具 8:延时关机——输入多少分钟后关机,交给系统倒计时 -->
+                <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="sd-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">输入多少分钟后关机,确认后交给系统倒计时(即使本程序退出仍会执行),也可随时取消。</p>
+                        <div class="tool-row">
+                            <input class="input" id="sd-minutes" type="number" min="1" max="1440" step="1" placeholder="分钟数" style="width:120px">
+                            <button class="btn-primary" id="sd-go">延时关机</button>
+                            <button class="btn-ghost" id="sd-cancel">取消</button>
+                            <span class="tool-hint" id="sd-hint"></span>
+                        </div>
+                    </div>
+                </div>
             </div>
             <div class="tab-panel" id="tab-host">
                 <div class="host-cards">
@@ -625,6 +642,23 @@
                             <label class="mini-label" title="显示朗读按钮"><input type="checkbox" id="poem-tts">朗读</label>
                         </div>
                     </div>
+
+                    <!-- 鼠标提醒(系统级气泡) -->
+                    <div class="setting-row" data-tab-group="host">
+                        <div class="setting-info">
+                            <div class="setting-title">鼠标提醒</div>
+                            <div class="setting-hint">触发提醒时在鼠标旁浮出透明气泡文字,可调字号、宽度、滚动速度、颜色与描边(描边形成反差,避免背景看不清)</div>
+                        </div>
+                        <div class="ticker-controls">
+                            <input class="input input-num" id="mouse-alert-size" type="number" min="10" max="48" title="字号 px">
+                            <input class="input input-num" id="mouse-alert-width" type="number" min="120" max="1200" title="窗口宽度 px">
+                            <input class="input input-num" id="mouse-alert-speed" type="number" min="5" max="300" title="滚动速度 px/s">
+                            <input class="input-color" id="mouse-alert-color" type="color" title="文字颜色">
+                            <input class="input input-num" id="mouse-alert-outline-width" type="number" min="0" max="6" title="文字描边宽度 px(0 关闭)">
+                            <input class="input-color" id="mouse-alert-outline-color" type="color" title="文字描边颜色(与文字形成反差)">
+                            <input class="input input-num" id="mouse-alert-duration" type="number" min="2" max="60" title="显示时长(秒)">
+                        </div>
+                    </div>
                 </div>
 
                 <!-- 保存栏 -->

+ 1 - 0
frontend/public/style.css

@@ -1073,6 +1073,7 @@ body::before {
     -webkit-backdrop-filter: blur(10px);
     color: var(--muted);
     font-size: calc(14px + var(--font-delta));
+    --wails-draggable: drag;                /* footer 区域允许拖动窗口 */
 }
 
 /* footer 说明 / 古诗滚动区:版本后面,时钟之前 */

+ 93 - 0
frontend/src/main.js

@@ -33,6 +33,7 @@ const FALLBACK_SETTINGS = {
     stockTicker: {enabled: false, width: 1400, fontSize: 14, color: '#ff4d4d', speed: 80, transparent: true, x: -1, y: -1},
     newsTicker: {enabled: false, width: 1400, fontSize: 14, color: '#3ddc84', speed: 60, transparent: true, x: -1, y: -1},
     poem: {enabled: false, color: '#c98a3a', fontSize: 16, tts: false, speed: 18},
+    mouseAlert: {fontSize: 20, width: 200, speed: 33, color: '#ffffff', outlineColor: '#000000', outlineWidth: 2, duration: 8},
 };
 
 // HTML 转义:外部来源的新闻 / 提交标题插入页面前先转义,防止注入
@@ -490,6 +491,17 @@ const poemInputs = {
     speed: document.getElementById('poem-speed'),
 };
 
+// 鼠标提醒配置控件(字号 / 窗口宽度 / 滚动速度 / 文字颜色 / 显示时长)
+const mouseAlertInputs = {
+    size: document.getElementById('mouse-alert-size'),
+    width: document.getElementById('mouse-alert-width'),
+    speed: document.getElementById('mouse-alert-speed'),
+    color: document.getElementById('mouse-alert-color'),
+    outlineWidth: document.getElementById('mouse-alert-outline-width'),
+    outlineColor: document.getElementById('mouse-alert-outline-color'),
+    duration: document.getElementById('mouse-alert-duration'),
+};
+
 // 股市 / 新闻 / Github 刷新间隔与涨跌色控件
 const stockUpColorInput = document.getElementById('stock-up-color');
 const stockDownColorInput = document.getElementById('stock-down-color');
@@ -552,6 +564,7 @@ async function syncForm(settings) {
     syncTickerForm('stock', settings.stockTicker);
     syncTickerForm('news', settings.newsTicker);
     syncPoemForm(settings.poem);
+    syncMouseAlertForm(settings.mouseAlert);
     applyTheme(settings.theme, false);
     applyFontDelta(settings.fontDelta ?? FALLBACK_SETTINGS.fontDelta); // 应用全局字号偏移(默认 +2)
     applyTabOrder(settings.tabOrder); // 按设置重排 Tab 顺序
@@ -605,6 +618,18 @@ function syncPoemForm(cfg) {
     poemInputs.speed.value = cfg.speed || 18;
 }
 
+// 鼠标提醒配置回填到对应控件
+function syncMouseAlertForm(cfg) {
+    cfg = cfg || {};
+    mouseAlertInputs.size.value = cfg.fontSize || 20;
+    mouseAlertInputs.width.value = cfg.width || 200;
+    mouseAlertInputs.speed.value = cfg.speed || 33;
+    mouseAlertInputs.color.value = cfg.color || '#ffffff';
+    mouseAlertInputs.outlineWidth.value = cfg.outlineWidth ?? 2;
+    mouseAlertInputs.outlineColor.value = cfg.outlineColor || '#000000';
+    mouseAlertInputs.duration.value = cfg.duration || 8;
+}
+
 // ------------------------------------------------------------------------
 // 热键录入:点击输入框后按下组合键
 // ------------------------------------------------------------------------
@@ -736,6 +761,7 @@ function collectForm() {
         stockTicker: collectTicker('stock'),
         newsTicker: collectTicker('news'),
         poem: collectPoem(),
+        mouseAlert: collectMouseAlert(),
     };
 }
 
@@ -778,6 +804,30 @@ function collectPoem() {
     };
 }
 
+// 从控件收集鼠标提醒配置(字号 / 窗口宽度 / 滚动速度 / 文字颜色 / 显示时长)
+function collectMouseAlert() {
+    const prev = currentSettings.mouseAlert || {};
+    // 颜色:color input 若为浏览器默认黑(#000000),回退到当前生效色或默认色,避免覆盖
+    const colorVal = mouseAlertInputs.color.value.trim().toLowerCase();
+    const prevColor = (prev.color || '').toLowerCase();
+    const color = (colorVal && colorVal !== '#000000') ? colorVal
+        : (prevColor && prevColor !== '#000000') ? prevColor : '#ffffff';
+    const prevOutlineColor = (prev.outlineColor || '').toLowerCase();
+    const outlineColorVal = mouseAlertInputs.outlineColor.value.trim().toLowerCase();
+    const outlineColor = (outlineColorVal && outlineColorVal !== '#000000') ? outlineColorVal
+        : (prevOutlineColor && prevOutlineColor !== '#000000') ? prevOutlineColor : '#000000';
+    const outlineWidth = parseInt(mouseAlertInputs.outlineWidth.value, 10);
+    return {
+        fontSize: parseInt(mouseAlertInputs.size.value, 10) || 20,
+        width: parseInt(mouseAlertInputs.width.value, 10) || 200,
+        speed: parseInt(mouseAlertInputs.speed.value, 10) || 33,
+        color,
+        outlineColor,
+        outlineWidth: (outlineWidth >= 0 && outlineWidth <= 6) ? outlineWidth : 2,
+        duration: parseInt(mouseAlertInputs.duration.value, 10) || 8,
+    };
+}
+
 // ------------------------------------------------------------------------
 // footer 说明区:启动时在版本后面水平滚动显示一首古诗;
 // 点击任意 Tab 后该区显示当前 Tab 的说明文字;古诗不自动刷新(手动点击换一首)
@@ -2067,6 +2117,49 @@ async function captureScreenshot() {
 
 if (screenshotCaptureBtn) screenshotCaptureBtn.addEventListener('click', captureScreenshot); // 点击截屏
 
+// ------------------------------------------------------------------------
+// 工具:延时关机——输入多少分钟后关机,交给系统倒计时;可取消
+// ------------------------------------------------------------------------
+const sdMinutesInput = document.getElementById('sd-minutes'); // 分钟输入框
+const sdGoBtn = document.getElementById('sd-go');             // 延时关机按钮
+const sdCancelBtn = document.getElementById('sd-cancel');     // 取消按钮
+const sdHintEl = document.getElementById('sd-hint');          // 提示文字
+const sdStatusEl = document.getElementById('sd-status');      // 卡片状态徽标
+
+// 延时关机:校验分钟数,调用后端下发系统关机倒计时
+async function shutdownDelay() {
+    const mins = parseInt(sdMinutesInput.value, 10);
+    if (!Number.isFinite(mins) || mins <= 0) {
+        sdHintEl.textContent = '请输入大于 0 的分钟数';
+        return;
+    }
+    sdHintEl.textContent = '正在下发…';
+    try {
+        const execAt = await SettingsService.ShutdownDelay(mins);
+        sdHintEl.textContent = `已设定 ${mins} 分钟后关机(${execAt})`;
+        sdStatusEl.textContent = `已设定 ${mins} 分钟`;
+    } catch (err) {
+        sdHintEl.textContent = '设置失败:' + (err?.message || err);
+    }
+}
+
+// 取消延时关机
+async function cancelShutdown() {
+    sdHintEl.textContent = '正在取消…';
+    try {
+        await SettingsService.CancelShutdown();
+        sdHintEl.textContent = '已取消关机';
+        sdStatusEl.textContent = '';
+    } catch (err) {
+        sdHintEl.textContent = '取消失败:' + (err?.message || err);
+    }
+}
+
+if (sdGoBtn) sdGoBtn.addEventListener('click', shutdownDelay);
+if (sdCancelBtn) sdCancelBtn.addEventListener('click', cancelShutdown);
+if (sdMinutesInput) sdMinutesInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') shutdownDelay(); }); // 回车触发
+
+
 async function loadHostPanel() {
     try {
         const status = await SettingsService.GetAPIStatus();

+ 3 - 3
frontend/src/ticker.js

@@ -7,7 +7,6 @@ WML.Enable(); // 绑定 data-wml-openURL 链接的打开行为(动态内容渲
 const kind = new URLSearchParams(location.search).get('kind') || 'stock';
 
 const track = document.getElementById('track'); // 滚动轨道容器
-const actions = document.getElementById('actions'); // 右侧控制按钮容器
 const btnRead = document.getElementById('btn-read'); // 阅读(TTS)按钮
 const btnClose = document.getElementById('btn-close'); // 关闭滚动条按钮
 
@@ -149,11 +148,12 @@ async function closeTicker() {
     }
 }
 
-// 按钮仅在新闻条显示(股票条不提供 TTS 阅读)
+// 按钮:新闻条显示「读」(TTS 朗读)与「关闭」;股票条只显示「关闭」,
+// 不隐藏整个 actions 区,保证滚动文字右侧始终有关闭按钮可点。
 if (kind === 'news') {
     btnRead.addEventListener('click', readTickerNews);
 } else {
-    actions.style.display = 'none'; // 股票条隐藏右侧按钮区
+    btnRead.style.display = 'none'; // 股票条隐藏「读」,保留「关闭」
 }
 btnClose.addEventListener('click', closeTicker);
 

+ 462 - 357
mousealert_windows.go

@@ -1,24 +1,34 @@
 //go:build windows
 
 // ============================================================================
-// 系统级鼠标提醒(Windows 原生实现)
+// 系统级鼠标提醒(Windows 原生实现)—— 可靠方案 v2
 //
-// 需求:平时鼠标是正常样式;有消息来(通过 API 触发)时,鼠标图标闪烁,
-// 且光标下方浮出简短的半透明提示文字,可覆盖任意窗口(系统级)。
+// 需求:平时鼠标是正常样式;有消息来(通过 API 触发)时,鼠标旁边浮出
+// 半透明气泡提示文字,跟随鼠标移动、闪烁,可覆盖任意窗口(系统级),
+// 默认 8 秒自动消退。
 //
-// 实现原理:
-//   1. 全局低层鼠标钩子 WH_MOUSE_LL:捕获系统级鼠标移动,获取屏幕坐标。
-//   2. 一个置顶、透明、点击穿透的覆盖层窗口(WS_EX_LAYERED|WS_EX_TRANSPARENT
-//      |WS_EX_TOOLWINDOW|WS_EX_NOACTIVATE),用 GDI UpdateLayeredWindow 在鼠标
-//      位置绘制闪烁光标环与下方半透明文字。
-//   3. 定时器驱动闪烁:普通光标样式 <-> 提醒光标样式交替。
-//   4. 提醒可自动消退(默认 8 秒),也可再调 API 停止。
+// v1 的失败原因:全局低层鼠标钩子 WH_MOUSE_LL 的回调由系统在非 Go 线程
+// 上调用,经 syscall.NewCallback 进入 Go runtime 时会导致进程静默退出;
+// 且 DIB 自绘 + UpdateLayeredWindow 尺寸不匹配时绘制失效。v2 全部移除:
+//
+//  1. 不装任何钩子(去掉唯一危险源),位置跟随改为窗口线程定时器轮询
+//     GetCursorPos(GetMessage 消息循环所在线程,安全)。
+//  2. 用 SetLayeredWindowAttributes(LWA_COLORKEY) 把背景色抠成透明(背景透明,
+//     只留白色文字),WM_PAINT 用普通 GDI(FillRect + TextOutW)绘制气泡,
+//     不再用 DIB/UpdateLayeredWindow。
+//  3. 窗口过程 WndProc 的 syscall.NewCallback 由 DispatchMessageW 在同线程
+//     同步调用,Go runtime 完全支持(安全)。
+//  4. 超长文字:TextOutW 单行绘制 + 定时器推进 scrollX 偏移,实现水平循环滚动。
 //
 // 说明:本文件仅在 Windows 编译。其他平台见 mousealert_other.go 空实现。
 // ============================================================================
 package main
 
 import (
+	"fmt"
+	"os"
+	"runtime"
+	"strings"
 	"sync"
 	"syscall"
 	"time"
@@ -27,14 +37,13 @@ import (
 
 // Windows API 常量
 const (
-	whMouseLL = 14 // WH_MOUSE_LL 全局低层鼠标钩子
-
 	wmTimer     = 0x0113
 	wmDestroy   = 0x0002
 	wmNcDestroy = 0x0082
+	wmPaint     = 0x000F
+	wmRefresh   = 0x8001 // 自定义:立即刷新(trigger 唤醒窗口线程)
 
 	wndExLayered   = 0x00080000 // WS_EX_LAYERED 分层窗口(支持透明)
-	wndExTrans     = 0x00000020 // WS_EX_TRANSPARENT 点击穿透
 	wndExTool      = 0x00000080 // WS_EX_TOOLWINDOW 不占任务栏
 	wndExTopMost   = 0x00000008 // WS_EX_TOPMOST 置顶
 	wndExNoActivate = 0x08000000 // WS_EX_NOACTIVATE 不抢焦点
@@ -42,27 +51,68 @@ const (
 	swpNoActivate = 0x0010 // SWP_NOACTIVATE
 	swpShowWindow = 0x0040 // SWP_SHOWWINDOW
 
-	biRgb = 0 // BI_RGB
+	lwaAlpha   = 0x2 // LWA_ALPHA:SetLayeredWindowAttributes 设置整体 alpha
+	lwaColorKey = 0x1 // LWA_COLORKEY:把指定颜色像素抠成透明(背景透明)
 
-	timerFlash = 1 // 闪烁定时器 ID
+	timerFollow = 1 // 位置跟随 + 闪烁定时器 ID
 
-	overlayWidth  = 220
-	overlayHeight = 90
-	textTopY      = 26 // 文字起始 Y 坐标(覆盖层内),用于区分半透明文字区
+	overlayWidth  = 200
+	overlayHeight = 54
 
 	alertDuration   = 8 * time.Second
-	flashIntervalMs = 250 // 闪烁周期 250ms
-)
-
-// 光标提示样式:普通状态 vs 提醒状态(闪烁交替)
-const (
-	flashColorNormal = 0x00FFFFFF // 普通:白
-	flashColorAlert  = 0x00FF5050 // 提醒:红
+	followIntervalMs = 60 // 跟随/闪烁轮询间隔 60ms
 )
 
 // hwndTopMost HWND_TOPMOST = (HWND)-1
 const hwndTopMost uintptr = ^uintptr(0)
 
+// 气泡背景色(深蓝黑):COLORREF = 0x00BBGGRR(LWA_COLORKEY 抠成透明,只留文字)
+const bgColorRef = 0x001E1E3C
+
+// cfgDurationOrDefault 返回配置的显示时长(秒),非法时用默认 8 秒
+func cfgDurationOrDefault(sec int) time.Duration {
+	if sec < 2 || sec > 60 {
+		sec = 8
+	}
+	return time.Duration(sec) * time.Second
+}
+
+// estimateTextWidth 估算文字像素宽度(微软雅黑:全角/中文≈字号,半角/ASCII≈0.55*字号)。
+// 用于在触发时预判文字是否超长,从而智能延长显示时长。
+func estimateTextWidth(text string, fontSize int32) int32 {
+	if text == "" || fontSize <= 0 {
+		return 0
+	}
+	var w float32
+	for _, r := range text {
+		if r < 0x2E80 { // 半角 / ASCII / 拉丁
+			w += 0.55 * float32(fontSize)
+		} else { // 全角 / 中日韩
+			w += float32(fontSize)
+		}
+	}
+	return int32(w)
+}
+
+// hexColorToRef 把 #RRGGBB 或 #RGB 颜色字符串转 COLORREF(0x00BBGGRR)。
+// 解析失败时回退 fallback。
+func hexColorToRef(s string, fallback uintptr) 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 fallback
+}
+
 // ------------------------------------------------------------------------
 // Windows 结构体与函数指针声明(用 syscall 直接调用,避免引入额外依赖)
 // ------------------------------------------------------------------------
@@ -72,7 +122,7 @@ type point struct {
 	Y int32
 }
 
-// rect Windows RECT 结构(GetClientRect 输出)
+// rect Windows RECT 结构(GetWindowRect / FillRect 输出)
 type rect struct {
 	Left   int32
 	Top    int32
@@ -90,14 +140,6 @@ type msg struct {
 	LPrivate uint32
 }
 
-type msllhookstruct struct {
-	Pt         point
-	MouseData  uint32
-	Flags      uint32
-	Time       uint32
-	DWExtraInfo uintptr
-}
-
 type wndclassex struct {
 	CbSize        uint32
 	Style         uint32
@@ -113,25 +155,20 @@ type wndclassex struct {
 	HIconSm       uintptr
 }
 
-type bitmapinfoheader struct {
-	BiSize          uint32
-	BiWidth         int32
-	BiHeight        int32
-	BiPlanes        uint16
-	BiBitCount      uint16
-	BiCompression   uint32
-	BiSizeImage     uint32
-	BiXPelsPerMeter int32
-	BiYPelsPerMeter int32
-	BiClrUsed       uint32
-	BiClrImportant  uint32
+// paintstruct PAINTSTRUCT(BeginPaint / EndPaint 输出)
+type paintstruct struct {
+	Hdc         uintptr
+	FErase      int32
+	RcPaint     rect
+	FRestore    int32
+	FIncUpdate  int32
+	RGBReserved [32]byte
 }
 
-type blendfunction struct {
-	BlendOp             byte
-	BlendFlags          byte
-	SourceConstantAlpha byte
-	AlphaFormat         byte
+// size SIZE 结构(GetTextExtentPoint32W 输出:文字像素尺寸)
+type size struct {
+	Cx int32
+	Cy int32
 }
 
 // 延迟加载的 user32.dll / gdi32.dll / kernel32.dll 函数
@@ -140,8 +177,6 @@ var (
 	gdi32    = syscall.NewLazyDLL("gdi32.dll")
 	kernel32 = syscall.NewLazyDLL("kernel32.dll")
 
-	procSetWindowsHookEx    = user32.NewProc("SetWindowsHookExW")
-	procUnhookWindowsHook   = user32.NewProc("UnhookWindowsHookEx")
 	procGetMessage          = user32.NewProc("GetMessageW")
 	procTranslateMessage    = user32.NewProc("TranslateMessage")
 	procDispatchMessage     = user32.NewProc("DispatchMessageW")
@@ -153,25 +188,28 @@ var (
 	procKillTimer           = user32.NewProc("KillTimer")
 	procShowWindow          = user32.NewProc("ShowWindow")
 	procSetWindowPos        = user32.NewProc("SetWindowPos")
-	procUpdateLayeredWindow = user32.NewProc("UpdateLayeredWindow")
-	procGetDC               = user32.NewProc("GetDC")
-	procReleaseDC           = user32.NewProc("ReleaseDC")
 	procGetCursorPos        = user32.NewProc("GetCursorPos")
 	procPostMessage         = user32.NewProc("PostMessageW")
-	procGetClientRect       = user32.NewProc("GetClientRect")
-	procGetWindowRect       = user32.NewProc("GetWindowRect")
-
-	procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC")
-	procDeleteDC           = gdi32.NewProc("DeleteDC")
-	procCreateDIBSection   = gdi32.NewProc("CreateDIBSection")
-	procSelectObject       = gdi32.NewProc("SelectObject")
-	procDeleteObject       = gdi32.NewProc("DeleteObject")
-	procSetBkMode          = gdi32.NewProc("SetBkMode")
-	procSetTextColor       = gdi32.NewProc("SetTextColor")
-	procSetTextAlign       = gdi32.NewProc("SetTextAlign")
-	procTextOutW           = gdi32.NewProc("TextOutW")
-	procCreateFontW        = gdi32.NewProc("CreateFontW")
-	procSetPixel           = gdi32.NewProc("SetPixel")
+	procSetLayeredWindowAttr = user32.NewProc("SetLayeredWindowAttributes")
+	procBeginPaint          = user32.NewProc("BeginPaint")
+	procEndPaint            = user32.NewProc("EndPaint")
+	procInvalidateRect      = user32.NewProc("InvalidateRect")
+	procFillRect            = user32.NewProc("FillRect") // 注意:FillRect 在 user32.dll,不在 gdi32.dll
+	procDrawTextW           = user32.NewProc("DrawTextW")
+
+	procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush")
+	procDeleteObject     = gdi32.NewProc("DeleteObject")
+	procSetBkMode        = gdi32.NewProc("SetBkMode")
+	procSetTextColor     = gdi32.NewProc("SetTextColor")
+	procGetStockObject   = gdi32.NewProc("GetStockObject")
+	procSelectObject     = gdi32.NewProc("SelectObject")
+	procCreateFontW      = gdi32.NewProc("CreateFontW")
+	procTextOutW         = gdi32.NewProc("TextOutW")                            // 单行文字输出(超长滚动用)
+	procGetTextExtentPoint32W = gdi32.NewProc("GetTextExtentPoint32W")          // 测量文字宽度(判断是否超长)
+	procCreateCompatibleDC     = gdi32.NewProc("CreateCompatibleDC")            // 双缓冲:创建内存 DC
+	procCreateCompatibleBitmap = gdi32.NewProc("CreateCompatibleBitmap")        // 双缓冲:创建兼容位图
+	procBitBlt                 = gdi32.NewProc("BitBlt")                        // 双缓冲:整块拷贝上屏
+	procDeleteDC               = gdi32.NewProc("DeleteDC")                      // 双缓冲:删除内存 DC
 
 	procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW")
 )
@@ -184,42 +222,93 @@ var (
 type mouseAlertState struct {
 	mu sync.Mutex
 
-	started     bool   // 工作线程是否已启动(持久化,仅启动一次)
-	hook        uintptr
-	overlayWnd  uintptr
-	active      bool
-	text        string
-	flashOn     bool
+	started    bool   // 工作线程是否已启动(持久化,仅启动一次)
+	overlayWnd uintptr
+	active     bool
+	text       string
+	flashOn    bool
 	lastX, lastY int32
-	stopTime    time.Time
-	hookProc    uintptr
-	wndProc     uintptr
-	classOK     bool
+	stopTime   time.Time
+	wndProc    uintptr
+	classOK    bool
+	bgBrush    uintptr
+	scrollX    int32  // 超长文字水平滚动偏移(px),定时器推进
+	textWidth  int32  // 当前文字像素宽度(>可用宽度时滚动)
+	textGap    int32  // 头尾相联间隔(两个空格宽度 px),滚动周期 = 文字宽 + 间隔
+
+	// 运行时配置(triggerMouseAlert 时从设置加载)
+	cfgWidth     int32         // 气泡窗口宽度(px)
+	cfgHeight    int32         // 气泡窗口高度(px,按字号自适应)
+	cfgFontSize  int32         // 字号(px)
+	cfgSpeed     int           // 超长文字滚动速度(px/s)
+	cfgDuration  time.Duration // 显示时长
+	cfgTextColor uintptr       // 文字颜色(COLORREF)
+	cfgBgColor   uintptr       // 气泡背景色(COLORKEY 抠透明)
+	cfgOutlineColor uintptr    // 文字描边颜色(COLORREF),形成反差
+	cfgOutlineWidth int32      // 文字描边宽度(px),0 关闭
 }
 
 var mouseAlert = &mouseAlertState{}
 
-// triggerMouseAlert 触发鼠标提醒:显示闪烁光标 + 光标下方透明文字。
+// triggerMouseAlert 触发鼠标提醒:鼠标旁浮出半透明气泡提示,跟随鼠标闪烁。
 // 文字为空时用默认提示。重复触发会刷新提醒计时并立即显示。
 //
-// 定位策略:主动用 GetCursorPos 读取当前鼠标位置并记录为 lastX/lastY,
-// 再通过 PostMessage 唤醒窗口线程立即重绘,避免依赖全局钩子回调(钩子
-// 回调因负担重可能被系统卸载,导致窗口停留在旧位置不跟随鼠标)。
+// 定位策略:窗口线程的定时器主动用 GetCursorPos 读取当前鼠标位置并跟随,
+// 不依赖任何全局钩子回调(钩子回调在系统线程执行,曾导致进程退出)。
 func triggerMouseAlert(text string) {
 	if text == "" {
 		text = "有新消息提醒"
 	}
 
-	// 读取当前鼠标位置,供窗口重绘时定位(不依赖钩子回调)
+	// 从设置加载鼠标提醒配置(字号/宽度/速度/颜色/时长)
+	cfg := loadSettings().MouseAlert
+
 	var pt point
 	curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
 	hasCursor := curRet != 0
 
+	// 智能延长显示时长:文字超长(需滚动)时,追加滚动一圈所需时间,保证能看完
+	cfgWidth := int32(cfg.Width)
+	if cfgWidth <= 0 {
+		cfgWidth = overlayWidth
+	}
+	cfgFontSize := int32(cfg.FontSize)
+	if cfgFontSize <= 0 {
+		cfgFontSize = DefaultMouseAlertFontSize
+	}
+	estW := estimateTextWidth(text, cfgFontSize)
+	baseDur := cfgDurationOrDefault(cfg.Duration)
+	totalDur := baseDur
+	if estW > cfgWidth-24 && cfg.Speed > 0 {
+		// 超出可用宽度的部分需滚动,所需时间 = 超出像素 / 速度
+		extra := estW - (cfgWidth - 24)
+		need := time.Duration(float64(extra) / float64(cfg.Speed) * float64(time.Second))
+		totalDur = baseDur + need
+	}
+
 	mouseAlert.mu.Lock()
 	mouseAlert.text = text
 	mouseAlert.active = true
 	mouseAlert.flashOn = true
-	mouseAlert.stopTime = time.Now().Add(alertDuration)
+	mouseAlert.stopTime = time.Now().Add(totalDur)
+	mouseAlert.scrollX = 0   // 新消息从头开始滚动
+	mouseAlert.textWidth = 0 // 强制重新测量
+	// 缓存本次提醒的显示配置
+	mouseAlert.cfgWidth = cfgWidth
+	mouseAlert.cfgFontSize = cfgFontSize
+	mouseAlert.cfgSpeed = cfg.Speed
+	mouseAlert.cfgDuration = totalDur
+	mouseAlert.cfgTextColor = hexColorToRef(cfg.Color, 0x00FFFFFF) // 默认白字
+	mouseAlert.cfgBgColor = bgColorRef
+	mouseAlert.cfgOutlineColor = hexColorToRef(cfg.OutlineColor, 0x00000000) // 默认黑边
+	mouseAlert.cfgOutlineWidth = int32(cfg.OutlineWidth)
+	// 头尾相联间隔 = 两个空格宽度(约 0.3*字号 每个),提前缓存供滚动周期使用
+	mouseAlert.textGap = int32(float32(cfgFontSize) * 0.3 * 2)
+	if mouseAlert.textGap < 4 {
+		mouseAlert.textGap = 4
+	}
+	// 气泡高度按字号自适应:字号 + 上下留白 + 内边距
+	mouseAlert.cfgHeight = mouseAlert.cfgFontSize + 34
 	if hasCursor {
 		mouseAlert.lastX, mouseAlert.lastY = pt.X, pt.Y
 	}
@@ -233,15 +322,13 @@ func triggerMouseAlert(text string) {
 		go mouseAlertLoop()
 		return
 	}
-	// 已启动:唤醒窗口线程立即重绘(窗口线程负责 SetWindowPos/绘制,
-	// 避免跨线程操作窗口;若窗口尚在初始化,线程建窗后也会主动绘制)
+	// 已启动:唤醒窗口线程立即刷新(窗口线程负责 SetWindowPos/绘制)
 	if overlay != 0 {
-		procShowWindow.Call(overlay, 5) // SW_SHOW
-		procPostMessage.Call(overlay, wmTimer, timerFlash, 0)
+		procPostMessage.Call(overlay, wmRefresh, 0, 0)
 	}
 }
 
-// stopMouseAlert 立即停止鼠标提醒(隐藏覆盖层;钩子保持常驻)
+// stopMouseAlert 立即停止鼠标提醒(隐藏气泡;线程保持常驻)
 func stopMouseAlert() {
 	mouseAlert.mu.Lock()
 	mouseAlert.active = false
@@ -252,13 +339,20 @@ func stopMouseAlert() {
 	}
 }
 
-// mouseAlertLoop 常驻工作线程:注册窗口类、安装全局钩子、创建覆盖层窗口,
-// 进入消息循环。低层钩子必须有消息循环,故全程运行在独立线程。
+// mouseAlertLoop 常驻工作线程:注册窗口类、创建半透明置顶气泡窗口、
+// 启动跟随/闪烁定时器,进入消息循环。
 func mouseAlertLoop() {
+	// 关键:Go 的 goroutine 会在 OS 线程间迁移。窗口消息循环(CreateWindowEx +
+	// GetMessage + DispatchMessage)必须固定在同一个 OS 线程上,否则 WM_TIMER /
+	// WM_PAINT 等消息投递到创建窗口的线程,而 GetMessage 在另一线程永远收不到,
+	// 导致窗口出现后卡死(不跟随鼠标、不重绘、无文字)。LockOSThread 锁定本
+	// goroutine 到当前 OS 线程,保证消息循环与窗口同线程。
+	runtime.LockOSThread()
+
 	hInst, _, _ := procGetModuleHandleW.Call(0)
 
 	mouseAlert.mu.Lock()
-	className, _ := syscall.UTF16PtrFromString("TengTuMouseAlertOverlay")
+	className, _ := syscall.UTF16PtrFromString("TengTuMouseAlertBubble")
 	wc := wndclassex{
 		CbSize:        uint32(unsafe.Sizeof(wndclassex{})),
 		LpfnWndProc:   mouseAlert.wndProcAddr(),
@@ -269,47 +363,65 @@ func mouseAlertLoop() {
 	atom, _, _ := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc)))
 	if atom != 0 {
 		mouseAlert.classOK = true
+	} else {
+		fmt.Fprintf(os.Stderr, "[mousealert] RegisterClassEx failed (classOK=%v)\n", mouseAlert.classOK)
 	}
 	mouseAlert.mu.Unlock()
 
-	// 安装全局低层鼠标钩子
-	hookProc := mouseAlert.hookProcAddr()
-	hHook, _, _ := procSetWindowsHookEx.Call(whMouseLL, hookProc, 0, 0)
-	if hHook != 0 {
-		mouseAlert.mu.Lock()
-		mouseAlert.hook = hHook
-		mouseAlert.mu.Unlock()
+	// 创建半透明置顶气泡窗口(不抢焦点、不占任务栏)
+	// dwStyle 必须含 WS_POPUP(0x80000000):无边框无标题的弹出窗口,
+	// 否则 CreateWindowEx 可能失败或窗口不可见。
+	style := uintptr(wndExLayered | wndExTool | wndExTopMost | wndExNoActivate)
+	mouseAlert.mu.Lock()
+	w, h := mouseAlert.cfgWidth, mouseAlert.cfgHeight
+	if w <= 0 {
+		w = overlayWidth // 首次触发配置尚未缓存时用默认
 	}
-
-	// 创建覆盖层窗口(初始 220x90,位置随后由钩子/定时器设置)
-	style := uintptr(wndExLayered | wndExTrans | wndExTool | wndExTopMost | wndExNoActivate)
+	if h <= 0 {
+		h = overlayHeight
+	}
+	mouseAlert.mu.Unlock()
 	overlay, _, _ := procCreateWindowEx.Call(
 		style,
 		uintptr(unsafe.Pointer(className)),
 		0,
-		0, 0, overlayWidth, overlayHeight, 0,
+		uintptr(0x80000000) /* WS_POPUP */, 0, 0, uintptr(w), uintptr(h), 0,
 		0, 0, hInst, 0,
 	)
+	if overlay == 0 {
+		fmt.Fprintf(os.Stderr, "[mousealert] CreateWindowEx failed\n")
+	}
 	mouseAlert.mu.Lock()
 	mouseAlert.overlayWnd = overlay
+	if overlay != 0 {
+		// 背景透明(LWA_COLORKEY):把背景色 bgColorRef 的像素抠成透明,只留白色
+		// 文字。WM_PAINT 用普通 GDI 绘制气泡(背景+文字)。
+		// 注意:不要用 UpdateLayeredWindow——它会接管分层窗口内容,导致
+		// WM_PAINT 完全失效(这正是当前「完全不显示」的根因)。
+		bg := mouseAlert.cfgBgColor
+		if bg == 0 {
+			bg = bgColorRef
+		}
+		brush, _, _ := procCreateSolidBrush.Call(bg)
+		mouseAlert.bgBrush = brush
+		// LWA_COLORKEY:crKey=背景色,bAlpha=0(忽略),dwFlags=LWA_COLORKEY
+		procSetLayeredWindowAttr.Call(overlay, bg, 0, uintptr(lwaColorKey))
+	}
 	mouseAlert.mu.Unlock()
 
-	// 启动闪烁定时器
+	// 启动跟随/闪烁定时器(窗口线程内轮询鼠标位置)
 	if overlay != 0 {
-		procSetTimer.Call(overlay, timerFlash, flashIntervalMs, 0)
+		procSetTimer.Call(overlay, timerFollow, followIntervalMs, 0)
 
-		// 建窗后立即用当前鼠标位置定位并重绘一次,保证首次触发即可见,
-		// 不依赖全局钩子回调(钩子回调在系统线程执行,仅做转发)。
+		// 建窗后立即用当前鼠标位置定位并重绘一次,保证首次触发即可见
 		var pt point
 		curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
 		if curRet != 0 {
 			mouseAlert.mu.Lock()
 			if mouseAlert.active {
 				mouseAlert.lastX, mouseAlert.lastY = pt.X, pt.Y
-				text := mouseAlert.text
-				flashOn := mouseAlert.flashOn
 				mouseAlert.mu.Unlock()
-				mouseAlert.renderOverlay(overlay, text, flashOn, pt.X, pt.Y)
+				mouseAlert.positionAndDraw(overlay, pt.X, pt.Y)
 			} else {
 				mouseAlert.mu.Unlock()
 			}
@@ -329,27 +441,19 @@ func mouseAlertLoop() {
 
 	// 清理
 	if overlay != 0 {
+		procKillTimer.Call(overlay, timerFollow)
 		procDestroyWindow.Call(overlay)
 	}
-	if hHook != 0 {
-		procUnhookWindowsHook.Call(hHook)
-	}
 	mouseAlert.mu.Lock()
-	mouseAlert.hook = 0
+	if mouseAlert.bgBrush != 0 {
+		procDeleteObject.Call(mouseAlert.bgBrush)
+		mouseAlert.bgBrush = 0
+	}
 	mouseAlert.overlayWnd = 0
 	mouseAlert.mu.Unlock()
 }
 
-// hookProcAddr 返回全局钩子回调地址(保持 Go 函数引用)
-func (s *mouseAlertState) hookProcAddr() uintptr {
-	if s.hookProc != 0 {
-		return s.hookProc
-	}
-	s.hookProc = syscall.NewCallback(s.hookCallback)
-	return s.hookProc
-}
-
-// wndProcAddr 返回覆盖层窗口过程地址
+// wndProcAddr 返回覆盖层窗口过程地址(保持 Go 函数引用,防 GC)
 func (s *mouseAlertState) wndProcAddr() uintptr {
 	if s.wndProc != 0 {
 		return s.wndProc
@@ -358,269 +462,270 @@ func (s *mouseAlertState) wndProcAddr() uintptr {
 	return s.wndProc
 }
 
-// hookCallback 全局低层鼠标钩子回调:仅转发给下一钩子,不访问任何 Go 状态。
-//
-// 重要:此回调在系统创建的钩子线程上执行,且 CGO 启用时 syscall.NewCallback
-// 的回调从该线程进入 Go runtime。此前的实现在此处访问 s.mu(sync.Mutex 锁)、
-// 调用 time.Now()、procShowWindow 等 Go 状态操作,会导致进程在钩子回调触发时
-// 静默退出(app.Run 返回,exit 0)。故此处只做 CallNextHookEx 转发,不做任何
-// 其他工作;鼠标位置跟踪与到期判断全部移到窗口线程的定时器(wndProcCallback
-// 的 wmTimer 分支,用 GetCursorPos 主动读取),避免在钩子线程做 Go 操作。
-func (s *mouseAlertState) hookCallback(nCode int, wParam uintptr, lParam uintptr) uintptr {
-	ret, _, _ := procCallNextHookEx.Call(0, uintptr(nCode), wParam, lParam)
-	return ret
-}
+// wndProcCallback 气泡窗口过程:处理跟随/闪烁定时器、自定义刷新、绘制与销毁。
+// 此回调由 DispatchMessageW 在消息循环线程同步调用,是 Go runtime 支持的安全路径。
+func (s *mouseAlertState) wndProcCallback(hwnd uintptr, msg uint32, wParam uintptr, lParam uintptr) uintptr {
+	switch msg {
+	case wmRefresh, wmTimer:
+		if msg == wmRefresh || uintptr(wParam) == timerFollow {
+			// 主动用 GetCursorPos 读取当前鼠标位置(窗口线程,安全)
+			var pt point
+			curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
+			s.mu.Lock()
+			if !s.active {
+				s.mu.Unlock()
+				procShowWindow.Call(hwnd, 0) // SW_HIDE
+				return 0
+			}
+			if curRet != 0 {
+				s.lastX, s.lastY = pt.X, pt.Y
+			}
+			// 到期判断
+			if !s.stopTime.IsZero() && time.Now().After(s.stopTime) {
+				s.active = false
+				s.mu.Unlock()
+				procShowWindow.Call(hwnd, 0) // SW_HIDE
+				return 0
+			}
+			s.flashOn = !s.flashOn
+			// 超长文字滚动:文字宽度超过可用区时,按配置速度推进偏移,循环滚动
+			w := s.cfgWidth
+			if w <= 0 {
+				w = overlayWidth
+			}
+			if s.textWidth > w-24 {
+				step := int32(s.cfgSpeed * 60 / 1000) // 每 60ms 帧推进(px)
+				if step < 1 {
+					step = 1
+				}
+				s.scrollX += step
+				// 头尾相联无缝循环:滚动一个完整周期(文字宽 + 间隔)后归零,
+				// 下一份正好接上,形成连续跑马灯。
+				cycle := s.textWidth + s.textGap
+				if cycle <= 0 {
+					cycle = s.textWidth + 40
+				}
+				if s.scrollX > cycle {
+					s.scrollX = 0
+				}
+			} else {
+				s.scrollX = 0
+			}
+			x, y := s.lastX, s.lastY
+			s.mu.Unlock()
+			s.positionAndDraw(hwnd, x, y)
+			return 0
+		}
+		return 0
+	case wmPaint:
+		// WM_PAINT 用普通 GDI 绘制气泡(背景 + 文字)。分层窗口已设 LWA_COLORKEY
+		// 抠透明,此处只画内容,不调用 UpdateLayeredWindow(会接管窗口)。
+		// 双缓冲:先在内存 DC(兼容位图)上合成背景+文字,再一次性 BitBlt 上屏,
+		// 避免多遍描边 + 正文逐笔直接画到屏幕导致滚动时闪烁。
+		var ps paintstruct
+		hdc, _, _ := procBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
+		if hdc != 0 {
+			s.mu.Lock()
+			fw, fh := s.cfgWidth, s.cfgHeight
+			s.mu.Unlock()
+			if fw <= 0 {
+				fw = overlayWidth
+			}
+			if fh <= 0 {
+				fh = overlayHeight
+			}
 
-// renderOverlay 定位覆盖层到鼠标位置并重绘闪烁光标 + 下方透明文字。
-// 在窗口线程内调用(mouseAlertLoop 建窗后 / wndProcCallback 定时器),
-// 窗口/DC 操作均为同线程,避免跨线程操作窗口导致绘制失败。
-func (s *mouseAlertState) renderOverlay(overlay uintptr, text string, flashOn bool, x, y int32) {
-	if overlay == 0 {
-		return
-	}
-	// 让覆盖层左上角位于鼠标位置偏左上,使光标环中心对准鼠标
-	left := x - 10
-	top := y - 4
-	procSetWindowPos.Call(overlay, hwndTopMost,
-		uintptr(int32(left)), uintptr(int32(top)),
-		overlayWidth, overlayHeight, swpNoActivate|swpShowWindow)
+			// 创建内存 DC + 兼容位图作为双缓冲画布
+			memDC, _, _ := procCreateCompatibleDC.Call(hdc)
+			memBmp, _, _ := procCreateCompatibleBitmap.Call(hdc, uintptr(fw), uintptr(fh))
+			oldBmp, _, _ := procSelectObject.Call(memDC, memBmp)
+			if memDC != 0 && memBmp != 0 {
+				// 背景:填满整个客户区(LWA_COLORKEY 把该色抠成透明,只留文字)
+				var full rect
+				full.Left = 0
+				full.Top = 0
+				full.Right = fw
+				full.Bottom = fh
+				if s.bgBrush != 0 {
+					procFillRect.Call(memDC, uintptr(unsafe.Pointer(&full)), s.bgBrush)
+				}
+				// 文字:在内存 DC 上绘制(TRANSPARENT 背景模式,不覆盖气泡背景)
+				s.paintText(memDC)
+				// 一次性把合成结果拷贝上屏,避免逐笔绘制的闪烁
+				procBitBlt.Call(hdc, 0, 0, uintptr(fw), uintptr(fh), memDC, 0, 0, 0x00CC0020 /* SRCCOPY */)
+			}
 
-	s.drawLayered(overlay, text, flashOn)
+			// 清理双缓冲对象
+			if memDC != 0 {
+				procSelectObject.Call(memDC, oldBmp)
+				if memBmp != 0 {
+					procDeleteObject.Call(memBmp)
+				}
+				procDeleteDC.Call(memDC)
+			}
+			procEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
+		}
+		return 0
+	case wmDestroy, wmNcDestroy:
+		procKillTimer.Call(hwnd, timerFollow)
+		return 0
+	}
+	ret, _, _ := procDefWindowProc.Call(hwnd, uintptr(msg), wParam, lParam)
+	return ret
 }
 
-// drawLayered 用 32 位 DIB + UpdateLayeredWindow 绘制闪烁光标环与半透明文字。
-func (s *mouseAlertState) drawLayered(overlay uintptr, text string, flashOn bool) {
-	// 用窗口实际总尺寸创建 DIB,并用 GetWindowRect 获取窗口屏幕位置,
-	// 显式传给 UpdateLayeredWindow 的 pptDst/psize,确保 DIB 与窗口完全
-	// 匹配(此前用 GetClientRect 且 psize=nil,当 DIB 尺寸与窗口实际尺寸
-	// 不一致时 UpdateLayeredWindow 绘制失效,导致覆盖层显示为空白)。
-	var wr rect
-	procGetWindowRect.Call(overlay, uintptr(unsafe.Pointer(&wr)))
-	w := int(wr.Right - wr.Left)
-	h := int(wr.Bottom - wr.Top)
-	if w < 8 || h < 8 { // 兜底:窗口尺寸异常时用默认尺寸
-		w, h = overlayWidth, overlayHeight
-	}
-
-	hdcScreen, _, _ := procGetDC.Call(0)
-	if hdcScreen == 0 {
+// positionAndDraw 把气泡窗口定位到鼠标旁,设置整体 alpha,并触发 WM_PAINT 重绘。
+// 窗口线程内调用。绘制本身由 WM_PAINT(普通 GDI)完成。
+// 不闪烁:固定 alpha=235(此前 60ms 翻转 235↔150 导致闪烁严重,已去掉)。
+func (s *mouseAlertState) positionAndDraw(overlay uintptr, x, y int32) {
+	if overlay == 0 {
 		return
 	}
-	defer procReleaseDC.Call(0, hdcScreen)
-
-	hdcMem, _, _ := procCreateCompatibleDC.Call(hdcScreen)
-	if hdcMem == 0 {
-		return
+	s.mu.Lock()
+	w, h := s.cfgWidth, s.cfgHeight
+	if w <= 0 {
+		w = overlayWidth
 	}
-	defer procDeleteDC.Call(hdcMem)
-
-	// 32 位 DIB 位图(负高度:自顶向下)
-	bi := bitmapinfoheader{}
-	bi.BiSize = 40 // sizeof(BITMAPINFOHEADER)
-	bi.BiWidth = int32(w)
-	bi.BiHeight = int32(-h)
-	bi.BiPlanes = 1
-	bi.BiBitCount = 32
-	bi.BiCompression = biRgb
-
-	var bits unsafe.Pointer
-	hBitmap, _, _ := procCreateDIBSection.Call(hdcScreen,
-		uintptr(unsafe.Pointer(&bi)), 0, uintptr(unsafe.Pointer(&bits)), 0, 0)
-	if hBitmap == 0 {
-		return
+	if h <= 0 {
+		h = overlayHeight
 	}
-	defer procDeleteObject.Call(hBitmap)
+	s.mu.Unlock()
 
-	oldBmp, _, _ := procSelectObject.Call(hdcMem, hBitmap)
+	// 气泡左上角位于鼠标右下偏移,避免遮挡光标
+	left := x + 20
+	top := y + 24
 
-	// 清空为全透明
-	clearPixels(bits, w, h)
+	// 背景透明(LWA_COLORKEY)只在创建窗口时设置一次,避免每帧重复设置导致闪烁。
 
-	// 光标环中心放在覆盖层 (10, 4) 附近,即鼠标位置
-	drawCursorRing(hdcMem, 10, 4, flashOn)
-
-	// 光标下方半透明文字
-	if text != "" {
-		drawAlertText(hdcMem, text, 10, textTopY)
-	}
-
-	// GDI 绘制到 DIB 不写 alpha 通道,此处按亮度补全 alpha,
-	// 使图形/文字边缘抗锯齿平滑(文字区半透明)
-	setAlphaFromLuma(bits, w, h)
-
-	procSelectObject.Call(hdcMem, oldBmp)
+	// 定位 + 显示(不激活)
+	procSetWindowPos.Call(overlay, hwndTopMost,
+		uintptr(int32(left)), uintptr(int32(top)),
+		uintptr(w), uintptr(h), swpNoActivate|swpShowWindow)
 
-	// UpdateLayeredWindow:显式传 pptDst(窗口屏幕左上角)与 psize(窗口尺寸),
-	// 使 DIB 内容精确定位到窗口,避免 psize=nil 时因尺寸不匹配绘制失效。
-	var blend blendfunction
-	blend.BlendOp = 0        // AC_SRC_OVER
-	blend.AlphaFormat = 0x01 // AC_SRC_ALPHA
-	blend.SourceConstantAlpha = 255
-	var dstPt point
-	dstPt.X = wr.Left
-	dstPt.Y = wr.Top
-	var szPt point
-	szPt.X = int32(w)
-	szPt.Y = int32(h)
-	procUpdateLayeredWindow.Call(overlay, hdcScreen,
-		uintptr(unsafe.Pointer(&dstPt)), // pptDst = 窗口左上角屏幕坐标
-		uintptr(unsafe.Pointer(&szPt)),  // psize = 窗口尺寸
-		hdcMem, 0, 0, uintptr(unsafe.Pointer(&blend)), 0x00000002 /*ULW_ALPHA*/)
+	// 触发重绘(WM_PAINT 绘制背景 + 文字)
+	procInvalidateRect.Call(overlay, 0, 0)
 }
 
-// clearPixels 把 DIB 位图全部置为透明(ARGB=0)
-func clearPixels(bits unsafe.Pointer, w, h int) {
-	if bits == nil {
+// paintText 在指定 HDC 上绘制白色提示文字(WM_PAINT 内调用,窗口线程)。
+// 用 DrawTextW + 系统字体 + TRANSPARENT 背景模式:只画字形、不填充背景,
+// 这样不会覆盖 FillRect 画的气泡背景(此前 OPAQUE 模式导致文字区被填成黑色
+// 背景而不可见)。
+func (s *mouseAlertState) paintText(hdc uintptr) {
+	s.mu.Lock()
+	text := s.text
+	scrollX := s.scrollX
+	fontSize := s.cfgFontSize
+	textColor := s.cfgTextColor
+	outlineColor := s.cfgOutlineColor
+	outlineWidth := s.cfgOutlineWidth
+	width := s.cfgWidth
+	s.mu.Unlock()
+	if text == "" || hdc == 0 {
 		return
 	}
-	total := uintptr(w) * uintptr(h) * 4
-	for i := uintptr(0); i+8 <= total; i += 8 {
-		*(*uint64)(unsafe.Add(bits, i)) = 0
+	if fontSize <= 0 {
+		fontSize = DefaultMouseAlertFontSize // 默认 20px
 	}
-}
-
-// setAlphaFromLuma 按亮度补全 alpha:非透明像素的 alpha = max(r,g,b),
-// 文字区(y >= textTopY)乘以 3/4 实现半透明。
-func setAlphaFromLuma(bits unsafe.Pointer, w, h int) {
-	if bits == nil {
-		return
+	if textColor == 0 {
+		textColor = 0x00FFFFFF // 默认白字
 	}
-	for y := 0; y < h; y++ {
-		for x := 0; x < w; x++ {
-			p := (*uint32)(unsafe.Add(bits, uintptr(y*w+x)*4))
-			v := *p
-			b := uint8(v)
-			g := uint8(v >> 8)
-			r := uint8(v >> 16)
-			m := uint32(r)
-			if uint32(g) > m {
-				m = uint32(g)
-			}
-			if uint32(b) > m {
-				m = uint32(b)
-			}
-			if m == 0 {
-				*p = 0 // 保持透明
-				continue
-			}
-			if y >= textTopY {
-				m = m * 3 / 4 // 文字半透明
-			}
-			*p = v&0x00FFFFFF | (m << 24) // 写入 alpha
-		}
+	if outlineColor == 0 {
+		outlineColor = 0x00000000 // 默认黑边
+	}
+	if width <= 0 {
+		width = overlayWidth
 	}
-}
 
-// drawCursorRing 在覆盖层坐标 (cx,cy) 画闪烁光标环(提醒样式)
-func drawCursorRing(hdc uintptr, cx, cy int32, flashOn bool) {
-	color := flashColorNormal
-	if flashOn {
-		color = flashColorAlert
-	}
-	const r = 12
-	for angle := 0; angle < 360; angle += 4 {
-		rad := float64(angle) * 3.14159265 / 180.0
-		px := cx + int32(float64(r)*cos(rad))
-		py := cy + int32(float64(r)*sin(rad))
-		procSetPixel.Call(hdc, uintptr(px), uintptr(py), uintptr(color))
-	}
-	// 中心小点
-	procSetPixel.Call(hdc, uintptr(cx), uintptr(cy), uintptr(color))
-}
+	// TRANSPARENT:只画字形,不填背景(保留气泡背景)
+	procSetBkMode.Call(hdc, 1)
+	procSetTextColor.Call(hdc, textColor)
 
-// drawAlertText 在 (x,y) 绘制白色提示文字(抗锯齿边缘由 setAlphaFromLuma 处理)
-func drawAlertText(hdc uintptr, text string, x, y int32) {
-	// CreateFontW 参数顺序:nHeight,nWidth,nEscapement,nOrientation,nWeight,
-	// bItalic,bUnderline,bStrikeOut,iCharSet,iOutPrecision,iClipPrecision,
-	// iQuality,iPitchAndFamily,pszFaceName。nHeight=-16 表示 16 像素高。
-	var height int32 = -16
+	// 大字体:CreateFontW 按配置字号创建清晰字体
+	// 参数:h=-fontSize(px), w=0, esc=0, orient=0, weight=FW_NORMAL(400), italic=0,
+	//       underline=0, strikeout=0, charset=DEFAULT_CHARSET(1), outprec=0, clipprec=0,
+	//       quality=ANTIALIASED(4), pitch=0, face=微软雅黑
+	facePtr, _ := syscall.UTF16PtrFromString("Microsoft YaHei")
+	var fontH int32 = -fontSize // 负值=像素高度(字号 px),用变量避免常量溢出
 	hFont, _, _ := procCreateFontW.Call(
-		uintptr(height), 0, 0, 0, 400, // 高16、粗体
-		0, 0, 0, 134, // 非斜体、GB2312_CHARSET
-		0, 0, 4, 2, // 抗锯齿、DEFAULT_PITCH
-		0, // lpszFace=NULL 用系统默认字体
+		uintptr(fontH), 0, 0, 0, 400, 0, 0, 0,
+		1 /* DEFAULT_CHARSET */, 0, 0, 4 /* ANTIALIASED */, 0,
+		uintptr(unsafe.Pointer(facePtr)),
 	)
 	if hFont == 0 {
-		return
+		// 回退系统字体
+		hFont, _, _ = procGetStockObject.Call(17) // DEFAULT_GUI_FONT
+	}
+	if hFont != 0 {
+		procSelectObject.Call(hdc, hFont)
 	}
-	defer procDeleteObject.Call(hFont)
-	oldFont, _, _ := procSelectObject.Call(hdc, hFont)
-
-	procSetBkMode.Call(hdc, 1)        // TRANSPARENT
-	procSetTextColor.Call(hdc, 0x00FFFFFF) // 白
-	procSetTextAlign.Call(hdc, 0)     // TA_LEFT
 
+	// 测量文字像素宽度(选中字体下),判断是否需要滚动
 	textPtr, _ := syscall.UTF16PtrFromString(text)
-	procTextOutW.Call(hdc, uintptr(x), uintptr(y), uintptr(unsafe.Pointer(textPtr)), uintptr(len([]rune(text))))
-
-	procSelectObject.Call(hdc, oldFont)
-}
-
-// wndProcCallback 覆盖层窗口过程:处理定时器(闪烁)与销毁
-func (s *mouseAlertState) wndProcCallback(hwnd uintptr, msg uint32, wParam uintptr, lParam uintptr) uintptr {
-	switch msg {
-	case wmTimer:
-		if uintptr(wParam) == timerFlash {
-			// 主动用 GetCursorPos 读取当前鼠标位置(窗口线程,安全)。
-			// 不再依赖全局钩子回调更新 lastX/lastY(钩子回调在系统线程
-			// 执行,任何 Go 状态操作都会导致进程退出,故仅做转发)。
-			var pt point
-			curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
-			s.mu.Lock()
-			if s.active {
-				if curRet != 0 {
-					s.lastX, s.lastY = pt.X, pt.Y
-				}
-				// 到期判断(原先在钩子回调,现移到窗口线程)
-				if !s.stopTime.IsZero() && time.Now().After(s.stopTime) {
-					s.active = false
-					s.mu.Unlock()
-					procShowWindow.Call(hwnd, 0) // SW_HIDE
-					return 0
-				}
-				s.flashOn = !s.flashOn
-				text := s.text
-				flashOn := s.flashOn
-				x, y := s.lastX, s.lastY
-				s.mu.Unlock()
-				s.renderOverlay(hwnd, text, flashOn, x, y)
-				return 0
-			}
-			s.mu.Unlock()
+	var ext size
+	procGetTextExtentPoint32W.Call(hdc, uintptr(unsafe.Pointer(textPtr)),
+		uintptr(len([]rune(text))), uintptr(unsafe.Pointer(&ext)))
+	textW := ext.Cx
+	availW := width - 24 // 左右各 12px 边距
+
+	s.mu.Lock()
+	s.textWidth = textW
+	s.mu.Unlock()
+
+	// 文字垂直居中:气泡高度 = 字号 + 34,上下留白各约 (34-字号)/2 + 基线偏移
+	baseY := (fontSize + 10) / 2 // 字形基线 y 坐标(垂直居中)
+
+	// 单行绘制(TextOutW)+ 超长时按 scrollX 偏移。文字从左边距开始画,
+	// 滚动时整体向左平移(x - scrollX),实现文字从右向左循环滚动。
+	// 文字超长时先画两遍(偏移 和 偏移+周期)实现头尾相联无缝循环。
+	//
+	// 描边反差:为避免文字在任意背景(亮/暗)上看不清,先用描边色把文字
+	// 向 8 个方向各偏移 outlineWidth 各画一遍形成外描边(stroke),最后用
+	// 正文色在中心画一遍(fill),构成带色边的文字,形成颜色反差。
+	textLen := uintptr(len([]rune(text)))
+	leftX := int32(12) - scrollX
+	if textW > availW {
+		// 头尾相联:第二份从「文字头 + 文字宽 + 间隔」开始画,间隔为两个空格
+		s.mu.Lock()
+		gap := s.textGap
+		s.mu.Unlock()
+		if gap < 4 {
+			gap = 4
 		}
-		return 0
-	case wmDestroy, wmNcDestroy:
-		procKillTimer.Call(hwnd, timerFlash)
-		return 0
+		// 第一遍:当前偏移处(文字头)
+		s.drawOutlinedText(hdc, textPtr, textLen, leftX, baseY, outlineColor, outlineWidth, textColor)
+		// 第二遍:文字头 + 文字宽 + 间隔,实现头尾相联无缝循环
+		s.drawOutlinedText(hdc, textPtr, textLen, leftX+textW+gap, baseY, outlineColor, outlineWidth, textColor)
+	} else {
+		// 未超长:左对齐单行
+		s.drawOutlinedText(hdc, textPtr, textLen, 12, baseY, outlineColor, outlineWidth, textColor)
 	}
-	ret, _, _ := procDefWindowProc.Call(hwnd, uintptr(msg), wParam, lParam)
-	return ret
 }
 
-// procCallNextHookEx 钩子链传递
-var procCallNextHookEx = user32.NewProc("CallNextHookEx")
-
-// cos / sin 简单实现(避免依赖 math 的浮点精度问题)
-func cos(rad float64) float64 {
-	r := rad
-	for r > 3.14159265 {
-		r -= 6.28318530
-	}
-	for r < -3.14159265 {
-		r += 6.28318530
+// drawOutlinedText 绘制带描边的文字:先用 outlineColor 在 8 方向各偏移
+// outlineWidth 描一圈形成外轮廓,再在中心用 textColor 画正文字形。
+// outlineWidth<=0 时不描边,直接画正文(等同旧行为)。
+func (s *mouseAlertState) drawOutlinedText(hdc uintptr, textPtr *uint16, textLen uintptr, x, y int32, outlineColor uintptr, outlineWidth int32, textColor uintptr) {
+	if textLen == 0 {
+		return
 	}
-	r2 := r * r
-	return 1 - r2/2 + r2*r2/24 - r2*r2*r2/720
-}
-
-func sin(rad float64) float64 {
-	r := rad
-	for r > 3.14159265 {
-		r -= 6.28318530
+	draw := func(dx, dy int32, color uintptr) {
+		procSetTextColor.Call(hdc, color)
+		procTextOutW.Call(hdc, uintptr(x+dx), uintptr(y+dy), uintptr(unsafe.Pointer(textPtr)), textLen)
 	}
-	for r < -3.14159265 {
-		r += 6.28318530
+	if outlineWidth > 0 {
+		// 8 方向外描边(每像素偏移),形成环绕轮廓,避免不同背景下文字难辨认
+		for dy := -outlineWidth; dy <= outlineWidth; dy++ {
+			for dx := -outlineWidth; dx <= outlineWidth; dx++ {
+				if dx == 0 && dy == 0 {
+					continue
+				}
+				draw(dx, dy, outlineColor)
+			}
+		}
 	}
-	r2 := r * r
-	return r - r2*r/6 + r2*r2*r/120 - r2*r2*r2*r/5040
+	// 中心正文字形
+	draw(0, 0, textColor)
 }
+
+// renderBubbleToDib 已移除:回退到 WM_PAINT 普通 GDI 绘制方案(见 wmPaint/paintText)。
+// 分层窗口用 LWA_ALPHA 整体半透明,不再用 DIB + UpdateLayeredWindow。

+ 78 - 0
power_shutdown.go

@@ -0,0 +1,78 @@
+// ============================================================================
+// power_shutdown.go — 延时关机(工具页)
+//
+// 用户在工具页输入「多少分钟后关机」,调用 ShutdownDelay 下发系统关机倒计时。
+// 复用 pccontrol.go 的系统命令构造(shutdown /s /t N /f),交给 Windows 系统计时,
+// 即使本程序退出,倒计时仍由系统执行。返回计划执行时间供前端展示。
+// ============================================================================
+package main
+
+import (
+	"fmt"
+	"strings"
+	"time"
+)
+
+// ShutdownDelay 在指定分钟后执行关机。
+// minutes 必须 > 0;返回计划执行时间(HH:MM:SS)与提示信息。
+func (s *SettingsService) ShutdownDelay(minutes int) (string, error) {
+	if minutes <= 0 {
+		return "", fmt.Errorf("关机分钟数必须大于 0")
+	}
+	if minutes > 24*60 {
+		return "", fmt.Errorf("关机分钟数过大(最多 24 小时)")
+	}
+
+	delaySec := minutes * 60
+	cmd := pcSystemCommand(pcPoweroff, delaySec)
+	if cmd == nil {
+		return "", fmt.Errorf("当前系统不支持 shutdown 命令")
+	}
+
+	// 标记为待执行(与 API 路由共用状态,便于「主机」页取消)
+	pcMu.Lock()
+	if pcPending {
+		remaining := time.Until(pcExecuteAt).Round(time.Second)
+		pcMu.Unlock()
+		return "", fmt.Errorf("已有待执行的电源命令,剩余 %v", remaining)
+	}
+	pcPending = true
+	pcPendingCmd = pcPoweroff
+	pcExecuteAt = time.Now().Add(time.Duration(delaySec) * time.Second)
+	pcSystemTimer = true
+	pcLastCmd = pcPoweroff
+	pcLastTime = time.Now()
+	pcRequested = true
+	pcMu.Unlock()
+
+	if output, err := cmd.CombinedOutput(); err != nil {
+		pcMu.Lock()
+		pcPending = false
+		pcPendingCmd = ""
+		pcExecuteAt = time.Time{}
+		pcSystemTimer = false
+		pcMu.Unlock()
+		return "", fmt.Errorf("关机命令下发失败: %v | %s", err, strings.TrimSpace(string(output)))
+	}
+
+	execAt := pcExecuteAt.Format("15:04:05")
+	fmt.Fprintf(apiLogRing, "[SYS] 工具页延时关机 | %d 分钟后(%s)\n", minutes, execAt)
+	return execAt, nil
+}
+
+// CancelShutdown 取消延时关机(工具页「取消」按钮调用)。
+func (s *SettingsService) CancelShutdown() error {
+	pcMu.Lock()
+	if !pcPending || pcPendingCmd != pcPoweroff {
+		pcMu.Unlock()
+		return fmt.Errorf("当前没有待执行的关机命令")
+	}
+	pcPending = false
+	pcPendingCmd = ""
+	pcExecuteAt = time.Time{}
+	pcSystemTimer = false
+	pcMu.Unlock()
+	pcAbortShutdown()
+	fmt.Fprintln(apiLogRing, "[SYS] 工具页取消延时关机")
+	return nil
+}

+ 50 - 0
settings.go

@@ -28,6 +28,15 @@ const (
 	DefaultNewsCount              = 30                  // 新闻页显示条数
 	DefaultGithubRefreshMinutes   = 120                 // Github 自动刷新间隔(分钟)
 	AppVersion                    = "0.1.2"             // 应用自身版本(与 build/windows/info.json 的 ProductVersion 一致),用于仓库更新提示
+
+	// 鼠标提醒默认配置
+	DefaultMouseAlertFontSize    = 20    // 字号(px)
+	DefaultMouseAlertWidth       = 200   // 气泡窗口宽度(px)
+	DefaultMouseAlertSpeed       = 33    // 超长文字滚动速度(px/s)
+	DefaultMouseAlertColor       = "#ffffff" // 文字颜色(白)
+	DefaultMouseAlertDuration    = 8     // 显示时长(秒)
+	DefaultMouseAlertOutlineColor = "#000000" // 文字描边颜色(黑边,与白字形成反差)
+	DefaultMouseAlertOutlineWidth = 2    // 文字描边宽度(px),0 表示关闭
 )
 
 // LinkItem 常用网址条目
@@ -62,6 +71,18 @@ type TickerConfig struct {
 	Y           int    `json:"y"`           // 窗口位置,-1 表示未设置
 }
 
+// MouseAlertConfig 系统级鼠标提醒气泡的显示配置
+type MouseAlertConfig struct {
+	FontSize  int    `json:"fontSize"`  // 字号(px)
+	Width     int    `json:"width"`     // 气泡窗口宽度(px)
+	Speed     int    `json:"speed"`     // 超长文字滚动速度(px/s)
+	Color     string `json:"color"`     // 文字颜色
+	Background string `json:"background"` // 气泡背景色(COLORKEY 抠成透明,仅作底色参考)
+	Duration  int    `json:"duration"`  // 显示时长(秒)
+	OutlineColor string `json:"outlineColor"` // 文字描边颜色(白边/黑边,与文字形成反差)
+	OutlineWidth  int    `json:"outlineWidth"` // 文字描边宽度(px),0 表示关闭描边
+}
+
 // AppSettings 保存全部可自定义配置,序列化为 JSON 存于用户配置目录
 type AppSettings struct {
 	Theme        string       `json:"theme"`        // 界面主题:dark / light
@@ -83,6 +104,7 @@ type AppSettings struct {
 	StockTicker  TickerConfig `json:"stockTicker"`  // 股票滚动条配置
 	NewsTicker   TickerConfig `json:"newsTicker"`   // 新闻滚动条配置
 	Poem         PoemConfig   `json:"poem"`         // 随机古诗显示配置
+	MouseAlert   MouseAlertConfig `json:"mouseAlert"` // 系统级鼠标提醒气泡配置
 
 	StockUpColor           string `json:"stockUpColor"`           // 股票上涨颜色
 	StockDownColor         string `json:"stockDownColor"`         // 股票下跌颜色
@@ -126,6 +148,7 @@ func defaultSettings() AppSettings {
 		StockTicker: TickerConfig{Enabled: false, Width: 1400, FontSize: 14, Color: "#ff4d4d", Speed: 80, Transparent: true, X: -1, Y: -1},
 		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},
 
 		StockUpColor:           DefaultStockUpColor,
 		StockDownColor:         DefaultStockDownColor,
@@ -238,6 +261,7 @@ func normalizeSettings(settings AppSettings) AppSettings {
 	settings.StockTicker = normalizeTicker(settings.StockTicker, "#ff4d4d", 80) // 股票条默认红字
 	settings.NewsTicker = normalizeTicker(settings.NewsTicker, "#3ddc84", 60)   // 新闻条默认绿字
 	settings.Poem = normalizePoemConfig(settings.Poem)                          // 古诗显示配置校正
+	settings.MouseAlert = normalizeMouseAlert(settings.MouseAlert)               // 鼠标提醒配置校正
 
 	if !isHexColor(settings.StockUpColor) {
 		settings.StockUpColor = DefaultStockUpColor // 涨色回填默认红
@@ -326,6 +350,32 @@ func normalizeTicker(cfg TickerConfig, defaultColor string, defaultSpeed int) Ti
 	return cfg
 }
 
+// normalizeMouseAlert 校正鼠标提醒配置:字号、宽度、速度、显示时长下限,颜色默认值
+func normalizeMouseAlert(cfg MouseAlertConfig) MouseAlertConfig {
+	if cfg.FontSize < 10 || cfg.FontSize > 48 {
+		cfg.FontSize = DefaultMouseAlertFontSize // 字号限定 10~48px
+	}
+	if cfg.Width < 120 || cfg.Width > 1200 {
+		cfg.Width = DefaultMouseAlertWidth // 宽度限定 120~1200px
+	}
+	if cfg.Speed < 5 || cfg.Speed > 300 {
+		cfg.Speed = DefaultMouseAlertSpeed // 滚动速度限定 5~300 px/s
+	}
+	if strings.TrimSpace(cfg.Color) == "" {
+		cfg.Color = DefaultMouseAlertColor
+	}
+	if strings.TrimSpace(cfg.OutlineColor) == "" {
+		cfg.OutlineColor = DefaultMouseAlertOutlineColor
+	}
+	if cfg.OutlineWidth < 0 || cfg.OutlineWidth > 6 {
+		cfg.OutlineWidth = DefaultMouseAlertOutlineWidth // 描边宽度限定 0~6 px,0 关闭
+	}
+	if cfg.Duration < 2 || cfg.Duration > 60 {
+		cfg.Duration = DefaultMouseAlertDuration // 显示时长限定 2~60 秒
+	}
+	return cfg
+}
+
 // isHexColor 判断是否为 #RGB / #RRGGBB 形式的颜色值
 func isHexColor(value string) bool {
 	value = strings.TrimSpace(value)