فهرست منبع

添加“记录”功能,用于灵活的记录一些连续数据

soease 1 هفته پیش
والد
کامیت
c6796bca54

+ 4 - 3
build/windows/Taskfile.yml

@@ -27,8 +27,9 @@ tasks:
           OBFUSCATED: '{{.OBFUSCATED}}'
           GARBLE_ARGS: '{{.GARBLE_ARGS}}'
     vars:
-      # Default to CGO_ENABLED=0 if not explicitly set
-      CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
+      # 鼠标提醒依赖 CGO(syscall.NewCallback 从系统钩子线程进入 Go runtime),
+      # 故默认启用 CGO=1;如无需 CGO 可显式传 CGO_ENABLED=0。
+      CGO_ENABLED: '{{.CGO_ENABLED | default "1"}}'
 
   build:native:
     summary: Builds the application using native Go cross-compilation
@@ -63,7 +64,7 @@ tasks:
       BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}'
     env:
       GOOS: windows
-      CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
+      CGO_ENABLED: '{{.CGO_ENABLED | default "1"}}'
       GOARCH: '{{.ARCH | default ARCH}}'
 
   build:docker:

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

@@ -23,6 +23,7 @@ export {
     PCStatus,
     PoemConfig,
     PoemData,
+    RecordItem,
     ReleaseItem,
     RepoFeed,
     RepoItem,

+ 78 - 6
frontend/bindings/TengTuToolbox/models.js

@@ -175,6 +175,22 @@ export class AppSettings {
              */
             this["todoPath"] = "";
         }
+        if (!("recordPath" in $$source)) {
+            /**
+             * 记录(Record)文件路径:每行「姓名|名称|值」(保留兼容)
+             * @member
+             * @type {string}
+             */
+            this["recordPath"] = "";
+        }
+        if (!("recordDBPath" in $$source)) {
+            /**
+             * 记录数据库路径:SQLite3 数据库文件(记录 Tab 实际存储)
+             * @member
+             * @type {string}
+             */
+            this["recordDBPath"] = "";
+        }
         if (!("backupDir" in $$source)) {
             /**
              * 文本文件备份目录(文件类自定义 Tab「备份」按钮使用)
@@ -286,9 +302,9 @@ export class AppSettings {
         const $$createField6_0 = $$createType2;
         const $$createField9_0 = $$createType4;
         const $$createField10_0 = $$createType6;
-        const $$createField14_0 = $$createType7;
-        const $$createField15_0 = $$createType7;
-        const $$createField16_0 = $$createType8;
+        const $$createField16_0 = $$createType7;
+        const $$createField17_0 = $$createType7;
+        const $$createField18_0 = $$createType8;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("tabOrder" in $$parsedSource) {
             $$parsedSource["tabOrder"] = $$createField4_0($$parsedSource["tabOrder"]);
@@ -306,13 +322,13 @@ export class AppSettings {
             $$parsedSource["customTabs"] = $$createField10_0($$parsedSource["customTabs"]);
         }
         if ("stockTicker" in $$parsedSource) {
-            $$parsedSource["stockTicker"] = $$createField14_0($$parsedSource["stockTicker"]);
+            $$parsedSource["stockTicker"] = $$createField16_0($$parsedSource["stockTicker"]);
         }
         if ("newsTicker" in $$parsedSource) {
-            $$parsedSource["newsTicker"] = $$createField15_0($$parsedSource["newsTicker"]);
+            $$parsedSource["newsTicker"] = $$createField17_0($$parsedSource["newsTicker"]);
         }
         if ("poem" in $$parsedSource) {
-            $$parsedSource["poem"] = $$createField16_0($$parsedSource["poem"]);
+            $$parsedSource["poem"] = $$createField18_0($$parsedSource["poem"]);
         }
         return new AppSettings(/** @type {Partial<AppSettings>} */($$parsedSource));
     }
@@ -958,6 +974,62 @@ export class PoemData {
     }
 }
 
+/**
+ * RecordItem 一条记录:姓名 + 名称 + 值 + 录入时间,供记录 Tab 列表与柱状图使用
+ */
+export class RecordItem {
+    /**
+     * Creates a new RecordItem instance.
+     * @param {Partial<RecordItem>} [$$source = {}] - The source object to create the RecordItem.
+     */
+    constructor($$source = {}) {
+        if (!("name" in $$source)) {
+            /**
+             * 姓名
+             * @member
+             * @type {string}
+             */
+            this["name"] = "";
+        }
+        if (!("item" in $$source)) {
+            /**
+             * 名称
+             * @member
+             * @type {string}
+             */
+            this["item"] = "";
+        }
+        if (!("value" in $$source)) {
+            /**
+             * 值
+             * @member
+             * @type {number}
+             */
+            this["value"] = 0;
+        }
+        if (!("createdAt" in $$source)) {
+            /**
+             * 录入时间 YYYY-MM-DD HH:MM:SS(前端新增时生成;为空时后端用当前时间)
+             * @member
+             * @type {string}
+             */
+            this["createdAt"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new RecordItem instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {RecordItem}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new RecordItem(/** @type {Partial<RecordItem>} */($$parsedSource));
+    }
+}
+
 /**
  * ReleaseItem 一个 GitHub release 的简略信息,用于列出 Latest 之后的版本(Pre-release 等)
  */

+ 48 - 5
frontend/bindings/TengTuToolbox/settingsservice.js

@@ -187,6 +187,26 @@ export function GetRandomPoem() {
     }));
 }
 
+/**
+ * GetRecordItems 返回所有历史名称(去重排序),供名称输入框自动补全。
+ * @returns {$CancellablePromise<string[]>}
+ */
+export function GetRecordItems() {
+    return $Call.ByID(1250968375).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType0($result);
+    }));
+}
+
+/**
+ * GetRecordNames 返回所有历史姓名(去重排序),供姓名输入框自动补全。
+ * @returns {$CancellablePromise<string[]>}
+ */
+export function GetRecordNames() {
+    return $Call.ByID(2424781591).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType0($result);
+    }));
+}
+
 /**
  * GetSettings 返回当前配置(读取配置文件,不存在则返回默认值)
  * @returns {$CancellablePromise<$models.AppSettings>}
@@ -235,6 +255,17 @@ export function PickWebDir() {
     return $Call.ByID(1493546416);
 }
 
+/**
+ * ReadRecords 读取全部记录(按 id 升序,保持录入顺序)。
+ * 前端 loadRecords 渲染列表;柱状图按返回顺序绘制。
+ * @returns {$CancellablePromise<$models.RecordItem[]>}
+ */
+export function ReadRecords() {
+    return $Call.ByID(2989114738).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType19($result);
+    }));
+}
+
 /**
  * ReadTextFile 读取任意文本文件内容(供「自定义 Tab」文件类型页面使用)。
  * 文件不存在时返回空内容;其他错误原样返回。
@@ -362,7 +393,7 @@ export function StartAPIService() {
  */
 export function StartWebService(dir) {
     return $Call.ByID(3007036157, dir).then(/** @type {($result: any) => any} */(($result) => {
-        return $$createType18($result);
+        return $$createType20($result);
     }));
 }
 
@@ -390,7 +421,7 @@ export function StopWebService() {
  */
 export function Translate(text) {
     return $Call.ByID(3284480866, text).then(/** @type {($result: any) => any} */(($result) => {
-        return $$createType19($result);
+        return $$createType21($result);
     }));
 }
 
@@ -400,10 +431,20 @@ export function Translate(text) {
  */
 export function WebServiceStatus() {
     return $Call.ByID(1311352355).then(/** @type {($result: any) => any} */(($result) => {
-        return $$createType18($result);
+        return $$createType20($result);
     }));
 }
 
+/**
+ * WriteRecords 全量替换记录:开启事务,先清空再批量插入。
+ * 前端 saveRecords 提交当前列表全部行,保证与数据库完全一致。
+ * @param {$models.RecordItem[]} items
+ * @returns {$CancellablePromise<void>}
+ */
+export function WriteRecords(items) {
+    return $Call.ByID(1978718139, items);
+}
+
 /**
  * WriteTextFile 把文本内容写回指定文件(目录不存在时自动创建)。
  * 供文件类自定义 Tab 的「保存」使用。
@@ -443,5 +484,7 @@ const $$createType14 = $models.StockQuote.createFrom;
 const $$createType15 = $Create.Array($$createType14);
 const $$createType16 = $models.TrendingRepo.createFrom;
 const $$createType17 = $Create.Array($$createType16);
-const $$createType18 = $models.WebServiceInfo.createFrom;
-const $$createType19 = $models.TranslateResult.createFrom;
+const $$createType18 = $models.RecordItem.createFrom;
+const $$createType19 = $Create.Array($$createType18);
+const $$createType20 = $models.WebServiceInfo.createFrom;
+const $$createType21 = $models.TranslateResult.createFrom;

+ 40 - 0
frontend/index.html

@@ -19,6 +19,7 @@
             <button class="tab-btn" data-tab="github">Github</button>
             <button class="tab-btn" data-tab="stock">股市</button>
             <button class="tab-btn" data-tab="todo">待办</button>
+            <button class="tab-btn" data-tab="record">记录</button>
             <button class="tab-btn" data-tab="tools">工具</button>
             <button class="tab-btn" data-tab="host">服务</button>
             <button class="tab-btn" data-tab="settings">设置</button>
@@ -116,6 +117,36 @@
                 <div class="todo-status" id="todo-status"></div>
             </div>
 
+            <!-- 记录:每行一条「姓名|名称|值」,保存到 SQLite 数据库;姓名/名称输入可自动补全历史值 -->
+            <div class="tab-panel panel-record" id="tab-record">
+                <div class="panel-head">
+                    <div class="record-path-desc" id="record-path-desc"></div>
+                    <button class="btn-ghost" id="record-save">保存</button>
+                </div>
+                <div class="record-toolbar">
+                    <label class="record-label">姓名
+                        <select class="input record-select" id="record-name-filter"><option value="">全部</option></select>
+                    </label>
+                    <label class="record-label">名称
+                        <select class="input record-select" id="record-item-filter"><option value="">全部</option></select>
+                    </label>
+                    <span class="record-count" id="record-count"></span>
+                </div>
+                <div class="record-chart-wrap">
+                    <canvas class="record-chart" id="record-chart"></canvas>
+                    <div class="record-chart-empty" id="record-chart-empty">请先选择姓名与名称,才显示该组合的柱状图</div>
+                </div>
+                <!-- 自动补全候选:姓名/名称输入框通过 list 属性引用;随数据库历史值刷新。
+                     必须放在 record-list 容器之外,避免 loadRecords() 的 innerHTML='' 把它们清空 -->
+                <datalist id="record-names-dl"></datalist>
+                <datalist id="record-items-dl"></datalist>
+                <div class="record-list" id="record-list"></div>
+                <div class="record-actions">
+                    <button class="btn-ghost" id="record-add">添加记录</button>
+                </div>
+                <div class="record-status" id="record-status"></div>
+            </div>
+
             <!-- 工具:本地开发/运维辅助工具集 -->
             <div class="tab-panel" id="tab-tools">
                 <!-- 工具 1:Web 服务——选择目录,开启本地浏览服务 -->
@@ -521,6 +552,15 @@
                         <input class="input" id="todo-path" type="text" placeholder="D:\My\腾图\待办.txt">
                     </div>
 
+                    <!-- 记录数据库路径 -->
+                    <div class="setting-row" data-tab-group="record">
+                        <div class="setting-info">
+                            <div class="setting-title">记录数据库路径</div>
+                            <div class="setting-hint">「记录」标签全部「姓名|名称|值」保存到该 SQLite 数据库文件;姓名/名称输入自动补全历史值,选择姓名与名称后按值绘柱状图</div>
+                        </div>
+                        <input class="input" id="record-db-path" type="text" placeholder="D:\My\腾图\记录.db">
+                    </div>
+
                     <!-- 文本备份目录 -->
                     <div class="setting-row">
                         <div class="setting-info">

+ 172 - 0
frontend/public/style.css

@@ -1750,6 +1750,178 @@ a.ver-muted:hover {
     font-size: calc(13.5px + var(--font-delta));
 }
 
+/* ---------- 记录页 ---------- */
+.panel-record {
+    display: none;                          /* 未激活时隐藏 */
+}
+.panel-record.active {
+    display: flex;                          /* 激活时纵向布局 */
+    flex-direction: column;
+    gap: 10px;
+}
+
+/* 记录文件路径说明(面板标题下方) */
+.record-path-desc {
+    margin-top: 2px;
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+}
+
+/* 筛选工具栏:姓名 / 名称下拉 + 匹配条数 */
+.record-toolbar {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    flex-wrap: wrap;
+    padding: 10px 12px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+}
+.record-label {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    font-size: calc(14.5px + var(--font-delta));
+    color: var(--muted);
+}
+.record-select {
+    min-width: 150px;
+    padding: 6px 8px;
+    border: 1px solid var(--glass-border);
+    border-radius: calc(var(--radius) - 2px);
+    background: var(--glass-strong);
+    color: var(--ink);
+    font-size: calc(14.5px + var(--font-delta));
+    outline: none;
+    cursor: pointer;
+    transition: border-color 0.15s;
+}
+.record-select:hover {
+    border-color: rgba(255, 77, 77, 0.45);
+}
+.record-select:focus {
+    border-color: var(--accent);
+}
+.record-count {
+    margin-left: auto;
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+}
+
+/* 柱状图容器:固定高度,canvas 撑满,空提示居中 */
+.record-chart-wrap {
+    position: relative;
+    height: 240px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+    overflow: hidden;
+}
+.record-chart {
+    display: none;                          /* 有数据时显示 */
+    width: 100%;
+    height: 100%;
+}
+.record-chart-empty {
+    position: absolute;
+    inset: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: var(--muted);
+    font-size: calc(15px + var(--font-delta));
+}
+
+/* 记录列表:每行「姓名|名称|值|删除」 */
+.record-list {
+    flex: 1;
+    min-height: 120px;                      /* 保证列表区有最小可用高度,避免被固定元素挤压成一行 */
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    padding: 2px;
+}
+.record-row {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 8px 12px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+}
+.record-input {
+    padding: 6px 8px;
+    border: 1px solid transparent;
+    border-radius: calc(var(--radius) - 2px);
+    background: transparent;
+    color: var(--ink);
+    font-size: calc(15px + var(--font-delta));
+    outline: none;
+    transition: border-color 0.15s;
+}
+.record-input:focus {
+    border-color: rgba(255, 77, 77, 0.55);
+}
+.record-name {
+    width: 150px;
+    flex-shrink: 0;
+}
+.record-item {
+    width: 200px;
+    flex-shrink: 0;
+}
+.record-value {
+    width: 110px;
+    flex-shrink: 0;
+}
+.record-time {
+    width: 170px;
+    flex-shrink: 0;
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+}
+.record-del {
+    flex-shrink: 0;
+    margin-left: auto;
+}
+
+/* 自定义自动补全下拉:挂到 body,绝对定位于输入框下方 */
+.record-ac {
+    position: fixed;
+    z-index: 9999;
+    max-height: 220px;
+    overflow-y: auto;
+    background: var(--glass-strong);
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+    padding: 4px;
+}
+.record-ac-item {
+    padding: 7px 10px;
+    border-radius: calc(var(--radius) - 4px);
+    cursor: pointer;
+    font-size: calc(14.5px + var(--font-delta));
+    color: var(--ink);
+}
+.record-ac-item:hover {
+    background: rgba(255, 77, 77, 0.16);
+}
+
+/* 添加按钮区 */
+.record-actions {
+    display: flex;
+    justify-content: flex-start;
+}
+.record-status {
+    min-height: 16px;
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+}
+
 /* ---------- 主机页 ---------- */
 .host-cards {
     display: grid;

+ 397 - 2
frontend/src/main.js

@@ -10,7 +10,7 @@ const FALLBACK_SETTINGS = {
     autostart: true,
     alwaysOnTop: false,
     hotkey: 'CmdOrCtrl+Shift+G',
-    tabOrder: ['links', 'news', 'github', 'stock', 'todo', 'tools', 'host', 'settings'],
+    tabOrder: ['links', 'news', 'github', 'stock', 'todo', 'record', 'tools', 'host', 'settings'],
     stockCodes: ['sh000001', 'sh600519', 'sz000001'],
     links: [{name: '煎蛋', url: 'https://jandan.net/'}],
     newsKeywords: '',
@@ -19,6 +19,8 @@ const FALLBACK_SETTINGS = {
     customTabs: [],                   // 用户自定义 Tab 页面(名字 + 地址;http/https 为网页,其余为本地文件)
     apiAddr: '127.0.0.1:17890',
     todoPath: '', // 待办路径:无默认值,空值提示用户到设置页配置
+    recordPath: '', // 记录路径:无默认值,空值提示用户到设置页配置(保留兼容,记录 Tab 实际使用数据库)
+    recordDBPath: '', // 记录数据库路径:无默认值,空值提示用户到设置页配置
     backupDir: '', // 备份目录:无默认值,空值提示用户到设置页配置
     stockUpColor: '#ff5a5a',      // 股票上涨颜色(红涨)
     stockDownColor: '#4d8dff',    // 股票下跌颜色(蓝跌)
@@ -57,7 +59,7 @@ const poemReadBtn = document.getElementById('poem-read');          // 朗读按
 // Tab 名称映射:id -> 显示名(用于顺序编辑与 footer 注释)
 const TAB_NAMES = {
     links: '网址', news: '新闻', github: 'Github', stock: '股市',
-    todo: '待办', tools: '工具', host: '服务', settings: '设置',
+    todo: '待办', record: '记录', tools: '工具', host: '服务', settings: '设置',
 };
 
 // Tab 注释映射:id -> 在 footer 显示的说明文字(切换时显示,不占页面空间)
@@ -67,6 +69,7 @@ const TAB_DESCS = {
     github: '监听仓库提交与 release,本地版本低于最新版提示更新。',
     stock: '自选股实时行情,点击行查看分时 / 日K。',
     todo: '每行一个待办任务,可勾选完成、编辑、删除,保存后写入本地文件。',
+    record: '每行一条「姓名|名称|值」,保存到 SQLite 数据库;姓名/名称输入自动补全历史值,选择姓名与名称后按值绘柱状图。',
     tools: '开发/运维辅助工具:Web 服务(选目录开启浏览)、翻译(中英互译)、二维码生成。',
     host: '内置 API 服务状态、电源命令与 Gin 日志。',
     settings: '修改后点击保存立即生效,焦点离开时自动保存。',
@@ -357,8 +360,20 @@ function switchTab(tabId) {
     if (!loadedOnce.has(tabId)) {
         loadedOnce.add(tabId);
         if (tabId === 'todo') loadTodo();
+        if (tabId === 'record') loadRecords();
         if (tabId === 'host') loadHostPanel();
         if (tabId === 'tools') loadToolsPanel();
+    } else if (tabId === 'record') {
+        // 记录依赖「记录数据库路径」设置:路径可能后配置或修改,切回时必须刷新状态。
+        // 有未保存修改时保留列表,仅更新路径说明,避免覆盖用户正在编辑的内容。
+        if (recordDirty) {
+            const path = currentSettings.recordDBPath || '';
+            recordPathDesc.textContent = path
+                ? `记录(姓名|名称|值),当前数据库:${path}`
+                : '记录:未设置「记录数据库路径」,请到设置页配置后再使用';
+        } else {
+            loadRecords();
+        }
     }
     // 文件类自定义 Tab:首次打开时自动读取并渲染 Markdown
     const custom = customTabs.find(t => t.id === tabId);
@@ -410,6 +425,7 @@ const biliCookieInput = document.getElementById('bili-cookie');      // B站登
 const githubReposText = document.getElementById('github-repos');    // Github 项目(每行 地址|说明)
 const apiAddrInput = document.getElementById('api-addr');           // API 监听地址
 const todoPathInput = document.getElementById('todo-path');         // 待办(TODO)文件路径
+const recordDBPathInput = document.getElementById('record-db-path'); // 记录(Record)数据库路径
 const backupDirInput = document.getElementById('backup-dir');       // 文本备份目录(文件类自定义 Tab「备份」按钮使用)
 const customTabsText = document.getElementById('custom-tabs');      // 自定义 Tab(每行 名称|地址|备注)
 const saveButton = document.getElementById('save-settings');        // 保存按钮
@@ -524,6 +540,7 @@ async function syncForm(settings) {
     customTabsText.value = (settings.customTabs || []).map(t => `${t.name}|${t.url}${t.desc ? '|' + t.desc : ''}`).join('\n');
     apiAddrInput.value = settings.apiAddr;
     todoPathInput.value = settings.todoPath;
+    recordDBPathInput.value = settings.recordDBPath;
     backupDirInput.value = settings.backupDir;
     stockUpColorInput.value = settings.stockUpColor || FALLBACK_SETTINGS.stockUpColor;
     stockDownColorInput.value = settings.stockDownColor || FALLBACK_SETTINGS.stockDownColor;
@@ -706,6 +723,7 @@ function collectForm() {
         customTabs: parsePipeLines3(fallbackArr(customTabsText, 'customTabs'), 'name', 'url', 'desc').filter(t => t.name && t.url),
         apiAddr: apiAddrInput.value.trim(),
         todoPath: todoPathInput.value.trim(),
+        recordDBPath: recordDBPathInput.value.trim(),
         backupDir: backupDirInput.value.trim(),
         stockUpColor: fallbackColor(stockUpColorInput, currentSettings.stockUpColor, FALLBACK_SETTINGS.stockUpColor),
         stockDownColor: fallbackColor(stockDownColorInput, currentSettings.stockDownColor, FALLBACK_SETTINGS.stockDownColor),
@@ -1608,6 +1626,383 @@ if (todoPanel) {
     }).observe(todoList, { childList: true, subtree: false });
 }
 
+// ------------------------------------------------------------------------
+// 记录:每行一条「姓名|名称|值」,可录入/保存;选择姓名+名称后按值绘柱状图
+// ------------------------------------------------------------------------
+const recordList = document.getElementById('record-list');                 // 记录行容器
+const recordSaveBtn = document.getElementById('record-save');              // 保存按钮
+const recordAddBtn = document.getElementById('record-add');                // 添加记录按钮
+const recordStatus = document.getElementById('record-status');             // 保存状态提示
+const recordPathDesc = document.getElementById('record-path-desc');        // 显示当前记录文件路径
+const recordNameFilter = document.getElementById('record-name-filter');    // 姓名筛选下拉
+const recordItemFilter = document.getElementById('record-item-filter');    // 名称筛选下拉
+const recordCount = document.getElementById('record-count');               // 匹配记录数提示
+const recordChart = document.getElementById('record-chart');               // 柱状图画布
+const recordChartEmpty = document.getElementById('record-chart-empty');    // 柱状图空提示
+let recordDirty = false; // 是否有未保存修改
+let recordData = [];     // 当前记录数组 [{name, item, value}]
+
+// 自动补全历史值(来自数据库,去重):姓名 / 名称
+// 用自定义下拉替代 <datalist>:WebView2 的 datalist 在输入框为空、仅聚焦点击时不会弹出候选,
+// 用户无法「选择之前输入过的值」。自定义下拉在聚焦时即展示全部历史值,点击即可填充。
+let recordNameOptions = [];
+let recordItemOptions = [];
+
+// 自动补全候选 = 数据库历史值 ∪ 当前页所有行已输入的值(去重,供聚焦时实时读取)。
+// 这样本次「添加记录」中新输入(含未保存)的姓名/名称也能被其他行的输入框选中,减少重复输入。
+function mergeRecordCandidates(dbOptions, selector) {
+    const set = new Set(dbOptions || []);
+    recordList.querySelectorAll(selector).forEach(inp => {
+        const v = inp.value.trim();
+        if (v) set.add(v);
+    });
+    return [...set];
+}
+
+// 创建自动补全下拉并绑定到输入框:聚焦时展示历史值(可按输入内容过滤),点击填充
+// optionsRef 为 getter 函数(() => string[]):每次聚焦实时读取全局候选,避免数组快照失效
+function attachAutocomplete(input, optionsRef, onChange) {
+    const dl = document.createElement('div');
+    dl.className = 'record-ac';
+    dl.style.display = 'none';
+    document.body.appendChild(dl); // 挂到 body,避免被 record-list 的 overflow 裁剪
+
+    const renderList = () => {
+        const kw = input.value.trim();
+        const opts = optionsRef().filter(o => !kw || o.toLowerCase().includes(kw.toLowerCase()));
+        if (!opts.length) { dl.style.display = 'none'; return; }
+        dl.innerHTML = '';
+        opts.forEach(o => {
+            const item = document.createElement('div');
+            item.className = 'record-ac-item';
+            item.textContent = o;
+            item.addEventListener('mousedown', (e) => {
+                e.preventDefault(); // 防止输入框失焦导致列表先关闭
+                input.value = o;
+                dl.style.display = 'none';
+                if (onChange) onChange();
+            });
+            dl.appendChild(item);
+        });
+        dl.style.display = 'block';
+        // 定位到输入框下方
+        const r = input.getBoundingClientRect();
+        dl.style.left = r.left + 'px';
+        dl.style.top = (r.bottom + 2) + 'px';
+        dl.style.width = r.width + 'px';
+    };
+
+    input.addEventListener('focus', renderList);
+    input.addEventListener('input', renderList);
+    input.addEventListener('blur', () => {
+        // 延迟隐藏,给 mousedown 点击选项的时间
+        setTimeout(() => { dl.style.display = 'none'; }, 150);
+    });
+    // 窗口滚动/尺寸变化时关闭,避免定位错位
+    window.addEventListener('resize', () => { dl.style.display = 'none'; }, { passive: true });
+    input._ac = dl; // 供销毁时清理
+}
+
+// 渲染一行记录:姓名输入(自动补全)+ 名称输入(自动补全)+ 值输入 + 时间 + 删除按钮
+function renderRecordRow(name, item, value, createdAt) {
+    const row = document.createElement('div');
+    row.className = 'record-row';
+
+    const nameInput = document.createElement('input');
+    nameInput.type = 'text';
+    nameInput.className = 'record-input record-name';
+    nameInput.value = name;
+    nameInput.placeholder = '姓名';
+    nameInput.spellcheck = false;
+    nameInput.setAttribute('list', 'record-names-dl'); // 保留 list 属性作为备用(自定义下拉为主)
+    nameInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
+    nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
+    attachAutocomplete(nameInput, () => mergeRecordCandidates(recordNameOptions, '.record-name'), () => { recordDirty = true; refreshRecordFilterOptions(); });
+
+    const itemInput = document.createElement('input');
+    itemInput.type = 'text';
+    itemInput.className = 'record-input record-item';
+    itemInput.value = item;
+    itemInput.placeholder = '名称';
+    itemInput.spellcheck = false;
+    itemInput.setAttribute('list', 'record-items-dl'); // 保留 list 属性作为备用(自定义下拉为主)
+    itemInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
+    itemInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
+    attachAutocomplete(itemInput, () => mergeRecordCandidates(recordItemOptions, '.record-item'), () => { recordDirty = true; refreshRecordFilterOptions(); });
+
+    const valueInput = document.createElement('input');
+    valueInput.type = 'number';
+    valueInput.className = 'record-input record-value';
+    valueInput.value = value;
+    valueInput.placeholder = '值';
+    valueInput.addEventListener('input', () => { recordDirty = true; redrawRecordChart(); });
+
+    // 录入时间:新增时自动填当前时间(可编辑,便于回溯)
+    const timeInput = document.createElement('input');
+    timeInput.type = 'text';
+    timeInput.className = 'record-input record-time';
+    timeInput.value = createdAt || formatNow();
+    timeInput.placeholder = '时间';
+    timeInput.title = '录入时间(自动记录当前时间,可修改)';
+    timeInput.addEventListener('input', () => { recordDirty = true; });
+
+    const del = document.createElement('button');
+    del.className = 'btn-ghost record-del';
+    del.textContent = '删除';
+    del.title = '删除此行';
+    del.addEventListener('click', () => {
+        row.remove();
+        if (nameInput._ac) nameInput._ac.remove();
+        if (itemInput._ac) itemInput._ac.remove();
+        recordDirty = true;
+        refreshRecordFilterOptions();
+        redrawRecordChart();
+    });
+
+    row.appendChild(nameInput);
+    row.appendChild(itemInput);
+    row.appendChild(valueInput);
+    row.appendChild(timeInput);
+    row.appendChild(del);
+    return row;
+}
+
+// 格式化当前时间为 YYYY-MM-DD HH:MM:SS(与数据库 datetime('now','localtime') 格式一致)
+function formatNow() {
+    const d = new Date();
+    const p = (n) => String(n).padStart(2, '0');
+    return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
+}
+
+// 添加一行空的记录(自动记录当前时间,聚焦姓名输入框)
+function addRecordRow() {
+    const row = renderRecordRow('', '', '', '');
+    recordList.appendChild(row);
+    row.querySelector('.record-name').focus();
+    recordDirty = true;
+}
+
+// 收集所有记录行的数据(过滤全空行)
+function collectRecords() {
+    const rows = [];
+    recordList.querySelectorAll('.record-row').forEach(row => {
+        const name = row.querySelector('.record-name').value.trim();
+        const item = row.querySelector('.record-item').value.trim();
+        const value = row.querySelector('.record-value').value.trim();
+        if (!name && !item && !value) return; // 全空行跳过
+        rows.push({ name, item, value: parseFloat(value) || 0, createdAt: row.querySelector('.record-time').value.trim() });
+    });
+    return rows;
+}
+
+// 刷新姓名/名称下拉的候选(来自当前所有记录行,去重)
+function refreshRecordFilterOptions() {
+    const names = new Set();
+    const items = new Set();
+    recordList.querySelectorAll('.record-row').forEach(row => {
+        const n = row.querySelector('.record-name').value.trim();
+        const it = row.querySelector('.record-item').value.trim();
+        if (n) names.add(n);
+        if (it) items.add(it);
+    });
+    // 保留当前选中值
+    const curName = recordNameFilter.value;
+    const curItem = recordItemFilter.value;
+    recordNameFilter.innerHTML = '<option value="">全部</option>';
+    [...names].sort().forEach(n => {
+        const o = document.createElement('option');
+        o.value = n; o.textContent = n;
+        recordNameFilter.appendChild(o);
+    });
+    if (curName && names.has(curName)) recordNameFilter.value = curName;
+    recordItemFilter.innerHTML = '<option value="">全部</option>';
+    [...items].sort().forEach(it => {
+        const o = document.createElement('option');
+        o.value = it; o.textContent = it;
+        recordItemFilter.appendChild(o);
+    });
+    if (curItem && items.has(curItem)) recordItemFilter.value = curItem;
+    redrawRecordChart();
+}
+
+// 刷新姓名/名称自动补全候选:从数据库拉取历史值(去重),供自定义下拉使用
+async function loadRecordAutocomplete() {
+    try {
+        const [names, items] = await Promise.all([
+            SettingsService.GetRecordNames(),
+            SettingsService.GetRecordItems(),
+        ]);
+        recordNameOptions = names || [];
+        recordItemOptions = items || [];
+        // 同步到 datalist 作为备用(部分场景下浏览器原生补全仍可用)
+        const namesDl = document.getElementById('record-names-dl');
+        const itemsDl = document.getElementById('record-items-dl');
+        if (namesDl) namesDl.innerHTML = recordNameOptions.map(n => `<option value="${escapeHtml(n)}"></option>`).join('');
+        if (itemsDl) itemsDl.innerHTML = recordItemOptions.map(it => `<option value="${escapeHtml(it)}"></option>`).join('');
+    } catch (err) {
+        // 自动补全候选加载失败不阻断主流程(未配置数据库路径时后端返回错误)
+    }
+}
+
+// 从后端读取记录并渲染
+// 清理所有已挂到 body 的自定义补全下拉(列表重建/清空前调用,避免残留隐藏节点累积)
+function clearRecordAutocomplete() {
+    document.querySelectorAll('.record-ac').forEach(el => el.remove());
+}
+
+async function loadRecords() {
+    const path = currentSettings.recordDBPath || '';
+    recordPathDesc.textContent = path
+        ? `记录(姓名|名称|值),当前数据库:${path}`
+        : '记录:未设置「记录数据库路径」,请到设置页配置后再使用';
+    clearRecordAutocomplete();
+    recordList.innerHTML = '';
+    if (!path) {
+        const hint = document.createElement('div');
+        hint.className = 'empty-state';
+        hint.innerHTML = `<div class="empty-mark">⚙</div><div class="empty-title">请先设置记录数据库路径</div><div class="empty-text">前往「设置」页填写「记录数据库路径」后保存即可使用</div>`;
+        const goBtn = document.createElement('button');
+        goBtn.className = 'btn-ghost';
+        goBtn.textContent = '前往设置';
+        goBtn.style.marginTop = '10px';
+        goBtn.addEventListener('click', () => switchTab('settings'));
+        hint.appendChild(goBtn);
+        recordList.appendChild(hint);
+        recordDirty = false;
+        recordStatus.textContent = '';
+        refreshRecordFilterOptions();
+        loadRecordAutocomplete();
+        return;
+    }
+    try {
+        const items = await SettingsService.ReadRecords();
+        if (items.length) {
+            items.forEach(it => recordList.appendChild(renderRecordRow(it.name, it.item, String(it.value), it.createdAt)));
+        } else {
+            addRecordRow();
+        }
+        recordDirty = false;
+        recordStatus.textContent = '';
+        refreshRecordFilterOptions();
+        loadRecordAutocomplete();
+    } catch (err) {
+        recordStatus.textContent = '读取失败:' + (err?.message || err);
+    }
+}
+
+// 保存所有记录:收集行数据,全量写入数据库(跳过空行)
+async function saveRecords() {
+    const items = [];
+    recordList.querySelectorAll('.record-row').forEach(row => {
+        const name = row.querySelector('.record-name').value.trim();
+        const item = row.querySelector('.record-item').value.trim();
+        const value = row.querySelector('.record-value').value.trim();
+        if (!name && !item && !value) return; // 空行不保存
+        items.push({ name, item, value: parseFloat(value) || 0, createdAt: row.querySelector('.record-time').value.trim() || formatNow() });
+    });
+    recordStatus.textContent = '保存中…';
+    try {
+        await SettingsService.WriteRecords(items);
+        recordStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
+        recordDirty = false;
+        refreshRecordFilterOptions();
+        loadRecordAutocomplete(); // 保存后刷新自动补全候选(新姓名/名称加入历史)
+    } catch (err) {
+        recordStatus.textContent = '保存失败:' + (err?.message || err);
+    }
+}
+
+// 计算柱状图数据:必须同时选择姓名与名称,才按该组合过滤并绘图。
+// 不同姓名、不同名称绝不混在同一张图上——只有同时选定姓名和名称,
+// 才显示该组合下的全部记录(每行一根柱,展示其随时间的值变化)。
+function recordChartData() {
+    const selName = recordNameFilter.value;
+    const selItem = recordItemFilter.value;
+    if (!selName || !selItem) return []; // 未同时选定姓名与名称:不出图
+    return collectRecords()
+        .filter(r => r.name === selName && r.item === selItem)
+        .map(r => ({ label: `${r.name}/${r.item}`, value: r.value }));
+}
+
+// 绘制柱状图(canvas),无数据时显示空提示
+function redrawRecordChart() {
+    const data = recordChartData();
+    if (!recordChart) return;
+    if (!data.length) {
+        recordChartEmpty.style.display = 'flex';   // 空提示居中(CSS 默认为 flex)
+        recordChart.style.display = 'none';
+        recordCount.textContent = '0 条记录';
+        return;
+    }
+    recordChartEmpty.style.display = 'none';
+    recordChart.style.display = 'block';           // canvas 用块级显示,否则 CSS 的 display:none 使其永不出现
+    recordCount.textContent = `${data.length} 条记录`;
+
+    const dpr = window.devicePixelRatio || 1;
+    const rect = recordChart.getBoundingClientRect();
+    const w = Math.max(rect.width, 300);
+    const h = Math.max(rect.height, 220);
+    recordChart.width = w * dpr;
+    recordChart.height = h * dpr;
+    const ctx = recordChart.getContext('2d');
+    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+    ctx.clearRect(0, 0, w, h);
+
+    const padL = 44, padR = 12, padT = 14, padB = 30;
+    const plotW = w - padL - padR, plotH = h - padT - padB;
+    const maxVal = Math.max(...data.map(d => Math.abs(d.value)), 1);
+    const barW = Math.min(46, Math.max(14, plotW / data.length * 0.62));
+    const gap = plotW / data.length;
+
+    // 背景网格线 + Y 轴刻度
+    ctx.strokeStyle = 'rgba(127,127,127,0.18)';
+    ctx.fillStyle = 'rgba(127,127,127,0.7)';
+    ctx.lineWidth = 1;
+    ctx.font = '10px sans-serif';
+    ctx.textAlign = 'right';
+    for (let i = 0; i <= 4; i++) {
+        const val = maxVal * i / 4;
+        const y = padT + plotH - plotH * (i / 4);
+        ctx.beginPath();
+        ctx.moveTo(padL, y);
+        ctx.lineTo(w - padR, y);
+        ctx.stroke();
+        ctx.fillText(val.toFixed(0), padL - 5, y + 3);
+    }
+
+    // 柱
+    const barColor = 'rgba(255,90,90,0.85)';
+    ctx.textAlign = 'center';
+    data.forEach((d, i) => {
+        const cx = padL + gap * i + gap / 2;
+        const bh = Math.abs(d.value) / maxVal * plotH;
+        const y0 = padT + plotH - bh;
+        ctx.fillStyle = barColor;
+        ctx.fillRect(cx - barW / 2, y0, barW, bh);
+        // 值标注
+        ctx.fillStyle = 'rgba(255,255,255,0.9)';
+        ctx.fillText(d.value.toFixed(1), cx, y0 - 4);
+        // x 轴标签:姓名/名称(长标签截断,避免重叠)
+        const lbl = d.label || '·';
+        ctx.fillText(lbl.length > 10 ? lbl.slice(0, 10) + '…' : lbl, cx, h - 10);
+    });
+}
+
+recordSaveBtn.addEventListener('click', saveRecords);
+recordAddBtn.addEventListener('click', addRecordRow);
+recordNameFilter.addEventListener('change', redrawRecordChart);
+recordItemFilter.addEventListener('change', redrawRecordChart);
+
+// 记录页 DOM 变化时自动刷新下拉与图表(例如删除行)
+const recordPanel = document.getElementById('tab-record');
+if (recordPanel) {
+    new MutationObserver(() => {
+        if (recordDirty) { refreshRecordFilterOptions(); }
+    }).observe(recordList, { childList: true, subtree: false });
+}
+// 窗口尺寸变化时重绘柱状图
+window.addEventListener('resize', () => { redrawRecordChart(); });
+
 // ------------------------------------------------------------------------
 // 主机:API 服务状态、电源命令状态与 Gin 日志
 // ------------------------------------------------------------------------

+ 10 - 2
go.mod

@@ -10,6 +10,7 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/wailsapp/wails/v3 v3.0.0
 	golang.org/x/text v0.42.0
+	modernc.org/sqlite v1.61.1
 )
 
 require (
@@ -20,6 +21,7 @@ require (
 	github.com/bytedance/sonic/loader v0.5.0 // indirect
 	github.com/cloudwego/base64x v0.1.6 // indirect
 	github.com/coder/websocket v1.8.14 // indirect
+	github.com/dustin/go-humanize v1.1.0 // indirect
 	github.com/esiqveland/notify v0.13.3 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.12 // indirect
 	github.com/gen2brain/shm v0.1.0 // indirect
@@ -31,6 +33,7 @@ require (
 	github.com/goccy/go-json v0.10.5 // indirect
 	github.com/goccy/go-yaml v1.19.2 // indirect
 	github.com/godbus/dbus/v5 v5.2.2 // indirect
+	github.com/google/uuid v1.6.0 // indirect
 	github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
 	github.com/jezek/xgb v1.1.1 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
@@ -38,13 +41,15 @@ require (
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
 	github.com/mattn/go-colorable v0.1.14 // indirect
-	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-isatty v0.0.24 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/ncruces/go-strftime v1.1.0 // indirect
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/quic-go v0.59.0 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/sergeymakinen/go-bmp v1.0.0 // indirect
 	github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
 	github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
@@ -55,6 +60,9 @@ require (
 	golang.org/x/crypto v0.57.0 // indirect
 	golang.org/x/image v0.45.0 // indirect
 	golang.org/x/net v0.60.0 // indirect
-	golang.org/x/sys v0.48.0 // indirect
+	golang.org/x/sys v0.49.0 // indirect
 	google.golang.org/protobuf v1.36.10 // indirect
+	modernc.org/libc v1.79.0 // indirect
+	modernc.org/mathutil v1.7.1 // indirect
+	modernc.org/memory v1.12.1 // indirect
 )

+ 50 - 5
go.sum

@@ -15,6 +15,8 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
+github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
 github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
 github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=
 github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
@@ -51,6 +53,12 @@ github.com/gomarkdown/markdown v0.0.0-20261026184151-79e163b7b632/go.mod h1:zLCv
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o=
 github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=
 github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
@@ -69,13 +77,15 @@ github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
 github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
+github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -86,6 +96,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
 github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
 github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
 github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
 github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
 github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
@@ -121,18 +133,51 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
 golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
 golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
 golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
+golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
+golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
 golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
 golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
+golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
+golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
 golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
-golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
+golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
 golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
 golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
+golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
+golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
 google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
 google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
+modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
+modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
+modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
+modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.61.1 h1:TBEE49wz7VlxS+qXRXMQiPUZYOXHJ4ku83daYv1fe04=
+modernc.org/sqlite v1.61.1/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

+ 101 - 44
mousealert_windows.go

@@ -72,6 +72,14 @@ type point struct {
 	Y int32
 }
 
+// rect Windows RECT 结构(GetClientRect 输出)
+type rect struct {
+	Left   int32
+	Top    int32
+	Right  int32
+	Bottom int32
+}
+
 type msg struct {
 	HWND    uintptr
 	Message uint32
@@ -149,6 +157,9 @@ var (
 	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")
@@ -190,28 +201,43 @@ var mouseAlert = &mouseAlertState{}
 
 // triggerMouseAlert 触发鼠标提醒:显示闪烁光标 + 光标下方透明文字。
 // 文字为空时用默认提示。重复触发会刷新提醒计时并立即显示。
+//
+// 定位策略:主动用 GetCursorPos 读取当前鼠标位置并记录为 lastX/lastY,
+// 再通过 PostMessage 唤醒窗口线程立即重绘,避免依赖全局钩子回调(钩子
+// 回调因负担重可能被系统卸载,导致窗口停留在旧位置不跟随鼠标)。
 func triggerMouseAlert(text string) {
-	mouseAlert.mu.Lock()
 	if text == "" {
 		text = "有新消息提醒"
 	}
+
+	// 读取当前鼠标位置,供窗口重绘时定位(不依赖钩子回调)
+	var pt point
+	curRet, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
+	hasCursor := curRet != 0
+
+	mouseAlert.mu.Lock()
 	mouseAlert.text = text
 	mouseAlert.active = true
 	mouseAlert.flashOn = true
 	mouseAlert.stopTime = time.Now().Add(alertDuration)
+	if hasCursor {
+		mouseAlert.lastX, mouseAlert.lastY = pt.X, pt.Y
+	}
 	started := mouseAlert.started
 	mouseAlert.started = true
 	overlay := mouseAlert.overlayWnd
 	mouseAlert.mu.Unlock()
 
-	// 首次触发:启动常驻工作线程(幂等,之后不再重复创建)
+	// 首次触发:启动常驻工作线程(幂等),建窗后由线程主动定位并重绘
 	if !started {
 		go mouseAlertLoop()
 		return
 	}
-	// 已启动:立即显示窗口(位置由钩子回调或定时器补充)
+	// 已启动:唤醒窗口线程立即重绘(窗口线程负责 SetWindowPos/绘制,
+	// 避免跨线程操作窗口;若窗口尚在初始化,线程建窗后也会主动绘制)
 	if overlay != 0 {
 		procShowWindow.Call(overlay, 5) // SW_SHOW
+		procPostMessage.Call(overlay, wmTimer, timerFlash, 0)
 	}
 }
 
@@ -271,6 +297,23 @@ func mouseAlertLoop() {
 	// 启动闪烁定时器
 	if overlay != 0 {
 		procSetTimer.Call(overlay, timerFlash, flashIntervalMs, 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)
+			} else {
+				mouseAlert.mu.Unlock()
+			}
+		}
 	}
 
 	// 消息循环(常驻,直到线程退出)
@@ -315,40 +358,22 @@ func (s *mouseAlertState) wndProcAddr() uintptr {
 	return s.wndProc
 }
 
-// hookCallback 全局低层鼠标钩子回调:跟踪鼠标位置、驱动自动消退与重绘。
-// 注意:此回调在钩子线程上下文执行,必须快速返回。
+// 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 {
-	if nCode >= 0 {
-		info := (*msllhookstruct)(unsafe.Pointer(lParam))
-		s.mu.Lock()
-		if s.active {
-			s.lastX, s.lastY = info.Pt.X, info.Pt.Y
-			expired := !s.stopTime.IsZero() && time.Now().After(s.stopTime)
-			if expired {
-				s.active = false
-			}
-			overlay := s.overlayWnd
-			text := s.text
-			flashOn := s.flashOn
-			x, y := s.lastX, s.lastY
-			s.mu.Unlock()
-			if expired {
-				if overlay != 0 {
-					procShowWindow.Call(overlay, 0) // SW_HIDE
-				}
-			} else {
-				s.renderOverlay(overlay, text, flashOn, x, y)
-			}
-		} else {
-			s.mu.Unlock()
-		}
-	}
 	ret, _, _ := procCallNextHookEx.Call(0, uintptr(nCode), wParam, lParam)
 	return ret
 }
 
 // renderOverlay 定位覆盖层到鼠标位置并重绘闪烁光标 + 下方透明文字。
-// 在钩子线程内调用,窗口/DC 操作均为同线程。
+// 在窗口线程内调用(mouseAlertLoop 建窗后 / wndProcCallback 定时器),
+// 窗口/DC 操作均为同线程,避免跨线程操作窗口导致绘制失败。
 func (s *mouseAlertState) renderOverlay(overlay uintptr, text string, flashOn bool, x, y int32) {
 	if overlay == 0 {
 		return
@@ -365,8 +390,17 @@ func (s *mouseAlertState) renderOverlay(overlay uintptr, text string, flashOn bo
 
 // drawLayered 用 32 位 DIB + UpdateLayeredWindow 绘制闪烁光标环与半透明文字。
 func (s *mouseAlertState) drawLayered(overlay uintptr, text string, flashOn bool) {
-	const w = overlayWidth
-	const h = overlayHeight
+	// 用窗口实际总尺寸创建 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 {
@@ -383,13 +417,13 @@ func (s *mouseAlertState) drawLayered(overlay uintptr, text string, flashOn bool
 	// 32 位 DIB 位图(负高度:自顶向下)
 	bi := bitmapinfoheader{}
 	bi.BiSize = 40 // sizeof(BITMAPINFOHEADER)
-	bi.BiWidth = w
-	bi.BiHeight = -h
+	bi.BiWidth = int32(w)
+	bi.BiHeight = int32(-h)
 	bi.BiPlanes = 1
 	bi.BiBitCount = 32
 	bi.BiCompression = biRgb
 
-	var bits uintptr
+	var bits unsafe.Pointer
 	hBitmap, _, _ := procCreateDIBSection.Call(hdcScreen,
 		uintptr(unsafe.Pointer(&bi)), 0, uintptr(unsafe.Pointer(&bits)), 0, 0)
 	if hBitmap == 0 {
@@ -416,36 +450,44 @@ func (s *mouseAlertState) drawLayered(overlay uintptr, text string, flashOn bool
 
 	procSelectObject.Call(hdcMem, oldBmp)
 
-	// UpdateLayeredWindow:pptDst 传 nil 保持 SetWindowPos 设定的位置
+	// 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,
-		0, 0, // pptDst=nil, psize=nil
+		uintptr(unsafe.Pointer(&dstPt)), // pptDst = 窗口左上角屏幕坐标
+		uintptr(unsafe.Pointer(&szPt)),  // psize = 窗口尺寸
 		hdcMem, 0, 0, uintptr(unsafe.Pointer(&blend)), 0x00000002 /*ULW_ALPHA*/)
 }
 
 // clearPixels 把 DIB 位图全部置为透明(ARGB=0)
-func clearPixels(bits uintptr, w, h int) {
-	if bits == 0 {
+func clearPixels(bits unsafe.Pointer, w, h int) {
+	if bits == nil {
 		return
 	}
 	total := uintptr(w) * uintptr(h) * 4
 	for i := uintptr(0); i+8 <= total; i += 8 {
-		*(*uint64)(unsafe.Pointer(bits + i)) = 0
+		*(*uint64)(unsafe.Add(bits, i)) = 0
 	}
 }
 
 // setAlphaFromLuma 按亮度补全 alpha:非透明像素的 alpha = max(r,g,b),
 // 文字区(y >= textTopY)乘以 3/4 实现半透明。
-func setAlphaFromLuma(bits uintptr, w, h int) {
-	if bits == 0 {
+func setAlphaFromLuma(bits unsafe.Pointer, w, h int) {
+	if bits == nil {
 		return
 	}
 	for y := 0; y < h; y++ {
 		for x := 0; x < w; x++ {
-			p := (*uint32)(unsafe.Pointer(bits + uintptr(y*w+x)*4))
+			p := (*uint32)(unsafe.Add(bits, uintptr(y*w+x)*4))
 			v := *p
 			b := uint8(v)
 			g := uint8(v >> 8)
@@ -519,8 +561,23 @@ func (s *mouseAlertState) wndProcCallback(hwnd uintptr, msg uint32, wParam uintp
 	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

+ 152 - 0
recordservice.go

@@ -0,0 +1,152 @@
+// ============================================================================
+// recordservice.go — 记录(Record)SQLite 存储
+//
+// 记录 Tab 的数据持久化:每行一条「姓名|名称|值」,全部保存到 SQLite3 数据库
+// (modernc.org/sqlite,纯 Go 驱动,无 CGO)。姓名/名称输入框自动补全的候选
+// 来自数据库中已存在的历史值(去重)。
+// ============================================================================
+package main
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	_ "modernc.org/sqlite" // 纯 Go SQLite 驱动(无需 CGO)
+)
+
+// RecordItem 一条记录:姓名 + 名称 + 值 + 录入时间,供记录 Tab 列表与柱状图使用
+type RecordItem struct {
+	Name      string  `json:"name"`      // 姓名
+	Item      string  `json:"item"`      // 名称
+	Value     float64 `json:"value"`     // 值
+	CreatedAt string  `json:"createdAt"` // 录入时间 YYYY-MM-DD HH:MM:SS(前端新增时生成;为空时后端用当前时间)
+}
+
+// openRecordDB 打开(或创建)记录数据库并确保表结构存在。
+// dbPath 为空时返回错误,提示用户到设置页配置。
+func openRecordDB(dbPath string) (*sql.DB, error) {
+	dbPath = strings.TrimSpace(dbPath)
+	if dbPath == "" {
+		return nil, errors.New("未设置「记录数据库路径」,请到设置页配置后再使用")
+	}
+	if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
+		return nil, err
+	}
+	// DSN 加 _pragma 参数:启用 WAL 提升并发读;foreign_keys 开启
+	dsn := "file:" + filepath.ToSlash(dbPath) + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)"
+	db, err := sql.Open("sqlite", dsn)
+	if err != nil {
+		return nil, err
+	}
+	if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS records (
+		id         INTEGER PRIMARY KEY AUTOINCREMENT,
+		name       TEXT NOT NULL DEFAULT '',
+		item       TEXT NOT NULL DEFAULT '',
+		value      REAL NOT NULL DEFAULT 0,
+		created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
+	)`); err != nil {
+		db.Close()
+		return nil, err
+	}
+	return db, nil
+}
+
+// ReadRecords 读取全部记录(按 id 升序,保持录入顺序)。
+// 前端 loadRecords 渲染列表;柱状图按返回顺序绘制。
+func (s *SettingsService) ReadRecords() ([]RecordItem, error) {
+	db, err := openRecordDB(loadSettings().RecordDBPath)
+	if err != nil {
+		return nil, err
+	}
+	defer db.Close()
+	rows, err := db.Query(`SELECT name, item, value, created_at FROM records ORDER BY id ASC`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := make([]RecordItem, 0, 64)
+	for rows.Next() {
+		var it RecordItem
+		if err := rows.Scan(&it.Name, &it.Item, &it.Value, &it.CreatedAt); err != nil {
+			return nil, err
+		}
+		items = append(items, it)
+	}
+	return items, rows.Err()
+}
+
+// WriteRecords 全量替换记录:开启事务,先清空再批量插入。
+// 前端 saveRecords 提交当前列表全部行,保证与数据库完全一致。
+func (s *SettingsService) WriteRecords(items []RecordItem) error {
+	db, err := openRecordDB(loadSettings().RecordDBPath)
+	if err != nil {
+		return err
+	}
+	defer db.Close()
+	tx, err := db.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+	if _, err := tx.Exec(`DELETE FROM records`); err != nil {
+		return err
+	}
+	stmt, err := tx.Prepare(`INSERT INTO records (name, item, value, created_at) VALUES (?, ?, ?, ?)`)
+	if err != nil {
+		return err
+	}
+	defer stmt.Close()
+	now := time.Now().Format("2006-01-02 15:04:05")
+	for _, it := range items {
+		ts := strings.TrimSpace(it.CreatedAt)
+		if ts == "" {
+			ts = now // 前端未传时间时用当前时间(数据库默认值仅在列缺省时生效,显式插入需自行填充)
+		}
+		if _, err := stmt.Exec(strings.TrimSpace(it.Name), strings.TrimSpace(it.Item), it.Value, ts); err != nil {
+			return err
+		}
+	}
+	return tx.Commit()
+}
+
+// GetRecordNames 返回所有历史姓名(去重排序),供姓名输入框自动补全。
+func (s *SettingsService) GetRecordNames() ([]string, error) {
+	return recordDistinctValues("name")
+}
+
+// GetRecordItems 返回所有历史名称(去重排序),供名称输入框自动补全。
+func (s *SettingsService) GetRecordItems() ([]string, error) {
+	return recordDistinctValues("item")
+}
+
+// recordDistinctValues 查询某列去重后的非空值(排序),供自动补全候选。
+func recordDistinctValues(col string) ([]string, error) {
+	// col 仅允许 name/item 白名单,避免 SQL 注入
+	if col != "name" && col != "item" {
+		return nil, fmt.Errorf("非法列名: %s", col)
+	}
+	db, err := openRecordDB(loadSettings().RecordDBPath)
+	if err != nil {
+		return nil, err
+	}
+	defer db.Close()
+	rows, err := db.Query(`SELECT DISTINCT ` + col + ` FROM records WHERE TRIM(` + col + `) <> '' ORDER BY ` + col + ` COLLATE NOCASE ASC`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	vals := make([]string, 0, 32)
+	for rows.Next() {
+		var v string
+		if err := rows.Scan(&v); err != nil {
+			return nil, err
+		}
+		vals = append(vals, v)
+	}
+	return vals, rows.Err()
+}

+ 117 - 0
recordservice_test.go

@@ -0,0 +1,117 @@
+package main
+
+import (
+	"database/sql"
+	"path/filepath"
+	"testing"
+)
+
+// TestRecordServiceSQLite 验证记录 SQLite 存储:建表、插入、查询、去重、全量替换。
+// 直接测试 openRecordDB 返回的 *sql.DB(绕过 SettingsService 的 loadSettings 依赖)。
+func TestRecordServiceSQLite(t *testing.T) {
+	dir := t.TempDir()
+	dbPath := filepath.Join(dir, "records.db")
+
+	// 未配置路径时应报错(openRecordDB 空路径)
+	if _, err := openRecordDB(""); err == nil {
+		t.Fatal("空数据库路径应返回错误")
+	}
+
+	// 打开(自动建表 + WAL)
+	db, err := openRecordDB(dbPath)
+	if err != nil {
+		t.Fatalf("openRecordDB 失败: %v", err)
+	}
+	defer db.Close()
+
+	// 插入 3 条
+	for _, r := range []struct{ n, i string; v float64 }{
+		{"张三", "一月", 12.5},
+		{"张三", "二月", 30},
+		{"李四", "一月", 8.2},
+	} {
+		if _, err := db.Exec(`INSERT INTO records (name, item, value) VALUES (?,?,?)`, r.n, r.i, r.v); err != nil {
+			t.Fatalf("插入失败: %v", err)
+		}
+	}
+
+	// 按 id 升序读取
+	rows, err := db.Query(`SELECT name, item, value FROM records ORDER BY id ASC`)
+	if err != nil {
+		t.Fatalf("查询失败: %v", err)
+	}
+	var got []RecordItem
+	for rows.Next() {
+		var it RecordItem
+		if err := rows.Scan(&it.Name, &it.Item, &it.Value); err != nil {
+			t.Fatalf("扫描失败: %v", err)
+		}
+		got = append(got, it)
+	}
+	rows.Close()
+	if len(got) != 3 {
+		t.Fatalf("期望 3 条记录,实际 %d", len(got))
+	}
+	if got[0].Name != "张三" || got[0].Item != "一月" || got[0].Value != 12.5 {
+		t.Fatalf("首条记录不符: %+v", got[0])
+	}
+
+	// 去重姓名(UTF-8 字节序:张三 < 李四)
+	names := distinctTest(t, db, "name")
+	if len(names) != 2 || names[0] != "张三" || names[1] != "李四" {
+		t.Fatalf("去重姓名不符: %v", names)
+	}
+	// 去重名称(排序:一月、二月)
+	items := distinctTest(t, db, "item")
+	if len(items) != 2 || items[0] != "一月" || items[1] != "二月" {
+		t.Fatalf("去重名称不符: %v", items)
+	}
+
+	// 非法列名应报错
+	if _, err := recordDistinctValues("evil"); err == nil {
+		t.Fatal("非法列名应返回错误")
+	}
+
+	// 全量替换:清空后写 1 条
+	tx, err := db.Begin()
+	if err != nil {
+		t.Fatalf("开启事务失败: %v", err)
+	}
+	if _, err := tx.Exec(`DELETE FROM records`); err != nil {
+		t.Fatalf("清空失败: %v", err)
+	}
+	stmt, err := tx.Prepare(`INSERT INTO records (name, item, value) VALUES (?,?,?)`)
+	if err != nil {
+		t.Fatalf("准备语句失败: %v", err)
+	}
+	if _, err := stmt.Exec("王五", "三月", 99); err != nil {
+		t.Fatalf("替换插入失败: %v", err)
+	}
+	stmt.Close()
+	if err := tx.Commit(); err != nil {
+		t.Fatalf("提交失败: %v", err)
+	}
+	var cnt int
+	if err := db.QueryRow(`SELECT COUNT(*) FROM records`).Scan(&cnt); err != nil || cnt != 1 {
+		t.Fatalf("全量替换后应有 1 条,实际 %d (err=%v)", cnt, err)
+	}
+}
+
+// distinctTest 对已打开 db 执行与 recordDistinctValues 相同的去重 SQL。
+func distinctTest(t *testing.T, db *sql.DB, col string) []string {
+	t.Helper()
+	rows, err := db.Query(`SELECT DISTINCT ` + col + ` FROM records WHERE TRIM(` + col + `) <> '' ORDER BY ` + col + ` COLLATE NOCASE ASC`)
+	if err != nil {
+		t.Fatalf("去重查询失败: %v", err)
+	}
+	defer rows.Close()
+	var vals []string
+	for rows.Next() {
+		var v string
+		if err := rows.Scan(&v); err != nil {
+			t.Fatalf("扫描去重值失败: %v", err)
+		}
+		vals = append(vals, v)
+	}
+	return vals
+}

+ 8 - 4
settings.go

@@ -77,6 +77,8 @@ type AppSettings struct {
 	CustomTabs   []CustomTab  `json:"customTabs"`   // 用户自定义 Tab 页面(名字 + 网址)
 	APIAddr      string       `json:"apiAddr"`      // 内置 API 服务监听地址
 	TodoPath     string       `json:"todoPath"`     // 待办(TODO)文件路径
+	RecordPath   string       `json:"recordPath"`   // 记录(Record)文件路径:每行「姓名|名称|值」(保留兼容)
+	RecordDBPath string       `json:"recordDBPath"` // 记录数据库路径:SQLite3 数据库文件(记录 Tab 实际存储)
 	BackupDir    string       `json:"backupDir"`    // 文本文件备份目录(文件类自定义 Tab「备份」按钮使用)
 	StockTicker  TickerConfig `json:"stockTicker"`  // 股票滚动条配置
 	NewsTicker   TickerConfig `json:"newsTicker"`   // 新闻滚动条配置
@@ -105,7 +107,7 @@ func defaultSettings() AppSettings {
 		Autostart:   true,
 		AlwaysOnTop: false,
 		Hotkey:      DefaultHotkey,
-		TabOrder:    []string{"links", "news", "github", "stock", "todo", "tools", "host", "settings"}, // 默认 Tab 顺序(控制/硬件等固定网页已改为自定义 Tab)
+		TabOrder:    []string{"links", "news", "github", "stock", "todo", "record", "tools", "host", "settings"}, // 默认 Tab 顺序(控制/硬件等固定网页已改为自定义 Tab)
 		StockCodes:  []string{"sh000001", "sh600519", "sz000001"},
 		Links: []LinkItem{
 			{Name: "煎蛋", URL: "https://jandan.net/"},
@@ -118,6 +120,8 @@ func defaultSettings() AppSettings {
 		},
 		APIAddr:     DefaultAPIAddr,
 		TodoPath:    "", // 待办路径:无默认值,用户须在设置中配置(空值时强制进入设置)
+		RecordPath:  "", // 记录路径:无默认值,用户须在设置中配置(空值时强制进入设置)
+		RecordDBPath: "", // 记录数据库路径:无默认值,用户须在设置中配置(空值时强制进入设置)
 		BackupDir:   "", // 备份目录:无默认值,用户须在设置中配置(空值时强制进入设置)
 		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},
@@ -203,7 +207,7 @@ func normalizeSettings(settings AppSettings) AppSettings {
 
 	// Tab 顺序:只保留已存在的 tabId(内置 + 自定义 custom-N),去重;未列出的追加到末尾,保证全部 Tab 都能显示
 	validTabs := map[string]bool{"links": true,
-		"news": true, "github": true, "stock": true, "todo": true, "tools": true, "host": true, "settings": true}
+		"news": true, "github": true, "stock": true, "todo": true, "record": true, "tools": true, "host": true, "settings": true}
 	for i := range settings.CustomTabs {
 		validTabs[fmt.Sprintf("custom-%d", i)] = true // 自定义 Tab id:custom-0, custom-1...
 	}
@@ -215,7 +219,7 @@ func normalizeSettings(settings AppSettings) AppSettings {
 			ordered = append(ordered, tab)
 		}
 	}
-	for _, tab := range []string{"links", "news", "github", "stock", "todo", "tools", "host", "settings"} {
+	for _, tab := range []string{"links", "news", "github", "stock", "todo", "record", "tools", "host", "settings"} {
 		if !seen[tab] {
 			ordered = append(ordered, tab) // 设置中未列出的内置 Tab 追加到末尾
 		}
@@ -230,7 +234,7 @@ func normalizeSettings(settings AppSettings) AppSettings {
 	if strings.TrimSpace(settings.APIAddr) == "" {
 		settings.APIAddr = DefaultAPIAddr
 	}
-	// TodoPath / BackupDir 无默认值:空值保留,前端据此强制用户进入设置配置(不做硬编码回填)
+	// TodoPath / RecordPath / RecordDBPath / BackupDir 无默认值:空值保留,前端据此强制用户进入设置配置(不做硬编码回填)
 	settings.StockTicker = normalizeTicker(settings.StockTicker, "#ff4d4d", 80) // 股票条默认红字
 	settings.NewsTicker = normalizeTicker(settings.NewsTicker, "#3ddc84", 60)   // 新闻条默认绿字
 	settings.Poem = normalizePoemConfig(settings.Poem)                          // 古诗显示配置校正