Browse Source

初始提交:腾图工具箱 Wails v3 桌面应用(多页面聚合、工具页 ntfy/翻译/二维码/古诗词/截屏、服务页 API、记录 Markdown、备份脚本)

soease 1 tuần trước cách đây
commit
704524ccd6
100 tập tin đã thay đổi với 18167 bổ sung và 0 xóa
  1. 41 0
      .gitignore
  2. 111 0
      README.md
  3. 70 0
      Taskfile.yml
  4. 336 0
      apiserver.go
  5. 129 0
      backup.ps1
  6. 277 0
      bilifeed.go
  7. 398 0
      build/Taskfile.yml
  8. BIN
      build/appicon.png
  9. 87 0
      build/config.yml
  10. BIN
      build/darwin/Assets.car
  11. 35 0
      build/darwin/Info.dev.plist
  12. 30 0
      build/darwin/Info.plist
  13. 232 0
      build/darwin/Taskfile.yml
  14. BIN
      build/darwin/dmg-background.png
  15. BIN
      build/darwin/dmg-file-icon.icns
  16. BIN
      build/darwin/dmg-file-icon.png
  17. BIN
      build/darwin/icon.icns
  18. BIN
      build/darwin/icons.icns
  19. 225 0
      build/docker/Dockerfile.cross
  20. 67 0
      build/docker/Dockerfile.server
  21. 116 0
      build/ios/Assets.xcassets
  22. 56 0
      build/ios/Info.dev.plist
  23. 54 0
      build/ios/Info.plist
  24. 53 0
      build/ios/LaunchScreen.storyboard
  25. 365 0
      build/ios/Taskfile.yml
  26. 10 0
      build/ios/app_options_default.go
  27. 11 0
      build/ios/app_options_ios.go
  28. 8 0
      build/ios/entitlements.plist
  29. 3 0
      build/ios/icon.png
  30. 22 0
      build/ios/main.m
  31. 23 0
      build/ios/main_ios.go
  32. 230 0
      build/ios/project.pbxproj
  33. 319 0
      build/ios/scripts/deps/install_deps.go
  34. 220 0
      build/linux/Taskfile.yml
  35. 35 0
      build/linux/appimage/build.sh
  36. 13 0
      build/linux/desktop
  37. 80 0
      build/linux/nfpm/nfpm.yaml
  38. 21 0
      build/linux/nfpm/scripts/postinstall.sh
  39. 1 0
      build/linux/nfpm/scripts/postremove.sh
  40. 1 0
      build/linux/nfpm/scripts/preinstall.sh
  41. 1 0
      build/linux/nfpm/scripts/preremove.sh
  42. 216 0
      build/windows/Taskfile.yml
  43. BIN
      build/windows/icon.ico
  44. 15 0
      build/windows/info.json
  45. 55 0
      build/windows/msix/app_manifest.xml
  46. 54 0
      build/windows/msix/template.xml
  47. 7 0
      build/windows/nsis/eula.txt
  48. 119 0
      build/windows/nsis/project.nsi
  49. 261 0
      build/windows/nsis/wails_tools.nsh
  50. 22 0
      build/windows/wails.exe.manifest
  51. 5 0
      frontend/.npmrc
  52. 37 0
      frontend/bindings/TengTuToolbox/greetservice.js
  53. 34 0
      frontend/bindings/TengTuToolbox/index.js
  54. 1632 0
      frontend/bindings/TengTuToolbox/models.js
  55. 432 0
      frontend/bindings/TengTuToolbox/settingsservice.js
  56. 28 0
      frontend/bindings/events/index.js
  57. 14 0
      frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js
  58. 17 0
      frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
  59. 67 0
      frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/dockservice.js
  60. 12 0
      frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/index.js
  61. 81 0
      frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/models.js
  62. 7 0
      frontend/bindings/image/color/index.js
  63. 63 0
      frontend/bindings/image/color/models.js
  64. 740 0
      frontend/index.html
  65. 837 0
      frontend/package-lock.json
  66. 18 0
      frontend/package.json
  67. BIN
      frontend/public/Inter-Medium.ttf
  68. BIN
      frontend/public/bg-desktop.jpg
  69. BIN
      frontend/public/bg-mobile.jpg
  70. 1 0
      frontend/public/javascript.svg
  71. 2387 0
      frontend/public/style.css
  72. BIN
      frontend/public/wails.png
  73. 147 0
      frontend/src/lunar.js
  74. 2808 0
      frontend/src/main.js
  75. 190 0
      frontend/src/ticker.js
  76. 118 0
      frontend/ticker.html
  77. 25 0
      frontend/tsconfig.json
  78. 20 0
      frontend/vite.config.js
  79. 261 0
      githubwatch.go
  80. 60 0
      go.mod
  81. 138 0
      go.sum
  82. 32 0
      greetservice.go
  83. 224 0
      main.go
  84. 17 0
      main_android.go
  85. 19 0
      mousealert_other.go
  86. 569 0
      mousealert_windows.go
  87. 390 0
      newsfeed.go
  88. 500 0
      ntfy.go
  89. 288 0
      pccontrol.go
  90. 114 0
      poem.go
  91. 41 0
      qrcode.go
  92. 39 0
      qrcode_test.go
  93. 61 0
      screenshot.go
  94. 394 0
      settings.go
  95. 247 0
      settingsservice.go
  96. 309 0
      stockquote.go
  97. 84 0
      ticker.go
  98. 96 0
      timesync.go
  99. 18 0
      timesync_other.go
  100. 117 0
      timesync_windows.go

+ 41 - 0
.gitignore

@@ -0,0 +1,41 @@
+# ============================================================================
+# .gitignore — 腾图工具箱 忽略规则
+# 只提交源码与配置,忽略构建产物、备份快照、日志等过程文件。
+# ============================================================================
+
+# --- 构建产物 ---
+bin/                            # 输出可执行文件
+build/bin/                      # 构建临时输出
+build/appicon.icon/             # 图标生成产物(appicon.png 为源,保留)
+*.syso                          # Windows 资源文件(由 wails3 generate syso 生成)
+TengTuToolbox.exe               # 根目录可执行文件
+
+# --- Android 构建(Wails 生成的移动端工程,整目录为产物) ---
+build/android/
+
+# --- 备份快照(bak 目录,按日期存放) ---
+bak/
+
+# --- 前端构建产物与依赖 ---
+frontend/dist/
+frontend/node_modules/
+
+# --- 截图临时 / 静态资源 ---
+.screenshots/
+static/
+
+# --- 运行日志(调试输出等过程文件) ---
+*.log
+
+# --- Wails 任务缓存 ---
+.task
+
+# --- 特定平台构建产物 ---
+build/linux/appimage/build/
+build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
+build/ios/xcode/overlay.json
+build/ios/xcode/gen/
+
+# --- 系统文件 ---
+Thumbs.db
+.DS_Store

+ 111 - 0
README.md

@@ -0,0 +1,111 @@
+# 腾图工具箱(TengTu Toolbox)
+
+基于 Wails v3 的桌面工具箱应用。前端使用原生 HTML/CSS/JS + Vite 构建,后端使用 Go;主窗口内嵌多个网页 Tab,并提供新闻聚合、Github 仓库监听、股票行情、记录/待办编辑、硬件监控、主机管理、工具(Web 服务 / 翻译 / 二维码)、聊天与设置等面板。
+
+## 快速开始
+
+```bash
+# 开发模式:启动应用并为前后端启用热重载
+wails3 dev
+
+# 构建生产版本,输出到 bin/ 目录
+wails3 build
+```
+
+## 主要功能
+
+| Tab | 说明 |
+| --- | --- |
+| 笔记 | 内嵌网页,地址可在设置中修改;底部 footer 显示随机古诗(水平滚动,可配置颜色/字号/刷新间隔) |
+| 控制 | 内嵌网页,地址可在设置中修改 |
+| 博客 | 内嵌网页,地址可在设置中修改 |
+| 网址 | 常用网址卡片,点击在浏览器打开,列表在设置中维护 |
+| 新闻 | 聚合煎蛋、头条热榜、腾讯科技/财经、Solidot;命中关键词置顶高亮,已查看标题灰显,条数可设置 |
+| Github | 监听配置仓库的最新提交与 release,本地版本低于最新版提示更新;Latest 之后的 Pre-release/草稿版本也会列出(仅展示,不参与更新比较);「本地 xxx」徽标与版本徽标均可点击跳转到对应 releases 列表 |
+| 股市 | 自选股实时行情,点击行查看分时 / 日K;交易时段自动刷新,休市用较长间隔 |
+| 记录 | 文本记录文件编辑,支持 Markdown 实时预览(编辑 / 预览切换,预览用后端 gomarkdown 渲染),路径可在设置中修改 |
+| 待办 | 每行一个待办任务,可勾选完成、编辑、删除;每条可设置提醒时间,到点自动弹出置顶消息窗提醒(即使窗口隐藏也会触发) |
+| 硬件 | 内嵌硬件监控页,地址可在设置中修改 |
+| 工具 | 开发/运维辅助工具:Web 服务(选目录开启浏览服务)、翻译(中英互译)、二维码生成、发送 ntfy 消息、ntfy 聊天室、古诗词、屏幕截图;各功能区默认折叠,点击标题展开 |
+| 服务 | 内置 API 服务状态、电源命令状态与 Gin 日志(「清空日志」在标题右侧);原截屏功能已迁移至工具页 |
+| 聊天 | 内嵌网页,地址可在设置中修改 |
+| 设置 | 按「系统 + 各 Tab」分组展示配置项,保存后立即生效 |
+
+### 随机古诗
+
+笔记页 footer 显示的水平滚动随机古诗,数据来源为 [古诗词网接口](https://poetry.palemoky.com/api/poems/random)(`https://poetry.palemoky.com/api/poems/random`,由 [古诗词网](https://poetry.palemoky.com) 提供)。每次随机取一首,返回标题 / 作者 / 朝代 / 内容;可在设置中调整显示开关(禁止显示)、文字颜色、字号、滚动速度(秒/轮,数值越小滚动越快)、朗读按钮;速度保存后立即生效。
+
+### 设置项
+
+- 系统:界面样式(暗色/亮色)、系统启动时运行、窗口置顶、呼出窗口热键
+- 各 Tab 对应设置:页面地址、Tab 显示顺序(逗号分隔)、股票代码与涨跌颜色、股市刷新间隔与休市刷新间隔、新闻关键词、新闻刷新间隔与显示条数、Github 项目与刷新间隔、API 监听地址、记录文件路径、待办文件路径、股票/新闻滚动条等
+
+### 滚动文字
+
+股票与新闻滚动条显示在屏幕顶部,位置与配置(宽度、字号、颜色、速度、背景透明)均可在设置中调整;位置会在拖动时自动保存,下次启动恢复。
+
+### 工具页
+
+- **Web 服务**:选择任意目录,开启本地静态文件浏览服务(默认页为 `index.html` 或 `readme.md`,README 会自动渲染为 HTML);启动后点击地址链接可在系统浏览器打开。
+- **翻译**:一个文本框,输入中文自动翻译为英文、输入英文自动翻译为中文(自动检测方向)。使用 MyMemory 免费接口,每日约 5000 词配额(超出会提示恢复时间);支持 Ctrl/Cmd+Enter 快捷翻译。
+- **二维码生成**:输入任意字符串(网址、文本、联系方式等),即时生成 PNG 二维码图片;点击图片或「下载 PNG」可保存,支持 Ctrl/Cmd+Enter 快捷生成。
+- **发送 ntfy 消息**:输入主题(Topic,用于指定监听频道,输入过的主题自动记忆、下拉可选)、标题(可空)、内容,推送到 ntfy 服务(默认 `https://ntfy.sh`),供手机 / 浏览器 / 其他设备即时提醒。主题历史保存于用户配置目录 `TengTuToolbox/ntfy_topics.json`。
+- **ntfy 聊天室**:输入聊天室名称(ntfy 主题)并「开始监听」,通过 SSE 长连接实时接收该主题消息并显示在列表中;主题留空则不监听。可与手机 / 其他设备用同一主题互发消息,实现跨设备聊天 / 通知。监听时收到消息自动滚动到底部(上滚可暂停跟随)。
+- **古诗词**:显示主窗口 footer 最近获取到的古诗词内容(题目 / 作者 / 诗句),可点击「刷新获取」拉取新的一首;footer 加载到的古诗也会同步显示在此。数据来自古诗词网随机接口。
+- **屏幕截图**:点击「截屏」后自动隐藏本程序窗口,截取整个屏幕(全部显示器拼接),完成后重新显示本程序并预览截图;截到的画面不含本程序自身。原位于主机页的截屏功能已迁移至此。
+
+### 鼠标提醒
+
+通过内置 API 触发系统级鼠标提醒:平时鼠标为正常样式;触发后鼠标图标闪烁(白↔红,约 250ms 周期),光标下方浮出半透明提示文字,可覆盖任意窗口(系统级),默认 8 秒自动消退。详见 [mousealert_windows.go](mousealert_windows.go)。
+
+## 项目结构
+
+- `main.go`:Go 后端入口,创建应用、主窗口、系统托盘,注册服务与窗口事件,启动时间广播与待办提醒调度协程
+- `greetservice.go`:Wails 示例服务(GreetService,用于验证 Go↔JS 绑定链路)
+- `settingsservice.go`:设置读写、新闻抓取、Github 监听、行情查询、翻译、二维码、截屏、Web 服务、待办读写、Markdown 渲染、API 状态等核心服务
+- `settings.go`:AppSettings 结构体、默认值与字段校验
+- `newsfeed.go`:新闻来源抓取(煎蛋 / 头条 / 腾讯 / Solidot)
+- `trending.go`:GitHub Trending 热门仓库抓取与自动翻译
+- `bilifeed.go`:B站关注动态抓取(需 cookie)
+- `githubwatch.go`:Github REST API 提交与版本监控(含 Latest 之后的版本列出)
+- `stockquote.go`:股票行情与分时 / 日K
+- `ticker.go`:滚动条窗口(位置保存)
+- `poem.go`:主窗口首 Tab footer 随机古诗(水平滚动)
+- `apiserver.go`:内置 API 服务(健康检查、通知、窗口控制、电源命令、鼠标提醒、屏幕截图等)
+- `pccontrol.go`:电源控制(关机 / 重启 / 休眠 / 锁屏)
+- `timesync.go`(及 `_windows.go` / `_other.go`):系统时间同步
+- `screenshot.go`:屏幕截图(多显示器拼接,返回 PNG base64 data URI)
+- `webservice.go`:工具页 Web 服务(目录选择、静态文件服务、README 渲染)
+- `translate.go`:工具页翻译(MyMemory 免费接口 + 中英文自动检测 + 限流提示)
+- `qrcode.go`:工具页二维码生成(go-qrcode,返回 PNG data URI)
+- `ntfy.go`:工具页 ntfy 消息推送与聊天室(POST ntfy.sh 指定主题、SSE 长连接订阅实时接收消息,主题历史持久化记忆)
+- `todoremind.go`:待办提醒调度(解析待办文件中的 `@ 时间` 标记,到点广播事件)
+- `mousealert_windows.go`:系统级鼠标提醒(Windows,WH_MOUSE_LL 全局钩子 + 置顶透明覆盖层 + GDI 绘制)
+- `mousealert_other.go`:鼠标提醒非 Windows 空实现
+- `frontend/`:前端代码(HTML、CSS、JavaScript)
+- `frontend/src/main.js`:前端逻辑,负责调用后端服务、监听后端事件(含 Tab 管理、工具页折叠、待办提醒弹窗、记录 Markdown 预览等)
+- `frontend/src/ticker.js`:滚动条逻辑
+- `frontend/src/lunar.js`:农历与节日计算
+- `wails.json`:Wails 项目配置
+
+## 构建说明
+
+- 开发调试:`wails3 dev`
+- 生产构建:`wails3 build`,输出 `bin/腾图工具箱.exe`(UPX 压缩)
+- 后端单独编译:`go build .`
+- 前端单独构建:`cd frontend && npm run build`
+
+## 备注
+
+1. `wails3 task windows:build BIN_DIR=Z:/` 可编译到指定目录并 UPX 压缩(见 `build/windows/taskfile.yml`)
+2. `wails3 dev` 会根据 `bin/config.yaml` 的配置决定输出目录
+3. 先 `npm run build` 完成后再单独 `go build`(后端需要读取构建后的前端 dist 清单,并行会导致 embed 失败)
+4. 修改 Go 结构体/事件后需运行 `wails3 generate bindings` 重新生成前端绑定
+5. 修改/新增前端页面后需 `cd frontend && npm run build`,再 `wails3 build` 才能打入产物
+
+## 需求
+
+1. 始终没有保存文字滚动条的位置(新闻/股票):移动滚动条后 10 秒保存一次位置,重启程序时调用最后一次保存的位置
+2. 系统级鼠标动态提醒:平时正常样式,API 触发时鼠标闪烁 + 光标下方半透明提示文字(已实现,见 mousealert_windows.go)
+3. 工具页各功能区加区隔,默认折叠(已实现,见 frontend/src/main.js 的 initToolCollapse)
+4. 记录界面支持 Markdown 格式(已实现,编辑/预览切换,见 settingsservice.go 的 RenderMarkdown)

+ 70 - 0
Taskfile.yml

@@ -0,0 +1,70 @@
+version: '3'
+
+vars:
+  APP_NAME: "腾图工具箱"
+  BIN_DIR: "bin"
+  PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "npm"}}'
+  VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
+  # Target OS for build/package/run. Defaults to the host OS, and is overridden
+  # by `wails3 build GOOS=...` (or the GOOS env var) for cross-compilation. The
+  # tasks below dispatch to the matching platform Taskfile via this variable.
+  GOOS: '{{.GOOS | default OS}}'
+
+includes:
+  common: ./build/Taskfile.yml
+  windows: ./build/windows/Taskfile.yml
+  darwin: ./build/darwin/Taskfile.yml
+  linux: ./build/linux/Taskfile.yml
+  ios: ./build/ios/Taskfile.yml
+  android: ./build/android/Taskfile.yml
+
+tasks:
+  build:
+    summary: Builds the application
+    cmds:
+      - task: "{{.GOOS}}:build"
+
+  package:
+    summary: Packages a production build of the application
+    cmds:
+      - task: "{{.GOOS}}:package"
+
+  run:
+    summary: Runs the application
+    cmds:
+      - task: "{{.GOOS}}:run"
+
+  dev:
+    summary: Runs the application in development mode
+    cmds:
+      - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
+
+  install:msix:tools:
+    summary: Installs tools required for Windows MSIX packaging
+    cmds:
+      - task: windows:install:msix:tools
+
+  setup:docker:
+    summary: Builds Docker image for cross-compilation (~800MB download)
+    cmds:
+      - task: common:setup:docker
+
+  build:server:
+    summary: Builds the application in server mode (no GUI, HTTP server only)
+    cmds:
+      - task: common:build:server
+
+  run:server:
+    summary: Runs the application in server mode
+    cmds:
+      - task: common:run:server
+
+  build:docker:
+    summary: Builds a Docker image for server mode deployment
+    cmds:
+      - task: common:build:docker
+
+  run:docker:
+    summary: Builds and runs the Docker image
+    cmds:
+      - task: common:run:docker

+ 336 - 0
apiserver.go

@@ -0,0 +1,336 @@
+// ============================================================================
+// apiserver.go — 内置 API 服务
+//
+// 用 Gin 提供的本机 HTTP API:健康检查、通知、窗口控制、电源命令、屏幕截图、
+// 鼠标提醒等;日志写控制台并存入环形缓冲(供「主机」页实时查看)。
+// ============================================================================
+package main
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/gen2brain/beeep" // 跨平台系统通知,Windows 下为托盘气泡/Toast
+	"github.com/gin-gonic/gin"
+)
+
+// notifyRequest 通知接口的请求体
+type notifyRequest struct {
+	Title   string `json:"title"`   // 通知标题,为空时使用应用名
+	Message string `json:"message"` // 通知正文
+}
+
+// mouseAlertRequest 鼠标提醒接口的请求体
+type mouseAlertRequest struct {
+	Text string `json:"text"` // 提示文字,为空时使用默认文案
+}
+
+// ------------------------------------------------------------------------
+// Gin 日志环形缓冲:日志同时写控制台与内存缓冲,供「主机」页实时查看
+// ------------------------------------------------------------------------
+
+const apiLogCapacity = 300 // 环形缓冲最多保留的日志行数
+
+// logRing 固定容量的日志环形缓冲,实现 io.Writer
+type logRing struct {
+	mu    sync.Mutex
+	lines []string
+}
+
+// Write 按行拆分写入缓冲,并向所有前端窗口广播 "apilog" 事件
+func (r *logRing) Write(p []byte) (int, error) {
+	text := strings.TrimRight(string(p), "\r\n")
+	r.mu.Lock()
+	for _, line := range strings.Split(text, "\n") {
+		if strings.TrimSpace(line) == "" {
+			continue
+		}
+		r.lines = append(r.lines, line)
+		if len(r.lines) > apiLogCapacity {
+			r.lines = r.lines[len(r.lines)-apiLogCapacity:] // 丢弃最旧的行
+		}
+		if app != nil {
+			app.Event.Emit("apilog", line) // 实时推送到「主机」页
+		}
+	}
+	r.mu.Unlock()
+	return len(p), nil
+}
+
+// Snapshot 返回当前缓冲的全部日志行
+func (r *logRing) Snapshot() []string {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	out := make([]string, len(r.lines))
+	copy(out, r.lines)
+	return out
+}
+
+// Clear 清空日志缓冲
+func (r *logRing) Clear() {
+	r.mu.Lock()
+	r.lines = nil
+	r.mu.Unlock()
+}
+
+var apiLogRing = &logRing{}
+
+// ------------------------------------------------------------------------
+// API 服务生命周期:地址变化时平滑重启(仅监听配置的地址)
+// ------------------------------------------------------------------------
+
+var (
+	apiMu        sync.Mutex
+	apiServer    *http.Server
+	apiAddr      string    // 当前监听地址
+	apiStartedAt time.Time // 本次启动时间
+	apiLastErr   string    // 最近一次启动 / 运行错误
+)
+
+// StopAPIServer 停止内置 API 服务(从服务页手动关闭)。
+// 停止后 apiServer 置 nil、apiAddr 保留,便于再次启动或恢复。
+func StopAPIServer() {
+	apiMu.Lock()
+	old := apiServer
+	apiServer = nil
+	apiAddr = ""
+	apiLastErr = ""
+	apiMu.Unlock()
+
+	if old != nil {
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+		_ = old.Shutdown(ctx) // 平滑关闭,中断不复用连接
+		cancel()
+	}
+	log.Printf("API server stopped by user")
+	fmt.Fprintf(apiLogRing, "[SYS] API 服务已停止\n")
+}
+
+// StartAPIServer 启动 / 重启内置 API 服务(从服务页手动启动或恢复)。
+// 使用配置中的地址(为空时用默认地址)。
+func StartAPIServer() {
+	ensureAPIServer(loadSettings().APIAddr)
+}
+
+// APIStatus API 服务运行状态,供「主机」页展示
+type APIStatus struct {
+	Addr      string `json:"addr"`      // 当前监听地址
+	Running   bool   `json:"running"`   // 是否正在监听
+	StartedAt string `json:"startedAt"` // 启动时间
+	Error     string `json:"error"`     // 最近错误信息
+}
+
+// ensureAPIServer 确保 API 服务监听在指定地址;地址变化时先关闭旧服务再启动
+func ensureAPIServer(addr string) {
+	addr = strings.TrimSpace(addr)
+	if addr == "" {
+		addr = DefaultAPIAddr
+	}
+
+	apiMu.Lock()
+	if apiServer != nil && apiAddr == addr && apiLastErr == "" {
+		apiMu.Unlock()
+		return // 地址未变且运行正常,无需重启
+	}
+
+	old := apiServer
+	apiServer = nil
+	apiAddr = addr
+	apiStartedAt = time.Now()
+	apiLastErr = ""
+	apiMu.Unlock()
+
+	if old != nil {
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+		_ = old.Shutdown(ctx) // 平滑关闭旧服务,中断不复用连接
+		cancel()
+	}
+
+	go runAPIServer(addr)
+}
+
+// runAPIServer 构建路由并启动监听(出错只记录状态,不影响应用本身)
+func runAPIServer(addr string) {
+	gin.SetMode(gin.ReleaseMode) // 生产模式,减少日志噪音
+
+	router := gin.New()
+	router.Use(gin.LoggerWithConfig(gin.LoggerConfig{
+		Output: io.MultiWriter(os.Stdout, apiLogRing), // 日志同时写控制台与环形缓冲
+	}))
+	router.Use(gin.Recovery())   // 捕获 panic,避免接口崩溃导致进程退出
+	router.Use(corsMiddleware()) // 允许跨域,便于前端或其他本机工具调用
+
+	router.GET("/health", func(c *gin.Context) {
+		c.JSON(http.StatusOK, gin.H{"status": "ok"}) // 健康检查
+	})
+
+	api := router.Group("/api")
+	api.POST("/notify", handleNotify)          // 弹出系统托盘通知
+	api.POST("/window/show", handleShowWindow) // 显示主窗口
+	api.POST("/mouse/alert", handleMouseAlert) // 触发系统级鼠标提醒(闪烁光标+提示文字)
+	api.GET("/settings", handleGetSettings)    // 读取配置
+	api.POST("/settings", handleSaveSettings)  // 保存配置
+	api.GET("/stocks", handleGetStocks)        // 获取股票行情
+	api.GET("/screenshot", handleScreenshot)   // 截取屏幕,返回 PNG 数据 URI
+
+	pc := api.Group("/pc")              // 主机控制接口(移植自主机服务)
+	pc.GET("/poweroff", handlePoweroff) // 30 秒后关机
+	pc.GET("/restart", handleRestart)   // 30 秒后重启
+	pc.GET("/sleep", handleSleep)       // 30 秒后休眠
+	pc.GET("/lock", handleLock)         // 立即锁屏
+	pc.GET("/cancel", handleCancel)     // 取消待执行命令
+	pc.GET("/status", handlePCStatus)   // 查询命令状态
+	pc.GET("/timesync", handleTimeSync) // 同步系统时间
+
+	server := &http.Server{Addr: addr, Handler: router}
+
+	apiMu.Lock()
+	apiServer = server
+	apiMu.Unlock()
+
+	log.Printf("API server listening on http://%s", addr)
+	fmt.Fprintf(apiLogRing, "[SYS] API 服务已启动 http://%s\n", addr)
+	if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+		apiMu.Lock()
+		apiLastErr = err.Error() // 端口被占用等错误只记录,不影响应用启动
+		apiMu.Unlock()
+		log.Printf("api server stopped: %v", err)
+		fmt.Fprintf(apiLogRing, "[SYS] API 服务异常停止: %v\n", err)
+	}
+}
+
+// getAPIStatus 返回当前 API 服务状态快照
+func getAPIStatus() APIStatus {
+	apiMu.Lock()
+	defer apiMu.Unlock()
+
+	started := ""
+	if !apiStartedAt.IsZero() {
+		started = apiStartedAt.Format("01-02 15:04:05")
+	}
+	return APIStatus{
+		Addr:      apiAddr,
+		Running:   apiServer != nil,
+		StartedAt: started,
+		Error:     apiLastErr,
+	}
+}
+
+// corsMiddleware 简单放行跨域请求(服务默认仅监听 127.0.0.1,风险可控)
+func corsMiddleware() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		c.Header("Access-Control-Allow-Origin", "*")
+		c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+		c.Header("Access-Control-Allow-Headers", "Content-Type")
+
+		if c.Request.Method == http.MethodOptions {
+			c.AbortWithStatus(http.StatusNoContent) // 预检请求直接返回
+			return
+		}
+		c.Next()
+	}
+}
+
+// handleNotify 通过系统通知弹出消息,例如:POST /api/notify {"message":"任务完成"}
+func handleNotify(c *gin.Context) {
+	var req notifyRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+	if req.Title == "" {
+		req.Title = "腾图工具箱" // 未指定标题时使用默认标题
+	}
+
+	if err := beeep.Notify(req.Title, req.Message, ""); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{"ok": true}) // 通知已发出
+}
+
+// handleMouseAlert 触发系统级鼠标提醒,例如:POST /api/mouse/alert {"text":"有新订单"}
+func handleMouseAlert(c *gin.Context) {
+	var req mouseAlertRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+	triggerMouseAlert(req.Text) // 触发鼠标闪烁 + 光标下方透明文字
+	c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+// handleShowWindow 显示并聚焦主窗口(从托盘恢复)
+func handleShowWindow(c *gin.Context) {
+	showMainWindow()
+	c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+// handleGetSettings 返回当前配置
+func handleGetSettings(c *gin.Context) {
+	c.JSON(http.StatusOK, loadSettings())
+}
+
+// handleSaveSettings 保存配置并立即生效
+func handleSaveSettings(c *gin.Context) {
+	var settings AppSettings
+	if err := c.ShouldBindJSON(&settings); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	settings = normalizeSettings(settings)
+	settings = mergeTickerPositions(settings) // 保留拖动保存的滚动条位置
+	if err := saveSettings(settings); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+	applySettings(settings)
+	c.JSON(http.StatusOK, settings) // 返回校正后的配置
+}
+
+// handleGetStocks 获取股票行情;支持 ?codes=sh600519,sz000001 临时指定代码
+func handleGetStocks(c *gin.Context) {
+	codes := loadSettings().StockCodes
+	if query := c.Query("codes"); query != "" {
+		codes = nil
+		for _, raw := range strings.Split(query, ",") {
+			if code := normalizeStockCode(raw); code != "" {
+				codes = append(codes, code)
+			}
+		}
+	}
+
+	quotes, err := fetchStockQuotes(codes)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{"codes": codes, "quotes": quotes})
+}
+
+// handleScreenshot 截取屏幕并返回 PNG 数据 URI。
+// 支持 ?display=N 指定显示器(0 起);缺省或 -1 截取全部屏幕。
+func handleScreenshot(c *gin.Context) {
+	idx := -1
+	if v := c.Query("display"); v != "" {
+		if n, err := strconv.Atoi(v); err == nil {
+			idx = n
+		}
+	}
+
+	dataURI, err := captureScreen(idx)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{"image": dataURI, "display": idx})
+}

+ 129 - 0
backup.ps1

@@ -0,0 +1,129 @@
+# ============================================================================
+# backup.ps1 — 腾图工具箱 项目备份脚本
+#
+# 用途:按日期将「必要文件」备份到项目根目录的 bak\<日期> 目录。
+#   - 项目源码:Go 后端(*.go)、前端源码(frontend/src、frontend/public)、
+#     前端入口(frontend/index.html、frontend/package.json、frontend/vite.config.js 等)
+#   - 项目配置:go.mod / go.sum / Taskfile.yml / config.yml / README.md
+#   - 用户配置:%APPDATA%\TengTuToolbox 下的 settings.json、ntfy_topics.json、
+#     ntfy_nicknames.json 等(应用运行产生的真实数据)
+#
+# 备份目录:<项目根>\bak\YYYYMMDD\     (与已有 bak\20261031\ 约定一致)
+# 用法:直接运行,或双击 / 加入计划任务每日执行。
+#   例:powershell -ExecutionPolicy Bypass -File D:\My\myapp\backup.ps1
+#
+# 说明:已存在的同日备份会被覆盖(重复运行即刷新当日快照)。
+# ============================================================================
+
+# 项目根目录:脚本所在目录(脚本放在项目根下)
+$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
+
+# 备份目标:<项目根>\bak\<日期>
+$dateStr = Get-Date -Format 'yyyyMMdd'
+$bakRoot = Join-Path $projectRoot 'bak'
+$bakDir = Join-Path $bakRoot $dateStr
+
+# 用户配置目录(Wails 应用的配置与历史数据)
+$configDir = Join-Path $env:APPDATA 'TengTuToolbox'
+
+# 需要备份的项目源码/配置项(相对 projectRoot 的路径;存在才复制)
+$projectItems = @(
+    '*.go',                 # Go 后端源码(含 *_test.go)
+    'go.mod',
+    'go.sum',
+    'Taskfile.yml',
+    'config.yml',
+    'README.md',
+    'frontend\src',         # 前端 JS 源码
+    'frontend\public',      # 前端静态资源
+    'frontend\index.html',
+    'frontend\package.json',
+    'frontend\package-lock.json',
+    'frontend\vite.config.js',
+    'frontend\tsconfig.json',
+    'frontend\.npmrc'
+)
+
+# 需要备份的用户配置文件名(相对 configDir)
+$configFiles = @(
+    'settings.json',
+    'settings.json.bak',
+    'ntfy_topics.json',
+    'ntfy_nicknames.json'
+)
+
+Write-Host "=== 腾图工具箱 备份 ===" -ForegroundColor Cyan
+Write-Host "项目根目录 : $projectRoot"
+Write-Host "备份目录   : $bakDir"
+Write-Host "配置目录   : $configDir"
+
+# 创建当日备份目录
+if (-not (Test-Path $bakDir)) {
+    New-Item -ItemType Directory -Path $bakDir -Force | Out-Null
+}
+
+$copied = 0
+$skipped = 0
+
+# 1) 备份项目源码 / 配置
+Write-Host "`n[项目文件]" -ForegroundColor Yellow
+foreach ($item in $projectItems) {
+    # 通配符(如 *.go)按匹配展开,普通路径按存在性处理
+    if ($item -like '*\*' -or $item -like '*.*') {
+        $matches = Get-ChildItem -Path (Join-Path $projectRoot $item) -ErrorAction SilentlyContinue
+        foreach ($m in $matches) {
+            $target = Join-Path $bakDir $m.Name
+            Copy-Item -Path $m.FullName -Destination $target -Recurse -Force
+            $copied++
+            Write-Host "  已复制 : $($m.Name)" -ForegroundColor Green
+        }
+        if (-not $matches) { $skipped++ }
+    } else {
+        $src = Join-Path $projectRoot $item
+        if (Test-Path $src) {
+            $target = Join-Path $bakDir $item
+            Copy-Item -Path $src -Destination $target -Recurse -Force
+            $copied++
+            Write-Host "  已复制 : $item" -ForegroundColor Green
+        } else {
+            $skipped++
+        }
+    }
+}
+
+# 2) 备份用户配置(应用真实数据)
+Write-Host "`n[用户配置]" -ForegroundColor Yellow
+if (Test-Path $configDir) {
+    $cfgBak = Join-Path $bakDir 'config'
+    if (-not (Test-Path $cfgBak)) { New-Item -ItemType Directory -Path $cfgBak -Force | Out-Null }
+    foreach ($f in $configFiles) {
+        $src = Join-Path $configDir $f
+        if (Test-Path $src) {
+            Copy-Item -Path $src -Destination (Join-Path $cfgBak $f) -Force
+            $copied++
+            Write-Host "  已复制 : config\$f" -ForegroundColor Green
+        } else {
+            $skipped++
+        }
+    }
+} else {
+    Write-Host "  配置目录不存在:$configDir(跳过)" -ForegroundColor DarkGray
+    $skipped++
+}
+
+Write-Host "`n完成:复制 $copied 项,跳过 $skipped 项(不存在)" -ForegroundColor Cyan
+Write-Host "备份位置:$bakDir" -ForegroundColor Cyan
+
+# 3) 清理旧备份:仅保留最近 30 天的 bak 日期目录
+Write-Host "`n[清理旧备份(保留 30 天)]" -ForegroundColor Yellow
+$keepDays = 30
+$cutoffStr = (Get-Date).AddDays(-$keepDays).ToString('yyyyMMdd')
+Get-ChildItem $bakRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object {
+    # 目录名为 yyyyMMdd 才按日期处理(字符串比较:yyyyMMdd 字典序=时间序)
+    if ($_.Name -match '^\d{8}$' -and $_.Name -lt $cutoffStr) {
+        Remove-Item $_.FullName -Recurse -Force
+        Write-Host "  已删除 : $($_.Name)" -ForegroundColor DarkRed
+    }
+}
+
+Write-Host "`n备份脚本执行完毕。" -ForegroundColor Green

+ 277 - 0
bilifeed.go

@@ -0,0 +1,277 @@
+// ============================================================================
+// bilifeed.go — B站关注动态抓取
+//
+// 需要登录态(cookie 含 SESSDATA),抓取「关注的人的最新动态」。
+// 接口:GET https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all?type=all&page=1
+// 该接口本身不需 WBI 签名,但需带登录 cookie 与 buvid3,否则返回 code=-101 账号未登录。
+// ============================================================================
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+)
+
+const (
+	biliDynamicURL = "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all"
+	biliHomeURL    = "https://www.bilibili.com/"
+)
+
+// BiliDynamicItem 一条 B站动态,供「新闻」页展示
+type BiliDynamicItem struct {
+	Source  string `json:"source"`  // 来源:B站动态
+	Title   string `json:"title"`   // 标题(动态文案或视频标题)
+	URL     string `json:"url"`     // 详情页地址
+	Matched bool   `json:"matched"` // 是否命中关注关键词
+}
+
+// biliFeedPayload 动态 feed 接口响应结构(只取需要的字段)
+type biliFeedPayload struct {
+	Code    int    `json:"code"`
+	Message string `json:"message"`
+	Data    struct {
+		HasMore bool `json:"has_more"`
+		Items   []struct {
+			IDStr   string `json:"id_str"`
+			Modules struct {
+				ModuleAuthor struct {
+					Name string `json:"name"` // 发布者昵称
+				} `json:"module_author"`
+				ModuleDynamic struct {
+					Desc struct {
+						Text string `json:"text"` // 动态文案
+					} `json:"desc"`
+				} `json:"module_dynamic"`
+				ModuleArchival struct {
+					Archival struct {
+						Title   string `json:"title"` // 视频标题
+						Bvid    string `json:"bvid"`  // 视频 bvid,用于拼详情地址
+						Desc    string `json:"desc"`  // 视频简介
+						History struct {
+							Text string `json:"text"` // 时长/进度
+						} `json:"history"`
+					} `json:"archival"`
+				} `json:"module_archival"`
+			} `json:"modules"`
+		} `json:"items"`
+	} `json:"data"`
+}
+
+// fetchBiliDynamic 抓取 B站关注动态(需登录 cookie)。cookie 为空返回明确错误。
+func fetchBiliDynamic(cookie string) ([]BiliDynamicItem, error) {
+	cookie = strings.TrimSpace(cookie)
+	if cookie == "" {
+		return nil, fmt.Errorf("未配置 B站登录 cookie(设置 → 新闻 → B站 cookie)")
+	}
+
+	// 先访问主页拿 buvid3/buvid4(动态接口可能要求),再把 cookie 一起带上
+	_, buvids := fetchBuvid3()
+	client := &http.Client{Timeout: 12 * time.Second}
+	req, err := http.NewRequest(http.MethodGet, biliDynamicURL+"?type=all&page=1", nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
+	req.Header.Set("Referer", biliHomeURL)
+	req.Header.Set("Accept", "application/json")
+	// cookie 拼接:登录 cookie + buvid3/buvid4(若有)
+	allCookie := cookie
+	if buvids != "" {
+		allCookie = strings.TrimSpace(allCookie + "; " + buvids)
+	}
+	req.Header.Set("Cookie", allCookie)
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	var payload biliFeedPayload
+	if err := json.Unmarshal(body, &payload); err != nil {
+		return nil, fmt.Errorf("解析 B站动态失败: %w", err)
+	}
+	if payload.Code == -101 {
+		return nil, fmt.Errorf("B站账号未登录:cookie 无效或已过期,请更新设置里的 B站 cookie")
+	}
+	if payload.Code != 0 {
+		return nil, fmt.Errorf("B站动态接口错误 code=%d message=%s", payload.Code, payload.Message)
+	}
+
+	items := make([]BiliDynamicItem, 0, len(payload.Data.Items))
+	for _, it := range payload.Data.Items {
+		author := strings.TrimSpace(it.Modules.ModuleAuthor.Name)
+		// 优先取视频标题(module_archival),否则取动态文案(module_dynamic.desc)
+		archTitle := strings.TrimSpace(it.Modules.ModuleArchival.Archival.Title)
+		bvid := strings.TrimSpace(it.Modules.ModuleArchival.Archival.Bvid)
+		dynText := strings.TrimSpace(it.Modules.ModuleDynamic.Desc.Text)
+
+		var title, link string
+		if archTitle != "" {
+			title = archTitle
+			if bvid != "" {
+				link = "https://www.bilibili.com/video/" + bvid
+			}
+		} else if dynText != "" {
+			title = dynText
+			link = "https://t.bilibili.com/" + it.IDStr
+		} else {
+			continue // 无标题无视频:跳过
+		}
+		title = truncateText(title, 120) // 长文案截断,避免卡片撑爆
+		if link == "" {
+			link = "https://t.bilibili.com/" + it.IDStr
+		}
+		if author != "" {
+			title = author + ":" + title
+		}
+		items = append(items, BiliDynamicItem{Source: "B站动态", Title: title, URL: link})
+		if len(items) >= 30 {
+			break
+		}
+	}
+	if len(items) == 0 {
+		return nil, fmt.Errorf("B站动态为空(可能没有关注动态)")
+	}
+	return items, nil
+}
+
+// fetchBuvid3 访问 B站主页获取 buvid3 / buvid4 cookie(动态接口可能要求校验)
+func fetchBuvid3() (buvid string, extra string) {
+	client := &http.Client{Timeout: 8 * time.Second}
+	req, err := http.NewRequest(http.MethodGet, biliHomeURL, nil)
+	if err != nil {
+		return "", ""
+	}
+	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", ""
+	}
+	defer resp.Body.Close()
+	io.Copy(io.Discard, resp.Body) // 读走 body 以便复用连接
+
+	var parts []string
+	for _, c := range resp.Cookies() {
+		if c.Name == "buvid3" || c.Name == "buvid4" {
+			parts = append(parts, c.Name+"="+c.Value)
+		}
+	}
+	if len(parts) == 0 {
+		return "", ""
+	}
+	return "", strings.Join(parts, "; ")
+}
+
+// truncateText 截断长文本到 maxRunes 个字符(按 rune 计,避免切断多字节)
+func truncateText(s string, maxRunes int) string {
+	r := []rune(s)
+	if len(r) <= maxRunes {
+		return s
+	}
+	return string(r[:maxRunes]) + "…"
+}
+
+// ------------------------------------------------------------------------
+// 备用:B站热门 / 排行榜(无需登录),可作为 cookie 失效时的兜底。
+// ------------------------------------------------------------------------
+
+const (
+	biliPopularURL = "https://api.bilibili.com/x/web-interface/popular"
+	biliRankURL    = "https://api.bilibili.com/x/web-interface/ranking/v2"
+)
+
+// biliVideoListPayload 热门 / 排行接口响应结构
+type biliVideoListPayload struct {
+	Code    int    `json:"code"`
+	Message string `json:"message"`
+	Data    struct {
+		List []struct {
+			Title string `json:"title"` // 视频标题
+			Bvid  string `json:"bvid"`  // 视频 bvid
+		} `json:"list"`
+	} `json:"data"`
+}
+
+// fetchBiliPopular 抓取 B站热门视频(取前 20 条,无需登录)
+func fetchBiliPopular() ([]BiliDynamicItem, error) {
+	return fetchBiliVideoList(biliPopularURL + "?ps=20&pn=1")
+}
+
+// fetchBiliRank 抓取 B站排行榜(取前 20 条,无需登录)
+func fetchBiliRank() ([]BiliDynamicItem, error) {
+	return fetchBiliVideoList(biliRankURL + "?rid=0&type=all")
+}
+
+func fetchBiliVideoList(apiURL string) ([]BiliDynamicItem, error) {
+	client := &http.Client{Timeout: 10 * time.Second}
+	req, err := http.NewRequest(http.MethodGet, apiURL, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
+	req.Header.Set("Referer", biliHomeURL)
+	req.Header.Set("Accept", "application/json")
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+	var payload biliVideoListPayload
+	if err := json.Unmarshal(body, &payload); err != nil {
+		return nil, fmt.Errorf("解析 B站列表失败: %w", err)
+	}
+	if payload.Code != 0 {
+		return nil, fmt.Errorf("B站列表接口错误 code=%d message=%s", payload.Code, payload.Message)
+	}
+	items := make([]BiliDynamicItem, 0, len(payload.Data.List))
+	for _, v := range payload.Data.List {
+		title := strings.TrimSpace(v.Title)
+		if title == "" || v.Bvid == "" {
+			continue
+		}
+		items = append(items, BiliDynamicItem{
+			Source: "B站",
+			Title:  truncateText(title, 120),
+			URL:    "https://www.bilibili.com/video/" + v.Bvid,
+		})
+		if len(items) >= 20 {
+			break
+		}
+	}
+	if len(items) == 0 {
+		return nil, fmt.Errorf("列表为空")
+	}
+	return items, nil
+}
+
+// ------------------------------------------------------------------------
+// 组合抓取:B站动态(需 cookie)+ 热门兜底。
+// ------------------------------------------------------------------------
+
+// fetchBiliFeed 优先抓关注动态,cookie 为空或失效时回退热门视频。
+// 返回 (动态列表, 是否使用了兜底, 错误)。
+func fetchBiliFeed(cookie string) ([]BiliDynamicItem, bool, error) {
+	dyn, err := fetchBiliDynamic(cookie)
+	if err == nil {
+		return dyn, false, nil // 正常拿到关注动态
+	}
+	// 兜底:热门视频
+	pop, popErr := fetchBiliPopular()
+	if popErr == nil {
+		return pop, true, nil // 用热门兜底(动态失败但热门成功)
+	}
+	return nil, true, fmt.Errorf("B站动态失败:%v;热门兜底也失败:%v", err, popErr)
+}

+ 398 - 0
build/Taskfile.yml

@@ -0,0 +1,398 @@
+version: '3'
+
+tasks:
+  go:mod:tidy:
+    summary: Runs `go mod tidy`
+    internal: true
+    # Universal/multi-arch builds invoke this concurrently from parallel deps;
+    # two `go mod tidy` processes racing on go.mod can corrupt it (#4637).
+    run: once
+    cmds:
+      - go mod tidy
+
+  install:frontend:deps:
+    summary: Install frontend dependencies
+    run: once
+    cmds:
+      - task: install:frontend:deps:{{.PACKAGE_MANAGER}}
+
+  install:frontend:deps:npm:
+    dir: frontend
+    sources:
+      - package.json
+      - package-lock.json
+    generates:
+      - node_modules
+    preconditions:
+      - sh: npm version
+        msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
+    cmds:
+      - npm install
+
+  install:frontend:deps:bun:
+    dir: frontend
+    sources:
+      - package.json
+      - bun.lock
+      - bun.lockb
+    generates:
+      - node_modules
+    preconditions:
+      - sh: bun --version
+        msg: "bun not found"
+    cmds:
+      - bun install
+
+  install:frontend:deps:pnpm:
+    dir: frontend
+    sources:
+      - package.json
+      - pnpm-lock.yaml
+    generates:
+      - node_modules
+    preconditions:
+      - sh: pnpm --version
+        msg: "pnpm not found"
+    cmds:
+      - pnpm install
+
+  install:frontend:deps:yarn:
+    dir: frontend
+    sources:
+      - package.json
+      - yarn.lock
+    status:
+      - test -d node_modules || test -f .pnp.cjs
+    preconditions:
+      - sh: yarn --version
+        msg: "yarn not found"
+    cmds:
+      - yarn install
+
+  build:frontend:
+    label: build:frontend (DEV={{.DEV}} RUNNER={{.PACKAGE_MANAGER}})
+    summary: Build the frontend project
+    # darwin:build:universal runs its per-arch builds as parallel deps, each of
+    # which depends on this task. Without run:once the two executions race:
+    # one regenerates frontend/bindings (-clean deletes it first) while the
+    # other's bundler is reading it, failing intermittently with
+    # 'Could not resolve "./bindings/<pkg>"' (#4637).
+    run: once
+    dir: frontend
+    sources:
+      - "**/*"
+      - exclude: node_modules/**/*
+    generates:
+      - dist/**/*
+    deps:
+      - task: install:frontend:deps
+      - task: generate:bindings
+        vars:
+          BUILD_FLAGS:
+            ref: .BUILD_FLAGS
+          OBFUSCATED:
+            ref: .OBFUSCATED
+    cmds:
+      - task: frontend:run
+        vars:
+          SCRIPT: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
+    env:
+      PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
+
+  frontend:run:
+    summary: Run a frontend script with selected runner
+    cmds:
+      - task: frontend:run:{{.PACKAGE_MANAGER}}
+        vars:
+          SCRIPT: "{{.SCRIPT}}"
+    vars:
+      SCRIPT: "{{.SCRIPT}}"
+
+  frontend:run:npm:
+    dir: frontend
+    cmds:
+      - npm run {{.SCRIPT}} -q
+    vars:
+      SCRIPT: "{{.SCRIPT}}"
+
+  frontend:run:yarn:
+    dir: frontend
+    cmds:
+      - yarn {{.SCRIPT}}
+    vars:
+      SCRIPT: "{{.SCRIPT}}"
+
+  frontend:run:pnpm:
+    dir: frontend
+    cmds:
+      - pnpm run {{.SCRIPT}}
+    vars:
+      SCRIPT: "{{.SCRIPT}}"
+
+  frontend:run:bun:
+    dir: frontend
+    cmds:
+      - bun run {{.SCRIPT}}
+    vars:
+      SCRIPT: "{{.SCRIPT}}"
+
+  frontend:vendor:puppertino:
+    summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
+    sources:
+      - frontend/public/puppertino/puppertino.css
+    generates:
+      - frontend/public/puppertino/puppertino.css
+    cmds:
+      - |
+        set -euo pipefail
+        mkdir -p frontend/public/puppertino
+        # If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
+        if [ ! -f frontend/public/puppertino/puppertino.css ]; then
+          echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
+          if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
+            curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
+            echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
+          else
+            echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
+          fi
+        else
+          echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
+        fi
+        # Ensure index.html includes Puppertino CSS and button classes
+        INDEX_HTML=frontend/index.html
+        if [ -f "$INDEX_HTML" ]; then
+          if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
+            # Insert Puppertino link tag after style.css link
+            awk '
+              /href="\/style.css"\/?/ && !x { print; print "    <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; x=1; next }1
+            ' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
+          fi
+          # Replace default .btn with Puppertino primary button classes if present
+          sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
+        fi
+
+
+
+  generate:bindings:
+    summary: Generates bindings for the frontend
+    run: once
+    deps:
+      - task: go:mod:tidy
+    sources:
+      - "**/*.[jt]s"
+      - exclude: frontend/**/*
+      - frontend/bindings/**/*  # Rerun when switching between dev/production mode causes changes in output
+      - "**/*.go"
+      - go.mod
+      - go.sum
+    generates:
+      - frontend/bindings/**/*
+    cmds:
+      - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -time-type=Date{{if eq .OBFUSCATED "true"}} -obfuscated{{end}}
+
+  generate:icons:
+    summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
+    run: once
+    dir: build
+    sources:
+      - "appicon.png"
+      - "appicon.icon"
+    generates:
+      - "darwin/icons.icns"
+      - "windows/icon.ico"
+    cmds:
+      - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
+
+  dev:frontend:
+    summary: Runs the frontend in development mode
+    deps:
+      - task: install:frontend:deps
+    cmds:
+      - task: frontend:dev:{{.PACKAGE_MANAGER}}
+
+  frontend:dev:npm:
+    dir: frontend
+    cmds:
+      - npm run dev -- --port {{.VITE_PORT}} --strictPort
+
+  frontend:dev:yarn:
+    dir: frontend
+    cmds:
+      - yarn dev --port {{.VITE_PORT}} --strictPort
+
+  frontend:dev:pnpm:
+    dir: frontend
+    cmds:
+      - pnpm dev --port {{.VITE_PORT}} --strictPort
+
+  frontend:dev:bun:
+    dir: frontend
+    cmds:
+      - bun run dev --port {{.VITE_PORT}} --strictPort
+
+  update:build-assets:
+    summary: Updates the build assets
+    dir: build
+    cmds:
+      - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
+
+  build:server:
+    summary: Builds the application in server mode (no GUI, HTTP server only)
+    desc: |
+      Builds a production server binary by default: -tags server,production,
+      -trimpath and a stripped binary, mirroring the desktop `build` task.
+      Server mode runs as a pure HTTP server without native GUI dependencies.
+
+      Usage: task build:server [DEV=true] [OBFUSCATED=true] [EXTRA_TAGS=tag1,tag2]
+        DEV=true        development server (-tags server, no strip, inlining kept)
+        OBFUSCATED=true obfuscated build via garble (requires garble installed)
+        EXTRA_TAGS      additional comma-separated build tags
+    deps:
+      - task: build:frontend
+        vars:
+          DEV:
+            ref: .DEV
+          BUILD_FLAGS:
+            ref: .BUILD_FLAGS
+          OBFUSCATED:
+            ref: .OBFUSCATED
+    preconditions:
+      - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
+        msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
+    cmds:
+      - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"'
+    vars:
+      BUILD_FLAGS: '-tags server{{if eq .DEV "true"}}{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -buildvcs=false -gcflags=all="-l"{{else}},production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
+
+  run:server:
+    summary: Builds and runs a development server (DEV=true)
+    deps:
+      - task: build:server
+        vars:
+          DEV: "true"
+    cmds:
+      - '"./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"'
+
+  build:docker:
+    summary: Builds a Docker image for server mode deployment
+    desc: |
+      Creates a minimal Docker image containing the production server binary.
+      Defaults to a pure-Go static binary on a distroless/static base. The
+      production frontend is built first so the embedded assets are current.
+
+      Usage: task build:docker [TAG=myapp:latest] [CGO_ENABLED=1] [GO_IMAGE=...] [RUNTIME_IMAGE=...]
+      For CGO apps, set CGO_ENABLED=1 with libc-compatible builder/runtime images, e.g.:
+        task build:docker CGO_ENABLED=1 GO_IMAGE=golang:bookworm RUNTIME_IMAGE=gcr.io/distroless/base-debian12
+    deps:
+      # Build the production frontend so frontend/dist (embedded by the Go build
+      # inside the image) is present and current in the Docker build context.
+      # Pass the server,production tags so binding generation analyses the same
+      # build the Docker image compiles, not the default-tag build.
+      - task: build:frontend
+        vars:
+          BUILD_FLAGS: "-tags server,production"
+    cmds:
+      - >-
+        docker build
+        --build-arg CGO_ENABLED={{.CGO_ENABLED | default "0"}}
+        --build-arg GO_IMAGE={{.GO_IMAGE | default "golang:alpine"}}
+        --build-arg RUNTIME_IMAGE={{.RUNTIME_IMAGE | default "gcr.io/distroless/static-debian12"}}
+        -t {{.TAG | default (printf "%s:latest" .APP_NAME)}}
+        -f build/docker/Dockerfile.server .
+    vars:
+      TAG: "{{.TAG}}"
+    preconditions:
+      - sh: docker info > /dev/null 2>&1
+        msg: "Docker is required. Please install Docker first."
+      - sh: test -f build/docker/Dockerfile.server
+        msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
+
+  run:docker:
+    summary: Builds and runs the Docker image
+    desc: |
+      Builds the Docker image and runs it, exposing port 8080.
+      Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
+      Note: The internal container port is always 8080. The PORT variable
+      only changes the host port mapping. Ensure your app uses port 8080
+      or modify the Dockerfile to match your ServerOptions.Port setting.
+    deps:
+      - task: build:docker
+        vars:
+          TAG:
+            ref: .TAG
+    cmds:
+      - docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
+    vars:
+      TAG: "{{.TAG}}"
+      PORT: "{{.PORT}}"
+
+  setup:docker:
+    summary: Builds Docker image for cross-compilation (~800MB download)
+    desc: |
+      Builds the Docker image needed for cross-compiling to any platform.
+      Run this once to enable cross-platform builds from any OS.
+    cmds:
+      - docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
+    preconditions:
+      - sh: docker info > /dev/null 2>&1
+        msg: "Docker is required. Please install Docker first."
+
+  ios:device:list:
+    summary: Lists connected iOS devices (UDIDs)
+    cmds:
+      - xcrun xcdevice list
+
+  ios:run:device:
+    summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
+    vars:
+      PROJECT: '{{.PROJECT}}'   # e.g., build/ios/xcode/<YourProject>.xcodeproj
+      SCHEME: '{{.SCHEME}}'     # e.g., ios.dev
+      CONFIG: '{{.CONFIG | default "Debug"}}'
+      DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
+      UDID: '{{.UDID}}'         # from `task ios:device:list`
+      BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
+      TEAM_ID: '{{.TEAM_ID}}'   # optional, if your project is not already set up for signing
+    preconditions:
+      - sh: xcrun -f xcodebuild
+        msg: "xcodebuild not found. Please install Xcode."
+      - sh: xcrun -f devicectl
+        msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
+      - sh: test -n '{{.PROJECT}}'
+        msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
+      - sh: test -n '{{.SCHEME}}'
+        msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
+      - sh: test -n '{{.UDID}}'
+        msg: "Set UDID to your device UDID (see: task ios:device:list)."
+      - sh: test -n '{{.BUNDLE_ID}}'
+        msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
+    cmds:
+      - |
+        set -euo pipefail
+        echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
+        XCB_ARGS=(
+          -project "{{.PROJECT}}"
+          -scheme "{{.SCHEME}}"
+          -configuration "{{.CONFIG}}"
+          -destination "id={{.UDID}}"
+          -derivedDataPath "{{.DERIVED}}"
+          -allowProvisioningUpdates
+          -allowProvisioningDeviceRegistration
+        )
+        # Optionally inject signing identifiers if provided
+        if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
+        if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
+        xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
+        # If xcpretty isn't installed, run without it
+        if [ "${PIPESTATUS[0]}" -ne 0 ]; then
+          xcodebuild "${XCB_ARGS[@]}" build
+        fi
+        # Find built .app
+        APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
+        if [ -z "$APP_PATH" ]; then
+          echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
+          exit 1
+        fi
+        echo "Installing: $APP_PATH"
+        xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
+        echo "Launching: {{.BUNDLE_ID}}"
+        xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"

BIN
build/appicon.png


+ 87 - 0
build/config.yml

@@ -0,0 +1,87 @@
+# This file contains the configuration for this project.
+# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
+# Note that this will overwrite any changes you have made to the assets.
+version: '3'
+
+# This information is used to generate the build assets.
+info:
+  companyName: "My Company" # The name of the company
+  productName: "My Product" # The name of the application
+  productIdentifier: "com.mycompany.myproduct" # The unique product identifier
+  description: "A program that does X" # The application description
+  copyright: "(c) 2025, My Company" # Copyright text
+  comments: "Some Product Comments" # Comments
+  version: "0.0.1" # The application version
+  # cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
+  #                               # Should match the name of your .icon file without the extension
+  #                               # If not set and Assets.car exists, defaults to "appicon"
+
+# iOS build configuration (uncomment to customise iOS project generation)
+# Note: Keys under `ios` OVERRIDE values under `info` when set.
+# ios:
+#   # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
+#   bundleID: "com.mycompany.myproduct"
+#   # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
+#   displayName: "My Product"
+#   # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
+#   version: "0.0.1"
+#   # The company/organisation name for templates and project settings
+#   company: "My Company"
+#   # Additional comments to embed in Info.plist metadata
+#   comments: "Some Product Comments"
+
+# Dev mode configuration
+dev_mode:
+  root_path: .
+  log_level: warn
+  debounce: 1000
+  ignore:
+    dir:
+      - .git
+      - node_modules
+      - frontend
+      - bin
+    file:
+      - .DS_Store
+      - .gitignore
+      - .gitkeep
+      - "*_test.go"
+    watched_extension:
+      - "*.go"
+      - "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
+      - "*.ts" # The frontend directory will be excluded entirely by the setting above.
+    git_ignore: true
+  executes:
+    - cmd: wails3 build DEV=true BIN_DIR=Z:/
+      type: blocking
+    - cmd: wails3 task common:dev:frontend
+      type: background
+      # The standard frontend task waits up to 60s for HTTP 200 from
+      # FRONTEND_DEVSERVER_URL before starting the app. Override if needed;
+      # custom background commands can opt in with the same readiness block.
+      # readiness:
+      #   http: "http://localhost:5173/"
+      #   timeout: "60s"
+      #   interval: "200ms"
+    - cmd: wails3 task run BIN_DIR=Z:/
+      type: primary
+      exit_policy: shutdown
+
+# File Associations
+# More information at: https://v3.wails.io/noit/done/yet
+fileAssociations:
+#  - ext: wails
+#    name: Wails
+#    description: Wails Application File
+#    iconName: wailsFileIcon
+#    role: Editor
+#  - ext: jpg
+#    name: JPEG
+#    description: Image File
+#    iconName: jpegFileIcon
+#    role: Editor
+#    mimeType: image/jpeg  # (optional)
+
+# Other data
+other:
+  - name: My Other Data

BIN
build/darwin/Assets.car


+ 35 - 0
build/darwin/Info.dev.plist

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+	<dict>
+		<key>CFBundleExecutable</key>
+		<string>腾图工具箱</string>
+		<key>CFBundleGetInfoString</key>
+		<string>Some Product Comments</string>
+		<key>CFBundleIconFile</key>
+		<string>icons</string>
+		<key>CFBundleIconName</key>
+		<string>appicon</string>
+		<key>CFBundleIdentifier</key>
+		<string>com.mycompany.myproduct</string>
+		<key>CFBundleName</key>
+		<string>My Product</string>
+		<key>CFBundlePackageType</key>
+		<string>APPL</string>
+		<key>CFBundleShortVersionString</key>
+		<string>0.0.1</string>
+		<key>CFBundleVersion</key>
+		<string>0.0.1</string>
+		<key>LSMinimumSystemVersion</key>
+		<string>12.0.0</string>
+		<key>NSAppTransportSecurity</key>
+		<dict>
+			<key>NSAllowsLocalNetworking</key>
+			<true/>
+		</dict>
+		<key>NSHighResolutionCapable</key>
+		<string>true</string>
+		<key>NSHumanReadableCopyright</key>
+		<string>(c) 2025, My Company</string>
+	</dict>
+</plist>

+ 30 - 0
build/darwin/Info.plist

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+	<dict>
+		<key>CFBundleExecutable</key>
+		<string>腾图工具箱</string>
+		<key>CFBundleGetInfoString</key>
+		<string>Some Product Comments</string>
+		<key>CFBundleIconFile</key>
+		<string>icons</string>
+		<key>CFBundleIconName</key>
+		<string>appicon</string>
+		<key>CFBundleIdentifier</key>
+		<string>com.mycompany.myproduct</string>
+		<key>CFBundleName</key>
+		<string>My Product</string>
+		<key>CFBundlePackageType</key>
+		<string>APPL</string>
+		<key>CFBundleShortVersionString</key>
+		<string>0.0.1</string>
+		<key>CFBundleVersion</key>
+		<string>0.0.1</string>
+		<key>LSMinimumSystemVersion</key>
+		<string>12.0.0</string>
+		<key>NSHighResolutionCapable</key>
+		<string>true</string>
+		<key>NSHumanReadableCopyright</key>
+		<string>(c) 2025, My Company</string>
+	</dict>
+</plist>

+ 232 - 0
build/darwin/Taskfile.yml

@@ -0,0 +1,232 @@
+version: '3'
+
+includes:
+  common: ../Taskfile.yml
+
+vars:
+  # Docker image for cross-compilation (used when building on non-macOS)
+  CROSS_IMAGE: wails-cross
+
+  SIGN_IDENTITY: "TengTu Studio"
+  KEYCHAIN_PROFILE: "ease"
+tasks:
+  build:
+    summary: Builds the application
+    cmds:
+      - task: '{{if eq OS "darwin"}}build:native{{else}}build:docker{{end}}'
+        vars:
+          ARCH: '{{.ARCH}}'
+          DEV: '{{.DEV}}'
+          OUTPUT: '{{.OUTPUT}}'
+          EXTRA_TAGS: '{{.EXTRA_TAGS}}'
+          OBFUSCATED: '{{.OBFUSCATED}}'
+          GARBLE_ARGS: '{{.GARBLE_ARGS}}'
+    vars:
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+
+  build:native:
+    summary: Builds the application natively on macOS
+    internal: true
+    deps:
+      - task: common:go:mod:tidy
+      - task: common:build:frontend
+        vars:
+          BUILD_FLAGS:
+            ref: .BUILD_FLAGS
+          OBFUSCATED:
+            ref: .OBFUSCATED
+          DEV:
+            ref: .DEV
+      - task: common:generate:icons
+    preconditions:
+      - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
+        msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
+    cmds:
+      - wails3 tool prepare-macos-output --dir "{{.BIN_DIR}}"
+      - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"'
+    vars:
+      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"{{end}}'
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+    env:
+      GOOS: darwin
+      CGO_ENABLED: 1
+      GOARCH: '{{.ARCH | default ARCH}}'
+      CGO_CFLAGS: "-mmacosx-version-min=12.0"
+      CGO_LDFLAGS: "-mmacosx-version-min=12.0"
+      MACOSX_DEPLOYMENT_TARGET: "12.0"
+
+  build:docker:
+    summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts)
+    internal: true
+    deps:
+      - task: common:build:frontend
+        vars:
+          OBFUSCATED:
+            ref: .OBFUSCATED
+      - task: common:generate:icons
+    preconditions:
+      - sh: docker info > /dev/null 2>&1
+        msg: "Docker is required for cross-compilation. Please install Docker."
+      - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
+        msg: |
+          Docker image '{{.CROSS_IMAGE}}' not found.
+          Build it first: wails3 task setup:docker
+    cmds:
+      - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}}
+      - cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
+        platforms: [linux, darwin]
+      - mkdir -p {{.BIN_DIR}}
+      - mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
+    vars:
+      DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}'
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+      # Generate Docker volume mounts: Go module cache + go.mod replace directives
+      # Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
+      DOCKER_MOUNTS:
+        sh: 'wails3 tool docker-mounts'
+
+  build:universal:
+    summary: Builds darwin universal binary (arm64 + amd64)
+    deps:
+      - task: build
+        vars:
+          ARCH: amd64
+          OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
+      - task: build
+        vars:
+          ARCH: arm64
+          OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+    cmds:
+      - task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}'
+
+  build:universal:lipo:native:
+    summary: Creates universal binary using native lipo (macOS)
+    internal: true
+    cmds:
+      - lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+      - rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+
+  build:universal:lipo:go:
+    summary: Creates universal binary using wails3 tool lipo (Linux/Windows)
+    internal: true
+    cmds:
+      - wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+      - rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+
+  package:
+    summary: Packages the application into a `.app` bundle
+    deps:
+      - task: build
+    cmds:
+      - task: create:app:bundle
+
+  package:universal:
+    summary: Packages darwin universal binary (arm64 + amd64)
+    deps:
+      - task: build:universal
+    cmds:
+      - task: create:app:bundle
+
+  package:dmg:
+    summary: Packages the application into a styled `.dmg`
+    deps:
+      - task: package
+    cmds:
+      - task: create:dmg
+
+  create:dmg:
+    summary: Creates a configurable `.dmg` from the `.app` bundle
+    platforms: [darwin]
+    cmds:
+      - >-
+        wails3 tool package --format dmg --name "{{.APP_NAME}}" --out "{{.BIN_DIR}}"
+        --background "{{.DMG_BACKGROUND}}"
+        --volume-icon "{{.DMG_VOLUME_ICON}}"
+        --file-icon "{{.DMG_FILE_ICON}}"
+        --window-width "{{.DMG_WINDOW_WIDTH}}"
+        --window-height "{{.DMG_WINDOW_HEIGHT}}"
+        {{if .DMG_FILES}}--files "{{.DMG_FILES}}"{{end}}
+    vars:
+      DMG_BACKGROUND: '{{.DMG_BACKGROUND | default "build/darwin/dmg-background.png"}}'
+      DMG_VOLUME_ICON: '{{.DMG_VOLUME_ICON | default "build/darwin/icons.icns"}}'
+      DMG_FILE_ICON: '{{.DMG_FILE_ICON | default "build/darwin/dmg-file-icon.icns"}}'
+      DMG_WINDOW_WIDTH: '{{.DMG_WINDOW_WIDTH | default "540"}}'
+      DMG_WINDOW_HEIGHT: '{{.DMG_WINDOW_HEIGHT | default "380"}}'
+      DMG_FILES: '{{.DMG_FILES | default ""}}'
+
+
+  create:app:bundle:
+    summary: Creates an `.app` bundle
+    cmds:
+      # A reused bundle can acquire Finder metadata in synced folders. Build a
+      # fresh bundle so codesign never has to replace a contaminated signature.
+      - test -n "{{.BIN_DIR}}" && test -n "{{.APP_NAME}}" && rm -rf -- "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+      - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
+      - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+      - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+      - |
+        if [ -f build/darwin/Assets.car ]; then
+          cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+        fi
+      - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
+      - cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents"
+      - task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}'
+
+  codesign:adhoc:
+    summary: Ad-hoc signs the app bundle (macOS only)
+    internal: true
+    cmds:
+      - xattr -cr "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+      - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+      - codesign --verify --deep --strict "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+
+  codesign:skip:
+    summary: Skips codesigning when cross-compiling
+    internal: true
+    cmds:
+      - 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."'
+
+  run:
+    cmds:
+      - wails3 tool prepare-macos-output --dir "{{.BIN_DIR}}"
+      - test -n "{{.BIN_DIR}}" && test -n "{{.APP_NAME}}" && rm -rf -- "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+      - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
+      - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+      - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+      - |
+        if [ -f build/darwin/Assets.car ]; then
+          cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+        fi
+      - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
+      - cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
+      - xattr -cr "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+      - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+      - codesign --verify --deep --strict "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+      - '"{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}"'
+
+  sign:
+    summary: Signs the application bundle with Developer ID
+    desc: |
+      Signs the .app bundle for distribution.
+      Uses signing identity from `wails3 setup` (stored in ~/.config/wails/defaults.yaml).
+      Override with: task darwin:sign -- --identity "Developer ID Application: ..."
+    deps:
+      - task: package
+    cmds:
+      - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" {{.CLI_ARGS}}
+
+  sign:notarize:
+    summary: Signs and notarizes the application bundle
+    desc: |
+      Signs the .app bundle and submits it for notarization.
+      Uses signing identity and keychain profile from `wails3 setup`.
+
+      First-time setup:
+        wails3 setup
+    deps:
+      - task: package
+    cmds:
+      - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --notarize {{.CLI_ARGS}}

BIN
build/darwin/dmg-background.png


BIN
build/darwin/dmg-file-icon.icns


BIN
build/darwin/dmg-file-icon.png


BIN
build/darwin/icon.icns


BIN
build/darwin/icons.icns


+ 225 - 0
build/docker/Dockerfile.cross

@@ -0,0 +1,225 @@
+# Cross-compile Wails v3 apps to any platform
+#
+# Darwin: Zig + macOS SDK
+# Linux: Native GCC when host matches target, Zig for cross-arch
+# Windows: Zig + bundled mingw
+#
+# Usage:
+#   docker build -t wails-cross -f Dockerfile.cross .
+#   docker run --rm -v $(pwd):/app wails-cross darwin arm64
+#   docker run --rm -v $(pwd):/app wails-cross darwin amd64
+#   docker run --rm -v $(pwd):/app wails-cross linux amd64
+#   docker run --rm -v $(pwd):/app wails-cross linux arm64
+#   docker run --rm -v $(pwd):/app wails-cross windows amd64
+#   docker run --rm -v $(pwd):/app wails-cross windows arm64
+
+# Debian 13 provides the GTK >= 4.14 API baseline used by the default Linux backend.
+FROM golang:1.26-trixie
+
+ARG TARGETARCH
+ARG GARBLE_VERSION=v0.16.0
+
+# Install base tools, GCC, and GTK/WebKit dev packages
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    curl xz-utils python3 nodejs npm pkg-config gcc libc6-dev \
+    libgtk-3-dev libwebkit2gtk-4.1-dev \
+    libgtk-4-dev libwebkitgtk-6.0-dev \
+    && pkg-config --atleast-version=4.14 gtk4 \
+    && pkg-config --exists webkitgtk-6.0 \
+    && pkg-config --exists gtk+-3.0 \
+    && pkg-config --exists webkit2gtk-4.1 \
+    && rm -rf /var/lib/apt/lists/*
+
+RUN go install mvdan.cc/garble@${GARBLE_VERSION}
+
+# Install Zig - automatically selects correct binary for host architecture.
+# The SHA-256 is fetched from Zig's official download manifest and verified
+# before extraction to protect against MITM or corrupted downloads.
+ARG ZIG_VERSION=0.14.0
+RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
+    ZIG_FILE="zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" && \
+    curl -fSL "https://ziglang.org/download/${ZIG_VERSION}/${ZIG_FILE}" -o "/tmp/${ZIG_FILE}" && \
+    EXPECTED=$(curl -fsSL "https://ziglang.org/download/index.json" | \
+        python3 -c "import json,sys; d=json.load(sys.stdin); print(d[sys.argv[1]][sys.argv[2]+'-linux']['shasum'])" \
+        "${ZIG_VERSION}" "${ZIG_ARCH}") && \
+    echo "${EXPECTED}  /tmp/${ZIG_FILE}" | sha256sum -c - && \
+    tar -xJ -f "/tmp/${ZIG_FILE}" -C /opt && \
+    rm "/tmp/${ZIG_FILE}" && \
+    ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
+
+# Download macOS SDK (required for darwin targets)
+ARG MACOS_SDK_VERSION=14.5
+RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \
+    | tar -xJ -C /opt \
+    && mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk
+
+ENV MACOS_SDK_PATH=/opt/macos-sdk
+
+# Create Zig CC wrappers for cross-compilation targets
+# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch)
+
+# Darwin arm64
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+    if [ $SKIP_NEXT -eq 1 ]; then
+        SKIP_NEXT=0
+        continue
+    fi
+    case "$arg" in
+        -target) SKIP_NEXT=1 ;;
+        -mmacosx-version-min=*) ;;
+        *) ARGS="$ARGS $arg" ;;
+    esac
+done
+exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-darwin-arm64
+
+# Darwin amd64
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+    if [ $SKIP_NEXT -eq 1 ]; then
+        SKIP_NEXT=0
+        continue
+    fi
+    case "$arg" in
+        -target) SKIP_NEXT=1 ;;
+        -mmacosx-version-min=*) ;;
+        *) ARGS="$ARGS $arg" ;;
+    esac
+done
+exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-darwin-amd64
+
+# Windows amd64 - uses Zig's bundled mingw
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+    if [ $SKIP_NEXT -eq 1 ]; then
+        SKIP_NEXT=0
+        continue
+    fi
+    case "$arg" in
+        -target) SKIP_NEXT=1 ;;
+        -Wl,*) ;;
+        *) ARGS="$ARGS $arg" ;;
+    esac
+done
+exec zig cc -target x86_64-windows-gnu $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-windows-amd64
+
+# Windows arm64 - uses Zig's bundled mingw
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+    if [ $SKIP_NEXT -eq 1 ]; then
+        SKIP_NEXT=0
+        continue
+    fi
+    case "$arg" in
+        -target) SKIP_NEXT=1 ;;
+        -Wl,*) ;;
+        *) ARGS="$ARGS $arg" ;;
+    esac
+done
+exec zig cc -target aarch64-windows-gnu $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-windows-arm64
+
+# Build script
+COPY <<'SCRIPT' /usr/local/bin/build.sh
+#!/bin/sh
+set -e
+
+OS=${1:-darwin}
+ARCH=${2:-arm64}
+
+case "${OS}-${ARCH}" in
+    darwin-arm64|darwin-aarch64)
+        export CC=zcc-darwin-arm64
+        export GOARCH=arm64
+        export GOOS=darwin
+        ;;
+    darwin-amd64|darwin-x86_64)
+        export CC=zcc-darwin-amd64
+        export GOARCH=amd64
+        export GOOS=darwin
+        ;;
+    linux-arm64|linux-aarch64)
+        export CC=gcc
+        export GOARCH=arm64
+        export GOOS=linux
+        ;;
+    linux-amd64|linux-x86_64)
+        export CC=gcc
+        export GOARCH=amd64
+        export GOOS=linux
+        ;;
+    windows-arm64|windows-aarch64)
+        export CC=zcc-windows-arm64
+        export GOARCH=arm64
+        export GOOS=windows
+        ;;
+    windows-amd64|windows-x86_64)
+        export CC=zcc-windows-amd64
+        export GOARCH=amd64
+        export GOOS=windows
+        ;;
+    *)
+        echo "Usage: <os> <arch>"
+        echo "  os: darwin, linux, windows"
+        echo "  arch: amd64, arm64"
+        exit 1
+        ;;
+esac
+
+export CGO_ENABLED=1
+export CGO_CFLAGS="-w"
+
+# Build frontend if exists and not already built (host may have built it)
+if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
+    (cd frontend && npm install --silent && npm run build --silent)
+fi
+
+# Build
+APP=${APP_NAME:-$(basename $(pwd))}
+mkdir -p bin
+
+EXT=""
+LDFLAGS="-s -w"
+if [ "$GOOS" = "windows" ]; then
+    EXT=".exe"
+    LDFLAGS="-s -w -H windowsgui"
+fi
+
+TAGS="production"
+if [ -n "$EXTRA_TAGS" ]; then
+    TAGS="${TAGS},${EXTRA_TAGS}"
+fi
+
+COMPILER="go build"
+if [ "$OBFUSCATED" = "true" ]; then
+    COMPILER="garble ${GARBLE_ARGS} build"
+    TAGS="${TAGS},wails_obfuscated"
+fi
+
+${COMPILER} -tags "$TAGS" -trimpath -buildvcs=false -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} .
+echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}"
+SCRIPT
+RUN chmod +x /usr/local/bin/build.sh
+
+WORKDIR /app
+ENTRYPOINT ["/usr/local/bin/build.sh"]
+CMD ["darwin", "arm64"]

+ 67 - 0
build/docker/Dockerfile.server

@@ -0,0 +1,67 @@
+# Wails Server Mode Dockerfile
+# Multi-stage build for a minimal image.
+#
+# Defaults produce a pure-Go, fully static binary on a distroless/static base:
+# the smallest, most secure image, and correct for the default (CGO-free) app.
+#
+# If your app needs CGO, override the build args, e.g.:
+#   docker build --build-arg CGO_ENABLED=1 \
+#                --build-arg GO_IMAGE=golang:bookworm \
+#                --build-arg RUNTIME_IMAGE=gcr.io/distroless/base-debian12 \
+#                -f build/docker/Dockerfile.server .
+# CGO needs a C toolchain in the builder and a libc in the runtime image; keep
+# the builder and runtime libc compatible (a glibc builder -> a glibc runtime).
+# The Taskfile passes these through: task build:docker CGO_ENABLED=1 GO_IMAGE=... RUNTIME_IMAGE=...
+
+ARG GO_IMAGE=golang:alpine
+ARG RUNTIME_IMAGE=gcr.io/distroless/static-debian12
+ARG CGO_ENABLED=0
+
+# Build stage
+FROM ${GO_IMAGE} AS builder
+ARG CGO_ENABLED
+
+WORKDIR /app
+
+# Install build dependencies: git always; a C compiler only when CGO is enabled.
+# Supports both apk (alpine) and apt (debian/bookworm) base images.
+RUN if command -v apk >/dev/null 2>&1; then \
+        apk add --no-cache git $([ "$CGO_ENABLED" = "1" ] && echo gcc musl-dev); \
+    else \
+        apt-get update && apt-get install -y --no-install-recommends git $([ "$CGO_ENABLED" = "1" ] && echo gcc libc6-dev) && rm -rf /var/lib/apt/lists/*; \
+    fi
+
+# Copy source code
+COPY . .
+
+# Remove local replace directive if present (for production builds)
+RUN sed -i '/^replace/d' go.mod || true
+
+# Download dependencies
+RUN go mod tidy
+
+# Build the production server binary.
+# - server,production tags: HTTP server mode with production code paths (no dev
+#   logger/asset middleware), matching the desktop production build.
+# - CGO_ENABLED defaults to 0 for a fully static binary (distroless/static);
+#   override with --build-arg CGO_ENABLED=1 (see header) for CGO apps.
+RUN CGO_ENABLED=${CGO_ENABLED} go build -tags server,production -trimpath -buildvcs=false -ldflags="-s -w" -o server .
+
+# Runtime stage - minimal image
+FROM ${RUNTIME_IMAGE}
+
+# Copy the binary
+COPY --from=builder /app/server /server
+
+# Copy frontend assets
+COPY --from=builder /app/frontend/dist /frontend/dist
+
+# Expose the default port
+EXPOSE 8080
+
+# Bind to all interfaces (required for Docker)
+# Can be overridden at runtime with -e WAILS_SERVER_HOST=...
+ENV WAILS_SERVER_HOST=0.0.0.0
+
+# Run the server
+ENTRYPOINT ["/server"]

+ 116 - 0
build/ios/Assets.xcassets

@@ -0,0 +1,116 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  },
+  "images" : [
+    {
+      "filename" : "icon-20@2x.png",
+      "idiom" : "iphone",
+      "scale" : "2x",
+      "size" : "20x20"
+    },
+    {
+      "filename" : "icon-20@3x.png",
+      "idiom" : "iphone",
+      "scale" : "3x",
+      "size" : "20x20"
+    },
+    {
+      "filename" : "icon-29@2x.png",
+      "idiom" : "iphone",
+      "scale" : "2x",
+      "size" : "29x29"
+    },
+    {
+      "filename" : "icon-29@3x.png",
+      "idiom" : "iphone",
+      "scale" : "3x",
+      "size" : "29x29"
+    },
+    {
+      "filename" : "icon-40@2x.png",
+      "idiom" : "iphone",
+      "scale" : "2x",
+      "size" : "40x40"
+    },
+    {
+      "filename" : "icon-40@3x.png",
+      "idiom" : "iphone",
+      "scale" : "3x",
+      "size" : "40x40"
+    },
+    {
+      "filename" : "icon-60@2x.png",
+      "idiom" : "iphone",
+      "scale" : "2x",
+      "size" : "60x60"
+    },
+    {
+      "filename" : "icon-60@3x.png",
+      "idiom" : "iphone",
+      "scale" : "3x",
+      "size" : "60x60"
+    },
+    {
+      "filename" : "icon-20.png",
+      "idiom" : "ipad",
+      "scale" : "1x",
+      "size" : "20x20"
+    },
+    {
+      "filename" : "icon-20@2x.png",
+      "idiom" : "ipad",
+      "scale" : "2x",
+      "size" : "20x20"
+    },
+    {
+      "filename" : "icon-29.png",
+      "idiom" : "ipad",
+      "scale" : "1x",
+      "size" : "29x29"
+    },
+    {
+      "filename" : "icon-29@2x.png",
+      "idiom" : "ipad",
+      "scale" : "2x",
+      "size" : "29x29"
+    },
+    {
+      "filename" : "icon-40.png",
+      "idiom" : "ipad",
+      "scale" : "1x",
+      "size" : "40x40"
+    },
+    {
+      "filename" : "icon-40@2x.png",
+      "idiom" : "ipad",
+      "scale" : "2x",
+      "size" : "40x40"
+    },
+    {
+      "filename" : "icon-76.png",
+      "idiom" : "ipad",
+      "scale" : "1x",
+      "size" : "76x76"
+    },
+    {
+      "filename" : "icon-76@2x.png",
+      "idiom" : "ipad",
+      "scale" : "2x",
+      "size" : "76x76"
+    },
+    {
+      "filename" : "icon-83.5@2x.png",
+      "idiom" : "ipad",
+      "scale" : "2x",
+      "size" : "83.5x83.5"
+    },
+    {
+      "filename" : "icon-1024.png",
+      "idiom" : "ios-marketing",
+      "scale" : "1x",
+      "size" : "1024x1024"
+    }
+  ]
+}

+ 56 - 0
build/ios/Info.dev.plist

@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+	<dict>
+		<key>CFBundleDisplayName</key>
+		<string>My Product (Dev)</string>
+		<key>CFBundleExecutable</key>
+		<string>腾图工具箱</string>
+		<key>CFBundleGetInfoString</key>
+		<string>Some Product Comments</string>
+		<key>CFBundleIdentifier</key>
+		<string>com.mycompany.myproduct.dev</string>
+		<key>CFBundleName</key>
+		<string>My Product (Dev)</string>
+		<key>CFBundlePackageType</key>
+		<string>APPL</string>
+		<key>CFBundleShortVersionString</key>
+		<string>0.0.1-dev</string>
+		<key>CFBundleVersion</key>
+		<string>0.0.1</string>
+		<key>LSRequiresIPhoneOS</key>
+		<true/>
+		<key>MinimumOSVersion</key>
+		<string>15.0</string>
+		<key>NSAppTransportSecurity</key>
+		<dict>
+			<key>NSAllowsArbitraryLoads</key>
+			<true/>
+			<key>NSAllowsLocalNetworking</key>
+			<true/>
+		</dict>
+		<key>NSHumanReadableCopyright</key>
+		<string>(c) 2025, My Company</string>
+		<key>UILaunchStoryboardName</key>
+		<string>LaunchScreen</string>
+		<key>UIRequiredDeviceCapabilities</key>
+		<array>
+			<string>arm64</string>
+		</array>
+		<key>UISupportedInterfaceOrientations</key>
+		<array>
+			<string>UIInterfaceOrientationPortrait</string>
+			<string>UIInterfaceOrientationLandscapeLeft</string>
+			<string>UIInterfaceOrientationLandscapeRight</string>
+		</array>
+		<key>UISupportedInterfaceOrientations~ipad</key>
+		<array>
+			<string>UIInterfaceOrientationPortrait</string>
+			<string>UIInterfaceOrientationPortraitUpsideDown</string>
+			<string>UIInterfaceOrientationLandscapeLeft</string>
+			<string>UIInterfaceOrientationLandscapeRight</string>
+		</array>
+		<key>WailsDevelopmentMode</key>
+		<true/>
+	</dict>
+</plist>

+ 54 - 0
build/ios/Info.plist

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+	<dict>
+		<key>CFBundleDisplayName</key>
+		<string>My Product</string>
+		<key>CFBundleExecutable</key>
+		<string>腾图工具箱</string>
+		<key>CFBundleGetInfoString</key>
+		<string>Some Product Comments</string>
+		<key>CFBundleIdentifier</key>
+		<string>com.mycompany.myproduct</string>
+		<key>CFBundleName</key>
+		<string>My Product</string>
+		<key>CFBundlePackageType</key>
+		<string>APPL</string>
+		<key>CFBundleShortVersionString</key>
+		<string>0.0.1</string>
+		<key>CFBundleVersion</key>
+		<string>0.0.1</string>
+		<key>LSRequiresIPhoneOS</key>
+		<true/>
+		<key>MinimumOSVersion</key>
+		<string>15.0</string>
+		<key>NSAppTransportSecurity</key>
+		<dict>
+			<key>NSAllowsArbitraryLoads</key>
+			<false/>
+			<key>NSAllowsLocalNetworking</key>
+			<true/>
+		</dict>
+		<key>NSHumanReadableCopyright</key>
+		<string>(c) 2025, My Company</string>
+		<key>UILaunchStoryboardName</key>
+		<string>LaunchScreen</string>
+		<key>UIRequiredDeviceCapabilities</key>
+		<array>
+			<string>arm64</string>
+		</array>
+		<key>UISupportedInterfaceOrientations</key>
+		<array>
+			<string>UIInterfaceOrientationPortrait</string>
+			<string>UIInterfaceOrientationLandscapeLeft</string>
+			<string>UIInterfaceOrientationLandscapeRight</string>
+		</array>
+		<key>UISupportedInterfaceOrientations~ipad</key>
+		<array>
+			<string>UIInterfaceOrientationPortrait</string>
+			<string>UIInterfaceOrientationPortraitUpsideDown</string>
+			<string>UIInterfaceOrientationLandscapeLeft</string>
+			<string>UIInterfaceOrientationLandscapeRight</string>
+		</array>
+	</dict>
+</plist>

+ 53 - 0
build/ios/LaunchScreen.storyboard

@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
+    <device id="retina6_12" orientation="portrait" appearance="light"/>
+    <dependencies>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
+        <capability name="Safe area layout guides" minToolsVersion="9.0"/>
+        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
+    </dependencies>
+    <scenes>
+        <!--View Controller-->
+        <scene sceneID="EHf-IW-A2E">
+            <objects>
+                <viewController id="01J-lp-oVM" sceneMemberID="viewController">
+                    <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
+                        <rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <subviews>
+                            <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="My Product" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
+                                <rect key="frame" x="0.0" y="397" width="393" height="43"/>
+                                <fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
+                                <nil key="textColor"/>
+                                <nil key="highlightedColor"/>
+                            </label>
+                            
+                            <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="A program that does X" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu">
+                                <rect key="frame" x="0.0" y="448" width="393" height="21"/>
+                                <fontDescription key="fontDescription" type="system" pointSize="17"/>
+                                <nil key="textColor"/>
+                                <nil key="highlightedColor"/>
+                            </label>
+                            
+                        </subviews>
+                        <viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
+                        <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
+                        <constraints>
+                            <constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/>
+                            <constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/2" constant="-20" id="moa-c2-u7t"/>
+                            <constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/>
+                            
+                            <constraint firstItem="MN2-I3-ftu" firstAttribute="top" secondItem="GJd-Yh-RWb" secondAttribute="bottom" constant="8" symbolic="YES" id="cPy-rs-vsC"/>
+                            <constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="OQL-iM-xY6"/>
+                            <constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="Dti-5h-tvW"/>
+                            
+                        </constraints>
+                    </view>
+                </viewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="53" y="375"/>
+        </scene>
+    </scenes>
+</document>

+ 365 - 0
build/ios/Taskfile.yml

@@ -0,0 +1,365 @@
+version: '3'
+
+includes:
+  common: ../Taskfile.yml
+
+# Platform selection:
+#   task ios:package                          -> simulator bundle (ad-hoc signed)
+#   task ios:package IOS_PLATFORM=device CODESIGN_IDENTITY="Apple Development: ..." \
+#        [PROVISIONING_PROFILE=path.mobileprovision]
+#                                             -> device bundle (real signing)
+#   task ios:package:ipa IOS_PLATFORM=device ... -> .ipa for distribution/install
+# For automatically managed signing & provisioning, use `task ios:xcode` and
+# build from the generated Xcode project instead.
+vars:
+  BUNDLE_ID: '{{.BUNDLE_ID | default "com.wails.app"}}'
+  IOS_PLATFORM: '{{.IOS_PLATFORM | default "simulator"}}'
+  MIN_IOS_VERSION: '{{.MIN_IOS_VERSION | default "15.0"}}'
+  IOS_SDK: '{{if eq .IOS_PLATFORM "device"}}iphoneos{{else}}iphonesimulator{{end}}'
+  IOS_TARGET: 'arm64-apple-ios{{.MIN_IOS_VERSION}}{{if ne .IOS_PLATFORM "device"}}-simulator{{end}}'
+  IOS_VERSION_MIN_FLAG: '{{if eq .IOS_PLATFORM "device"}}-miphoneos-version-min={{.MIN_IOS_VERSION}}{{else}}-mios-simulator-version-min={{.MIN_IOS_VERSION}}{{end}}'
+  # "-" is ad-hoc signing: fine for the simulator, rejected by real devices.
+  CODESIGN_IDENTITY: '{{.CODESIGN_IDENTITY | default "-"}}'
+  # Optional path to a .mobileprovision embedded into device bundles
+  PROVISIONING_PROFILE: '{{.PROVISIONING_PROFILE | default ""}}'
+  # Optional entitlements applied at codesign time (generated default below)
+  ENTITLEMENTS_FILE: '{{.ENTITLEMENTS_FILE | default "build/ios/entitlements.plist"}}'
+  # SDK_PATH is computed lazily at task-level to avoid errors on non-macOS systems
+
+tasks:
+  install:deps:
+    summary: Check and install iOS development dependencies
+    cmds:
+      - go run build/ios/scripts/deps/install_deps.go
+    env:
+      TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}'
+    prompt: This will check and install iOS development dependencies. Continue?
+
+  # Note: Bindings generation may show CGO warnings for iOS C imports.
+  # These warnings are harmless and don't affect the generated bindings,
+  # as the generator only needs to parse Go types, not C implementations.
+  build:
+    summary: Creates a build of the application for iOS
+    deps:
+      - task: generate:ios:overlay
+      - task: generate:ios:xcode
+      - task: common:go:mod:tidy
+      - task: common:build:frontend
+        vars:
+          BUILD_FLAGS:
+            ref: .BUILD_FLAGS
+          PRODUCTION:
+            ref: .PRODUCTION
+    cmds:
+      - echo "Building iOS app {{.APP_NAME}} ({{.IOS_PLATFORM}})..."
+      - go build -buildmode=c-archive -overlay build/ios/xcode/overlay.json {{.BUILD_FLAGS}} -o {{.OUTPUT}}.a
+    vars:
+      BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,ios -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags ios,debug -buildvcs=false -gcflags=all="-l"{{end}}'
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+      SDK_PATH:
+        sh: xcrun --sdk {{.IOS_SDK}} --show-sdk-path
+    env:
+      GOOS: ios
+      CGO_ENABLED: 1
+      GOARCH: '{{.ARCH | default "arm64"}}'
+      PRODUCTION: '{{.PRODUCTION | default "false"}}'
+      CGO_CFLAGS: '-isysroot {{.SDK_PATH}} -target {{.IOS_TARGET}} {{.IOS_VERSION_MIN_FLAG}}'
+      CGO_LDFLAGS: '-isysroot {{.SDK_PATH}} -target {{.IOS_TARGET}}'
+
+  package:
+    summary: Packages a production build of the application into a `.app` bundle
+    deps:
+      - task: build
+        vars:
+          PRODUCTION: "true"
+    cmds:
+      - task: compile:ios:link
+      - task: create:app:bundle
+
+  package:ipa:
+    summary: Packages a production build into an .ipa (requires IOS_PLATFORM=device and a real CODESIGN_IDENTITY)
+    deps:
+      - task: package
+    cmds:
+      - rm -rf "{{.BIN_DIR}}/Payload" "{{.BIN_DIR}}/{{.APP_NAME}}.ipa"
+      - mkdir -p "{{.BIN_DIR}}/Payload"
+      - cp -R "{{.BIN_DIR}}/{{.APP_NAME}}.app" "{{.BIN_DIR}}/Payload/"
+      - cd "{{.BIN_DIR}}" && zip -qry "{{.APP_NAME}}.ipa" Payload
+      - rm -rf "{{.BIN_DIR}}/Payload"
+      - echo "Created {{.BIN_DIR}}/{{.APP_NAME}}.ipa"
+
+  # Link the Go c-archive with main.m into the final executable.
+  # Used by both the dev `run` flow and `package`.
+  compile:ios:link:
+    internal: true
+    summary: Link the iOS executable from the Go archive and main.m
+    vars:
+      SDK_PATH:
+        sh: xcrun --sdk {{.IOS_SDK}} --show-sdk-path
+    cmds:
+      - |
+        MAIN_M=build/ios/xcode/main/main.m
+        if [ ! -f "$MAIN_M" ]; then
+          MAIN_M=build/ios/main.m
+        fi
+        xcrun -sdk {{.IOS_SDK}} clang \
+          -target {{.IOS_TARGET}} \
+          -isysroot {{.SDK_PATH}} \
+          -framework Foundation -framework UIKit -framework WebKit \
+          -framework Security -framework CoreFoundation \
+          -framework UniformTypeIdentifiers \
+          -framework LocalAuthentication -framework UserNotifications \
+          -framework AVFoundation \
+          -framework CoreLocation -framework CoreMotion -framework SystemConfiguration \
+          -lresolv \
+          -o "{{.BIN_DIR}}/{{.APP_NAME | lower}}" \
+          "$MAIN_M" -Wl,-force_load,"{{.BIN_DIR}}/{{.APP_NAME}}.a"
+
+  create:app:bundle:
+    summary: Creates an iOS `.app` bundle
+    cmds:
+      - rm -rf "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+      - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+      - cp "{{.BIN_DIR}}/{{.APP_NAME | lower}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/"
+      - cp build/ios/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/"
+      - |
+        # Embed the provisioning profile for device builds when provided
+        if [ "{{.IOS_PLATFORM}}" = "device" ] && [ -n "{{.PROVISIONING_PROFILE}}" ]; then
+          cp "{{.PROVISIONING_PROFILE}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/embedded.mobileprovision"
+        fi
+      - |
+        # Compile asset catalog and embed icons in the app bundle
+        APP_BUNDLE="{{.BIN_DIR}}/{{.APP_NAME}}.app"
+        AC_IN="build/ios/xcode/main/Assets.xcassets"
+        if [ -d "$AC_IN" ]; then
+          TMP_AC=$(mktemp -d)
+          xcrun actool \
+            --compile "$TMP_AC" \
+            --app-icon AppIcon \
+            --platform {{.IOS_SDK}} \
+            --minimum-deployment-target {{.MIN_IOS_VERSION}} \
+            --product-type com.apple.product-type.application \
+            --target-device iphone \
+            --target-device ipad \
+            --output-partial-info-plist "$APP_BUNDLE/assetcatalog_generated_info.plist" \
+            "$AC_IN"
+          if [ -f "$TMP_AC/Assets.car" ]; then
+            cp -f "$TMP_AC/Assets.car" "$APP_BUNDLE/Assets.car"
+          fi
+          rm -rf "$TMP_AC"
+          if [ -f "$APP_BUNDLE/assetcatalog_generated_info.plist" ]; then
+            /usr/libexec/PlistBuddy -c "Merge $APP_BUNDLE/assetcatalog_generated_info.plist" "$APP_BUNDLE/Info.plist" || true
+          fi
+        fi
+      - |
+        # Sign the bundle. Ad-hoc ("-") works on the simulator only; device
+        # builds need a real identity (and usually entitlements + profile).
+        # Entitlements are only applied to device builds - simulator bundles
+        # must not carry them (SpringBoard refuses to launch the app).
+        if [ "{{.IOS_PLATFORM}}" = "device" ] && [ -f "{{.ENTITLEMENTS_FILE}}" ]; then
+          codesign --force --sign "{{.CODESIGN_IDENTITY}}" --entitlements "{{.ENTITLEMENTS_FILE}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+        else
+          codesign --force --sign "{{.CODESIGN_IDENTITY}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+        fi
+
+  deploy-simulator:
+    summary: Deploy to iOS Simulator
+    deps: [package]
+    cmds:
+      - |
+        # Read the real bundle id from the built bundle (it comes from
+        # build/config.yml, not the BUNDLE_ID var, so it varies per project).
+        APP="{{.BIN_DIR}}/{{.APP_NAME}}.app"
+        BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Info.plist")
+        xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true
+        xcrun simctl uninstall booted "$BUNDLE_ID" 2>/dev/null || true
+        xcrun simctl install booted "$APP"
+        xcrun simctl launch booted "$BUNDLE_ID"
+
+  deploy-device:
+    summary: Install and launch on a connected device (requires IOS_PLATFORM=device build and real signing)
+    cmds:
+      - |
+        DEVICE="{{.DEVICE_ID}}"
+        if [ -z "$DEVICE" ]; then
+          DEVICE=$(xcrun devicectl list devices --hide-headers 2>/dev/null | awk 'NR==1 {print $3}')
+        fi
+        if [ -z "$DEVICE" ]; then
+          echo "No connected device found. Pass DEVICE_ID=<identifier> (see: xcrun devicectl list devices)"
+          exit 1
+        fi
+        xcrun devicectl device install app --device "$DEVICE" "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+        BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "{{.BIN_DIR}}/{{.APP_NAME}}.app/Info.plist")
+        xcrun devicectl device process launch --device "$DEVICE" "$BUNDLE_ID"
+
+  compile:ios:
+    summary: Compile the iOS executable from Go archive and main.m
+    deps:
+      - task: build
+    cmds:
+      - task: compile:ios:link
+
+  generate:ios:bindings:
+    internal: true
+    summary: Generates bindings for iOS with proper CGO flags
+    sources:
+      - "**/*.go"
+      - go.mod
+      - go.sum
+    generates:
+      - frontend/bindings/**/*
+    vars:
+      SDK_PATH:
+        sh: xcrun --sdk {{.IOS_SDK}} --show-sdk-path
+    cmds:
+      - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
+    env:
+      GOOS: ios
+      CGO_ENABLED: 1
+      GOARCH: '{{.ARCH | default "arm64"}}'
+      CGO_CFLAGS: '-isysroot {{.SDK_PATH}} -target {{.IOS_TARGET}} {{.IOS_VERSION_MIN_FLAG}}'
+      CGO_LDFLAGS: '-isysroot {{.SDK_PATH}} -target {{.IOS_TARGET}}'
+
+  ensure-simulator:
+    internal: true
+    summary: Ensure iOS Simulator is running and booted
+    silent: true
+    cmds:
+      - |
+        if ! xcrun simctl list devices booted | grep -q "Booted"; then
+          echo "Starting iOS Simulator..."
+          # Get first available iPhone device
+          DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone" | head -1 | grep -o "[A-F0-9-]\{36\}" || true)
+          if [ -z "$DEVICE_ID" ]; then
+            echo "No iPhone simulator found. Creating one..."
+            RUNTIME=$(xcrun simctl list runtimes | grep iOS | tail -1 | awk '{print $NF}')
+            # Use the newest iPhone device type this Xcode knows about
+            DEVICE_TYPE=$(xcrun simctl list devicetypes | grep -oE "iPhone [0-9][^(]*" | tail -1 | xargs)
+            DEVICE_ID=$(xcrun simctl create "Wails iPhone" "${DEVICE_TYPE:-iPhone 15}" "$RUNTIME")
+          fi
+          # Boot the device
+          echo "Booting device $DEVICE_ID..."
+          xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true
+          # Open Simulator app
+          open -a Simulator
+          # Wait for boot (max 30 seconds)
+          for i in {1..30}; do
+            if xcrun simctl list devices booted | grep -q "Booted"; then
+              echo "Simulator booted successfully"
+              break
+            fi
+            sleep 1
+          done
+          # Final check
+          if ! xcrun simctl list devices booted | grep -q "Booted"; then
+            echo "Failed to boot simulator after 30 seconds"
+            exit 1
+          fi
+        fi
+    preconditions:
+      - sh: command -v xcrun
+        msg: "xcrun not found. Please run 'wails3 task ios:install:deps' to install iOS development dependencies"
+
+  generate:ios:overlay:
+    internal: true
+    summary: Generate Go build overlay and iOS shim
+    sources:
+      - build/config.yml
+    generates:
+      - build/ios/xcode/overlay.json
+      - build/ios/xcode/gen/main_ios.gen.go
+    cmds:
+      - wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml
+
+  generate:ios:xcode:
+    internal: true
+    summary: Generate iOS Xcode project structure and assets
+    sources:
+      - build/config.yml
+      - build/appicon.png
+    generates:
+      - build/ios/xcode/main/main.m
+      - build/ios/xcode/main/Assets.xcassets/**/*
+      - build/ios/xcode/project.pbxproj
+    cmds:
+      - wails3 ios xcode:gen -outdir build/ios/xcode -config build/config.yml
+
+  run:
+    summary: Run the application in iOS Simulator
+    deps:
+      - task: ensure-simulator
+      - task: compile:ios
+    cmds:
+      - rm -rf "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+      - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+      - cp "{{.BIN_DIR}}/{{.APP_NAME | lower}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/{{.APP_NAME | lower}}"
+      - cp build/ios/Info.dev.plist "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Info.plist"
+      - |
+        # Compile asset catalog and embed icons for dev bundle
+        APP_BUNDLE="{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+        AC_IN="build/ios/xcode/main/Assets.xcassets"
+        if [ -d "$AC_IN" ]; then
+          TMP_AC=$(mktemp -d)
+          xcrun actool \
+            --compile "$TMP_AC" \
+            --app-icon AppIcon \
+            --platform iphonesimulator \
+            --minimum-deployment-target {{.MIN_IOS_VERSION}} \
+            --product-type com.apple.product-type.application \
+            --target-device iphone \
+            --target-device ipad \
+            --output-partial-info-plist "$APP_BUNDLE/assetcatalog_generated_info.plist" \
+            "$AC_IN"
+          if [ -f "$TMP_AC/Assets.car" ]; then
+            cp -f "$TMP_AC/Assets.car" "$APP_BUNDLE/Assets.car"
+          fi
+          rm -rf "$TMP_AC"
+          if [ -f "$APP_BUNDLE/assetcatalog_generated_info.plist" ]; then
+            /usr/libexec/PlistBuddy -c "Merge $APP_BUNDLE/assetcatalog_generated_info.plist" "$APP_BUNDLE/Info.plist" || true
+          fi
+        fi
+      - codesign --force --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+      - |
+        # The dev bundle id comes from build/ios/Info.dev.plist (derived from
+        # build/config.yml), so read it from the built bundle rather than
+        # assuming a com.wails.* prefix.
+        DEV_APP="{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+        BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$DEV_APP/Info.plist")
+        xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true
+        xcrun simctl uninstall booted "$BUNDLE_ID" 2>/dev/null || true
+        xcrun simctl install booted "$DEV_APP"
+        xcrun simctl launch booted "$BUNDLE_ID"
+
+  xcode:
+    summary: Open the generated Xcode project for this app
+    cmds:
+      - task: generate:ios:xcode
+      - open build/ios/xcode/main.xcodeproj
+
+  logs:
+    summary: Stream iOS Simulator logs filtered to this app
+    cmds:
+      - |
+        xcrun simctl spawn booted log stream \
+          --level debug \
+          --style compact \
+          --predicate 'senderImagePath CONTAINS[c] "{{.APP_NAME | lower}}.app/" OR composedMessage CONTAINS[c] "{{.APP_NAME | lower}}" OR eventMessage CONTAINS[c] "{{.APP_NAME | lower}}" OR process == "{{.APP_NAME | lower}}" OR category CONTAINS[c] "{{.APP_NAME | lower}}"'
+
+  logs:dev:
+    summary: Stream logs for the dev bundle (used by `task ios:run`)
+    cmds:
+      - |
+        xcrun simctl spawn booted log stream \
+          --level debug \
+          --style compact \
+          --predicate 'senderImagePath CONTAINS[c] ".dev.app/" OR subsystem == "com.wails.{{.APP_NAME | lower}}.dev" OR process == "{{.APP_NAME | lower}}"'
+
+  logs:wide:
+    summary: Wide log stream to help discover the exact process/bundle identifiers
+    cmds:
+      - |
+        xcrun simctl spawn booted log stream \
+          --level debug \
+          --style compact \
+          --predicate 'senderImagePath CONTAINS[c] ".app/"'

+ 10 - 0
build/ios/app_options_default.go

@@ -0,0 +1,10 @@
+//go:build !ios
+
+package main
+
+import "github.com/wailsapp/wails/v3/pkg/application"
+
+// modifyOptionsForIOS is a no-op on non-iOS platforms
+func modifyOptionsForIOS(opts *application.Options) {
+	// No modifications needed for non-iOS platforms
+}

+ 11 - 0
build/ios/app_options_ios.go

@@ -0,0 +1,11 @@
+//go:build ios
+
+package main
+
+import "github.com/wailsapp/wails/v3/pkg/application"
+
+// modifyOptionsForIOS adjusts the application options for iOS
+func modifyOptionsForIOS(opts *application.Options) {
+	// Disable signal handlers on iOS to prevent crashes
+	opts.DisableDefaultSignalHandler = true
+}

+ 8 - 0
build/ios/entitlements.plist

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+	<dict>
+		<key>get-task-allow</key>
+		<true/>
+	</dict>
+</plist>

+ 3 - 0
build/ios/icon.png

@@ -0,0 +1,3 @@
+# iOS Icon Placeholder
+# This file should be replaced with the actual app icon (1024x1024 PNG)
+# The build process will generate all required icon sizes from this base icon

+ 22 - 0
build/ios/main.m

@@ -0,0 +1,22 @@
+//go:build ios
+// Minimal bootstrap: delegate comes from Go archive (WailsAppDelegate)
+#import <UIKit/UIKit.h>
+#include <stdio.h>
+
+int main(int argc, char * argv[]) {
+    @autoreleasepool {
+        // Disable buffering so stdout/stderr from Go log.Printf flush immediately
+        setvbuf(stdout, NULL, _IONBF, 0);
+        setvbuf(stderr, NULL, _IONBF, 0);
+
+        // Call UIApplicationMain IMMEDIATELY and start NOTHING else here. Do not
+        // start the Go runtime yet: starting it concurrently with UIApplicationMain
+        // intermittently corrupts the FrontBoard launch handshake on a physical
+        // device, so the app delegate's didFinishLaunchingWithOptions never fires
+        // (blank cold launch / 0x8BADF00D). Instead, the WailsAppDelegate (provided
+        // by the Go archive) starts the Go runtime itself from
+        // didFinishLaunchingWithOptions — i.e. only AFTER UIKit has delivered the
+        // launch — so the runtime never races the launch handshake.
+        return UIApplicationMain(argc, argv, nil, @"WailsAppDelegate");
+    }
+}

+ 23 - 0
build/ios/main_ios.go

@@ -0,0 +1,23 @@
+//go:build ios
+
+package main
+
+import (
+	"C"
+)
+
+// For iOS builds we export a function the WailsAppDelegate can call.
+// This wrapper keeps the user's main.go unmodified.
+
+// WailsIOSMain runs the user's main() (application.New / app.Run). It is invoked
+// by the WailsAppDelegate from didFinishLaunchingWithOptions — i.e. only AFTER
+// UIKit has launched — on a BACKGROUND thread, so the Go runtime never starts
+// concurrently with UIApplicationMain (that race intermittently corrupts the
+// FrontBoard launch handshake on a physical device → blank cold launch /
+// scene-create watchdog 0x8BADF00D). Keeping all app setup off the OS main
+// thread also leaves UIApplicationMain unobstructed on the main thread.
+//
+//export WailsIOSMain
+func WailsIOSMain() {
+	main()
+}

+ 230 - 0
build/ios/project.pbxproj

@@ -0,0 +1,230 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {};
+	objectVersion = 56;
+	objects = {
+
+/* Begin PBXBuildFile section */
+		C0DEBEEF0000000000000001 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000002 /* main.m */; };
+		C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000101 /* UIKit.framework */; };
+		C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000102 /* Foundation.framework */; };
+		C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000103 /* WebKit.framework */; };
+		C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000104 /* Security.framework */; };
+		C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000105 /* CoreFoundation.framework */; };
+		C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000106 /* libresolv.tbd */; };
+		C0DEBEEF00000000000000F7 /* My Product.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000107 /* My Product.a */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+		C0DEBEEF0000000000000002 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
+		C0DEBEEF0000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+		C0DEBEEF0000000000000004 /* My Product.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "My Product.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+		C0DEBEEF0000000000000101 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000102 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000103 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000104 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000105 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000106 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.text-based-dylib-definition; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; };
+		C0DEBEEF0000000000000107 /* My Product.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "My Product.a"; path = "../../../bin/My Product.a"; sourceTree = SOURCE_ROOT; };
+/* End PBXFileReference section */
+
+/* Begin PBXGroup section */
+		C0DEBEEF0000000000000010 = {
+			isa = PBXGroup;
+			children = (
+				C0DEBEEF0000000000000020 /* Products */,
+				C0DEBEEF0000000000000045 /* Frameworks */,
+				C0DEBEEF0000000000000030 /* main */,
+			);
+			sourceTree = "<group>";
+		};
+		C0DEBEEF0000000000000020 /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				C0DEBEEF0000000000000004 /* My Product.app */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
+		C0DEBEEF0000000000000030 /* main */ = {
+			isa = PBXGroup;
+			children = (
+				C0DEBEEF0000000000000002 /* main.m */,
+				C0DEBEEF0000000000000003 /* Info.plist */,
+			);
+			path = main;
+			sourceTree = SOURCE_ROOT;
+		};
+		C0DEBEEF0000000000000045 /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+				C0DEBEEF0000000000000101 /* UIKit.framework */,
+				C0DEBEEF0000000000000102 /* Foundation.framework */,
+				C0DEBEEF0000000000000103 /* WebKit.framework */,
+				C0DEBEEF0000000000000104 /* Security.framework */,
+				C0DEBEEF0000000000000105 /* CoreFoundation.framework */,
+				C0DEBEEF0000000000000106 /* libresolv.tbd */,
+				C0DEBEEF0000000000000107 /* My Product.a */,
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		C0DEBEEF0000000000000040 /* My Product */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "My Product" */;
+			buildPhases = (
+				C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */,
+				C0DEBEEF0000000000000050 /* Sources */,
+				C0DEBEEF0000000000000056 /* Frameworks */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = "My Product";
+			productName = "My Product";
+			productReference = C0DEBEEF0000000000000004 /* My Product.app */;
+			productType = "com.apple.product-type.application";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		C0DEBEEF0000000000000060 /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				LastUpgradeCheck = 1500;
+				ORGANIZATIONNAME = "My Company";
+				TargetAttributes = {
+					C0DEBEEF0000000000000040 = {
+						CreatedOnToolsVersion = 15.0;
+					};
+				};
+			};
+			buildConfigurationList = C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */;
+			compatibilityVersion = "Xcode 15.0";
+			developmentRegion = en;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				en,
+			);
+			mainGroup = C0DEBEEF0000000000000010;
+			productRefGroup = C0DEBEEF0000000000000020 /* Products */;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				C0DEBEEF0000000000000040 /* My Product */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXFrameworksBuildPhase section */
+		C0DEBEEF0000000000000056 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C0DEBEEF00000000000000F7 /* My Product.a in Frameworks */,
+				C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */,
+				C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */,
+				C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */,
+				C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */,
+				C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */,
+				C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+		C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+			);
+			name = "Prebuild: Wails Go Archive";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "set -e\nAPP_ROOT=\"${PROJECT_DIR}/../../..\"\nPLATFORM=${PLATFORM_NAME:-iphonesimulator}\nSDK_PATH=${SDKROOT:-$(xcrun --sdk ${PLATFORM} --show-sdk-path)}\nif [ ${PLATFORM} = iphonesimulator ]; then GO_TARGET=arm64-apple-ios15.0-simulator; MIN_FLAG=-mios-simulator-version-min=15.0; else GO_TARGET=arm64-apple-ios15.0; MIN_FLAG=-miphoneos-version-min=15.0; fi\nexport GOOS=ios\nexport GOARCH=arm64\nexport CGO_ENABLED=1\nexport CGO_CFLAGS=\"-isysroot ${SDK_PATH} -target ${GO_TARGET} ${MIN_FLAG}\"\nexport CGO_LDFLAGS=\"-isysroot ${SDK_PATH} -target ${GO_TARGET}\"\ncd \"${APP_ROOT}\"\n# Ensure overlay exists\nif [ ! -f build/ios/xcode/overlay.json ]; then\n  wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml || true\nfi\n# Always rebuild the Go c-archive; Go's build cache keeps this fast\n# and avoids stale binaries when .go sources change.\necho \"Building Go c-archive...\"\ngo build -buildmode=c-archive -overlay build/ios/xcode/overlay.json -o \"bin/My Product.a\"\n";
+		};
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		C0DEBEEF0000000000000050 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C0DEBEEF0000000000000001 /* main.m in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+		C0DEBEEF0000000000000090 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				INFOPLIST_FILE = main/Info.plist;
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				OTHER_LDFLAGS = (
+					"$(inherited)",
+					"-ObjC",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = "com.mycompany.myproduct";
+				PRODUCT_NAME = "My Product";
+				CODE_SIGNING_ALLOWED = NO;
+				SDKROOT = iphoneos;
+			};
+			name = Debug;
+		};
+		C0DEBEEF00000000000000A0 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				INFOPLIST_FILE = main/Info.plist;
+				IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+				OTHER_LDFLAGS = (
+					"$(inherited)",
+					"-ObjC",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = "com.mycompany.myproduct";
+				PRODUCT_NAME = "My Product";
+				CODE_SIGNING_ALLOWED = NO;
+				SDKROOT = iphoneos;
+			};
+			name = Release;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "My Product" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				C0DEBEEF0000000000000090 /* Debug */,
+				C0DEBEEF00000000000000A0 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+		C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				C0DEBEEF0000000000000090 /* Debug */,
+				C0DEBEEF00000000000000A0 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+/* End XCConfigurationList section */
+	};
+	rootObject = C0DEBEEF0000000000000060 /* Project object */;
+}

+ 319 - 0
build/ios/scripts/deps/install_deps.go

@@ -0,0 +1,319 @@
+// install_deps.go - iOS development dependency checker
+// This script checks for required iOS development tools.
+// It's designed to be portable across different shells by using Go instead of shell scripts.
+//
+// Usage:
+//   go run install_deps.go                      # Interactive mode
+//   TASK_FORCE_YES=true go run install_deps.go  # Auto-accept prompts
+//   CI=true go run install_deps.go              # CI mode (auto-accept)
+
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+)
+
+type Dependency struct {
+	Name       string
+	CheckFunc  func() (bool, string) // Returns (success, details)
+	Required   bool
+	InstallCmd []string
+	InstallMsg string
+	SuccessMsg string
+	FailureMsg string
+}
+
+func main() {
+	fmt.Println("Checking iOS development dependencies...")
+	fmt.Println("=" + strings.Repeat("=", 50))
+	fmt.Println()
+
+	hasErrors := false
+	dependencies := []Dependency{
+		{
+			Name: "Xcode",
+			CheckFunc: func() (bool, string) {
+				// Check if xcodebuild exists
+				if !checkCommand([]string{"xcodebuild", "-version"}) {
+					return false, ""
+				}
+				// Get version info
+				out, err := exec.Command("xcodebuild", "-version").Output()
+				if err != nil {
+					return false, ""
+				}
+				lines := strings.Split(string(out), "\n")
+				if len(lines) > 0 {
+					return true, strings.TrimSpace(lines[0])
+				}
+				return true, ""
+			},
+			Required:   true,
+			InstallMsg: "Please install Xcode from the Mac App Store:\n   https://apps.apple.com/app/xcode/id497799835\n   Xcode is REQUIRED for iOS development (includes iOS SDKs, simulators, and frameworks)",
+			SuccessMsg: "✅ Xcode found",
+			FailureMsg: "❌ Xcode not found (REQUIRED)",
+		},
+		{
+			Name: "Xcode Developer Path",
+			CheckFunc: func() (bool, string) {
+				// Check if xcode-select points to a valid Xcode path
+				out, err := exec.Command("xcode-select", "-p").Output()
+				if err != nil {
+					return false, "xcode-select not configured"
+				}
+				path := strings.TrimSpace(string(out))
+
+				// Check if path exists and is in Xcode.app
+				if _, err := os.Stat(path); err != nil {
+					return false, "Invalid Xcode path"
+				}
+
+				// Verify it's pointing to Xcode.app (not just Command Line Tools)
+				if !strings.Contains(path, "Xcode.app") {
+					return false, fmt.Sprintf("Points to %s (should be Xcode.app)", path)
+				}
+
+				return true, path
+			},
+			Required:   true,
+			InstallCmd: []string{"sudo", "xcode-select", "-s", "/Applications/Xcode.app/Contents/Developer"},
+			InstallMsg: "Xcode developer path needs to be configured",
+			SuccessMsg: "✅ Xcode developer path configured",
+			FailureMsg: "❌ Xcode developer path not configured correctly",
+		},
+		{
+			Name: "iOS SDK",
+			CheckFunc: func() (bool, string) {
+				// Get the iOS Simulator SDK path
+				cmd := exec.Command("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path")
+				output, err := cmd.Output()
+				if err != nil {
+					return false, "Cannot find iOS SDK"
+				}
+				sdkPath := strings.TrimSpace(string(output))
+
+				// Check if the SDK path exists
+				if _, err := os.Stat(sdkPath); err != nil {
+					return false, "iOS SDK path not found"
+				}
+
+				// Check for UIKit framework (essential for iOS development)
+				uikitPath := fmt.Sprintf("%s/System/Library/Frameworks/UIKit.framework", sdkPath)
+				if _, err := os.Stat(uikitPath); err != nil {
+					return false, "UIKit.framework not found"
+				}
+
+				// Get SDK version
+				versionCmd := exec.Command("xcrun", "--sdk", "iphonesimulator", "--show-sdk-version")
+				versionOut, _ := versionCmd.Output()
+				version := strings.TrimSpace(string(versionOut))
+
+				return true, fmt.Sprintf("iOS %s SDK", version)
+			},
+			Required:   true,
+			InstallMsg: "iOS SDK comes with Xcode. Please ensure Xcode is properly installed.",
+			SuccessMsg: "✅ iOS SDK found with UIKit framework",
+			FailureMsg: "❌ iOS SDK not found or incomplete",
+		},
+		{
+			Name: "iOS Simulator Runtime",
+			CheckFunc: func() (bool, string) {
+				if !checkCommand([]string{"xcrun", "simctl", "help"}) {
+					return false, ""
+				}
+				// Check if we can list runtimes
+				out, err := exec.Command("xcrun", "simctl", "list", "runtimes").Output()
+				if err != nil {
+					return false, "Cannot access simulator"
+				}
+				// Count iOS runtimes
+				lines := strings.Split(string(out), "\n")
+				count := 0
+				var versions []string
+				for _, line := range lines {
+					if strings.Contains(line, "iOS") && !strings.Contains(line, "unavailable") {
+						count++
+						// Extract version number
+						if parts := strings.Fields(line); len(parts) > 2 {
+							for _, part := range parts {
+								if strings.HasPrefix(part, "(") && strings.HasSuffix(part, ")") {
+									versions = append(versions, strings.Trim(part, "()"))
+									break
+								}
+							}
+						}
+					}
+				}
+				if count > 0 {
+					return true, fmt.Sprintf("%d runtime(s): %s", count, strings.Join(versions, ", "))
+				}
+				return false, "No iOS runtimes installed"
+			},
+			Required:   true,
+			InstallMsg: "iOS Simulator runtimes come with Xcode. You may need to download them:\n   Xcode → Settings → Platforms → iOS",
+			SuccessMsg: "✅ iOS Simulator runtime available",
+			FailureMsg: "❌ iOS Simulator runtime not available",
+		},
+	}
+
+	// Check each dependency
+	for _, dep := range dependencies {
+		success, details := dep.CheckFunc()
+		if success {
+			msg := dep.SuccessMsg
+			if details != "" {
+				msg = fmt.Sprintf("%s (%s)", dep.SuccessMsg, details)
+			}
+			fmt.Println(msg)
+		} else {
+			fmt.Println(dep.FailureMsg)
+			if details != "" {
+				fmt.Printf("   Details: %s\n", details)
+			}
+			if dep.Required {
+				hasErrors = true
+				if len(dep.InstallCmd) > 0 {
+					fmt.Println()
+					fmt.Println("   " + dep.InstallMsg)
+					fmt.Printf("   Fix command: %s\n", strings.Join(dep.InstallCmd, " "))
+					if promptUser("Do you want to run this command?") {
+						fmt.Println("Running command...")
+						cmd := exec.Command(dep.InstallCmd[0], dep.InstallCmd[1:]...)
+						cmd.Stdout = os.Stdout
+						cmd.Stderr = os.Stderr
+						cmd.Stdin = os.Stdin
+						if err := cmd.Run(); err != nil {
+							fmt.Printf("Command failed: %v\n", err)
+							os.Exit(1)
+						}
+						fmt.Println("✅ Command completed. Please run this check again.")
+					} else {
+						fmt.Printf("   Please run manually: %s\n", strings.Join(dep.InstallCmd, " "))
+					}
+				} else {
+					fmt.Println("   " + dep.InstallMsg)
+				}
+			}
+		}
+	}
+
+	// Check for iPhone simulators
+	fmt.Println()
+	fmt.Println("Checking for iPhone simulator devices...")
+	if !checkCommand([]string{"xcrun", "simctl", "list", "devices"}) {
+		fmt.Println("❌ Cannot check for iPhone simulators")
+		hasErrors = true
+	} else {
+		out, err := exec.Command("xcrun", "simctl", "list", "devices").Output()
+		if err != nil {
+			fmt.Println("❌ Failed to list simulator devices")
+			hasErrors = true
+		} else if !strings.Contains(string(out), "iPhone") {
+			fmt.Println("⚠️  No iPhone simulator devices found")
+			fmt.Println()
+
+			// Get the latest iOS runtime
+			runtimeOut, err := exec.Command("xcrun", "simctl", "list", "runtimes").Output()
+			if err != nil {
+				fmt.Println("   Failed to get iOS runtimes:", err)
+			} else {
+				lines := strings.Split(string(runtimeOut), "\n")
+				var latestRuntime string
+				for _, line := range lines {
+					if strings.Contains(line, "iOS") && !strings.Contains(line, "unavailable") {
+						// Extract runtime identifier
+						parts := strings.Fields(line)
+						if len(parts) > 0 {
+							latestRuntime = parts[len(parts)-1]
+						}
+					}
+				}
+
+				if latestRuntime == "" {
+					fmt.Println("   No iOS runtime found. Please install iOS simulators in Xcode:")
+					fmt.Println("   Xcode → Settings → Platforms → iOS")
+				} else {
+					fmt.Println("   Would you like to create an iPhone 15 Pro simulator?")
+					createCmd := []string{"xcrun", "simctl", "create", "iPhone 15 Pro", "iPhone 15 Pro", latestRuntime}
+					fmt.Printf("   Command: %s\n", strings.Join(createCmd, " "))
+					if promptUser("Create simulator?") {
+						cmd := exec.Command(createCmd[0], createCmd[1:]...)
+						cmd.Stdout = os.Stdout
+						cmd.Stderr = os.Stderr
+						if err := cmd.Run(); err != nil {
+							fmt.Printf("   Failed to create simulator: %v\n", err)
+						} else {
+							fmt.Println("   ✅ iPhone 15 Pro simulator created")
+						}
+					} else {
+						fmt.Println("   Skipping simulator creation")
+						fmt.Printf("   Create manually: %s\n", strings.Join(createCmd, " "))
+					}
+				}
+			}
+		} else {
+			// Count iPhone devices
+			count := 0
+			lines := strings.Split(string(out), "\n")
+			for _, line := range lines {
+				if strings.Contains(line, "iPhone") && !strings.Contains(line, "unavailable") {
+					count++
+				}
+			}
+			fmt.Printf("✅ %d iPhone simulator device(s) available\n", count)
+		}
+	}
+
+	// Final summary
+	fmt.Println()
+	fmt.Println("=" + strings.Repeat("=", 50))
+	if hasErrors {
+		fmt.Println("❌ Some required dependencies are missing or misconfigured.")
+		fmt.Println()
+		fmt.Println("Quick setup guide:")
+		fmt.Println("1. Install Xcode from Mac App Store (if not installed)")
+		fmt.Println("2. Open Xcode once and agree to the license")
+		fmt.Println("3. Install additional components when prompted")
+		fmt.Println("4. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer")
+		fmt.Println("5. Download iOS simulators: Xcode → Settings → Platforms → iOS")
+		fmt.Println("6. Run this check again")
+		os.Exit(1)
+	} else {
+		fmt.Println("✅ All required dependencies are installed!")
+		fmt.Println("   You're ready for iOS development with Wails!")
+	}
+}
+
+func checkCommand(args []string) bool {
+	if len(args) == 0 {
+		return false
+	}
+	cmd := exec.Command(args[0], args[1:]...)
+	cmd.Stdout = nil
+	cmd.Stderr = nil
+	err := cmd.Run()
+	return err == nil
+}
+
+func promptUser(question string) bool {
+	// Check if we're in a non-interactive environment
+	if os.Getenv("CI") != "" || os.Getenv("TASK_FORCE_YES") == "true" {
+		fmt.Printf("%s [y/N]: y (auto-accepted)\n", question)
+		return true
+	}
+
+	reader := bufio.NewReader(os.Stdin)
+	fmt.Printf("%s [y/N]: ", question)
+
+	response, err := reader.ReadString('\n')
+	if err != nil {
+		return false
+	}
+
+	response = strings.ToLower(strings.TrimSpace(response))
+	return response == "y" || response == "yes"
+}

+ 220 - 0
build/linux/Taskfile.yml

@@ -0,0 +1,220 @@
+version: '3'
+
+includes:
+  common: ../Taskfile.yml
+
+vars:
+  # Signing configuration - edit these values for your project
+  # PGP_KEY: "path/to/signing-key.asc"
+  # SIGN_ROLE: "builder"  # Options: origin, maint, archive, builder
+  #
+  # Password is stored securely in system keychain. Run: wails3 setup signing
+
+  # Docker image for cross-compilation (used when building on non-Linux or no CC available)
+  CROSS_IMAGE: wails-cross
+
+tasks:
+  build:
+    summary: Builds the application for Linux
+    cmds:
+      # Linux requires CGO - use Docker when:
+      # 1. Cross-compiling from non-Linux, OR
+      # 2. No C compiler is available, OR
+      # 3. Target architecture differs from host architecture (cross-arch compilation)
+      - task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
+        vars:
+          ARCH: '{{.ARCH}}'
+          DEV: '{{.DEV}}'
+          OUTPUT: '{{.OUTPUT}}'
+          EXTRA_TAGS: '{{.EXTRA_TAGS}}'
+          OBFUSCATED: '{{.OBFUSCATED}}'
+          GARBLE_ARGS: '{{.GARBLE_ARGS}}'
+    vars:
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+      # Determine target architecture (defaults to host ARCH if not specified)
+      TARGET_ARCH: '{{.ARCH | default ARCH}}'
+      # Check if a C compiler is available (gcc or clang) — cross-platform via wails3 tool
+      HAS_CC:
+        sh: 'wails3 tool has "gcc|clang"'
+
+  build:native:
+    summary: Builds the application natively on Linux
+    internal: true
+    deps:
+      - task: common:go:mod:tidy
+      - task: common:build:frontend
+        vars:
+          BUILD_FLAGS:
+            ref: .BUILD_FLAGS
+          OBFUSCATED:
+            ref: .OBFUSCATED
+          DEV:
+            ref: .DEV
+      - task: common:generate:icons
+      - task: generate:dotdesktop
+    preconditions:
+      - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
+        msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
+    cmds:
+      - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o {{.OUTPUT}}'
+    vars:
+      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"{{end}}'
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+    env:
+      GOOS: linux
+      CGO_ENABLED: 1
+      GOARCH: '{{.ARCH | default ARCH}}'
+
+  build:docker:
+    summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
+    internal: true
+    deps:
+      - task: common:build:frontend
+        vars:
+          OBFUSCATED:
+            ref: .OBFUSCATED
+      - task: common:generate:icons
+      - task: generate:dotdesktop
+    preconditions:
+      - sh: docker info > /dev/null 2>&1
+        msg: "Docker is required for cross-compilation to Linux. Please install Docker."
+      - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
+        msg: |
+          Docker image '{{.CROSS_IMAGE}}' not found.
+          Build it first: wails3 task setup:docker
+    cmds:
+      - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
+      - cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
+        platforms: [linux, darwin]
+      - mkdir -p {{.BIN_DIR}}
+      - mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
+    vars:
+      DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
+      DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+      OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+      # Generate Docker volume mounts: Go module cache + go.mod replace directives
+      # Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
+      DOCKER_MOUNTS:
+        sh: 'wails3 tool docker-mounts'
+
+  package:
+    summary: Packages the application for Linux
+    deps:
+      - task: build
+    cmds:
+      - task: create:appimage
+      - task: create:deb
+      - task: create:rpm
+      - task: create:aur
+
+  create:appimage:
+    summary: Creates an AppImage
+    dir: build/linux/appimage
+    deps:
+      - task: build
+      - task: generate:dotdesktop
+    cmds:
+      - cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
+      - cp ../../appicon.png "{{.APP_NAME}}.png"
+      - wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
+    vars:
+      APP_NAME: '{{.APP_NAME}}'
+      APP_BINARY: '../../../bin/{{.APP_NAME}}'
+      ICON: '{{.APP_NAME}}.png'
+      DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
+      OUTPUT_DIR: '../../../bin'
+
+  create:deb:
+    summary: Creates a deb package
+    deps:
+      - task: build
+    cmds:
+      - task: generate:dotdesktop
+      - task: generate:deb
+
+  create:rpm:
+    summary: Creates a rpm package
+    deps:
+      - task: build
+    cmds:
+      - task: generate:dotdesktop
+      - task: generate:rpm
+
+  create:aur:
+    summary: Creates a arch linux packager package
+    deps:
+      - task: build
+    cmds:
+      - task: generate:dotdesktop
+      - task: generate:aur
+
+  generate:deb:
+    summary: Creates a deb package
+    cmds:
+      - wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+  generate:rpm:
+    summary: Creates a rpm package
+    cmds:
+      - wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+  generate:aur:
+    summary: Creates a arch linux packager package
+    cmds:
+      - wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+  generate:dotdesktop:
+    summary: Generates a `.desktop` file
+    dir: build
+    cmds:
+      - mkdir -p {{.ROOT_DIR}}/build/linux/appimage
+      - wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
+    vars:
+      APP_NAME: '{{.APP_NAME}}'
+      EXEC: '{{.APP_NAME}}'
+      ICON: '{{.APP_NAME}}'
+      CATEGORIES: 'Development;'
+      OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
+
+  run:
+    cmds:
+      - '{{.BIN_DIR}}/{{.APP_NAME}}'
+
+  sign:deb:
+    summary: Signs the DEB package
+    desc: |
+      Signs the .deb package with a PGP key.
+      Set PGP_KEY in the vars section to override the key configured globally via
+      `wails3 setup` (~/.config/wails/defaults.yaml).
+      Password is retrieved from system keychain (run: wails3 setup signing)
+    deps:
+      - task: create:deb
+    cmds:
+      # PGP_KEY is optional: if unset, `wails3 tool sign` falls back to the key
+      # configured globally via `wails3 setup`.
+      - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}} {{if .SIGN_ROLE}}--role "{{.SIGN_ROLE}}"{{end}}
+
+  sign:rpm:
+    summary: Signs the RPM package
+    desc: |
+      Signs the .rpm package with a PGP key.
+      Set PGP_KEY in the vars section to override the key configured globally via
+      `wails3 setup` (~/.config/wails/defaults.yaml).
+      Password is retrieved from system keychain (run: wails3 setup signing)
+    deps:
+      - task: create:rpm
+    cmds:
+      - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}}
+
+  sign:packages:
+    summary: Signs all Linux packages (DEB and RPM)
+    desc: |
+      Signs both .deb and .rpm packages with a PGP key.
+      Set PGP_KEY in the vars section to override the key configured globally via
+      `wails3 setup` (~/.config/wails/defaults.yaml).
+      Password is retrieved from system keychain (run: wails3 setup signing)
+    cmds:
+      - task: sign:deb
+      - task: sign:rpm

+ 35 - 0
build/linux/appimage/build.sh

@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Copyright (c) 2018-Present Lea Anthony
+# SPDX-License-Identifier: MIT
+
+# Fail script on any error
+set -euxo pipefail
+
+# Define variables
+APP_DIR="${APP_NAME}.AppDir"
+
+# Create AppDir structure
+mkdir -p "${APP_DIR}/usr/bin"
+cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
+cp "${ICON_PATH}" "${APP_DIR}/"
+cp "${DESKTOP_FILE}" "${APP_DIR}/"
+
+if [[ $(uname -m) == *x86_64* ]]; then
+    # Download linuxdeploy and make it executable
+    wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
+    chmod +x linuxdeploy-x86_64.AppImage
+
+    # Run linuxdeploy to bundle the application
+    ./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
+else
+    # Download linuxdeploy and make it executable (arm64)
+    wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage
+    chmod +x linuxdeploy-aarch64.AppImage
+
+    # Run linuxdeploy to bundle the application (arm64)
+    ./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage
+fi
+
+# Rename the generated AppImage
+mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"
+

+ 13 - 0
build/linux/desktop

@@ -0,0 +1,13 @@
+[Desktop Entry]
+Version=1.0
+Name=My Product
+Comment=A program that does X
+# The Exec line includes %u to pass the URL to the application
+Exec=/usr/local/bin/腾图工具箱 %u
+Terminal=false
+Type=Application
+Icon=腾图工具箱
+Categories=Utility;
+StartupWMClass=腾图工具箱
+
+ 

+ 80 - 0
build/linux/nfpm/nfpm.yaml

@@ -0,0 +1,80 @@
+# Feel free to remove those if you don't want/need to use them.
+# Make sure to check the documentation at https://nfpm.goreleaser.com
+#
+# The lines below are called `modelines`. See `:help modeline`
+
+name: "腾图工具箱"
+arch: ${GOARCH}
+platform: "linux"
+version: "0.0.1"
+section: "default"
+priority: "extra"
+maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
+description: "A program that does X"
+vendor: "My Company"
+homepage: "https://wails.io"
+license: "MIT"
+release: "1"
+
+contents:
+  - src: "./bin/腾图工具箱"
+    dst: "/usr/local/bin/腾图工具箱"
+  - src: "./build/appicon.png"
+    dst: "/usr/share/icons/hicolor/128x128/apps/腾图工具箱.png"
+  - src: "./build/linux/腾图工具箱.desktop"
+    dst: "/usr/share/applications/腾图工具箱.desktop"
+
+# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
+depends:
+  - libgtk-4-1
+  - libwebkitgtk-6.0-4
+
+# Distribution-specific overrides for different package formats
+overrides:
+  # RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
+  rpm:
+    depends:
+      - gtk4
+      - webkitgtk6.0
+
+  # Arch Linux packages
+  archlinux:
+    depends:
+      - gtk4
+      - webkitgtk-6.0
+
+# scripts section to ensure desktop database is updated after install
+scripts:
+  postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
+  # You can also add preremove, postremove if needed
+  # preremove: "./build/linux/nfpm/scripts/preremove.sh"
+  # postremove: "./build/linux/nfpm/scripts/postremove.sh"
+
+# If you build your app with -tags gtk3 (legacy WebKit2GTK 4.1 stack — supported through v3.0.x, removed in v3.1),
+# replace the depends/overrides above with these:
+#
+# depends:
+#   - libgtk-3-0
+#   - libwebkit2gtk-4.1-0
+# overrides:
+#   rpm:
+#     depends:
+#       - gtk3
+#       - webkit2gtk4.1
+#   archlinux:
+#     depends:
+#       - gtk3
+#       - webkit2gtk-4.1
+#
+# replaces:
+#   - foobar
+# provides:
+#   - bar
+# recommends:
+#   - whatever
+# suggests:
+#   - something-else
+# conflicts:
+#   - not-foo
+#   - not-bar
+# changelog: "changelog.yaml"

+ 21 - 0
build/linux/nfpm/scripts/postinstall.sh

@@ -0,0 +1,21 @@
+#!/bin/sh
+
+# Update desktop database for .desktop file changes
+# This makes the application appear in application menus and registers its capabilities.
+if command -v update-desktop-database >/dev/null 2>&1; then
+  echo "Updating desktop database..."
+  update-desktop-database -q /usr/share/applications
+else
+  echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2
+fi
+
+# Update MIME database for custom URL schemes (x-scheme-handler)
+# This ensures the system knows how to handle your custom protocols.
+if command -v update-mime-database >/dev/null 2>&1; then
+  echo "Updating MIME database..."
+  update-mime-database -n /usr/share/mime
+else
+  echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2
+fi
+
+exit 0

+ 1 - 0
build/linux/nfpm/scripts/postremove.sh

@@ -0,0 +1 @@
+#!/bin/bash

+ 1 - 0
build/linux/nfpm/scripts/preinstall.sh

@@ -0,0 +1 @@
+#!/bin/bash

+ 1 - 0
build/linux/nfpm/scripts/preremove.sh

@@ -0,0 +1 @@
+#!/bin/bash

+ 216 - 0
build/windows/Taskfile.yml

@@ -0,0 +1,216 @@
+version: '3'
+
+includes:
+  common: ../Taskfile.yml
+
+vars:
+  # Signing configuration - edit these values for your project
+  SIGN_CERTIFICATE: "signing.pfx"
+  # SIGN_THUMBPRINT: "certificate-thumbprint"  # Alternative to SIGN_CERTIFICATE
+  # TIMESTAMP_SERVER: "http://timestamp.digicert.com"
+  #
+  # Password is stored securely in system keychain. Run: wails3 setup signing
+
+  # Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows)
+  CROSS_IMAGE: wails-cross
+
+tasks:
+  build:
+    summary: Builds the application for Windows
+    cmds:
+      # Auto-detect CGO: if CGO_ENABLED=1, use Docker; otherwise use native Go cross-compile
+      - task: '{{if and (ne OS "windows") (eq .CGO_ENABLED "1")}}build:docker{{else}}build:native{{end}}'
+        vars:
+          ARCH: '{{.ARCH}}'
+          DEV: '{{.DEV}}'
+          EXTRA_TAGS: '{{.EXTRA_TAGS}}'
+          OBFUSCATED: '{{.OBFUSCATED}}'
+          GARBLE_ARGS: '{{.GARBLE_ARGS}}'
+    vars:
+      # Default to CGO_ENABLED=0 if not explicitly set
+      CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
+
+  build:native:
+    summary: Builds the application using native Go cross-compilation
+    internal: true
+    deps:
+      - task: common:go:mod:tidy
+      - task: common:build:frontend
+        vars:
+          BUILD_FLAGS:
+            ref: .BUILD_FLAGS
+          OBFUSCATED:
+            ref: .OBFUSCATED
+          DEV:
+            ref: .DEV
+      - task: common:generate:icons
+    preconditions:
+      - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
+        msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
+    cmds:
+      - task: generate:syso
+        vars:
+          ARCH: '{{.ARCH}}'
+      - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}.exe"'
+      - task: upx:compress
+        vars:
+          TARGET_FILE: '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
+      - cmd: powershell Remove-item *.syso
+        platforms: [windows]
+      - cmd: rm -f *.syso
+        platforms: [linux, darwin]
+    vars:
+      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"}}'
+      GOARCH: '{{.ARCH | default ARCH}}'
+
+  build:docker:
+    summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
+    internal: true
+    deps:
+      - task: common:build:frontend
+        vars:
+          OBFUSCATED:
+            ref: .OBFUSCATED
+      - task: common:generate:icons
+    preconditions:
+      - sh: docker info > /dev/null 2>&1
+        msg: "Docker is required for CGO cross-compilation. Please install Docker."
+      - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
+        msg: |
+          Docker image '{{.CROSS_IMAGE}}' not found.
+          Build it first: wails3 task setup:docker
+    cmds:
+      - task: generate:syso
+        vars:
+          ARCH: '{{.ARCH}}'
+      - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}}
+      - cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
+        platforms: [linux, darwin]
+      - rm -f *.syso
+    vars:
+      DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
+      # Generate Docker volume mounts: Go module cache + go.mod replace directives
+      # Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
+      DOCKER_MOUNTS:
+        sh: 'wails3 tool docker-mounts'
+
+  package:
+    summary: Packages the application
+    cmds:
+      - task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
+        vars:
+          INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
+    vars:
+      FORMAT: '{{.FORMAT | default "nsis"}}'
+      INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
+
+  generate:syso:
+    summary: Generates Windows `.syso` file
+    dir: build
+    cmds:
+      - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
+    vars:
+      ARCH: '{{.ARCH | default ARCH}}'
+
+  upx:compress:
+    summary: Compress the built binary with UPX (if available)
+    internal: true
+    cmds:
+      - |
+        if [ "{{.DEV}}" = "true" ]; then
+          echo "DEV mode, skipping UPX."
+          exit 0
+        fi      
+        if command -v upx >/dev/null 2>&1; then
+          echo "Compressing {{.TARGET_FILE}} with UPX..."
+          upx --best --lzma "{{.TARGET_FILE}}" || true
+        else
+          echo "UPX not found in PATH, skipping compression."
+        fi
+    vars:
+      TARGET_FILE: '{{.TARGET_FILE}}'
+
+  create:nsis:installer:
+    summary: Creates an NSIS installer
+    dir: build/windows/nsis
+    deps:
+      - task: build
+    preconditions:
+      - sh: '[ "{{.INSTALL_SCOPE}}" = "user" ] || [ "{{.INSTALL_SCOPE}}" = "machine" ]'
+        msg: "INSTALL_SCOPE must be 'user' or 'machine', got '{{.INSTALL_SCOPE}}'"
+    cmds:
+      # Create the Microsoft WebView2 bootstrapper if it doesn't exist
+      - wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
+      - |
+        {{if eq OS "windows"}}
+        makensis {{if eq .INSTALL_SCOPE "user"}}-DWAILS_INSTALL_SCOPE=user -DREQUEST_EXECUTION_LEVEL=user {{end}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
+        {{else}}
+        makensis {{if eq .INSTALL_SCOPE "user"}}-DWAILS_INSTALL_SCOPE=user -DREQUEST_EXECUTION_LEVEL=user {{end}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
+        {{end}}
+    vars:
+      ARCH: '{{.ARCH | default ARCH}}'
+      ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
+      INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
+
+  create:msix:package:
+    summary: Creates an MSIX package
+    deps:
+      - task: build
+    cmds:
+      - |-
+        wails3 tool msix \
+          --config "{{.ROOT_DIR}}/build/config.yml" \
+          --name "{{.APP_NAME}}.exe" \
+          --executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
+          --arch "{{.ARCH}}" \
+          --out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
+          {{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
+          {{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
+          {{if eq .USE_MSIX_TOOL "true"}}--use-msix-tool{{else}}--use-makeappx{{end}}
+    vars:
+      ARCH: '{{.ARCH | default ARCH}}'
+      CERT_PATH: '{{.CERT_PATH | default ""}}'
+      PUBLISHER: '{{.PUBLISHER | default ""}}'
+      USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
+
+  install:msix:tools:
+    summary: Installs tools required for MSIX packaging
+    cmds:
+      - wails3 tool msix-install-tools
+
+  run:
+    cmds:
+      - '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
+
+  sign:
+    summary: Signs the Windows executable
+    desc: |
+      Signs the .exe with an Authenticode certificate.
+      Set SIGN_CERTIFICATE or SIGN_THUMBPRINT to override the certificate
+      configured globally via `wails3 setup` (~/.config/wails/defaults.yaml).
+      Password is retrieved from system keychain (run: wails3 setup signing)
+    deps:
+      - task: build
+    cmds:
+      # Certificate vars are optional: if unset, `wails3 tool sign` falls back to
+      # the certificate configured globally via `wails3 setup`.
+      - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.exe" {{if .SIGN_CERTIFICATE}}--certificate "{{.SIGN_CERTIFICATE}}"{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint "{{.SIGN_THUMBPRINT}}"{{end}} {{if .TIMESTAMP_SERVER}}--timestamp "{{.TIMESTAMP_SERVER}}"{{end}}
+
+  sign:installer:
+    summary: Signs the NSIS installer
+    desc: |
+      Creates and signs the NSIS installer.
+      Set SIGN_CERTIFICATE or SIGN_THUMBPRINT to override the certificate
+      configured globally via `wails3 setup` (~/.config/wails/defaults.yaml).
+      Password is retrieved from system keychain (run: wails3 setup signing)
+    deps:
+      - task: create:nsis:installer
+        vars:
+          INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
+    vars:
+      INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
+    cmds:
+      - wails3 tool sign --input "build/windows/nsis/{{.APP_NAME}}-installer.exe" {{if .SIGN_CERTIFICATE}}--certificate "{{.SIGN_CERTIFICATE}}"{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint "{{.SIGN_THUMBPRINT}}"{{end}} {{if .TIMESTAMP_SERVER}}--timestamp "{{.TIMESTAMP_SERVER}}"{{end}}

BIN
build/windows/icon.ico


+ 15 - 0
build/windows/info.json

@@ -0,0 +1,15 @@
+{
+	"fixed": {
+		"file_version": "0.0.1"
+	},
+	"info": {
+		"0409": {
+			"ProductVersion": "0.0.1",
+			"CompanyName": "My Company",
+			"FileDescription": "A program that does X",
+			"LegalCopyright": "(c) 2025, My Company",
+			"ProductName": "My Product",
+			"Comments": "Some Product Comments"
+		}
+	}
+}

+ 55 - 0
build/windows/msix/app_manifest.xml

@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Package
+  xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
+  xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
+  xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
+  xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
+  xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
+  IgnorableNamespaces="uap3">
+  
+  <Identity 
+    Name="net.scwy.myapp"
+    Publisher="CN=TengTu Studio"
+    Version="0.1.0.0" 
+    ProcessorArchitecture="x64" />
+  
+  <Properties>
+    <DisplayName>My Product</DisplayName>
+    <PublisherDisplayName>TengTu Studio</PublisherDisplayName>
+    <Description>A myapp application</Description>
+    <Logo>Assets\StoreLogo.png</Logo>
+  </Properties>
+  
+  <Dependencies>
+    <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
+  </Dependencies>
+  
+  <Resources>
+    <Resource Language="en-us" />
+  </Resources>
+  
+  <Applications>
+    <Application Id="net.scwy.myapp" Executable="myapp.exe" EntryPoint="Windows.FullTrustApplication">
+      <uap:VisualElements
+        DisplayName="My Product"
+        Description="A myapp application"
+        BackgroundColor="transparent"
+        Square150x150Logo="Assets\Square150x150Logo.png"
+        Square44x44Logo="Assets\Square44x44Logo.png">
+        <uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
+        <uap:SplashScreen Image="Assets\SplashScreen.png" />
+      </uap:VisualElements>
+      
+      <Extensions>
+        <desktop:Extension Category="windows.fullTrustProcess" Executable="myapp.exe" />
+        
+        
+      </Extensions>
+    </Application>
+  </Applications>
+  
+  <Capabilities>
+    <rescap:Capability Name="runFullTrust" />
+    
+  </Capabilities>
+</Package>

+ 54 - 0
build/windows/msix/template.xml

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?>
+<MsixPackagingToolTemplate
+    xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
+    <Settings
+        AllowTelemetry="false"
+        ApplyACLsToPackageFiles="true"
+        GenerateCommandLineFile="true"
+        AllowPromptForPassword="false">
+    </Settings>
+    <Installer
+        Path="myapp.exe"
+        Arguments=""
+        InstallLocation="C:\Program Files\TengTu Studio\My Product">
+    </Installer>
+    <PackageInformation
+        PackageName="My Product"
+        PackageDisplayName="My Product"
+        PublisherName="CN=TengTu Studio"
+        PublisherDisplayName="TengTu Studio"
+        Version="0.1.0.0"
+        PackageDescription="A myapp application">
+        <Capabilities>
+            <Capability Name="runFullTrust" />
+            
+        </Capabilities>
+        <Applications>
+            <Application
+                Id="net.scwy.myapp"
+                Description="A myapp application"
+                DisplayName="My Product"
+                ExecutableName="myapp.exe"
+                EntryPoint="Windows.FullTrustApplication">
+                
+            </Application>
+        </Applications>
+        <Resources>
+            <Resource Language="en-us" />
+        </Resources>
+        <Dependencies>
+            <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
+        </Dependencies>
+        <Properties>
+            <Framework>false</Framework>
+            <DisplayName>My Product</DisplayName>
+            <PublisherDisplayName>TengTu Studio</PublisherDisplayName>
+            <Description>A myapp application</Description>
+            <Logo>Assets\AppIcon.png</Logo>
+        </Properties>
+    </PackageInformation>
+    <SaveLocation PackagePath="myapp.msix" />
+    <PackageIntegrity>
+        <CertificatePath></CertificatePath>
+    </PackageIntegrity>
+</MsixPackagingToolTemplate>

+ 7 - 0
build/windows/nsis/eula.txt

@@ -0,0 +1,7 @@
+软件授权协议
+
+1. 此为内部工具,请勿传播使用。
+2. 此为网络工具,需要接通互联网才能正常使用。
+3. 使用前请先联系管理员进行授权注册。
+4. 授权期满需要再次注册才可以使用。
+5. 任何第三方的破坏行为将受到法律制裁。

+ 119 - 0
build/windows/nsis/project.nsi

@@ -0,0 +1,119 @@
+Unicode true
+
+####
+## Please note: Template replacements don't work in this file. They are provided with default defines like
+## mentioned underneath.
+## If the keyword is not defined, "wails_tools.nsh" will populate them.
+## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
+## from outside of Wails for debugging and development of the installer.
+## 
+## For development first make a wails nsis build to populate the "wails_tools.nsh":
+## > wails build --target windows/amd64 --nsis
+## Then you can call makensis on this file with specifying the path to your binary:
+## For a AMD64 only installer:
+## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
+## For a ARM64 only installer:
+## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
+## For a installer with both architectures:
+## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
+####
+## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
+####
+## !define INFO_PROJECTNAME    "my-project" # Default "myapp"
+## !define INFO_COMPANYNAME    "My Company" # Default "TengTu Studio"
+## !define INFO_PRODUCTNAME    "My Product Name" # Default "My Product"
+## !define INFO_PRODUCTVERSION "1.0.0"     # Default "0.1.0"
+## !define INFO_COPYRIGHT      "(c) Now, My Company" # Default "© 2026, TengTu Studio"
+###
+## !define PRODUCT_EXECUTABLE  "Application.exe"      # Default "${INFO_PROJECTNAME}.exe"
+## !define UNINST_KEY_NAME     "UninstKeyInRegistry"  # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
+####
+## !define REQUEST_EXECUTION_LEVEL "admin"            # Default "admin"  see also https://nsis.sourceforge.io/Docs/Chapter4.html
+## !define WAILS_INSTALL_SCOPE     "user"             # Default "machine" - set to "user" for per-user install ($LOCALAPPDATA) without UAC prompt
+####
+## Include the wails tools
+####
+!include "wails_tools.nsh"
+
+# The version information for this two must consist of 4 parts
+VIProductVersion "${INFO_PRODUCTVERSION}.0"
+VIFileVersion    "${INFO_PRODUCTVERSION}.0"
+
+VIAddVersionKey "CompanyName"     "${INFO_COMPANYNAME}"
+VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
+VIAddVersionKey "ProductVersion"  "${INFO_PRODUCTVERSION}"
+VIAddVersionKey "FileVersion"     "${INFO_PRODUCTVERSION}"
+VIAddVersionKey "LegalCopyright"  "${INFO_COPYRIGHT}"
+VIAddVersionKey "ProductName"     "${INFO_PRODUCTNAME}"
+
+# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
+ManifestDPIAware true
+
+!include "MUI.nsh"
+
+!define MUI_ICON "..\icon.ico"
+!define MUI_UNICON "..\icon.ico"
+# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
+!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
+!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
+
+!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
+!insertmacro MUI_PAGE_LICENSE "eula.txt" # Adds a EULA page to the installer
+!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
+!insertmacro MUI_PAGE_INSTFILES # Installing page.
+!insertmacro MUI_PAGE_FINISH # Finished installation page.
+
+!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
+
+!insertmacro MUI_LANGUAGE "SimpChinese" # Set the Language of the installer
+
+## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
+#!uninstfinalize 'signtool --file "%1"'
+#!finalize 'signtool --file "%1"'
+
+Name "${INFO_PRODUCTNAME}"
+OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
+!if "${WAILS_INSTALL_SCOPE}" == "user"
+    InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
+!else
+    InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}"
+!endif
+ShowInstDetails show # This will always show the installation details.
+
+Function .onInit
+   !insertmacro wails.checkArchitecture
+FunctionEnd
+
+Section
+    !insertmacro wails.setShellContext
+
+    !insertmacro wails.webview2runtime
+
+    SetOutPath $INSTDIR
+    
+    !insertmacro wails.files
+
+    CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+    CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+
+    !insertmacro wails.associateFiles
+    !insertmacro wails.associateCustomProtocols
+    
+    !insertmacro wails.writeUninstaller
+SectionEnd
+
+Section "uninstall" 
+    !insertmacro wails.setShellContext
+
+    RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
+
+    RMDir /r $INSTDIR
+
+    Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
+    Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+
+    !insertmacro wails.unassociateFiles
+    !insertmacro wails.unassociateCustomProtocols
+
+    !insertmacro wails.deleteUninstaller
+SectionEnd

+ 261 - 0
build/windows/nsis/wails_tools.nsh

@@ -0,0 +1,261 @@
+# DO NOT EDIT - Generated automatically by `wails build`
+
+!include "x64.nsh"
+!include "WinVer.nsh"
+!include "FileFunc.nsh"
+
+!ifndef INFO_PROJECTNAME
+    !define INFO_PROJECTNAME "腾图工具箱"
+!endif
+!ifndef INFO_COMPANYNAME
+    !define INFO_COMPANYNAME "My Company"
+!endif
+!ifndef INFO_PRODUCTNAME
+    !define INFO_PRODUCTNAME "My Product"
+!endif
+!ifndef INFO_PRODUCTVERSION
+    !define INFO_PRODUCTVERSION "0.0.1"
+!endif
+!ifndef INFO_COPYRIGHT
+    !define INFO_COPYRIGHT "(c) 2025, My Company"
+!endif
+!ifndef PRODUCT_EXECUTABLE
+    !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
+!endif
+!ifndef UNINST_KEY_NAME
+    !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
+!endif
+!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
+
+!ifndef WAILS_INSTALL_SCOPE
+    !define WAILS_INSTALL_SCOPE "machine"
+!endif
+
+!ifndef REQUEST_EXECUTION_LEVEL
+    !if "${WAILS_INSTALL_SCOPE}" == "user"
+        !define REQUEST_EXECUTION_LEVEL "user"
+    !else
+        !define REQUEST_EXECUTION_LEVEL "admin"
+    !endif
+!endif
+
+RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
+
+!ifdef ARG_WAILS_AMD64_BINARY
+    !define SUPPORTS_AMD64
+!endif
+
+!ifdef ARG_WAILS_ARM64_BINARY
+    !define SUPPORTS_ARM64
+!endif
+
+!ifdef SUPPORTS_AMD64
+    !ifdef SUPPORTS_ARM64
+        !define ARCH "amd64_arm64"
+    !else
+        !define ARCH "amd64"
+    !endif
+!else
+    !ifdef SUPPORTS_ARM64
+        !define ARCH "arm64"
+    !else
+        !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
+    !endif
+!endif
+
+!macro wails.checkArchitecture
+    !ifndef WAILS_WIN10_REQUIRED
+        !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
+    !endif
+
+    !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
+        !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
+    !endif
+
+    ${If} ${AtLeastWin10}
+        !ifdef SUPPORTS_AMD64
+            ${if} ${IsNativeAMD64}
+                Goto ok
+            ${EndIf}
+        !endif
+
+        !ifdef SUPPORTS_ARM64
+            ${if} ${IsNativeARM64}
+                Goto ok
+            ${EndIf}
+        !endif
+
+        IfSilent silentArch notSilentArch
+        silentArch:
+            SetErrorLevel 65
+            Abort
+        notSilentArch:
+            MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
+            Quit
+    ${else}
+        IfSilent silentWin notSilentWin
+        silentWin:
+            SetErrorLevel 64
+            Abort
+        notSilentWin:
+            MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
+            Quit
+    ${EndIf}
+
+    ok:
+!macroend
+
+!macro wails.files
+    !ifdef SUPPORTS_AMD64
+        ${if} ${IsNativeAMD64}
+            File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
+        ${EndIf}
+    !endif
+
+    !ifdef SUPPORTS_ARM64
+        ${if} ${IsNativeARM64}
+            File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
+        ${EndIf}
+    !endif
+!macroend
+
+!macro wails.writeUninstaller
+    WriteUninstaller "$INSTDIR\uninstall.exe"
+
+    SetRegView 64
+    !if "${WAILS_INSTALL_SCOPE}" == "user"
+        WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
+        WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
+        WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
+        WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+        WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
+        WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
+
+        ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
+        IntFmt $0 "0x%08X" $0
+        WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
+    !else
+        WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
+        WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
+        WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
+        WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+        WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
+        WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
+
+        ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
+        IntFmt $0 "0x%08X" $0
+        WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
+    !endif
+!macroend
+
+!macro wails.deleteUninstaller
+    Delete "$INSTDIR\uninstall.exe"
+
+    SetRegView 64
+    !if "${WAILS_INSTALL_SCOPE}" == "user"
+        DeleteRegKey HKCU "${UNINST_KEY}"
+    !else
+        DeleteRegKey HKLM "${UNINST_KEY}"
+    !endif
+!macroend
+
+!macro wails.setShellContext
+    ${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
+        SetShellVarContext all
+    ${else}
+        SetShellVarContext current
+    ${EndIf}
+!macroend
+
+# Install webview2 by launching the bootstrapper
+# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
+!macro wails.webview2runtime
+    !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
+        !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
+    !endif
+
+    SetRegView 64
+	# If the admin key exists and is not empty then webview2 is already installed
+	ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
+    ${If} $0 != ""
+        Goto ok
+    ${EndIf}
+
+    ${If} ${REQUEST_EXECUTION_LEVEL} == "user"
+        # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
+	    ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
+        ${If} $0 != ""
+            Goto ok
+        ${EndIf}
+     ${EndIf}
+    
+	SetDetailsPrint both
+    DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
+    SetDetailsPrint listonly
+    
+    InitPluginsDir
+    CreateDirectory "$pluginsdir\webview2bootstrapper"
+    SetOutPath "$pluginsdir\webview2bootstrapper"
+    File "MicrosoftEdgeWebview2Setup.exe"
+    ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
+    
+    SetDetailsPrint both
+    ok:
+!macroend
+
+# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
+!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
+  ; Backup the previously associated file class
+  ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
+  WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
+
+  WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
+
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
+!macroend
+
+!macro APP_UNASSOCIATE EXT FILECLASS
+  ; Backup the previously associated file class
+  ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
+  WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
+
+  DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
+!macroend
+
+!macro wails.associateFiles
+    ; Create file associations
+    
+!macroend
+
+!macro wails.unassociateFiles
+    ; Delete app associations
+    
+!macroend
+
+!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
+  DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
+  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
+!macroend
+
+!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
+  DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
+!macroend
+
+!macro wails.associateCustomProtocols
+    ; Create custom protocols associations
+    
+!macroend
+
+!macro wails.unassociateCustomProtocols
+    ; Delete app custom protocol associations
+    
+!macroend

+ 22 - 0
build/windows/wails.exe.manifest

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
+    <assemblyIdentity type="win32" name="com.mycompany.myproduct" version="0.0.1" processorArchitecture="*"/>
+    <dependency>
+        <dependentAssembly>
+            <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
+        </dependentAssembly>
+    </dependency>
+    <asmv3:application>
+        <asmv3:windowsSettings>
+            <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
+            <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
+        </asmv3:windowsSettings>
+    </asmv3:application>
+    <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
+        <security>
+            <requestedPrivileges>
+                <requestedExecutionLevel level="asInvoker" uiAccess="false"/>
+            </requestedPrivileges>
+        </security>
+    </trustInfo>
+</assembly>

+ 5 - 0
frontend/.npmrc

@@ -0,0 +1,5 @@
+# Refuse to install package versions published less than 7 days (10080 minutes)
+# ago, reducing exposure to freshly compromised or malicious releases.
+# Honoured by pnpm and bun; npm currently has no native support and ignores it,
+# so this is harmless today and forward-compatible.
+minimum-release-age=10080

+ 37 - 0
frontend/bindings/TengTuToolbox/greetservice.js

@@ -0,0 +1,37 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * GreetService 是一个暴露给前端调用的示例服务。
+ * 在 main.go 中通过 application.NewService(&GreetService{}) 注册后,
+ * Wails 会自动为其生成 JS/TS 绑定(frontend/bindings 目录),
+ * 前端即可像调用本地函数一样直接调用 Greet 方法(底层通过 IPC 桥接)。
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+/**
+ * Greet 返回对 name 的问候语。
+ * 
+ * 调用链:前端 main.js 的 doGreet() → GreetService.Greet(name) → 本方法。
+ * 前端调用示例:GreetService.Greet("张三") → 返回 "您好 张三!"
+ * 参数与返回值由 Wails 自动完成 Go 与 JS 之间的序列化/反序列化。
+ * @param {string} name
+ * @returns {$CancellablePromise<string>}
+ */
+export function Greet(name) {
+    return $Call.ByID(1411160069, name);
+}
+
+/**
+ * GreetMany 返回对一批 name 的问候语(示例方法,演示切片参数的绑定与序列化)。
+ * @param {string[]} names
+ * @returns {$CancellablePromise<string>}
+ */
+export function GreetMany(names) {
+    return $Call.ByID(1896900098, names);
+}

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

@@ -0,0 +1,34 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+import * as GreetService from "./greetservice.js";
+import * as SettingsService from "./settingsservice.js";
+export {
+    GreetService,
+    SettingsService
+};
+
+export {
+    APIStatus,
+    AppSettings,
+    BiliDynamicItem,
+    CommitItem,
+    CustomTab,
+    KLineBar,
+    LinkItem,
+    MinuteChart,
+    MinutePoint,
+    NewsItem,
+    PCStatus,
+    PoemConfig,
+    PoemData,
+    ReleaseItem,
+    RepoFeed,
+    RepoItem,
+    StockQuote,
+    TickerConfig,
+    TranslateResult,
+    TrendingRepo,
+    WebServiceInfo
+} from "./models.js";

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

@@ -0,0 +1,1632 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+/**
+ * APIStatus API 服务运行状态,供「主机」页展示
+ */
+export class APIStatus {
+    /**
+     * Creates a new APIStatus instance.
+     * @param {Partial<APIStatus>} [$$source = {}] - The source object to create the APIStatus.
+     */
+    constructor($$source = {}) {
+        if (!("addr" in $$source)) {
+            /**
+             * 当前监听地址
+             * @member
+             * @type {string}
+             */
+            this["addr"] = "";
+        }
+        if (!("running" in $$source)) {
+            /**
+             * 是否正在监听
+             * @member
+             * @type {boolean}
+             */
+            this["running"] = false;
+        }
+        if (!("startedAt" in $$source)) {
+            /**
+             * 启动时间
+             * @member
+             * @type {string}
+             */
+            this["startedAt"] = "";
+        }
+        if (!("error" in $$source)) {
+            /**
+             * 最近错误信息
+             * @member
+             * @type {string}
+             */
+            this["error"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new APIStatus instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {APIStatus}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new APIStatus(/** @type {Partial<APIStatus>} */($$parsedSource));
+    }
+}
+
+/**
+ * AppSettings 保存全部可自定义配置,序列化为 JSON 存于用户配置目录
+ */
+export class AppSettings {
+    /**
+     * Creates a new AppSettings instance.
+     * @param {Partial<AppSettings>} [$$source = {}] - The source object to create the AppSettings.
+     */
+    constructor($$source = {}) {
+        if (!("theme" in $$source)) {
+            /**
+             * 界面主题:dark / light
+             * @member
+             * @type {string}
+             */
+            this["theme"] = "";
+        }
+        if (!("autostart" in $$source)) {
+            /**
+             * 系统启动时运行
+             * @member
+             * @type {boolean}
+             */
+            this["autostart"] = false;
+        }
+        if (!("alwaysOnTop" in $$source)) {
+            /**
+             * 窗口置顶
+             * @member
+             * @type {boolean}
+             */
+            this["alwaysOnTop"] = false;
+        }
+        if (!("hotkey" in $$source)) {
+            /**
+             * 呼出(显示)窗口的全局热键
+             * @member
+             * @type {string}
+             */
+            this["hotkey"] = "";
+        }
+        if (!("notesUrl" in $$source)) {
+            /**
+             * 笔记页地址
+             * @member
+             * @type {string}
+             */
+            this["notesUrl"] = "";
+        }
+        if (!("controlUrl" in $$source)) {
+            /**
+             * 控制页地址
+             * @member
+             * @type {string}
+             */
+            this["controlUrl"] = "";
+        }
+        if (!("blogUrl" in $$source)) {
+            /**
+             * 博客页地址
+             * @member
+             * @type {string}
+             */
+            this["blogUrl"] = "";
+        }
+        if (!("chatUrl" in $$source)) {
+            /**
+             * 聊天页地址
+             * @member
+             * @type {string}
+             */
+            this["chatUrl"] = "";
+        }
+        if (!("tabOrder" in $$source)) {
+            /**
+             * Tab 显示顺序(tabId 列表,未列出的追加到末尾)
+             * @member
+             * @type {string[]}
+             */
+            this["tabOrder"] = [];
+        }
+        if (!("stockCodes" in $$source)) {
+            /**
+             * 股市页展示的股票代码列表
+             * @member
+             * @type {string[]}
+             */
+            this["stockCodes"] = [];
+        }
+        if (!("links" in $$source)) {
+            /**
+             * 常用网址列表
+             * @member
+             * @type {LinkItem[]}
+             */
+            this["links"] = [];
+        }
+        if (!("newsKeywords" in $$source)) {
+            /**
+             * 新闻关注关键词,逗号分隔
+             * @member
+             * @type {string}
+             */
+            this["newsKeywords"] = "";
+        }
+        if (!("biliCookie" in $$source)) {
+            /**
+             * B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
+             * @member
+             * @type {string}
+             */
+            this["biliCookie"] = "";
+        }
+        if (!("githubRepos" in $$source)) {
+            /**
+             * 关注的 GitHub 仓库列表
+             * @member
+             * @type {RepoItem[]}
+             */
+            this["githubRepos"] = [];
+        }
+        if (!("customTabs" in $$source)) {
+            /**
+             * 用户自定义 Tab 页面(名字 + 网址)
+             * @member
+             * @type {CustomTab[]}
+             */
+            this["customTabs"] = [];
+        }
+        if (!("apiAddr" in $$source)) {
+            /**
+             * 内置 API 服务监听地址
+             * @member
+             * @type {string}
+             */
+            this["apiAddr"] = "";
+        }
+        if (!("recordPath" in $$source)) {
+            /**
+             * 记录文件路径
+             * @member
+             * @type {string}
+             */
+            this["recordPath"] = "";
+        }
+        if (!("todoPath" in $$source)) {
+            /**
+             * 待办(TODO)文件路径
+             * @member
+             * @type {string}
+             */
+            this["todoPath"] = "";
+        }
+        if (!("hardwareUrl" in $$source)) {
+            /**
+             * 硬件页地址
+             * @member
+             * @type {string}
+             */
+            this["hardwareUrl"] = "";
+        }
+        if (!("stockTicker" in $$source)) {
+            /**
+             * 股票滚动条配置
+             * @member
+             * @type {TickerConfig}
+             */
+            this["stockTicker"] = (new TickerConfig());
+        }
+        if (!("newsTicker" in $$source)) {
+            /**
+             * 新闻滚动条配置
+             * @member
+             * @type {TickerConfig}
+             */
+            this["newsTicker"] = (new TickerConfig());
+        }
+        if (!("poem" in $$source)) {
+            /**
+             * 随机古诗显示配置
+             * @member
+             * @type {PoemConfig}
+             */
+            this["poem"] = (new PoemConfig());
+        }
+        if (!("stockUpColor" in $$source)) {
+            /**
+             * 股票上涨颜色
+             * @member
+             * @type {string}
+             */
+            this["stockUpColor"] = "";
+        }
+        if (!("stockDownColor" in $$source)) {
+            /**
+             * 股票下跌颜色
+             * @member
+             * @type {string}
+             */
+            this["stockDownColor"] = "";
+        }
+        if (!("stockRefreshSecs" in $$source)) {
+            /**
+             * 股市自动刷新间隔(秒)
+             * @member
+             * @type {number}
+             */
+            this["stockRefreshSecs"] = 0;
+        }
+        if (!("stockClosedRefreshSecs" in $$source)) {
+            /**
+             * 股市休市时自动刷新间隔(秒)
+             * @member
+             * @type {number}
+             */
+            this["stockClosedRefreshSecs"] = 0;
+        }
+        if (!("newsRefreshMinutes" in $$source)) {
+            /**
+             * 新闻自动刷新间隔(分钟)
+             * @member
+             * @type {number}
+             */
+            this["newsRefreshMinutes"] = 0;
+        }
+        if (!("newsCount" in $$source)) {
+            /**
+             * 新闻页显示条数
+             * @member
+             * @type {number}
+             */
+            this["newsCount"] = 0;
+        }
+        if (!("githubRefreshMinutes" in $$source)) {
+            /**
+             * Github 自动刷新间隔(分钟)
+             * @member
+             * @type {number}
+             */
+            this["githubRefreshMinutes"] = 0;
+        }
+        if (!("fontDelta" in $$source)) {
+            /**
+             * 全局字号偏移(px),+-3 范围内调整
+             * @member
+             * @type {number}
+             */
+            this["fontDelta"] = 0;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new AppSettings instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {AppSettings}
+     */
+    static createFrom($$source = {}) {
+        const $$createField8_0 = $$createType0;
+        const $$createField9_0 = $$createType0;
+        const $$createField10_0 = $$createType2;
+        const $$createField13_0 = $$createType4;
+        const $$createField14_0 = $$createType6;
+        const $$createField19_0 = $$createType7;
+        const $$createField20_0 = $$createType7;
+        const $$createField21_0 = $$createType8;
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        if ("tabOrder" in $$parsedSource) {
+            $$parsedSource["tabOrder"] = $$createField8_0($$parsedSource["tabOrder"]);
+        }
+        if ("stockCodes" in $$parsedSource) {
+            $$parsedSource["stockCodes"] = $$createField9_0($$parsedSource["stockCodes"]);
+        }
+        if ("links" in $$parsedSource) {
+            $$parsedSource["links"] = $$createField10_0($$parsedSource["links"]);
+        }
+        if ("githubRepos" in $$parsedSource) {
+            $$parsedSource["githubRepos"] = $$createField13_0($$parsedSource["githubRepos"]);
+        }
+        if ("customTabs" in $$parsedSource) {
+            $$parsedSource["customTabs"] = $$createField14_0($$parsedSource["customTabs"]);
+        }
+        if ("stockTicker" in $$parsedSource) {
+            $$parsedSource["stockTicker"] = $$createField19_0($$parsedSource["stockTicker"]);
+        }
+        if ("newsTicker" in $$parsedSource) {
+            $$parsedSource["newsTicker"] = $$createField20_0($$parsedSource["newsTicker"]);
+        }
+        if ("poem" in $$parsedSource) {
+            $$parsedSource["poem"] = $$createField21_0($$parsedSource["poem"]);
+        }
+        return new AppSettings(/** @type {Partial<AppSettings>} */($$parsedSource));
+    }
+}
+
+/**
+ * BiliDynamicItem 一条 B站动态,供「新闻」页展示
+ */
+export class BiliDynamicItem {
+    /**
+     * Creates a new BiliDynamicItem instance.
+     * @param {Partial<BiliDynamicItem>} [$$source = {}] - The source object to create the BiliDynamicItem.
+     */
+    constructor($$source = {}) {
+        if (!("source" in $$source)) {
+            /**
+             * 来源:B站动态
+             * @member
+             * @type {string}
+             */
+            this["source"] = "";
+        }
+        if (!("title" in $$source)) {
+            /**
+             * 标题(动态文案或视频标题)
+             * @member
+             * @type {string}
+             */
+            this["title"] = "";
+        }
+        if (!("url" in $$source)) {
+            /**
+             * 详情页地址
+             * @member
+             * @type {string}
+             */
+            this["url"] = "";
+        }
+        if (!("matched" in $$source)) {
+            /**
+             * 是否命中关注关键词
+             * @member
+             * @type {boolean}
+             */
+            this["matched"] = false;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new BiliDynamicItem instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {BiliDynamicItem}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new BiliDynamicItem(/** @type {Partial<BiliDynamicItem>} */($$parsedSource));
+    }
+}
+
+/**
+ * CommitItem 仓库的一条提交记录
+ */
+export class CommitItem {
+    /**
+     * Creates a new CommitItem instance.
+     * @param {Partial<CommitItem>} [$$source = {}] - The source object to create the CommitItem.
+     */
+    constructor($$source = {}) {
+        if (!("title" in $$source)) {
+            /**
+             * 提交标题(首行)
+             * @member
+             * @type {string}
+             */
+            this["title"] = "";
+        }
+        if (!("url" in $$source)) {
+            /**
+             * 提交详情页地址
+             * @member
+             * @type {string}
+             */
+            this["url"] = "";
+        }
+        if (!("updated" in $$source)) {
+            /**
+             * 提交时间(MM-DD HH:MM)
+             * @member
+             * @type {string}
+             */
+            this["updated"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new CommitItem instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {CommitItem}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new CommitItem(/** @type {Partial<CommitItem>} */($$parsedSource));
+    }
+}
+
+/**
+ * CustomTab 用户自定义 Tab 页面
+ */
+export class CustomTab {
+    /**
+     * Creates a new CustomTab instance.
+     * @param {Partial<CustomTab>} [$$source = {}] - The source object to create the CustomTab.
+     */
+    constructor($$source = {}) {
+        if (!("name" in $$source)) {
+            /**
+             * 页面名称(Tab 按钮显示文字)
+             * @member
+             * @type {string}
+             */
+            this["name"] = "";
+        }
+        if (!("url" in $$source)) {
+            /**
+             * 页面地址(iframe 加载)
+             * @member
+             * @type {string}
+             */
+            this["url"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new CustomTab instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {CustomTab}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new CustomTab(/** @type {Partial<CustomTab>} */($$parsedSource));
+    }
+}
+
+/**
+ * KLineBar 日 K 的一根柱子
+ */
+export class KLineBar {
+    /**
+     * Creates a new KLineBar instance.
+     * @param {Partial<KLineBar>} [$$source = {}] - The source object to create the KLineBar.
+     */
+    constructor($$source = {}) {
+        if (!("date" in $$source)) {
+            /**
+             * 交易日
+             * @member
+             * @type {string}
+             */
+            this["date"] = "";
+        }
+        if (!("open" in $$source)) {
+            /**
+             * 开盘价
+             * @member
+             * @type {number}
+             */
+            this["open"] = 0;
+        }
+        if (!("close" in $$source)) {
+            /**
+             * 收盘价
+             * @member
+             * @type {number}
+             */
+            this["close"] = 0;
+        }
+        if (!("high" in $$source)) {
+            /**
+             * 最高价
+             * @member
+             * @type {number}
+             */
+            this["high"] = 0;
+        }
+        if (!("low" in $$source)) {
+            /**
+             * 最低价
+             * @member
+             * @type {number}
+             */
+            this["low"] = 0;
+        }
+        if (!("volume" in $$source)) {
+            /**
+             * 成交量(手)
+             * @member
+             * @type {number}
+             */
+            this["volume"] = 0;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new KLineBar instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {KLineBar}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new KLineBar(/** @type {Partial<KLineBar>} */($$parsedSource));
+    }
+}
+
+/**
+ * LinkItem 常用网址条目
+ */
+export class LinkItem {
+    /**
+     * Creates a new LinkItem instance.
+     * @param {Partial<LinkItem>} [$$source = {}] - The source object to create the LinkItem.
+     */
+    constructor($$source = {}) {
+        if (!("name" in $$source)) {
+            /**
+             * 网站名称
+             * @member
+             * @type {string}
+             */
+            this["name"] = "";
+        }
+        if (!("url" in $$source)) {
+            /**
+             * 网站地址
+             * @member
+             * @type {string}
+             */
+            this["url"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new LinkItem instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {LinkItem}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new LinkItem(/** @type {Partial<LinkItem>} */($$parsedSource));
+    }
+}
+
+/**
+ * MinuteChart 分时图数据
+ */
+export class MinuteChart {
+    /**
+     * Creates a new MinuteChart instance.
+     * @param {Partial<MinuteChart>} [$$source = {}] - The source object to create the MinuteChart.
+     */
+    constructor($$source = {}) {
+        if (!("code" in $$source)) {
+            /**
+             * 股票代码
+             * @member
+             * @type {string}
+             */
+            this["code"] = "";
+        }
+        if (!("prevClose" in $$source)) {
+            /**
+             * 昨收,作为分时基准线
+             * @member
+             * @type {number}
+             */
+            this["prevClose"] = 0;
+        }
+        if (!("points" in $$source)) {
+            /**
+             * 分时点序列
+             * @member
+             * @type {MinutePoint[]}
+             */
+            this["points"] = [];
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new MinuteChart instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {MinuteChart}
+     */
+    static createFrom($$source = {}) {
+        const $$createField2_0 = $$createType10;
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        if ("points" in $$parsedSource) {
+            $$parsedSource["points"] = $$createField2_0($$parsedSource["points"]);
+        }
+        return new MinuteChart(/** @type {Partial<MinuteChart>} */($$parsedSource));
+    }
+}
+
+/**
+ * MinutePoint 分时图上的一个点
+ */
+export class MinutePoint {
+    /**
+     * Creates a new MinutePoint instance.
+     * @param {Partial<MinutePoint>} [$$source = {}] - The source object to create the MinutePoint.
+     */
+    constructor($$source = {}) {
+        if (!("time" in $$source)) {
+            /**
+             * HH:MM
+             * @member
+             * @type {string}
+             */
+            this["time"] = "";
+        }
+        if (!("price" in $$source)) {
+            /**
+             * 当前价
+             * @member
+             * @type {number}
+             */
+            this["price"] = 0;
+        }
+        if (!("avg" in $$source)) {
+            /**
+             * 均价
+             * @member
+             * @type {number}
+             */
+            this["avg"] = 0;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new MinutePoint instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {MinutePoint}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new MinutePoint(/** @type {Partial<MinutePoint>} */($$parsedSource));
+    }
+}
+
+/**
+ * NewsItem 一条新闻标题,供「新闻」页与新闻滚动条使用
+ */
+export class NewsItem {
+    /**
+     * Creates a new NewsItem instance.
+     * @param {Partial<NewsItem>} [$$source = {}] - The source object to create the NewsItem.
+     */
+    constructor($$source = {}) {
+        if (!("source" in $$source)) {
+            /**
+             * 来源:煎蛋 / 头条 / 腾讯
+             * @member
+             * @type {string}
+             */
+            this["source"] = "";
+        }
+        if (!("title" in $$source)) {
+            /**
+             * 标题
+             * @member
+             * @type {string}
+             */
+            this["title"] = "";
+        }
+        if (!("url" in $$source)) {
+            /**
+             * 详情页地址
+             * @member
+             * @type {string}
+             */
+            this["url"] = "";
+        }
+        if (!("matched" in $$source)) {
+            /**
+             * 是否命中关注关键词
+             * @member
+             * @type {boolean}
+             */
+            this["matched"] = false;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new NewsItem instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {NewsItem}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new NewsItem(/** @type {Partial<NewsItem>} */($$parsedSource));
+    }
+}
+
+/**
+ * PCStatus 电源命令状态快照,供 API 与「主机」页查询
+ */
+export class PCStatus {
+    /**
+     * Creates a new PCStatus instance.
+     * @param {Partial<PCStatus>} [$$source = {}] - The source object to create the PCStatus.
+     */
+    constructor($$source = {}) {
+        if (!("command" in $$source)) {
+            /**
+             * 最近一次命令类型
+             * @member
+             * @type {string}
+             */
+            this["command"] = "";
+        }
+        if (!("pending" in $$source)) {
+            /**
+             * 是否有待执行命令
+             * @member
+             * @type {boolean}
+             */
+            this["pending"] = false;
+        }
+        if (!("executeAt" in $$source)) {
+            /**
+             * 计划执行时间
+             * @member
+             * @type {string}
+             */
+            this["executeAt"] = "";
+        }
+        if (!("remainingSecs" in $$source)) {
+            /**
+             * 剩余秒数
+             * @member
+             * @type {number}
+             */
+            this["remainingSecs"] = 0;
+        }
+        if (!("clientIp" in $$source)) {
+            /**
+             * 最近一次请求的客户端 IP
+             * @member
+             * @type {string}
+             */
+            this["clientIp"] = "";
+        }
+        if (!("requestTime" in $$source)) {
+            /**
+             * 最近一次请求时间
+             * @member
+             * @type {string}
+             */
+            this["requestTime"] = "";
+        }
+        if (!("requested" in $$source)) {
+            /**
+             * 是否收到过请求
+             * @member
+             * @type {boolean}
+             */
+            this["requested"] = false;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new PCStatus instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {PCStatus}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new PCStatus(/** @type {Partial<PCStatus>} */($$parsedSource));
+    }
+}
+
+/**
+ * PoemConfig 古诗显示的配置
+ */
+export class PoemConfig {
+    /**
+     * Creates a new PoemConfig instance.
+     * @param {Partial<PoemConfig>} [$$source = {}] - The source object to create the PoemConfig.
+     */
+    constructor($$source = {}) {
+        if (!("enabled" in $$source)) {
+            /**
+             * 是否启用古诗显示
+             * @member
+             * @type {boolean}
+             */
+            this["enabled"] = false;
+        }
+        if (!("color" in $$source)) {
+            /**
+             * 文字颜色
+             * @member
+             * @type {string}
+             */
+            this["color"] = "";
+        }
+        if (!("fontSize" in $$source)) {
+            /**
+             * 字号(px)
+             * @member
+             * @type {number}
+             */
+            this["fontSize"] = 0;
+        }
+        if (!("tts" in $$source)) {
+            /**
+             * 允许 TTS 阅读(显示朗读按钮)
+             * @member
+             * @type {boolean}
+             */
+            this["tts"] = false;
+        }
+        if (!("interval" in $$source)) {
+            /**
+             * 自动换诗间隔(秒),0 表示不自动换
+             * @member
+             * @type {number}
+             */
+            this["interval"] = 0;
+        }
+        if (!("speed" in $$source)) {
+            /**
+             * 滚动速度(秒/轮),即滚动一个文本周期所需秒数,越小越快
+             * @member
+             * @type {number}
+             */
+            this["speed"] = 0;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new PoemConfig instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {PoemConfig}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new PoemConfig(/** @type {Partial<PoemConfig>} */($$parsedSource));
+    }
+}
+
+/**
+ * PoemData 一首古诗的完整数据(从接口解析)
+ */
+export class PoemData {
+    /**
+     * Creates a new PoemData instance.
+     * @param {Partial<PoemData>} [$$source = {}] - The source object to create the PoemData.
+     */
+    constructor($$source = {}) {
+        if (!("title" in $$source)) {
+            /**
+             * 题目
+             * @member
+             * @type {string}
+             */
+            this["title"] = "";
+        }
+        if (!("author" in $$source)) {
+            /**
+             * 作者
+             * @member
+             * @type {string}
+             */
+            this["author"] = "";
+        }
+        if (!("dynasty" in $$source)) {
+            /**
+             * 朝代
+             * @member
+             * @type {string}
+             */
+            this["dynasty"] = "";
+        }
+        if (!("content" in $$source)) {
+            /**
+             * 每句诗(数组,按行显示)
+             * @member
+             * @type {string[]}
+             */
+            this["content"] = [];
+        }
+        if (!("type" in $$source)) {
+            /**
+             * 体裁,如 五言律诗
+             * @member
+             * @type {string}
+             */
+            this["type"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new PoemData instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {PoemData}
+     */
+    static createFrom($$source = {}) {
+        const $$createField3_0 = $$createType0;
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        if ("content" in $$parsedSource) {
+            $$parsedSource["content"] = $$createField3_0($$parsedSource["content"]);
+        }
+        return new PoemData(/** @type {Partial<PoemData>} */($$parsedSource));
+    }
+}
+
+/**
+ * ReleaseItem 一个 GitHub release 的简略信息,用于列出 Latest 之后的版本(Pre-release 等)
+ */
+export class ReleaseItem {
+    /**
+     * Creates a new ReleaseItem instance.
+     * @param {Partial<ReleaseItem>} [$$source = {}] - The source object to create the ReleaseItem.
+     */
+    constructor($$source = {}) {
+        if (!("tagName" in $$source)) {
+            /**
+             * 版本标签,如 v1.3.0-beta.1
+             * @member
+             * @type {string}
+             */
+            this["tagName"] = "";
+        }
+        if (!("name" in $$source)) {
+            /**
+             * release 显示名称(标题),可能比 tag 更完整,如 "Rolling: v2.1.0-..."
+             * @member
+             * @type {string}
+             */
+            this["name"] = "";
+        }
+        if (!("url" in $$source)) {
+            /**
+             * release 详情页地址
+             * @member
+             * @type {string}
+             */
+            this["url"] = "";
+        }
+        if (!("isPrerelease" in $$source)) {
+            /**
+             * 是否预发布版本
+             * @member
+             * @type {boolean}
+             */
+            this["isPrerelease"] = false;
+        }
+        if (!("isDraft" in $$source)) {
+            /**
+             * 是否草稿版本
+             * @member
+             * @type {boolean}
+             */
+            this["isDraft"] = false;
+        }
+        if (!("publishedAt" in $$source)) {
+            /**
+             * 发布时间(MM-DD HH:MM)
+             * @member
+             * @type {string}
+             */
+            this["publishedAt"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new ReleaseItem instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {ReleaseItem}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new ReleaseItem(/** @type {Partial<ReleaseItem>} */($$parsedSource));
+    }
+}
+
+/**
+ * RepoFeed 一个 GitHub 仓库的最新提交摘要
+ */
+export class RepoFeed {
+    /**
+     * Creates a new RepoFeed instance.
+     * @param {Partial<RepoFeed>} [$$source = {}] - The source object to create the RepoFeed.
+     */
+    constructor($$source = {}) {
+        if (!("url" in $$source)) {
+            /**
+             * 仓库地址(设置中配置)
+             * @member
+             * @type {string}
+             */
+            this["url"] = "";
+        }
+        if (!("desc" in $$source)) {
+            /**
+             * 项目说明
+             * @member
+             * @type {string}
+             */
+            this["desc"] = "";
+        }
+        if (!("repo" in $$source)) {
+            /**
+             * owner/repo 短名
+             * @member
+             * @type {string}
+             */
+            this["repo"] = "";
+        }
+        if (!("version" in $$source)) {
+            /**
+             * 本地已安装版本(设置中配置)
+             * @member
+             * @type {string}
+             */
+            this["version"] = "";
+        }
+        if (!("latest" in $$source)) {
+            /**
+             * 最新正式 release 版本号,无正式 release 时为空
+             * @member
+             * @type {string}
+             */
+            this["latest"] = "";
+        }
+        if (!("hasUpdate" in $$source)) {
+            /**
+             * 是否存在可用更新(本地版本低于最新正式 release)
+             * @member
+             * @type {boolean}
+             */
+            this["hasUpdate"] = false;
+        }
+        if (!("releaseURL" in $$source)) {
+            /**
+             * 最新正式 release 的下载 / 详情页地址
+             * @member
+             * @type {string}
+             */
+            this["releaseURL"] = "";
+        }
+        if (!("newer" in $$source)) {
+            /**
+             * Latest 之后的版本(Pre-release 等,仅列出不参与更新比较)
+             * @member
+             * @type {ReleaseItem[]}
+             */
+            this["newer"] = [];
+        }
+        if (!("commits" in $$source)) {
+            /**
+             * 最近提交(最多 5 条)
+             * @member
+             * @type {CommitItem[]}
+             */
+            this["commits"] = [];
+        }
+        if (!("error" in $$source)) {
+            /**
+             * 抓取失败原因,为空表示正常
+             * @member
+             * @type {string}
+             */
+            this["error"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new RepoFeed instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {RepoFeed}
+     */
+    static createFrom($$source = {}) {
+        const $$createField7_0 = $$createType12;
+        const $$createField8_0 = $$createType14;
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        if ("newer" in $$parsedSource) {
+            $$parsedSource["newer"] = $$createField7_0($$parsedSource["newer"]);
+        }
+        if ("commits" in $$parsedSource) {
+            $$parsedSource["commits"] = $$createField8_0($$parsedSource["commits"]);
+        }
+        return new RepoFeed(/** @type {Partial<RepoFeed>} */($$parsedSource));
+    }
+}
+
+/**
+ * RepoItem 关注的 GitHub 仓库条目
+ */
+export class RepoItem {
+    /**
+     * Creates a new RepoItem instance.
+     * @param {Partial<RepoItem>} [$$source = {}] - The source object to create the RepoItem.
+     */
+    constructor($$source = {}) {
+        if (!("url" in $$source)) {
+            /**
+             * 仓库地址,如 https://github.com/wailsapp/wails
+             * @member
+             * @type {string}
+             */
+            this["url"] = "";
+        }
+        if (!("desc" in $$source)) {
+            /**
+             * 项目说明
+             * @member
+             * @type {string}
+             */
+            this["desc"] = "";
+        }
+        if (!("version" in $$source)) {
+            /**
+             * 本地已安装版本(用于与最新 release 比较),可为空
+             * @member
+             * @type {string}
+             */
+            this["version"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new RepoItem instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {RepoItem}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new RepoItem(/** @type {Partial<RepoItem>} */($$parsedSource));
+    }
+}
+
+/**
+ * StockQuote 单只股票的行情快照,供前端「股市」页展示
+ */
+export class StockQuote {
+    /**
+     * Creates a new StockQuote instance.
+     * @param {Partial<StockQuote>} [$$source = {}] - The source object to create the StockQuote.
+     */
+    constructor($$source = {}) {
+        if (!("code" in $$source)) {
+            /**
+             * 带市场前缀的代码,如 sh600519
+             * @member
+             * @type {string}
+             */
+            this["code"] = "";
+        }
+        if (!("name" in $$source)) {
+            /**
+             * 股票名称
+             * @member
+             * @type {string}
+             */
+            this["name"] = "";
+        }
+        if (!("price" in $$source)) {
+            /**
+             * 当前价
+             * @member
+             * @type {number}
+             */
+            this["price"] = 0;
+        }
+        if (!("change" in $$source)) {
+            /**
+             * 涨跌额
+             * @member
+             * @type {number}
+             */
+            this["change"] = 0;
+        }
+        if (!("changePercent" in $$source)) {
+            /**
+             * 涨跌幅(%)
+             * @member
+             * @type {number}
+             */
+            this["changePercent"] = 0;
+        }
+        if (!("amount" in $$source)) {
+            /**
+             * 成交额(万元)
+             * @member
+             * @type {number}
+             */
+            this["amount"] = 0;
+        }
+        if (!("time" in $$source)) {
+            /**
+             * 行情时间 HH:MM:SS
+             * @member
+             * @type {string}
+             */
+            this["time"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new StockQuote instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {StockQuote}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new StockQuote(/** @type {Partial<StockQuote>} */($$parsedSource));
+    }
+}
+
+/**
+ * TickerConfig 置顶滚动条(股票 / 新闻)的显示配置
+ */
+export class TickerConfig {
+    /**
+     * Creates a new TickerConfig instance.
+     * @param {Partial<TickerConfig>} [$$source = {}] - The source object to create the TickerConfig.
+     */
+    constructor($$source = {}) {
+        if (!("enabled" in $$source)) {
+            /**
+             * 是否显示滚动条窗口
+             * @member
+             * @type {boolean}
+             */
+            this["enabled"] = false;
+        }
+        if (!("width" in $$source)) {
+            /**
+             * 窗口宽度(px),0 表示默认 1400
+             * @member
+             * @type {number}
+             */
+            this["width"] = 0;
+        }
+        if (!("fontSize" in $$source)) {
+            /**
+             * 字号(px)
+             * @member
+             * @type {number}
+             */
+            this["fontSize"] = 0;
+        }
+        if (!("color" in $$source)) {
+            /**
+             * 文字颜色
+             * @member
+             * @type {string}
+             */
+            this["color"] = "";
+        }
+        if (!("speed" in $$source)) {
+            /**
+             * 滚动速度(px/s)
+             * @member
+             * @type {number}
+             */
+            this["speed"] = 0;
+        }
+        if (!("transparent" in $$source)) {
+            /**
+             * 背景透明,不遮挡下方内容
+             * @member
+             * @type {boolean}
+             */
+            this["transparent"] = false;
+        }
+        if (!("x" in $$source)) {
+            /**
+             * 窗口位置,-1 表示未设置(系统默认位置)
+             * @member
+             * @type {number}
+             */
+            this["x"] = 0;
+        }
+        if (!("y" in $$source)) {
+            /**
+             * 窗口位置,-1 表示未设置
+             * @member
+             * @type {number}
+             */
+            this["y"] = 0;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new TickerConfig instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {TickerConfig}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new TickerConfig(/** @type {Partial<TickerConfig>} */($$parsedSource));
+    }
+}
+
+/**
+ * TranslateResult 一次翻译的结果,供前端展示。
+ */
+export class TranslateResult {
+    /**
+     * Creates a new TranslateResult instance.
+     * @param {Partial<TranslateResult>} [$$source = {}] - The source object to create the TranslateResult.
+     */
+    constructor($$source = {}) {
+        if (!("text" in $$source)) {
+            /**
+             * 翻译结果文本
+             * @member
+             * @type {string}
+             */
+            this["text"] = "";
+        }
+        if (!("detected" in $$source)) {
+            /**
+             * 检测到的源语言(zh / en)
+             * @member
+             * @type {string}
+             */
+            this["detected"] = "";
+        }
+        if (!("target" in $$source)) {
+            /**
+             * 目标语言(zh / en)
+             * @member
+             * @type {string}
+             */
+            this["target"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new TranslateResult instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {TranslateResult}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new TranslateResult(/** @type {Partial<TranslateResult>} */($$parsedSource));
+    }
+}
+
+/**
+ * TrendingRepo 一个热门仓库条目(来源可为 GitHub Trending 或 HelloGitHub)
+ */
+export class TrendingRepo {
+    /**
+     * Creates a new TrendingRepo instance.
+     * @param {Partial<TrendingRepo>} [$$source = {}] - The source object to create the TrendingRepo.
+     */
+    constructor($$source = {}) {
+        if (!("name" in $$source)) {
+            /**
+             * owner/repo 短名
+             * @member
+             * @type {string}
+             */
+            this["name"] = "";
+        }
+        if (!("url" in $$source)) {
+            /**
+             * 仓库主页地址(GitHub 直链)
+             * @member
+             * @type {string}
+             */
+            this["url"] = "";
+        }
+        if (!("desc" in $$source)) {
+            /**
+             * 简介(英文已自动翻译为中文,翻译失败保留原文)
+             * @member
+             * @type {string}
+             */
+            this["desc"] = "";
+        }
+        if (!("lang" in $$source)) {
+            /**
+             * 主要语言,无则空
+             * @member
+             * @type {string}
+             */
+            this["lang"] = "";
+        }
+        if (!("stars" in $$source)) {
+            /**
+             * 总星数(如 "106,375")
+             * @member
+             * @type {string}
+             */
+            this["stars"] = "";
+        }
+        if (!("todayStars" in $$source)) {
+            /**
+             * 今日新增星数(如 "1,140",无则空)
+             * @member
+             * @type {string}
+             */
+            this["todayStars"] = "";
+        }
+        if (!("forks" in $$source)) {
+            /**
+             * fork 数(如 "9,365",无则空)
+             * @member
+             * @type {string}
+             */
+            this["forks"] = "";
+        }
+        if (!("source" in $$source)) {
+            /**
+             * 来源:"github"(GitHub Trending)/ "hellogithub"
+             * @member
+             * @type {string}
+             */
+            this["source"] = "";
+        }
+        if (!("heat" in $$source)) {
+            /**
+             * 热度(HelloGitHub 点击量,其它来源为空)
+             * @member
+             * @type {string}
+             */
+            this["heat"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new TrendingRepo instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {TrendingRepo}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new TrendingRepo(/** @type {Partial<TrendingRepo>} */($$parsedSource));
+    }
+}
+
+/**
+ * WebServiceInfo 描述一次 Web 服务运行实例的状态,供前端展示。
+ */
+export class WebServiceInfo {
+    /**
+     * Creates a new WebServiceInfo instance.
+     * @param {Partial<WebServiceInfo>} [$$source = {}] - The source object to create the WebServiceInfo.
+     */
+    constructor($$source = {}) {
+        if (!("running" in $$source)) {
+            /**
+             * 是否运行中
+             * @member
+             * @type {boolean}
+             */
+            this["running"] = false;
+        }
+        if (!("dir" in $$source)) {
+            /**
+             * 服务目录
+             * @member
+             * @type {string}
+             */
+            this["dir"] = "";
+        }
+        if (!("url" in $$source)) {
+            /**
+             * 访问地址 http://127.0.0.1:port/
+             * @member
+             * @type {string}
+             */
+            this["url"] = "";
+        }
+        if (!("indexName" in $$source)) {
+            /**
+             * 实际用作默认页的文件(index.html / readme.md / 空)
+             * @member
+             * @type {string}
+             */
+            this["indexName"] = "";
+        }
+        if (!("startedAt" in $$source)) {
+            /**
+             * 启动时间
+             * @member
+             * @type {string}
+             */
+            this["startedAt"] = "";
+        }
+        if (!("error" in $$source)) {
+            /**
+             * 最近一次错误
+             * @member
+             * @type {string}
+             */
+            this["error"] = "";
+        }
+        if (!("connections" in $$source)) {
+            /**
+             * 累计请求次数
+             * @member
+             * @type {number}
+             */
+            this["connections"] = 0;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new WebServiceInfo instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {WebServiceInfo}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new WebServiceInfo(/** @type {Partial<WebServiceInfo>} */($$parsedSource));
+    }
+}
+
+// Private type creation functions
+const $$createType0 = $Create.Array($Create.Any);
+const $$createType1 = LinkItem.createFrom;
+const $$createType2 = $Create.Array($$createType1);
+const $$createType3 = RepoItem.createFrom;
+const $$createType4 = $Create.Array($$createType3);
+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);

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

@@ -0,0 +1,432 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * SettingsService 暴露「设置」页需要的后端能力给前端调用。
+ * 前端通过 bindings 调用 GetSettings / SaveSettings / GetStocks 等方法。
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * AppVersion 返回应用版本号,供 footer 展示。
+ * 版本常量在 settings.go 的 AppVersion(与 build/windows/info.json 的 ProductVersion 一致)。
+ * @returns {$CancellablePromise<string>}
+ */
+export function AppVersion() {
+    return $Call.ByID(609040257);
+}
+
+/**
+ * CaptureScreen 截取屏幕并返回 PNG 数据 URI,供「主机」页预览。
+ * display 为显示器索引,-1 表示全部屏幕。
+ * @param {number} display
+ * @returns {$CancellablePromise<string>}
+ */
+export function CaptureScreen(display) {
+    return $Call.ByID(2784207426, display);
+}
+
+/**
+ * ClearAPILogs 清空 Gin 日志缓冲
+ * @returns {$CancellablePromise<void>}
+ */
+export function ClearAPILogs() {
+    return $Call.ByID(1756290968);
+}
+
+/**
+ * GenerateQRCode 把输入字符串生成 PNG 二维码,返回 data URI 供前端展示。
+ * size 为图片边长(像素),<=0 时用默认值 300。
+ * @param {string} text
+ * @param {number} size
+ * @returns {$CancellablePromise<string>}
+ */
+export function GenerateQRCode(text, size) {
+    return $Call.ByID(3713602369, text, size);
+}
+
+/**
+ * GetAPILogs 返回 Gin 日志环形缓冲的全部内容
+ * @returns {$CancellablePromise<string[]>}
+ */
+export function GetAPILogs() {
+    return $Call.ByID(524575579).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType0($result);
+    }));
+}
+
+/**
+ * GetAPIStatus 返回内置 API 服务运行状态
+ * @returns {$CancellablePromise<$models.APIStatus>}
+ */
+export function GetAPIStatus() {
+    return $Call.ByID(3299247002).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType1($result);
+    }));
+}
+
+/**
+ * GetBiliDynamic 抓取 B站「关注的人最新动态」。
+ * 需要设置里配置 B站登录 cookie(含 SESSDATA);cookie 为空或失效时回退热门视频。
+ * @returns {$CancellablePromise<$models.BiliDynamicItem[]>}
+ */
+export function GetBiliDynamic() {
+    return $Call.ByID(258599011).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType3($result);
+    }));
+}
+
+/**
+ * GetGithubFeeds 抓取设置中所有 GitHub 仓库的最新提交
+ * @returns {$CancellablePromise<$models.RepoFeed[]>}
+ */
+export function GetGithubFeeds() {
+    return $Call.ByID(2441556896).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType5($result);
+    }));
+}
+
+/**
+ * GetKLine 拉取指定股票的日 K 数据,count 为交易日数量
+ * @param {string} code
+ * @param {number} count
+ * @returns {$CancellablePromise<$models.KLineBar[]>}
+ */
+export function GetKLine(code, count) {
+    return $Call.ByID(1009336803, code, count).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType7($result);
+    }));
+}
+
+/**
+ * GetMinuteChart 拉取指定股票的分时数据(点击股市行打开分时图时调用)
+ * @param {string} code
+ * @returns {$CancellablePromise<$models.MinuteChart>}
+ */
+export function GetMinuteChart(code) {
+    return $Call.ByID(1171130824, code).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType8($result);
+    }));
+}
+
+/**
+ * GetNews 抓取新闻标题,按设置的关键词标记命中项并优先排序
+ * @returns {$CancellablePromise<$models.NewsItem[]>}
+ */
+export function GetNews() {
+    return $Call.ByID(1306230863).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType10($result);
+    }));
+}
+
+/**
+ * GetNtfyChatTopic 返回当前监听的聊天室主题(空表示未监听)
+ * @returns {$CancellablePromise<string>}
+ */
+export function GetNtfyChatTopic() {
+    return $Call.ByID(3733537608);
+}
+
+/**
+ * GetNtfyNicknames 返回已记忆的昵称列表(用于前端下拉建议)
+ * @returns {$CancellablePromise<string[]>}
+ */
+export function GetNtfyNicknames() {
+    return $Call.ByID(3922015524).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType0($result);
+    }));
+}
+
+/**
+ * GetNtfyTopics 返回已记忆的主题列表(用于前端下拉建议)
+ * @returns {$CancellablePromise<string[]>}
+ */
+export function GetNtfyTopics() {
+    return $Call.ByID(1599033699).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType0($result);
+    }));
+}
+
+/**
+ * GetPCStatus 返回电源控制命令状态
+ * @returns {$CancellablePromise<$models.PCStatus>}
+ */
+export function GetPCStatus() {
+    return $Call.ByID(4166939475).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType11($result);
+    }));
+}
+
+/**
+ * GetRandomPoem 返回一首随机古诗(供主窗口前端加载 / 手动刷新调用)
+ * @returns {$CancellablePromise<$models.PoemData>}
+ */
+export function GetRandomPoem() {
+    return $Call.ByID(3860766604).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType12($result);
+    }));
+}
+
+/**
+ * GetSettings 返回当前配置(读取配置文件,不存在则返回默认值)
+ * @returns {$CancellablePromise<$models.AppSettings>}
+ */
+export function GetSettings() {
+    return $Call.ByID(3034808949).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType13($result);
+    }));
+}
+
+/**
+ * GetStocks 按已保存的股票代码拉取最新行情
+ * @returns {$CancellablePromise<$models.StockQuote[]>}
+ */
+export function GetStocks() {
+    return $Call.ByID(3187718815).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType15($result);
+    }));
+}
+
+/**
+ * GetTrendingRepos 抓取 GitHub Trending 页的热门仓库列表(英文简介已自动翻译为中文)
+ * @returns {$CancellablePromise<$models.TrendingRepo[]>}
+ */
+export function GetTrendingRepos() {
+    return $Call.ByID(4191072422).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType17($result);
+    }));
+}
+
+/**
+ * MarkGithubRepoChecked 标记某仓库已检视:把本地版本更新为最新 release 版本,消除「发现新版本」提示
+ * @param {string} url
+ * @param {string} latest
+ * @returns {$CancellablePromise<void>}
+ */
+export function MarkGithubRepoChecked(url, latest) {
+    return $Call.ByID(1378316359, url, latest);
+}
+
+/**
+ * PickWebDir 弹出目录选择对话框,返回选中的目录(取消返回空串)。
+ * @returns {$CancellablePromise<string>}
+ */
+export function PickWebDir() {
+    return $Call.ByID(1493546416);
+}
+
+/**
+ * ReadRecord 读取记录文件内容;文件不存在时返回空内容
+ * @returns {$CancellablePromise<string>}
+ */
+export function ReadRecord() {
+    return $Call.ByID(92427381);
+}
+
+/**
+ * ReadTodo 读取待办文件内容(每行一个任务);文件不存在时返回空
+ * @returns {$CancellablePromise<string>}
+ */
+export function ReadTodo() {
+    return $Call.ByID(1630526072);
+}
+
+/**
+ * RenderMarkdown 把 Markdown 文本渲染为安全 HTML 片段(用于「记录」页预览)。
+ * 使用 gomarkdown 渲染,并启用 CommonMark 扩展(表格、任务列表、围栏代码等)。
+ * @param {string} md
+ * @returns {$CancellablePromise<string>}
+ */
+export function RenderMarkdown(md) {
+    return $Call.ByID(3516797141, md);
+}
+
+/**
+ * SaveSettings 保存配置并立即生效:热键、窗口置顶、开机启动、页面地址、股票代码等
+ * @param {$models.AppSettings} settings
+ * @returns {$CancellablePromise<$models.AppSettings>}
+ */
+export function SaveSettings(settings) {
+    return $Call.ByID(2662658340, settings).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType13($result);
+    }));
+}
+
+/**
+ * SaveTickerEnabled 保存滚动条开关状态(新闻条 / 股票条右侧「关闭」按钮调用)
+ * @param {string} kind
+ * @param {boolean} enabled
+ * @returns {$CancellablePromise<void>}
+ */
+export function SaveTickerEnabled(kind, enabled) {
+    return $Call.ByID(3033168348, kind, enabled);
+}
+
+/**
+ * SaveTickerPos 保存滚动条窗口拖动后的位置(只更新配置,不重建窗口)
+ * @param {string} kind
+ * @param {number} x
+ * @param {number} y
+ * @returns {$CancellablePromise<void>}
+ */
+export function SaveTickerPos(kind, x, y) {
+    return $Call.ByID(371275047, kind, x, y);
+}
+
+/**
+ * SendNtfyChat 向聊天室(主题)发送一条消息,nickname 作为标题发送。
+ * 与 SendNtfyMessage 的区别:昵称固定作为标题,便于聊天室按昵称区分发言者。
+ * @param {string} topic
+ * @param {string} nickname
+ * @param {string} message
+ * @returns {$CancellablePromise<void>}
+ */
+export function SendNtfyChat(topic, nickname, message) {
+    return $Call.ByID(2994892543, topic, nickname, message);
+}
+
+/**
+ * SendNtfyMessage 向指定主题推送一条 ntfy 消息。
+ * topic 为推送主题(频道名),title 可为空(使用主题名),message 为消息内容。
+ * @param {string} topic
+ * @param {string} title
+ * @param {string} message
+ * @returns {$CancellablePromise<void>}
+ */
+export function SendNtfyMessage(topic, title, message) {
+    return $Call.ByID(1563997112, topic, title, message);
+}
+
+/**
+ * SetMobileMode 前端在窗口尺寸变化时调用:移动端(窄屏)隐藏主机 Tab 并禁止滚动条
+ * @param {boolean} mobile
+ * @returns {$CancellablePromise<void>}
+ */
+export function SetMobileMode(mobile) {
+    return $Call.ByID(2881494037, mobile);
+}
+
+/**
+ * SetNtfyChatTopic 设置聊天室要监听的主题。
+ * 传入空字符串则停止监听(无主题不监听)。
+ * 若与当前监听主题相同则不做任何事;不同则先停止旧监听再启动新监听。
+ * @param {string} topic
+ * @returns {$CancellablePromise<void>}
+ */
+export function SetNtfyChatTopic(topic) {
+    return $Call.ByID(1718497116, topic);
+}
+
+/**
+ * ShowWindow 供前端主动呼出窗口(例如通知点击后回到主界面)
+ * @returns {$CancellablePromise<void>}
+ */
+export function ShowWindow() {
+    return $Call.ByID(3712560909);
+}
+
+/**
+ * StartAPIService 启动 / 重启内置 API 服务(服务页手动启动)
+ * @returns {$CancellablePromise<void>}
+ */
+export function StartAPIService() {
+    return $Call.ByID(3942830483);
+}
+
+/**
+ * StartWebService 启动静态文件服务(默认页 index.html 或 readme.md)。
+ * @param {string} dir
+ * @returns {$CancellablePromise<$models.WebServiceInfo>}
+ */
+export function StartWebService(dir) {
+    return $Call.ByID(3007036157, dir).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType18($result);
+    }));
+}
+
+/**
+ * StopAPIService 停止内置 API 服务(服务页手动停止)
+ * @returns {$CancellablePromise<void>}
+ */
+export function StopAPIService() {
+    return $Call.ByID(1470228927);
+}
+
+/**
+ * StopWebService 停止静态文件服务。
+ * @returns {$CancellablePromise<void>}
+ */
+export function StopWebService() {
+    return $Call.ByID(1864477289);
+}
+
+/**
+ * Translate 翻译文本:输入中文翻译为英文,输入英文翻译为中文(自动检测方向)。
+ * 使用 MyMemory 免费 API,有每日配额限制。
+ * @param {string} text
+ * @returns {$CancellablePromise<$models.TranslateResult>}
+ */
+export function Translate(text) {
+    return $Call.ByID(3284480866, text).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType19($result);
+    }));
+}
+
+/**
+ * WebServiceStatus 返回静态文件服务当前状态。
+ * @returns {$CancellablePromise<$models.WebServiceInfo>}
+ */
+export function WebServiceStatus() {
+    return $Call.ByID(1311352355).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType18($result);
+    }));
+}
+
+/**
+ * WriteRecord 把记录内容写回记录文件(目录不存在时自动创建)
+ * @param {string} content
+ * @returns {$CancellablePromise<void>}
+ */
+export function WriteRecord(content) {
+    return $Call.ByID(2400471626, content);
+}
+
+/**
+ * WriteTodo 把待办内容写回待办文件(目录不存在时自动创建)
+ * @param {string} content
+ * @returns {$CancellablePromise<void>}
+ */
+export function WriteTodo(content) {
+    return $Call.ByID(3129393963, content);
+}
+
+// Private type creation functions
+const $$createType0 = $Create.Array($Create.Any);
+const $$createType1 = $models.APIStatus.createFrom;
+const $$createType2 = $models.BiliDynamicItem.createFrom;
+const $$createType3 = $Create.Array($$createType2);
+const $$createType4 = $models.RepoFeed.createFrom;
+const $$createType5 = $Create.Array($$createType4);
+const $$createType6 = $models.KLineBar.createFrom;
+const $$createType7 = $Create.Array($$createType6);
+const $$createType8 = $models.MinuteChart.createFrom;
+const $$createType9 = $models.NewsItem.createFrom;
+const $$createType10 = $Create.Array($$createType9);
+const $$createType11 = $models.PCStatus.createFrom;
+const $$createType12 = $models.PoemData.createFrom;
+const $$createType13 = $models.AppSettings.createFrom;
+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;

+ 28 - 0
frontend/bindings/events/index.js

@@ -0,0 +1,28 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/// <reference path="../github.com/wailsapp/wails/v3/internal/eventdata.d.ts" />
+
+// Make sure event data creation code is registered with the runtime.
+import "../github.com/wailsapp/wails/v3/internal/eventcreate.js";
+
+/**
+ * Name of the custom event "apilog".
+ */
+export const Apilog = "apilog";
+
+/**
+ * Name of the custom event "ntfyChat".
+ */
+export const NtfyChat = "ntfyChat";
+
+/**
+ * Name of the custom event "time".
+ */
+export const Time = "time";
+
+/**
+ * Name of the custom event "todoReminder".
+ */
+export const TodoReminder = "todoReminder";
+

+ 14 - 0
frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js

@@ -0,0 +1,14 @@
+//@ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+function configure() {
+    Object.freeze(Object.assign($Create.Events, {
+    }));
+}
+
+configure();

+ 17 - 0
frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts

@@ -0,0 +1,17 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import type { Events } from "@wailsio/runtime";
+
+declare module "@wailsio/runtime" {
+    namespace Events {
+        interface CustomEvents {
+            "apilog": string;
+            "ntfyChat": string;
+            "time": string;
+            "todoReminder": string;
+        }
+    }
+}

+ 67 - 0
frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/dockservice.js

@@ -0,0 +1,67 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * Service represents the dock service
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * GetBadge returns the badge label on the application icon.
+ * @returns {$CancellablePromise<string | null>}
+ */
+export function GetBadge() {
+    return $Call.ByID(1150236961);
+}
+
+/**
+ * HideAppIcon hides the app icon in the dock/taskbar.
+ * @returns {$CancellablePromise<void>}
+ */
+export function HideAppIcon() {
+    return $Call.ByID(3413658144);
+}
+
+/**
+ * RemoveBadge removes the badge label from the application icon.
+ * @returns {$CancellablePromise<void>}
+ */
+export function RemoveBadge() {
+    return $Call.ByID(2752757297);
+}
+
+/**
+ * SetBadge sets the badge label on the application icon.
+ * @param {string} label
+ * @returns {$CancellablePromise<void>}
+ */
+export function SetBadge(label) {
+    return $Call.ByID(1717705661, label);
+}
+
+/**
+ * SetCustomBadge sets the badge label on the application icon with custom options.
+ * @param {string} label
+ * @param {$models.BadgeOptions} options
+ * @returns {$CancellablePromise<void>}
+ */
+export function SetCustomBadge(label, options) {
+    return $Call.ByID(2730169760, label, options);
+}
+
+/**
+ * ShowAppIcon shows the app icon in the dock/taskbar.
+ * @returns {$CancellablePromise<void>}
+ */
+export function ShowAppIcon() {
+    return $Call.ByID(3409697379);
+}

+ 12 - 0
frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/index.js

@@ -0,0 +1,12 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+import * as DockService from "./dockservice.js";
+export {
+    DockService
+};
+
+export {
+    BadgeOptions
+} from "./models.js";

+ 81 - 0
frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/dock/models.js

@@ -0,0 +1,81 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as color$0 from "../../../../../../../image/color/models.js";
+
+/**
+ * BadgeOptions represents options for customizing badge appearance
+ */
+export class BadgeOptions {
+    /**
+     * Creates a new BadgeOptions instance.
+     * @param {Partial<BadgeOptions>} [$$source = {}] - The source object to create the BadgeOptions.
+     */
+    constructor($$source = {}) {
+        if (!("TextColour" in $$source)) {
+            /**
+             * @member
+             * @type {color$0.RGBA}
+             */
+            this["TextColour"] = (new color$0.RGBA());
+        }
+        if (!("BackgroundColour" in $$source)) {
+            /**
+             * @member
+             * @type {color$0.RGBA}
+             */
+            this["BackgroundColour"] = (new color$0.RGBA());
+        }
+        if (!("FontName" in $$source)) {
+            /**
+             * @member
+             * @type {string}
+             */
+            this["FontName"] = "";
+        }
+        if (!("FontSize" in $$source)) {
+            /**
+             * @member
+             * @type {number}
+             */
+            this["FontSize"] = 0;
+        }
+        if (!("SmallFontSize" in $$source)) {
+            /**
+             * @member
+             * @type {number}
+             */
+            this["SmallFontSize"] = 0;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new BadgeOptions instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {BadgeOptions}
+     */
+    static createFrom($$source = {}) {
+        const $$createField0_0 = $$createType0;
+        const $$createField1_0 = $$createType0;
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        if ("TextColour" in $$parsedSource) {
+            $$parsedSource["TextColour"] = $$createField0_0($$parsedSource["TextColour"]);
+        }
+        if ("BackgroundColour" in $$parsedSource) {
+            $$parsedSource["BackgroundColour"] = $$createField1_0($$parsedSource["BackgroundColour"]);
+        }
+        return new BadgeOptions(/** @type {Partial<BadgeOptions>} */($$parsedSource));
+    }
+}
+
+// Private type creation functions
+const $$createType0 = color$0.RGBA.createFrom;

+ 7 - 0
frontend/bindings/image/color/index.js

@@ -0,0 +1,7 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export {
+    RGBA
+} from "./models.js";

+ 63 - 0
frontend/bindings/image/color/models.js

@@ -0,0 +1,63 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+/**
+ * RGBA represents a traditional 32-bit alpha-premultiplied color, having 8
+ * bits for each of red, green, blue and alpha.
+ * 
+ * An alpha-premultiplied color component C has been scaled by alpha (A), so
+ * has valid values 0 <= C <= A.
+ */
+export class RGBA {
+    /**
+     * Creates a new RGBA instance.
+     * @param {Partial<RGBA>} [$$source = {}] - The source object to create the RGBA.
+     */
+    constructor($$source = {}) {
+        if (!("R" in $$source)) {
+            /**
+             * @member
+             * @type {number}
+             */
+            this["R"] = 0;
+        }
+        if (!("G" in $$source)) {
+            /**
+             * @member
+             * @type {number}
+             */
+            this["G"] = 0;
+        }
+        if (!("B" in $$source)) {
+            /**
+             * @member
+             * @type {number}
+             */
+            this["B"] = 0;
+        }
+        if (!("A" in $$source)) {
+            /**
+             * @member
+             * @type {number}
+             */
+            this["A"] = 0;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new RGBA instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {RGBA}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new RGBA(/** @type {Partial<RGBA>} */($$parsedSource));
+    }
+}

+ 740 - 0
frontend/index.html

@@ -0,0 +1,740 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8"/>
+    <link rel="icon" type="image/svg+xml" href="/wails.png"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
+    <link rel="stylesheet" href="/style.css"/>
+    <title>腾图工具箱</title>
+    <!-- 首屏前置应用主题,避免暗色/亮色切换时闪烁 -->
+    <script>document.documentElement.dataset.theme = localStorage.getItem('tengtu-theme') || 'dark';</script>
+</head>
+<body data-file-drop-target>
+
+    <div class="tab-container">
+        <!-- 标签栏(兼作标题栏):左侧 tab,右侧窗口关闭按钮 -->
+        <div class="tab-buttons">
+            <button class="tab-btn active" data-tab="notes">笔记</button>
+            <button class="tab-btn" data-tab="control">控制</button>
+            <button class="tab-btn" data-tab="blog">博客</button>
+            <button class="tab-btn" data-tab="links">网址</button>
+            <button class="tab-btn" data-tab="news">新闻</button>
+            <button class="tab-btn" data-tab="github">Github</button>
+            <button class="tab-btn" data-tab="stock">股市</button>
+            <button class="tab-btn" data-tab="record">记录</button>
+            <button class="tab-btn" data-tab="todo">待办</button>
+            <button class="tab-btn" data-tab="hardware">硬件</button>
+            <button class="tab-btn" data-tab="tools">工具</button>
+            <button class="tab-btn" data-tab="host">服务</button>
+            <button class="tab-btn" data-tab="chat">聊天</button>
+            <button class="tab-btn" data-tab="settings">设置</button>
+            <div class="win-spacer"></div>
+            <button class="win-close" id="win-close" title="关闭到托盘">×</button>
+        </div>
+
+        <!-- 内容面板 -->
+        <div class="tab-content">
+            <!-- 笔记:内嵌网页,地址可在设置中修改 -->
+            <div class="tab-panel panel-embed active" id="tab-notes">
+                <iframe id="frame-notes" src="https://m.scwy.net" title="笔记" loading="lazy"></iframe>
+            </div>
+
+            <!-- 控制:内嵌网页,地址可在设置中修改 -->
+            <div class="tab-panel panel-embed" id="tab-control">
+                <iframe id="frame-control" src="https://a7.scwy.net/4191" title="控制" loading="lazy"></iframe>
+            </div>
+
+            <!-- 博客:内嵌网页,地址可在设置中修改 -->
+            <div class="tab-panel panel-embed" id="tab-blog">
+                <iframe id="frame-blog" src="https://i.scwy.net" title="博客" loading="lazy"></iframe>
+            </div>
+
+            <!-- 常用网址:设置中配置,点击卡片在浏览器打开 -->
+            <div class="tab-panel" id="tab-links">
+                <div class="card-grid" id="links-grid"></div>
+            </div>
+
+            <!-- 新闻:煎蛋 + 头条热榜,命中关键词的排在前面并高亮 -->
+            <div class="tab-panel" id="tab-news">
+                <div class="panel-head">
+                    <div>
+                        <h2>新闻</h2>
+                    </div>
+                    <div class="panel-head-actions">
+                        <span class="github-refresh-time" id="news-refresh-time" title="最后一次成功刷新的时间"></span>
+                        <button class="btn-ghost" id="news-read" title="朗读未点击过的新闻标题">阅读</button>
+                        <button class="btn-ghost" id="news-refresh">刷新</button>
+                    </div>
+                </div>
+                <div class="news-list" id="news-list">
+                    <div class="empty-state">
+                        <div class="empty-mark">◈</div>
+                        <div class="empty-title">正在获取新闻…</div>
+                        <div class="empty-text">首次加载需要几秒</div>
+                    </div>
+                </div>
+            </div>
+
+            <!-- Github:监听设置中仓库的最新提交 -->
+            <div class="tab-panel" id="tab-github">
+                <div class="panel-head">
+                    <div class="panel-head-actions">
+                        <span class="github-refresh-time" id="github-refresh-time" title="最后一次成功刷新的时间"></span>
+                        <button class="btn-ghost" id="github-refresh">刷新</button>
+                    </div>
+                </div>
+                <div class="repo-list" id="repo-list">
+                    <div class="empty-state">
+                        <div class="empty-mark">◈</div>
+                        <div class="empty-title">正在获取仓库动态…</div>
+                        <div class="empty-text">通过 GitHub API 拉取提交与最新版本</div>
+                    </div>
+                </div>
+                <div class="trending-block">
+                    <div class="trending-head">
+                        <h3 class="trending-title">热门仓库 <span class="trending-sub">GitHub Trending</span></h3>
+                        <span class="github-refresh-time" id="trending-refresh-time" title="最后一次成功刷新的时间"></span>
+                        <button class="btn-ghost" id="trending-refresh">刷新</button>
+                    </div>
+                    <div class="trending-list" id="trending-list">
+                        <div class="empty-state">
+                            <div class="empty-mark">◈</div>
+                            <div class="empty-title">正在获取热门仓库…</div>
+                            <div class="empty-text">抓取 github.com/trending,英文简介自动翻译为中文</div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <!-- 股市:按设置的代码展示行情 -->
+            <div class="tab-panel" id="tab-stock">
+                <div class="panel-head">
+                    <div class="panel-head-actions">
+                        <span class="market-status" id="market-status"><span class="dot"></span>加载中…</span>
+                        <span class="github-refresh-time" id="stock-refresh-time" title="最后一次成功刷新的时间"></span>
+                        <button class="btn-ghost" id="stock-refresh">刷新</button>
+                    </div>
+                </div>
+                <div class="stock-list" id="stock-list">
+                    <div class="empty-state">
+                        <div class="empty-mark">◈</div>
+                        <div class="empty-title">正在获取行情…</div>
+                        <div class="empty-text">首次加载需要几秒</div>
+                    </div>
+                </div>
+            </div>
+
+            <!-- 记录:支持 Markdown 源码编辑与实时预览切换 -->
+            <div class="tab-panel panel-record" id="tab-record">
+                <div class="panel-head">
+                    <div>
+                        <div class="record-path-desc" id="record-path-desc"></div>
+                    </div>
+                    <div class="record-head-actions">
+                        <div class="record-mode" id="record-mode" role="group">
+                            <button class="record-mode-btn is-active" id="record-mode-edit" type="button">编辑</button>
+                            <button class="record-mode-btn" id="record-mode-preview" type="button">预览</button>
+                        </div>
+                        <button class="btn-ghost" id="record-save">保存</button>
+                    </div>
+                </div>
+                <textarea class="record-editor" id="record-editor" placeholder="在这里记录零碎信息…(支持 Markdown,可切换预览查看效果)" spellcheck="false"></textarea>
+                <div class="record-preview markdown-body" id="record-preview"></div>
+                <div class="record-status" id="record-status"></div>
+            </div>
+
+            <!-- 待办:每行记录一个待完成任务,可勾选完成 / 编辑 / 删除 -->
+            <div class="tab-panel panel-todo" id="tab-todo">
+                <div class="panel-head">
+                    <div class="todo-path-desc" id="todo-path-desc"></div>
+                    <button class="btn-ghost" id="todo-save">保存</button>
+                </div>
+                <div class="todo-list" id="todo-list"></div>
+                <div class="todo-actions">
+                    <button class="btn-ghost" id="todo-add">添加任务</button>
+                </div>
+                <div class="todo-status" id="todo-status"></div>
+            </div>
+
+            <!-- 硬件:内嵌硬件监控页,地址可在设置中修改 -->
+            <div class="tab-panel panel-embed" id="tab-hardware">
+                <iframe id="frame-hardware" src="http://192.168.100.190/" title="硬件" loading="lazy"></iframe>
+            </div>
+
+            <!-- 工具:本地开发/运维辅助工具集 -->
+            <div class="tab-panel" id="tab-tools">
+                <!-- 工具 1:Web 服务——选择目录,开启本地浏览服务 -->
+                <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>Web 服务</span>
+                        <span class="tool-status" id="websvc-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">选择任意目录,在本机开启浏览服务,默认页为 <code>index.html</code> 或 <code>readme.md</code>。</p>
+                        <div class="tool-row">
+                            <input class="input tool-dir" id="websvc-dir" type="text" placeholder="未选择目录" readonly>
+                            <button class="btn-ghost" id="websvc-pick">选择目录</button>
+                        </div>
+                        <div class="tool-row tool-actions">
+                            <button class="btn-primary" id="websvc-start">开启浏览服务</button>
+                            <button class="btn-ghost" id="websvc-stop" disabled>停止服务</button>
+                            <span class="tool-hint" id="websvc-hint"></span>
+                        </div>
+                        <div class="websvc-preview" id="websvc-preview" hidden>
+                            <iframe id="websvc-frame" title="Web 服务预览"></iframe>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- 工具 2:翻译——中文→英文 / 英文→中文,自动检测 -->
+                <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="tr-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">输入中文自动翻译为英文,输入英文自动翻译为中文。免费接口有每日配额,超额请稍后再试。</p>
+                        <textarea class="input tr-input" id="tr-source" rows="3" placeholder="输入要翻译的中文或英文……"></textarea>
+                        <div class="tool-row tool-actions">
+                            <button class="btn-primary" id="tr-go">翻译</button>
+                            <span class="tool-hint" id="tr-hint"></span>
+                        </div>
+                        <div class="tr-result" id="tr-result" hidden></div>
+                    </div>
+                </div>
+
+                <!-- 工具 3:二维码生成——输入字符串生成二维码图片 -->
+                <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="qr-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">输入任意字符串,即时生成二维码图片。可用于网址、文本、联系方式等。</p>
+                        <textarea class="input tr-input" id="qr-input" rows="3" placeholder="输入要生成二维码的内容……"></textarea>
+                        <div class="tool-row tool-actions">
+                            <button class="btn-primary" id="qr-go">生成二维码</button>
+                            <span class="tool-hint" id="qr-hint"></span>
+                        </div>
+                        <div class="qr-box" id="qr-box" hidden>
+                            <img id="qr-img" alt="二维码" title="点击下载 PNG">
+                            <button class="btn-ghost" id="qr-save">下载 PNG</button>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- 工具 4:发送 ntfy 消息——输入主题 / 标题 / 内容,推送到 ntfy 频道 -->
+                <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>发送 ntfy 消息</span>
+                        <span class="tool-status" id="ntfy-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">向 ntfy 服务推送通知,用于手机 / 浏览器 / 其他设备即时提醒。主题(Topic)为监听频道名,输入过的主题会自动记忆。</p>
+                        <div class="tool-row">
+                            <input class="input" id="ntfy-topic" type="text" placeholder="主题(Topic),如 my-alerts" list="ntfy-topic-list" autocomplete="off">
+                            <datalist id="ntfy-topic-list"></datalist>
+                        </div>
+                        <input class="input" id="ntfy-title" type="text" placeholder="标题(可留空)">
+                        <textarea class="input tr-input" id="ntfy-message" rows="3" placeholder="输入要发送的消息内容……"></textarea>
+                        <div class="tool-row tool-actions">
+                            <button class="btn-primary" id="ntfy-go">发送</button>
+                            <span class="tool-hint" id="ntfy-hint"></span>
+                        </div>
+                    </div>
+                </div>
+                <!-- 工具 5:ntfy 聊天室——输入昵称与主题,订阅并实时收发消息(QQ 气泡风格) -->
+                <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>ntfy 聊天室</span>
+                        <span class="tool-status" id="chat-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">输入昵称(作为发送方标识,记忆上次使用)与聊天室名称(ntfy 主题)后开始监听,像 QQ 一样左右气泡收发消息。</p>
+                        <div class="tool-row">
+                            <input class="input" id="chat-nickname" type="text" placeholder="昵称(作为发送方标题)" list="ntfy-nickname-list" autocomplete="off">
+                            <input class="input" id="chat-topic" type="text" placeholder="聊天室名称(ntfy 主题),留空则不监听" list="ntfy-topic-list" autocomplete="off">
+                            <button class="btn-ghost" id="chat-toggle">开始监听</button>
+                        </div>
+                        <div class="chat-box" id="chat-box" hidden>
+                            <div class="chat-list" id="chat-list"></div>
+                            <div class="chat-input-row">
+                                <input class="input" id="chat-message" type="text" placeholder="输入消息,回车发送" autocomplete="off">
+                                <button class="btn-ghost" id="chat-send">发送</button>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                <!-- 工具 6:古诗词——显示 footer 最近获取的古诗,可刷新获取新诗 -->
+                <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="poem-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">显示主窗口 footer 最近获取到的古诗词内容,可手动刷新获取新的一首。数据来自古诗词网随机接口。</p>
+                        <div class="tool-row tool-actions">
+                            <button class="btn-primary" id="poem-tool-refresh">刷新获取</button>
+                            <button class="btn-ghost" id="poem-tool-read">朗读</button>
+                            <span class="tool-hint" id="poem-tool-hint"></span>
+                        </div>
+                        <div class="poem-tool" id="poem-tool-box" hidden>
+                            <div class="poem-tool-title" id="poem-tool-title"></div>
+                            <div class="poem-tool-author" id="poem-tool-author"></div>
+                            <div class="poem-tool-content" id="poem-tool-content"></div>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- 工具 7:屏幕截图——隐藏本程序后截取全屏,完成后重新显示 -->
+                <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="shot-status"></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <p class="tool-desc">点击截屏后自动隐藏本程序窗口,截取整个屏幕(全部显示器),完成后重新显示并预览。截到的画面不含本程序自身。</p>
+                        <div class="tool-row tool-actions">
+                            <button class="btn-primary" id="shot-capture">截屏</button>
+                            <span class="tool-hint" id="shot-hint"></span>
+                        </div>
+                        <div class="screenshot-box" id="screenshot-box" hidden>
+                            <div class="screenshot-head">
+                                <span class="card-title"><span class="dot"></span>屏幕截图</span>
+                                <span class="screenshot-hint" id="screenshot-hint"></span>
+                            </div>
+                            <img id="screenshot-img" alt="屏幕截图预览">
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="tab-panel" id="tab-host">
+                <div class="host-cards">
+                    <div class="card">
+                        <div class="card-title-row">
+                            <span class="card-title"><span class="dot"></span>API 服务</span>
+                            <button class="btn-ghost" id="api-toggle">停止服务</button>
+                        </div>
+                        <p id="api-status">检查中…</p>
+                    </div>
+                    <div class="card">
+                        <div class="card-title"><span class="dot"></span>电源命令</div>
+                        <p id="pc-status">暂无请求</p>
+                    </div>
+                </div>
+
+                <h3 class="section-title">提供的功能</h3>
+                <ul class="api-list" id="api-list">
+                    <li><code>GET /health</code>健康检查</li>
+                    <li><code>POST /api/notify</code>弹出系统通知</li>
+                    <li><code>POST /api/window/show</code>显示主窗口</li>
+                    <li><code>GET /api/settings</code>读取配置</li>
+                    <li><code>POST /api/settings</code>保存配置</li>
+                    <li><code>GET /api/stocks</code>股票行情(?codes= 可临时指定)</li>
+                    <li><code>GET /api/screenshot</code>屏幕截图(?display= 指定显示器)</li>
+                    <li><code>GET /api/pc/poweroff</code>30 秒后关机</li>
+                    <li><code>GET /api/pc/restart</code>30 秒后重启</li>
+                    <li><code>GET /api/pc/sleep</code>30 秒后休眠</li>
+                    <li><code>GET /api/pc/lock</code>立即锁屏</li>
+                    <li><code>GET /api/pc/cancel</code>取消待执行命令</li>
+                    <li><code>GET /api/pc/status</code>查询命令状态</li>
+                    <li><code>GET /api/pc/timesync</code>同步系统时间</li>
+                </ul>
+
+                <div class="section-title section-title-row">
+                    <span>Gin 日志</span>
+                    <button class="btn-ghost" id="log-clear">清空日志</button>
+                </div>
+                <pre class="log-view" id="log-view">暂无日志</pre>
+            </div>
+
+            <!-- 聊天:内嵌网页,地址可在设置中修改 -->
+            <div class="tab-panel panel-embed" id="tab-chat">
+                <iframe id="frame-chat" src="https://c.scwy.net" title="聊天" loading="lazy"></iframe>
+            </div>
+
+            <!-- 设置 -->
+            <div class="tab-panel" id="tab-settings">
+                <h2>设置</h2>
+
+                <div class="setting-list">
+                    <!-- 界面样式:暗色 / 亮色 -->
+                    <div class="setting-row">
+                        <div class="setting-info">
+                            <div class="setting-title">界面样式</div>
+                            <div class="setting-hint">在暗色与亮色主题之间切换</div>
+                        </div>
+                        <div class="segmented" id="theme-seg">
+                            <button class="seg-btn active" data-theme-value="dark">暗色</button>
+                            <button class="seg-btn" data-theme-value="light">亮色</button>
+                        </div>
+                    </div>
+
+                    <!-- 界面字号:全局 +- 调整 -->
+                    <div class="setting-row">
+                        <div class="setting-info">
+                            <div class="setting-title">界面字号</div>
+                            <div class="setting-hint">统一调整全部文字大小,范围 +-3</div>
+                        </div>
+                        <div class="stepper">
+                            <button class="step-btn" id="font-minus" title="减小字号">−</button>
+                            <span class="step-value" id="font-delta-value">0</span>
+                            <button class="step-btn" id="font-plus" title="增大字号">+</button>
+                        </div>
+                    </div>
+
+                    <!-- 系统启动时运行:默认勾选 -->
+                    <div class="setting-row">
+                        <div class="setting-info">
+                            <div class="setting-title">系统启动时运行</div>
+                            <div class="setting-hint">开机后自动启动腾图工具箱(默认开启)</div>
+                        </div>
+                        <label class="switch">
+                            <input type="checkbox" id="autostart" checked>
+                            <span class="switch-track"></span>
+                        </label>
+                    </div>
+
+                    <!-- 窗口置顶 -->
+                    <div class="setting-row">
+                        <div class="setting-info">
+                            <div class="setting-title">窗口置顶</div>
+                            <div class="setting-hint">主窗口始终显示在其他窗口之上</div>
+                        </div>
+                        <label class="switch">
+                            <input type="checkbox" id="always-on-top">
+                            <span class="switch-track"></span>
+                        </label>
+                    </div>
+
+                    <!-- 呼出窗口热键 -->
+                    <div class="setting-row">
+                        <div class="setting-info">
+                            <div class="setting-title">呼出窗口热键</div>
+                            <div class="setting-hint">点击输入框后按下组合键,例如 Ctrl+Shift+G</div>
+                        </div>
+                        <input class="input hotkey-input" id="hotkey" readonly placeholder="按下组合键">
+                    </div>
+
+                    <!-- 笔记页地址 -->
+                    <div class="setting-row" data-tab-group="notes">
+                        <div class="setting-info">
+                            <div class="setting-title">笔记页地址</div>
+                            <div class="setting-hint">「笔记」标签内嵌的网页</div>
+                        </div>
+                        <input class="input" id="notes-url" type="text" placeholder="https://m.scwy.net">
+                    </div>
+
+                    <!-- 控制页地址 -->
+                    <div class="setting-row" data-tab-group="control">
+                        <div class="setting-info">
+                            <div class="setting-title">控制页地址</div>
+                            <div class="setting-hint">「控制」标签内嵌的网页</div>
+                        </div>
+                        <input class="input" id="control-url" type="text" placeholder="https://a7.scwy.net/4191">
+                    </div>
+
+                    <!-- 博客页地址 -->
+                    <div class="setting-row" data-tab-group="blog">
+                        <div class="setting-info">
+                            <div class="setting-title">博客页地址</div>
+                            <div class="setting-hint">「博客」标签内嵌的网页</div>
+                        </div>
+                        <input class="input" id="blog-url" type="text" placeholder="https://i.scwy.net">
+                    </div>
+
+                    <!-- 聊天页地址 -->
+                    <div class="setting-row" data-tab-group="chat">
+                        <div class="setting-info">
+                            <div class="setting-title">聊天页地址</div>
+                            <div class="setting-hint">「聊天」标签内嵌的网页</div>
+                        </div>
+                        <input class="input" id="chat-url" type="text" placeholder="https://c.scwy.net">
+                    </div>
+
+                    <!-- Tab 显示顺序 -->
+                    <div class="setting-row" data-tab-group="settings">
+                        <div class="setting-info">
+                            <div class="setting-title">Tab 显示顺序</div>
+                            <div class="setting-hint">用逗号分隔 Tab 名称,从左到右依次显示;未列出的追加到末尾</div>
+                        </div>
+                        <input class="input" id="tab-order" type="text" placeholder="笔记, 控制, 博客, 聊天, 网址, 新闻">
+                    </div>
+
+                    <!-- 自定义 Tab 页面 -->
+                    <div class="setting-row setting-row-col" data-tab-group="settings">
+                        <div class="setting-info">
+                            <div class="setting-title">自定义 Tab 页面</div>
+                            <div class="setting-hint">每行一条,格式:页面名称|网址;保存后新增 Tab,名称也用于 Tab 显示顺序</div>
+                        </div>
+                        <textarea class="input textarea" id="custom-tabs" rows="3" placeholder="示例|https://example.com"></textarea>
+                    </div>
+
+                    <!-- 硬件页地址 -->
+                    <div class="setting-row" data-tab-group="hardware">
+                        <div class="setting-info">
+                            <div class="setting-title">硬件页地址</div>
+                            <div class="setting-hint">「硬件」标签内嵌的网页</div>
+                        </div>
+                        <input class="input" id="hardware-url" type="text" placeholder="http://192.168.100.190/">
+                    </div>
+
+                    <!-- 股市代码 -->
+                    <div class="setting-row" data-tab-group="stock">
+                        <div class="setting-info">
+                            <div class="setting-title">股市代码</div>
+                            <div class="setting-hint">多个代码用逗号分隔,纯数字会自动补市场前缀</div>
+                        </div>
+                        <input class="input" id="stock-codes" type="text" placeholder="600519, 000001">
+                    </div>
+
+                    <!-- 涨跌颜色 -->
+                    <div class="setting-row" data-tab-group="stock">
+                        <div class="setting-info">
+                            <div class="setting-title">涨跌颜色</div>
+                            <div class="setting-hint">上涨 / 下跌在列表、图表与滚动条中的显示颜色</div>
+                        </div>
+                        <div class="ticker-controls">
+                            <span class="num-unit">涨</span>
+                            <input class="input-color" id="stock-up-color" type="color" title="上涨颜色">
+                            <span class="num-unit">跌</span>
+                            <input class="input-color" id="stock-down-color" type="color" title="下跌颜色">
+                        </div>
+                    </div>
+
+                    <!-- 股市刷新间隔 -->
+                    <div class="setting-row" data-tab-group="stock">
+                        <div class="setting-info">
+                            <div class="setting-title">股市刷新间隔</div>
+                            <div class="setting-hint">自动刷新行情的间隔,默认 30 秒</div>
+                        </div>
+                        <div class="num-group">
+                            <input class="input input-num" id="stock-refresh-secs" type="number" min="5" max="3600">
+                            <span class="num-unit">秒</span>
+                        </div>
+                    </div>
+
+                    <!-- 休市刷新间隔 -->
+                    <div class="setting-row" data-tab-group="stock">
+                        <div class="setting-info">
+                            <div class="setting-title">休市刷新间隔</div>
+                            <div class="setting-hint">休市时自动刷新行情的间隔,默认 2 小时</div>
+                        </div>
+                        <div class="num-group">
+                            <input class="input input-num" id="stock-closed-refresh-secs" type="number" min="60" max="86400">
+                            <span class="num-unit">秒</span>
+                        </div>
+                    </div>
+
+                    <!-- 常用网址 -->
+                    <div class="setting-row setting-row-col" data-tab-group="links">
+                        <div class="setting-info">
+                            <div class="setting-title">常用网址</div>
+                            <div class="setting-hint">每行一条,格式:名称|网址,例如 煎蛋|https://jandan.net/</div>
+                        </div>
+                        <textarea class="input textarea" id="links-text" rows="4" placeholder="名称|网址,每行一条"></textarea>
+                    </div>
+
+                    <!-- 新闻关键词 -->
+                    <div class="setting-row" data-tab-group="news">
+                        <div class="setting-info">
+                            <div class="setting-title">新闻关键词</div>
+                            <div class="setting-hint">逗号分隔;命中关键词的新闻在「新闻」页置顶高亮</div>
+                        </div>
+                        <input class="input" id="news-keywords" type="text" placeholder="AI, 芯片, 苹果">
+                    </div>
+
+                    <!-- B站登录 cookie -->
+                    <div class="setting-row setting-row-col" data-tab-group="news">
+                        <div class="setting-info">
+                            <div class="setting-title">B站 cookie</div>
+                            <div class="setting-hint">登录 bilibili.com 后从浏览器 Cookie 复制 SESSDATA=xxx(整个 cookie 串)。用于在「新闻」页展示你关注的人的最新动态;为空或失效时回退热门视频。cookie 会过期,需定期更新。</div>
+                        </div>
+                        <input class="input" id="bili-cookie" type="text" placeholder="SESSDATA=xxxxxxxx; bili_jct=xxxx" spellcheck="false">
+                    </div>
+
+                    <!-- 新闻刷新间隔 -->
+                    <div class="setting-row" data-tab-group="news">
+                        <div class="setting-info">
+                            <div class="setting-title">新闻刷新间隔</div>
+                            <div class="setting-hint">自动抓取新闻的间隔,默认 2 小时</div>
+                        </div>
+                        <div class="num-group">
+                            <input class="input input-num" id="news-refresh-mins" type="number" min="5" max="1440">
+                            <span class="num-unit">分钟</span>
+                        </div>
+                    </div>
+
+                    <!-- 新闻显示条数 -->
+                    <div class="setting-row" data-tab-group="news">
+                        <div class="setting-info">
+                            <div class="setting-title">新闻显示条数</div>
+                            <div class="setting-hint">「新闻」页最多显示的条目数,默认 30 条</div>
+                        </div>
+                        <div class="num-group">
+                            <input class="input input-num" id="news-count" type="number" min="5" max="200">
+                            <span class="num-unit">条</span>
+                        </div>
+                    </div>
+
+                    <!-- Github 项目 -->
+                    <div class="setting-row setting-row-col" data-tab-group="github">
+                        <div class="setting-info">
+                            <div class="setting-title">Github 项目</div>
+                            <div class="setting-hint">每行一条,格式:仓库地址|项目说明|本地版本(版本可选,用于提示更新)</div>
+                        </div>
+                        <textarea class="input textarea" id="github-repos" rows="4" placeholder="https://github.com/wailsapp/wails|Wails 框架|3.0.0"></textarea>
+                    </div>
+
+                    <!-- Github 刷新间隔 -->
+                    <div class="setting-row" data-tab-group="github">
+                        <div class="setting-info">
+                            <div class="setting-title">Github 刷新间隔</div>
+                            <div class="setting-hint">自动监听仓库更新的间隔,默认 2 小时</div>
+                        </div>
+                        <div class="num-group">
+                            <input class="input input-num" id="github-refresh-mins" type="number" min="5" max="1440">
+                            <span class="num-unit">分钟</span>
+                        </div>
+                    </div>
+
+                    <!-- API 监听地址 -->
+                    <div class="setting-row" data-tab-group="host">
+                        <div class="setting-info">
+                            <div class="setting-title">API 监听地址</div>
+                            <div class="setting-hint">内置 API 服务监听地址,保存后自动重启服务</div>
+                        </div>
+                        <input class="input" id="api-addr" type="text" placeholder="127.0.0.1:17890">
+                    </div>
+
+                    <!-- 记录文件路径 -->
+                    <div class="setting-row" data-tab-group="record">
+                        <div class="setting-info">
+                            <div class="setting-title">记录文件路径</div>
+                            <div class="setting-hint">「记录」标签编辑的文本文件</div>
+                        </div>
+                        <input class="input" id="record-path" type="text" placeholder="D:\My\腾图\记录.txt">
+                    </div>
+
+                    <!-- 待办文件路径 -->
+                    <div class="setting-row" data-tab-group="todo">
+                        <div class="setting-info">
+                            <div class="setting-title">待办文件路径</div>
+                            <div class="setting-hint">「待办」标签每行一个任务,保存到该文本文件</div>
+                        </div>
+                        <input class="input" id="todo-path" type="text" placeholder="D:\My\腾图\待办.txt">
+                    </div>
+
+                    <!-- 股票滚动条 -->
+                    <div class="setting-row" data-tab-group="stock">
+                        <div class="setting-info">
+                            <div class="setting-title">股票滚动条</div>
+                            <div class="setting-hint">屏幕顶部一行置顶滚动显示实时行情,可拖动位置</div>
+                        </div>
+                        <div class="ticker-controls">
+                            <label class="switch" title="显示滚动条">
+                                <input type="checkbox" id="stock-ticker-enabled">
+                                <span class="switch-track"></span>
+                            </label>
+                            <label class="mini-label" title="背景透明"><input type="checkbox" id="stock-ticker-transparent">透明</label>
+                            <input class="input input-num" id="stock-ticker-width" type="number" min="200" max="6000" title="宽度 px">
+                            <input class="input input-num" id="stock-ticker-size" type="number" min="10" max="40" title="字号 px">
+                            <input class="input-color" id="stock-ticker-color" type="color" title="文字颜色">
+                            <input class="input input-num" id="stock-ticker-speed" type="number" min="20" max="400" title="滚动速度 px/s">
+                        </div>
+                    </div>
+
+                    <!-- 新闻滚动条 -->
+                    <div class="setting-row" data-tab-group="news">
+                        <div class="setting-info">
+                            <div class="setting-title">新闻滚动条</div>
+                            <div class="setting-hint">屏幕顶部一行置顶滚动显示新闻标题,点击标题打开详情</div>
+                        </div>
+                        <div class="ticker-controls">
+                            <label class="switch" title="显示滚动条">
+                                <input type="checkbox" id="news-ticker-enabled">
+                                <span class="switch-track"></span>
+                            </label>
+                            <label class="mini-label" title="背景透明"><input type="checkbox" id="news-ticker-transparent">透明</label>
+                            <input class="input input-num" id="news-ticker-width" type="number" min="200" max="6000" title="宽度 px">
+                            <input class="input input-num" id="news-ticker-size" type="number" min="10" max="40" title="字号 px">
+                            <input class="input-color" id="news-ticker-color" type="color" title="文字颜色">
+                            <input class="input input-num" id="news-ticker-speed" type="number" min="20" max="400" title="滚动速度 px/s">
+                        </div>
+                    </div>
+
+                    <!-- 随机古诗 -->
+                    <div class="setting-row" data-tab-group="poem">
+                        <div class="setting-info">
+                            <div class="setting-title">随机古诗</div>
+                            <div class="setting-hint">footer 版本后面水平滚动显示一首随机古诗,可点击换一首、朗读(TTS)</div>
+                        </div>
+                        <div class="ticker-controls">
+                            <label class="switch" title="显示古诗">
+                                <input type="checkbox" id="poem-enabled">
+                                <span class="switch-track"></span>
+                            </label>
+                            <input class="input-color" id="poem-color" type="color" title="文字颜色">
+                            <input class="input input-num" id="poem-size" type="number" min="12" max="48" title="字号 px">
+                            <label class="mini-label" title="滚动速度(秒/轮),数值越小滚动越快"><input type="number" id="poem-speed" class="input input-num" min="5" max="120" title="滚动速度(秒/轮)">速度</label>
+                            <label class="mini-label" title="显示朗读按钮"><input type="checkbox" id="poem-tts">朗读</label>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- 保存栏 -->
+                <div class="save-bar">
+                    <button class="btn-primary" id="save-settings">保存设置</button>
+                    <span class="save-status" id="save-status"></span>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- 股票图表弹窗:点击股市行打开,可切换分时图 / 日K图 -->
+    <div class="chart-modal" id="chart-modal" hidden>
+        <div class="chart-mask" id="chart-mask"></div>
+        <div class="chart-dialog" role="dialog" aria-modal="true" aria-label="股票图表">
+            <div class="chart-head">
+                <div class="chart-title-wrap">
+                    <div class="chart-title" id="chart-title">--</div>
+                    <div class="chart-sub" id="chart-sub"></div>
+                </div>
+                <div class="segmented" id="chart-kind">
+                    <button class="seg-btn active" data-chart-kind="minute">分时</button>
+                    <button class="seg-btn" data-chart-kind="kline">日K</button>
+                </div>
+                <button class="chart-close" id="chart-close" title="关闭">×</button>
+            </div>
+            <canvas id="chart-canvas"></canvas>
+            <div class="chart-legend" id="chart-legend"></div>
+        </div>
+    </div>
+
+    <!-- 待办提醒弹窗:到点时弹出,置顶显示 -->
+    <div class="remind-modal" id="remind-modal" hidden>
+        <div class="remind-mask" id="remind-mask"></div>
+        <div class="remind-dialog" role="alertdialog" aria-modal="true" aria-label="待办提醒">
+            <div class="remind-icon">⏰</div>
+            <div class="remind-title">待办提醒</div>
+            <div class="remind-text" id="remind-text"></div>
+            <div class="remind-time" id="remind-time"></div>
+            <div class="remind-actions">
+                <button class="btn-primary" id="remind-ok">知道了</button>
+            </div>
+        </div>
+    </div>
+
+    <footer class="footer">
+        <span class="footer-version"><span class="dot"></span>腾图工具箱 <span id="footer-version-text"></span></span>
+        <div class="footer-desc" id="footer-desc">
+            <span class="footer-marquee" id="footer-marquee"></span>
+            <button class="poem-read" id="poem-read" title="朗读古诗" hidden>🔊</button>
+        </div>
+        <span class="footer-time" id="time">--:--:--</span>
+        <a class="footer-docs" data-wml-openURL="https://v3.wails.io" aria-label="Wails documentation">官方文档</a>
+    </footer>
+
+<script type="module" src="/src/main.js"></script>
+</body>
+</html>

+ 837 - 0
frontend/package-lock.json

@@ -0,0 +1,837 @@
+{
+  "name": "frontend",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "frontend",
+      "version": "0.0.0",
+      "dependencies": {
+        "@wailsio/runtime": "3.0.0"
+      },
+      "devDependencies": {
+        "vite": "^8.2.4"
+      }
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.155.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
+      "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/oxc-project"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm-eabi": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
+      "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
+      "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
+      "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
+      "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
+      "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
+      "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
+      "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
+      "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
+      "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
+      "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
+      "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
+      "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
+      "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
+      "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
+      "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@wailsio/runtime": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0.tgz",
+      "integrity": "sha512-NkFJXYWD/4Y3TcHAqfIeST4oybtwv9dVWTmriZTMFbINYiQ2FB3Wr4MjsQ7o0BNynEj2GslmUisNejnixEmLVQ==",
+      "license": "MIT"
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+      "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.33.0",
+        "lightningcss-darwin-arm64": "1.33.0",
+        "lightningcss-darwin-x64": "1.33.0",
+        "lightningcss-freebsd-x64": "1.33.0",
+        "lightningcss-linux-arm-gnueabihf": "1.33.0",
+        "lightningcss-linux-arm64-gnu": "1.33.0",
+        "lightningcss-linux-arm64-musl": "1.33.0",
+        "lightningcss-linux-x64-gnu": "1.33.0",
+        "lightningcss-linux-x64-musl": "1.33.0",
+        "lightningcss-win32-arm64-msvc": "1.33.0",
+        "lightningcss-win32-x64-msvc": "1.33.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.20",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+      "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+      "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.32",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
+      "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.20",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rolldown": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
+      "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.155.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm-eabi": "1.2.15",
+        "@rolldown/binding-android-arm64": "1.2.15",
+        "@rolldown/binding-darwin-arm64": "1.2.15",
+        "@rolldown/binding-darwin-x64": "1.2.15",
+        "@rolldown/binding-freebsd-x64": "1.2.15",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
+        "@rolldown/binding-linux-arm64-gnu": "1.2.15",
+        "@rolldown/binding-linux-arm64-musl": "1.2.15",
+        "@rolldown/binding-linux-ppc64-gnu": "1.2.15",
+        "@rolldown/binding-linux-s390x-gnu": "1.2.15",
+        "@rolldown/binding-linux-x64-gnu": "1.2.15",
+        "@rolldown/binding-linux-x64-musl": "1.2.15",
+        "@rolldown/binding-openharmony-arm64": "1.2.15",
+        "@rolldown/binding-win32-arm64-msvc": "1.2.15",
+        "@rolldown/binding-win32-x64-msvc": "1.2.15"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+      "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/vite": {
+      "version": "8.3.5",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
+      "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.33.0",
+        "picomatch": "^4.0.7",
+        "postcss": "^8.5.30",
+        "rolldown": "~1.2.14",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.7.1 || ^0.8.0",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 18 - 0
frontend/package.json

@@ -0,0 +1,18 @@
+{
+  "name": "frontend",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build:dev": "vite build --minify false --mode development",
+    "build": "vite build --mode production",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@wailsio/runtime": "3.0.0"
+  },
+  "devDependencies": {
+    "vite": "^8.2.4"
+  }
+}

BIN
frontend/public/Inter-Medium.ttf


BIN
frontend/public/bg-desktop.jpg


BIN
frontend/public/bg-mobile.jpg


+ 1 - 0
frontend/public/javascript.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

+ 2387 - 0
frontend/public/style.css

@@ -0,0 +1,2387 @@
+:root {
+    /* ---------- 颜色(暗色主题,默认) ---------- */
+    --bg: #06070f;                          /* 主背景:与窗口 BackgroundColour 一致 */
+    --bg-soft: #0c0e1a;                     /* 次级背景 */
+    --ink: #e9ecf5;                         /* 主文字:浅色 */
+    --muted: #8b93a7;                       /* 次要文字:灰蓝 */
+    --accent: #ff4d4d;                      /* 主题红 */
+    --accent-2: #ff2d72;                    /* 主题粉,用于渐变 */
+    --grad: linear-gradient(135deg, #ff5a4d 0%, #ff2d72 100%);
+    --glass: rgba(255, 255, 255, 0.045);     /* 玻璃底 */
+    --glass-strong: rgba(255, 255, 255, 0.075);
+    --glass-border: rgba(255, 255, 255, 0.09);
+    --chrome-bg: rgba(1, 2, 6, 0.55);       /* 标签栏 / 页脚的毛玻璃底 */
+    --tab-bg: rgba(4, 6, 14, 0.92);          /* 标签栏底色:近实深色,与内容区明显区分 */
+    --tab-border: rgba(255, 255, 255, 0.10); /* 标签栏与内容区分隔线 */
+    --scroll-thumb: rgba(255, 255, 255, 0.09);
+    --close-fg: rgba(255, 255, 255, 0.55);  /* 右上角关闭按钮颜色 */
+    --title-grad: linear-gradient(120deg, #ffffff 18%, #ffb4c1 100%); /* 面板标题渐变 */
+    --up: #ff5a5a;                          /* 上涨色(红涨) */
+    --down: #3ddc84;                        /* 下跌色(绿跌) */
+    --radius: 12px;
+    --font-delta: 0px;                      /* 全局字号偏移(px),设置中 +- 调整,默认 0(基准字号已含 +2) */
+    --read-bg: rgba(255, 224, 138, 0.12);    /* 朗读中新闻行的背景(暗色主题) */
+    --read-border: rgba(255, 224, 138, 0.55);/* 朗读中新闻行的边框(暗色主题) */
+    --read-ink: #ffe08a;                     /* 朗读中新闻标题文字(暗色主题:金色) */
+
+    /* ---------- 间距(黄金比 φ ≈ 1.618) ---------- */
+    --s-1: 0.618rem;
+    --s-2: 1rem;
+    --s-3: 1.618rem;
+
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
+        "Apple Color Emoji", "Segoe UI Emoji";
+    color-scheme: dark;
+    -webkit-font-smoothing: antialiased;
+    -moz-osx-font-smoothing: grayscale;
+    -webkit-text-size-adjust: 100%;
+}
+
+/* 亮色主题:只覆盖颜色变量,其余布局与组件样式完全复用 */
+:root[data-theme="light"] {
+    --bg: #f4f6fb;
+    --bg-soft: #ffffff;
+    --ink: #1a1f2b;
+    --muted: #5d6678;
+    --glass: rgba(255, 255, 255, 0.78);
+    --glass-strong: rgba(255, 255, 255, 0.96);
+    --glass-border: rgba(20, 26, 40, 0.10);
+    --chrome-bg: rgba(255, 255, 255, 0.74);
+    --tab-bg: rgba(245, 247, 252, 0.95);       /* 亮色标签栏:近实浅色 */
+    --tab-border: rgba(20, 26, 40, 0.12);
+    --scroll-thumb: rgba(20, 26, 40, 0.18);
+    --close-fg: rgba(20, 26, 40, 0.60);     /* 亮色主题下关闭按钮用深色 */
+    --title-grad: linear-gradient(120deg, #1a1f2b 22%, #d93a4e 100%);
+    --up: #d92b2b;
+    --down: #15803d;
+    --read-bg: rgba(217, 138, 30, 0.14);      /* 朗读中新闻行的背景(亮色主题:淡琥珀) */
+    --read-border: rgba(180, 110, 10, 0.55);  /* 朗读中新闻行的边框(亮色主题) */
+    --read-ink: #8a5a00;                       /* 朗读中新闻标题文字(亮色主题:深琥珀,浅底清晰) */
+    color-scheme: light;
+}
+
+* { box-sizing: border-box; }
+
+/* ============================== 页面骨架 ============================== */
+html {
+    background: var(--bg);                  /* 深色底色 */
+    overflow: hidden;                       /* 防止页面级滚动接管,滚动统一交给面板内部 */
+}
+html, body {
+    margin: 0;
+}
+body {
+    height: 100vh;
+    height: 100dvh;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;                 /* 标签容器 + 页脚纵向排列 */
+    padding-top: env(safe-area-inset-top);
+    padding-bottom: env(safe-area-inset-bottom);
+    overflow: hidden;                       /* 页面本身不滚动,内容区内部滚动 */
+    color: var(--ink);
+    font-size: calc(16px + var(--font-delta));
+    -webkit-user-select: none;
+    user-select: none;
+    --wails-draggable: no-drag;             /* 默认不可拖动:只有标签栏(模拟标题栏)可拖动 */
+}
+
+/* 背景氛围:左上红光 + 右上粉光,向下渐隐(亮色主题下改为柔和淡色) */
+body::before {
+    content: "";
+    position: fixed;
+    inset: 0;
+    z-index: -1;
+    pointer-events: none;
+    background:
+        radial-gradient(52% 44% at 10% 0%, rgba(255, 77, 77, 0.16), transparent 70%),
+        radial-gradient(46% 42% at 92% 6%, rgba(255, 45, 114, 0.13), transparent 72%),
+        linear-gradient(180deg, #0a0c17 0%, var(--bg) 64%);
+}
+
+/* 亮色主题的环境光:更淡,避免刺眼 */
+:root[data-theme="light"] body::before {
+    background:
+        radial-gradient(52% 44% at 10% 0%, rgba(255, 77, 77, 0.10), transparent 70%),
+        radial-gradient(46% 42% at 92% 6%, rgba(255, 45, 114, 0.08), transparent 72%),
+        linear-gradient(180deg, #ffffff 0%, var(--bg) 68%);
+}
+
+/* ============================== 标签页容器 ============================== */
+.tab-container {
+    display: flex;
+    flex-direction: column;
+    flex: 1;                                /* 填满 body 剩余空间 */
+    min-height: 0;                          /* 允许 flex 子项正确收缩 */
+}
+
+/* 标签栏:胶囊式标签 + 毛玻璃;整行兼作窗口标题栏(可拖动) */
+.tab-buttons {
+    display: flex;
+    align-items: center;
+    gap: 0;                                 /* 间距改由 tab-btn margin 控制,便于区分选中/未选中 */
+    height: 46px;
+    padding: 0 10px;
+    flex-shrink: 0;                         /* 标签栏不被内容挤扁 */
+    background: var(--tab-bg);               /* 标签栏专用底色,与内容区明显区分 */
+    backdrop-filter: blur(14px) saturate(140%);
+    -webkit-backdrop-filter: blur(14px) saturate(140%);
+    border-bottom: 1px solid var(--tab-border);
+    box-shadow: 0 1px 0 rgba(0, 0, 0, 0.35); /* 加深分隔,增强与内容区的边界感 */
+    --wails-draggable: drag;                /* 标签行可拖动窗口,模拟原标题栏 */
+    overflow-x: auto;                       /* Tab 过多时横向滚动 */
+    scrollbar-width: none;                  /* 隐藏滚动条(Firefox) */
+}
+.tab-buttons::-webkit-scrollbar {
+    display: none;                          /* 隐藏滚动条(WebKit) */
+}
+.tab-btn {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    height: 30px;
+    padding: 0 14px;
+    margin: 0 1px;                              /* 未选中:相邻间距 2px(原 4px 收紧 2px) */
+    border: 1px solid transparent;
+    border-radius: 8px;
+    background: transparent;
+    color: var(--muted);                    /* 未激活:灰蓝 */
+    font-size: calc(15px + var(--font-delta));
+    font-weight: 500;
+    letter-spacing: 0.02em;
+    cursor: pointer;
+    transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease,
+        transform 0.18s ease, border-color 0.18s ease;
+    --wails-draggable: no-drag;             /* 按钮自身不可拖动,确保可点击 */
+    flex-shrink: 0;                         /* Tab 不被压缩,超出才滚动 */
+}
+
+.tab-btn:hover {
+    color: var(--ink);                      /* 悬停:浅色文字 */
+    background: var(--glass-strong);        /* 悬停:明显玻璃底 */
+    border-color: var(--glass-border);      /* 悬停:描边提示 */
+    transform: translateY(-1px);            /* 悬停:轻微上浮 */
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
+}
+
+.tab-btn.active {
+    color: #fff;                            /* 激活:白字 + 渐变底 */
+    background: var(--grad);
+    border-color: rgba(255, 255, 255, 0.18);
+    margin: 0 3px;                          /* 选中:与邻居间距 4px(3+1),保持原样 */
+    font-weight: 600;
+    box-shadow: 0 4px 14px rgba(255, 61, 90, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.18);
+    transform: none;                        /* 选中项不浮起,避免跳动 */
+}
+
+/* 把关闭按钮推到最右端,同时这段空白可拖动窗口 */
+.win-spacer {
+    flex: 1;
+    --wails-draggable: drag;
+}
+
+/* 右上角窗口关闭按钮(无边框窗口的自定义标题栏按钮) */
+.win-close {
+    width: 30px;
+    height: 30px;
+    margin-left: 10px;
+    border: none;
+    border-radius: 8px;
+    background: transparent;
+    color: var(--close-fg);                 /* 跟随界面风格变化 */
+    font-size: calc(19px + var(--font-delta));
+    line-height: 1;
+    cursor: pointer;
+    transition: background 0.15s ease, color 0.15s ease;
+    --wails-draggable: no-drag;             /* 按钮自身不可拖动 */
+}
+.win-close:hover {
+    background: #e81123;                    /* Windows 风格:悬停红底 */
+    color: #fff;
+}
+
+/* ============================== 内容区 ============================== */
+.tab-content {
+    flex: 1;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;                 /* 面板垂直排列:flex:1 限制高度,内容超高时面板内部滚动 */
+    overflow: hidden;
+    color: var(--ink);
+}
+
+/* 细滚动条:半透明,悬停加亮 */
+.tab-panel::-webkit-scrollbar {
+    width: 10px;
+}
+.tab-panel::-webkit-scrollbar-thumb {
+    border: 3px solid transparent;
+    border-radius: 8px;
+    background: var(--scroll-thumb);
+    background-clip: padding-box;
+}
+.tab-panel::-webkit-scrollbar-thumb:hover {
+    background: rgba(255, 255, 255, 0.2);
+    background-clip: padding-box;
+}
+
+/* 面板:默认隐藏,active 才显示并淡入;自身负责内边距与滚动 */
+.tab-panel {
+    display: none;
+    flex: 1;
+    min-height: 0;
+    min-width: 0;
+    overflow-y: auto;
+    scrollbar-gutter: stable;               /* 预留滚动条位置,设置页内容超高时始终可滚动 */
+    padding: 26px 30px 30px;
+}
+.tab-panel.active {
+    display: block;
+    animation: tabFadeIn 0.28s ease both;
+}
+
+/* 内嵌网页面板(笔记):去掉内边距,让 iframe 铺满整个内容区 */
+.panel-embed.active {
+    display: flex;
+    padding: 0;
+    overflow: hidden;
+}
+.panel-embed iframe {
+    flex: 1;
+    width: 100%;
+    height: 100%;
+    border: 0;
+    background: #fff;                       /* iframe 未加载完成时显示白底 */
+}
+
+/* 自定义 Tab 面板:纵向布局,顶部常驻工具条 + 下方 iframe */
+.panel-custom.active {
+    flex-direction: column;
+}
+.custom-tab-bar {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    flex-shrink: 0;
+    gap: 8px;
+    padding: 6px 10px;
+    border-bottom: 1px solid var(--glass-border);
+    background: var(--glass);
+}
+.custom-tab-bar .btn-ghost {
+    font-size: calc(14px + var(--font-delta));
+}
+
+@keyframes tabFadeIn {
+    from { opacity: 0; transform: translateY(8px); }
+    to   { opacity: 1; transform: translateY(0); }
+}
+
+/* 面板标题:渐变文字 */
+.tab-panel h2 {
+    margin: 0 0 6px;
+    font-size: calc(23px + var(--font-delta));
+    font-weight: 650;
+    letter-spacing: -0.01em;
+    background: var(--title-grad);
+    -webkit-background-clip: text;
+    background-clip: text;
+    color: transparent;
+}
+
+/* 面板说明文字 */
+.panel-desc {
+    margin: 0 0 22px;
+    max-width: 100%;
+    color: var(--muted);
+    font-size: calc(15.5px + var(--font-delta));
+    line-height: 1.7;
+}
+
+/* ---------- 卡片 ---------- */
+.card-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
+    gap: 14px;
+}
+
+.card {
+    padding: 16px 18px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+    backdrop-filter: blur(6px);             /* 毛玻璃质感加强 */
+    -webkit-backdrop-filter: blur(6px);
+    transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
+}
+.card:hover {
+    transform: translateY(-2px);            /* 悬停轻微抬升 */
+    border-color: rgba(255, 77, 77, 0.35);
+    background: var(--glass-strong);
+}
+
+.card-title {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 7px;
+    color: var(--ink);
+    font-size: calc(15.5px + var(--font-delta));
+    font-weight: 600;
+}
+
+.card p {
+    margin: 0;
+    color: var(--muted);
+    font-size: calc(14.5px + var(--font-delta));
+    line-height: 1.65;
+}
+
+/* 小圆点指示器 */
+.dot {
+    width: 7px;
+    height: 7px;
+    border-radius: 50%;
+    flex-shrink: 0;
+    background: var(--accent);
+    box-shadow: 0 0 8px var(--accent);
+}
+
+/* ---------- 空状态(控制 / 服务 / 股市占位) ---------- */
+.empty-state {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+    min-height: 220px;
+    padding: 32px 20px;
+    border: 1px dashed var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+    text-align: center;
+}
+
+.empty-mark {
+    font-size: calc(32px + var(--font-delta));
+    line-height: 1;
+    color: var(--accent);
+    opacity: 0.75;
+}
+
+.empty-title {
+    color: var(--ink);
+    font-size: calc(16px + var(--font-delta));
+    font-weight: 600;
+}
+
+.empty-text {
+    color: var(--muted);
+    font-size: calc(14.5px + var(--font-delta));
+}
+
+/* ---------- 设置项 ---------- */
+.setting-list {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+}
+
+/* 设置分组标题:每组设置项前显示所属 Tab 名称 */
+.setting-group-title {
+    margin: 14px 2px 2px;
+    color: var(--muted);
+    font-size: calc(14.5px + var(--font-delta));
+    font-weight: 600;
+    letter-spacing: 0.5px;
+}
+
+.setting-group-title:first-child {
+    margin-top: 0;
+}
+
+.setting-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 18px;
+    padding: 16px 18px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+}
+
+.setting-title {
+    margin-bottom: 4px;
+    color: var(--ink);
+    font-size: calc(15.5px + var(--font-delta));
+    font-weight: 600;
+}
+
+.setting-hint {
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+    line-height: 1.5;
+}
+
+/* 分段控件:暗色 / 亮色 */
+.segmented {
+    display: inline-flex;
+    padding: 3px;
+    border: 1px solid var(--glass-border);
+    border-radius: 9px;
+    background: rgba(127, 127, 127, 0.12);  /* 中性底,明暗主题都适用 */
+    flex-shrink: 0;
+}
+
+.seg-btn {
+    min-width: 62px;
+    height: 28px;
+    padding: 0 14px;
+    border: none;
+    border-radius: 7px;
+    background: transparent;
+    color: var(--muted);
+    font-size: calc(14.5px + var(--font-delta));
+    cursor: pointer;
+    transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
+}
+
+.seg-btn.active {
+    color: #fff;
+    background: var(--grad);
+    font-weight: 600;
+    box-shadow: 0 2px 8px rgba(255, 61, 90, 0.28);
+}
+
+/* 步进器:界面字号 +- 调整 */
+.stepper {
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    flex-shrink: 0;
+}
+.step-btn {
+    width: 30px;
+    height: 30px;
+    border: 1px solid var(--glass-border);
+    border-radius: 8px;
+    background: var(--glass);
+    color: var(--ink);
+    font-size: calc(18px + var(--font-delta));
+    line-height: 1;
+    cursor: pointer;
+    transition: background 0.18s ease, border-color 0.18s ease;
+}
+.step-btn:hover {
+    background: var(--glass-strong);
+    border-color: rgba(255, 77, 77, 0.35);
+}
+.step-btn:disabled {
+    opacity: 0.4;
+    cursor: not-allowed;
+}
+.step-value {
+    min-width: 22px;
+    text-align: center;
+    color: var(--ink);
+    font-size: calc(15.5px + var(--font-delta));
+    font-weight: 600;
+    font-variant-numeric: tabular-nums;
+}
+
+/* 开关:checkbox 隐藏,用轨道 + 滑块绘制 */
+.switch {
+    position: relative;
+    display: inline-flex;
+    flex-shrink: 0;
+    width: 44px;
+    height: 24px;
+    cursor: pointer;
+}
+
+.switch input {
+    position: absolute;
+    inset: 0;
+    margin: 0;
+    opacity: 0;
+    cursor: pointer;
+}
+
+.switch-track {
+    display: block;                         /* span 需转为块级才能生效宽高 */
+    width: 100%;
+    height: 100%;
+    border-radius: 999px;
+    background: rgba(127, 127, 127, 0.35);
+    transition: background 0.2s ease;
+}
+
+.switch-track::after {
+    content: "";
+    position: absolute;
+    top: 3px;
+    left: 3px;
+    width: 18px;
+    height: 18px;
+    border-radius: 50%;
+    background: #fff;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
+    transition: transform 0.2s ease;
+}
+
+.switch input:checked + .switch-track {
+    background: var(--grad);
+}
+
+.switch input:checked + .switch-track::after {
+    transform: translateX(20px);            /* 开启时滑块滑到右侧 */
+}
+
+.switch input:focus-visible + .switch-track {
+    outline: 2px solid var(--accent);
+    outline-offset: 2px;
+}
+
+/* 文本输入框(地址、代码、热键) */
+.input {
+    width: 260px;
+    height: 32px;
+    padding: 0 10px;
+    border: 1px solid var(--glass-border);
+    border-radius: 8px;
+    background: rgba(127, 127, 127, 0.10);
+    color: var(--ink);
+    font-size: calc(14.5px + var(--font-delta));
+    font-family: inherit;
+    outline: none;
+    flex-shrink: 0;
+    -webkit-user-select: text;              /* 输入框允许选择文本 */
+    user-select: text;
+    transition: border-color 0.18s ease, background 0.18s ease;
+}
+.input::placeholder {
+    color: var(--muted);
+    opacity: 0.7;
+}
+.input:focus {
+    border-color: rgba(255, 77, 77, 0.55);
+    background: rgba(127, 127, 127, 0.16);
+}
+
+.hotkey-input {
+    width: 190px;
+    text-align: center;
+    letter-spacing: 0.04em;
+    cursor: pointer;
+}
+
+/* 保存栏 */
+.save-bar {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    margin-top: 18px;
+}
+
+.btn-primary {
+    height: 34px;
+    padding: 0 20px;
+    border: none;
+    border-radius: 9px;
+    background: var(--grad);
+    color: #fff;
+    font-size: calc(15px + var(--font-delta));
+    font-weight: 600;
+    cursor: pointer;
+    box-shadow: 0 4px 14px rgba(255, 61, 90, 0.28);
+    transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
+}
+.btn-primary:hover {
+    transform: translateY(-1px);
+    box-shadow: 0 6px 18px rgba(255, 61, 90, 0.38);
+}
+.btn-primary:disabled {
+    opacity: 0.6;
+    cursor: default;
+    transform: none;
+}
+
+.btn-ghost {
+    height: 30px;
+    padding: 0 14px;
+    border: 1px solid var(--glass-border);
+    border-radius: 8px;
+    background: var(--glass);
+    color: var(--ink);
+    font-size: calc(14.5px + var(--font-delta));
+    cursor: pointer;
+    flex-shrink: 0;
+    transition: background 0.18s ease, border-color 0.18s ease;
+}
+.btn-ghost:hover {
+    background: var(--glass-strong);
+    border-color: rgba(255, 77, 77, 0.35);
+}
+
+/* 保存状态提示 */
+.save-status {
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+}
+.save-status.is-ok {
+    color: var(--down);                     /* 成功:绿色 */
+}
+.save-status.is-error {
+    color: var(--up);                       /* 失败:红色 */
+}
+
+/* 面板标题行:标题在左,操作按钮在右 */
+.panel-head {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 16px;
+    margin-bottom: 18px;
+}
+.panel-head .panel-desc {
+    margin-bottom: 0;
+}
+
+/* 面板头部右侧操作组:状态徽标 + 刷新按钮,水平排列 */
+.panel-head-actions {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-shrink: 0;
+}
+
+/* Github 最后一次成功刷新时间徽标 */
+.github-refresh-time {
+    color: var(--muted);
+    font-size: 14px;
+    white-space: nowrap;
+}
+
+/* 交易 / 休市 状态徽标:圆点 + 文字 */
+.market-status {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    padding: 5px 12px;
+    border: 1px solid var(--glass-border);
+    border-radius: 999px;
+    background: var(--glass);
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+    font-weight: 500;
+    white-space: nowrap;
+}
+.market-status .dot {
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: var(--muted);              /* 未知 / 加载中:灰 */
+    flex-shrink: 0;
+}
+.market-status.is-open .dot {
+    background: var(--up);                 /* 交易中:红(主题色) */
+    box-shadow: 0 0 6px rgba(255, 90, 90, 0.6);
+    animation: marketPulse 1.6s ease-in-out infinite;
+}
+.market-status.is-closed .dot {
+    background: var(--down);               /* 休市:绿 */
+}
+@keyframes marketPulse {
+    0%, 100% { opacity: 1; }
+    50% { opacity: 0.45; }
+}
+
+/* ---------- 股市列表 ---------- */
+.stock-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+}
+
+.stock-row {
+    display: grid;
+    grid-template-columns: minmax(140px, 1.4fr) 1fr 1.2fr auto;
+    align-items: center;
+    gap: 14px;
+    padding: 13px 16px;
+    border: 1px solid var(--glass-border);
+    border-radius: 10px;
+    background: var(--glass);
+    cursor: pointer;                        /* 点击行打开分时 / 日K 弹窗 */
+    transition: background 0.18s ease, border-color 0.18s ease;
+}
+.stock-row:hover {
+    background: var(--glass-strong);
+    border-color: rgba(255, 77, 77, 0.3);
+}
+
+.stock-main {
+    display: flex;
+    flex-direction: column;
+    gap: 3px;
+    min-width: 0;
+}
+
+.stock-name {
+    color: var(--ink);
+    font-size: calc(15.5px + var(--font-delta));
+    font-weight: 600;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.stock-code {
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+    letter-spacing: 0.04em;
+}
+
+.stock-price {
+    font-size: calc(19px + var(--font-delta));
+    font-weight: 650;
+    font-variant-numeric: tabular-nums;     /* 数字等宽,刷新时不抖动 */
+}
+
+.stock-change {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    font-size: calc(14.5px + var(--font-delta));
+    font-variant-numeric: tabular-nums;
+}
+
+.stock-amount {
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+    font-variant-numeric: tabular-nums;
+    justify-self: end;
+    white-space: nowrap;
+}
+
+.stock-price.up, .stock-change.up {
+    color: var(--up);                       /* 红涨 */
+}
+.stock-price.down, .stock-change.down {
+    color: var(--down);                     /* 绿跌 */
+}
+
+/* ---------- 待办提醒弹窗(置顶) ---------- */
+.remind-modal {
+    position: fixed;
+    inset: 0;
+    z-index: 200;                /* 高于股票图表弹窗,确保置顶 */
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    pointer-events: auto;
+}
+.remind-modal[hidden] {
+    display: none;
+}
+
+/* 遮罩:压暗背景 */
+.remind-mask {
+    position: absolute;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.5);
+    backdrop-filter: blur(3px);
+    -webkit-backdrop-filter: blur(3px);
+}
+
+/* 提醒对话框:居中卡片 */
+.remind-dialog {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 10px;
+    width: min(360px, 88vw);
+    padding: 24px 26px;
+    border: 1px solid rgba(255, 77, 77, 0.35);
+    border-radius: 16px;
+    background: var(--bg-soft);
+    box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5), 0 0 24px rgba(255, 77, 77, 0.15);
+    animation: remindIn 0.25s ease both;
+}
+
+@keyframes remindIn {
+    from { opacity: 0; transform: translateY(12px) scale(0.96); }
+    to   { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+/* 提醒图标:铃铛脉冲 */
+.remind-icon {
+    font-size: 44px;
+    line-height: 1;
+    animation: remindPulse 1.2s ease-in-out infinite;
+}
+@keyframes remindPulse {
+    0%, 100% { transform: scale(1); }
+    50%      { transform: scale(1.12); }
+}
+
+.remind-title {
+    color: var(--accent);
+    font-size: calc(19px + var(--font-delta));
+    font-weight: 700;
+    letter-spacing: 0.04em;
+}
+
+/* 任务文本:可选中复制 */
+.remind-text {
+    max-width: 100%;
+    max-height: 160px;
+    overflow-y: auto;
+    color: var(--ink);
+    font-size: calc(17px + var(--font-delta));
+    font-weight: 550;
+    text-align: center;
+    word-break: break-word;
+    user-select: text;
+}
+
+.remind-time {
+    color: var(--muted);
+    font-size: calc(14.5px + var(--font-delta));
+}
+
+.remind-actions {
+    margin-top: 6px;
+}
+
+/* ---------- 股票图表弹窗 ---------- */
+.chart-modal {
+    position: fixed;
+    inset: 0;
+    z-index: 100;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+.chart-modal[hidden] {
+    display: none;
+}
+
+/* 遮罩:压暗背景并虚化 */
+.chart-mask {
+    position: absolute;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.45);
+    backdrop-filter: blur(3px);
+    -webkit-backdrop-filter: blur(3px);
+}
+
+.chart-dialog {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    width: min(860px, 92vw);
+    height: min(520px, 78vh);
+    padding: 16px 18px;
+    border: 1px solid var(--glass-border);
+    border-radius: 14px;
+    background: var(--bg-soft);
+    box-shadow: 0 18px 60px rgba(0, 0, 0, 0.45);
+    animation: chartIn 0.2s ease both;
+}
+
+@keyframes chartIn {
+    from { opacity: 0; transform: translateY(10px) scale(0.98); }
+    to   { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+.chart-head {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+}
+
+.chart-title-wrap {
+    flex: 1;
+    min-width: 0;
+}
+
+.chart-title {
+    color: var(--ink);
+    font-size: calc(17px + var(--font-delta));
+    font-weight: 650;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.chart-sub {
+    margin-top: 2px;
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+    font-variant-numeric: tabular-nums;
+}
+
+/* 弹窗右上角关闭按钮,颜色同样跟随主题 */
+.chart-close {
+    width: 28px;
+    height: 28px;
+    border: none;
+    border-radius: 7px;
+    background: transparent;
+    color: var(--close-fg);
+    font-size: calc(18px + var(--font-delta));
+    line-height: 1;
+    cursor: pointer;
+    flex-shrink: 0;
+    transition: background 0.15s ease, color 0.15s ease;
+}
+.chart-close:hover {
+    background: #e81123;
+    color: #fff;
+}
+
+#chart-canvas {
+    flex: 1;
+    min-height: 0;
+    width: 100%;
+}
+
+.chart-legend {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    min-height: 16px;
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+}
+.chart-legend .lg {
+    display: inline-flex;
+    align-items: center;
+    gap: 5px;
+}
+/* 图例前的色条 */
+.chart-legend .lg i {
+    width: 14px;
+    height: 2px;
+    border-radius: 1px;
+    background: currentColor;
+}
+
+/* ============================== 页脚 ============================== */
+.footer {
+    display: grid;
+    grid-template-columns: auto 1fr auto auto;  /* 版本 | 说明/古诗区(伸缩) | 时钟 | 文档 */
+    align-items: center;
+    gap: 14px;
+    flex-shrink: 0;
+    padding: 9px 14px;
+    border-top: 1px solid var(--glass-border);
+    background: var(--chrome-bg);            /* 半透明底,跟随主题 */
+    backdrop-filter: blur(10px);
+    -webkit-backdrop-filter: blur(10px);
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+}
+
+/* footer 说明 / 古诗滚动区:版本后面,时钟之前 */
+.footer-desc {
+    display: flex;
+    align-items: center;
+    justify-self: stretch;
+    min-width: 0;
+    overflow: hidden;
+}
+
+/* 滚动文字(古诗或 Tab 说明),超出宽度时水平循环滚动 */
+.footer-marquee {
+    flex: 1;
+    min-width: 0;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    color: var(--muted);
+    opacity: 0.85;
+    cursor: pointer;
+    --wails-draggable: no-drag;
+}
+/* 内层文字 span:单份静态显示;超宽时由 JS 复制一份并整体平移 -50%,实现无缝循环 */
+.footer-marquee-inner {
+    display: inline-block;
+    white-space: nowrap;
+    padding-right: 0;
+}
+.footer-marquee:hover {
+    opacity: 1;
+    color: var(--ink);
+}
+/* 滚动动画:由 JS 检测溢出后添加 .scrolling 类;时长由 --poem-speed(秒/轮)控制,默认 18s */
+.footer-marquee.scrolling {
+    text-overflow: clip;
+}
+.footer-marquee.scrolling .footer-marquee-inner {
+    animation: footerMarquee var(--poem-speed, 18s) linear infinite;
+}
+@keyframes footerMarquee {
+    0%   { transform: translateX(0); }
+    100% { transform: translateX(-50%); }
+}
+
+/* 朗读按钮:古诗显示时可见 */
+.poem-read {
+    flex-shrink: 0;
+    margin-left: 8px;
+    border: none;
+    background: transparent;
+    color: var(--muted);
+    font-size: calc(15px + var(--font-delta));
+    cursor: pointer;
+    transition: color 0.2s ease, transform 0.15s ease;
+    --wails-draggable: no-drag;
+}
+.poem-read:hover {
+    color: var(--accent);
+    transform: scale(1.12);
+}
+.poem-read.is-active {
+    color: var(--accent);                  /* 朗读中:主题红 */
+}
+.poem-read[hidden] {
+    display: none;
+}
+
+.footer-version {
+    justify-self: start;
+    display: inline-flex;
+    align-items: center;
+    gap: 7px;
+    white-space: nowrap;
+}
+
+.footer-time {
+    justify-self: center;
+    display: inline-flex;
+    align-items: center;
+    gap: 7px;
+    white-space: nowrap;                     /* 农历+公历+节日较长,禁止换行 */
+    font-variant-numeric: tabular-nums;     /* 时钟数字等宽,避免跳动 */
+    color: var(--muted);
+}
+
+.footer-docs {
+    justify-self: end;
+    display: inline-flex;
+    align-items: center;
+    gap: 5px;
+    color: var(--muted);
+    text-decoration: none;
+    white-space: nowrap;
+    cursor: pointer;
+    transition: color 0.2s ease;
+    --wails-draggable: no-drag;
+}
+.footer-docs:hover {
+    color: var(--accent);                   /* 悬停变主题红 */
+}
+
+/* ---------- 常用网址卡片 ---------- */
+.link-card {
+    display: block;
+    color: inherit;
+    text-decoration: none;
+    cursor: pointer;
+}
+.link-card:hover .card-title {
+    color: var(--accent);
+}
+
+/* ---------- 新闻列表 ---------- */
+.news-list {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+}
+
+.news-row {
+    display: flex;
+    align-items: baseline;
+    gap: 10px;
+    padding: 10px 14px;
+    border: 1px solid var(--glass-border);
+    border-radius: 10px;
+    background: var(--glass);
+    color: var(--ink);
+    text-decoration: none;
+    transition: background 0.18s ease, border-color 0.18s ease;
+}
+.news-row:hover {
+    background: var(--glass-strong);
+    border-color: rgba(255, 77, 77, 0.3);
+}
+/* 命中关键词的新闻:左侧红条标记 */
+.news-row.matched {
+    border-left: 3px solid var(--accent);
+}
+/* 已查看过的新闻:标题灰显 */
+.news-row.viewed .news-title {
+    color: var(--muted);
+}
+.news-row.viewed .news-src {
+    opacity: 0.6;
+}
+/* 当前正在朗读的新闻行:高亮(颜色随主题变化,避免亮色下看不清) */
+.news-row.reading {
+    background: var(--read-bg);
+    border-color: var(--read-border);
+}
+.news-row.reading .news-title {
+    color: var(--read-ink);
+}
+
+.news-src {
+    flex-shrink: 0;
+    padding: 1px 8px;
+    border-radius: 6px;
+    background: rgba(127, 127, 127, 0.16);
+    color: var(--muted);
+    font-size: calc(13px + var(--font-delta));
+}
+
+.news-title {
+    min-width: 0;
+    font-size: calc(15.5px + var(--font-delta));
+    line-height: 1.5;
+}
+
+/* 新闻分组标题:关注关键词组 / 其他组 */
+.news-group-label {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin: 4px 2px 2px;
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+    font-weight: 600;
+    letter-spacing: 0.04em;
+}
+.news-group-label .news-group-dot {
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: var(--accent);
+    box-shadow: 0 0 8px rgba(255, 77, 77, 0.5);
+}
+/* 其他组:圆点改为灰蓝色,弱化提示 */
+.news-group-other .news-group-dot {
+    background: var(--muted);
+    box-shadow: none;
+}
+
+/* 两组之间的小区格分隔线 */
+.news-divider {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin: 12px 0 8px;
+    color: var(--muted);
+    font-size: calc(13px + var(--font-delta));
+}
+.news-divider::before,
+.news-divider::after {
+    content: '';
+    flex: 1;
+    height: 1px;
+    background: var(--glass-border);
+}
+
+/* B站动态区块的提示文字(如 cookie 未配置 / 已过期) */
+.news-bili-hint {
+    margin: 2px 2px 10px;
+    padding: 8px 12px;
+    border-radius: 8px;
+    background: var(--glass-bg);
+    border: 1px solid var(--glass-border);
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+    line-height: 1.5;
+}
+
+/* ---------- Github 仓库卡片 ---------- */
+.repo-list {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+}
+
+/* ---------- GitHub 热门仓库(Trending)区块 ---------- */
+.trending-block {
+    margin-top: 24px; /* 位于关注仓库之下,用上间距分隔 */
+}
+.trending-head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 12px;
+}
+.trending-head .github-refresh-time {
+    margin-left: auto; /* 徽标紧跟刷新按钮靠右,不占据中间 */
+}
+.trending-title {
+    margin: 0;
+    font-size: calc(17px + var(--font-delta));
+    font-weight: 650;
+}
+.trending-title .trending-sub {
+    margin-left: 6px;
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+    font-weight: 400;
+}
+.trending-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+}
+.trending-card {
+    display: block;
+    padding: 12px 14px;
+    border: 1px solid var(--glass-border);
+    border-radius: 10px;
+    background: var(--glass);
+    color: var(--ink);
+    text-decoration: none;
+    transition: background 0.18s ease, border-color 0.18s ease;
+}
+.trending-card:hover {
+    background: var(--glass-strong);
+    border-color: rgba(255, 77, 77, 0.3);
+}
+.trending-card-head {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-wrap: wrap;
+}
+.trending-repo {
+    font-size: calc(15.5px + var(--font-delta));
+    font-weight: 650;
+}
+.trending-lang {
+    padding: 1px 8px;
+    border-radius: 6px;
+    background: rgba(127, 127, 127, 0.16);
+    color: var(--muted);
+    font-size: calc(13px + var(--font-delta));
+}
+.trending-desc {
+    margin-top: 6px;
+    color: var(--muted);
+    font-size: calc(14.5px + var(--font-delta));
+    line-height: 1.5;
+}
+.trending-meta {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    margin-top: 8px;
+    flex-wrap: wrap;
+}
+.trending-stat {
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+}
+.trending-stat.trending-today {
+    color: var(--up); /* 今日新增星数用上涨色突出 */
+    font-weight: 600;
+}
+
+.repo-card {
+    padding: 14px 16px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+}
+
+.repo-head {
+    display: flex;
+    align-items: baseline;
+    gap: 10px;
+    margin-bottom: 8px;
+    flex-wrap: wrap;
+}
+
+.repo-name {
+    color: var(--ink);
+    font-size: calc(16px + var(--font-delta));
+    font-weight: 650;
+    text-decoration: none;
+}
+.repo-name:hover {
+    color: var(--accent);
+}
+
+.repo-desc {
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+}
+
+/* Latest 之后的版本(Pre-release 等):仅列出展示,不参与更新比较 */
+.repo-newer {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin-top: 2px;
+}
+.repo-newer-item {
+    padding: 1px 8px;
+    border-radius: 999px;
+    font-size: calc(13px + var(--font-delta));
+    color: var(--muted);
+    border: 1px solid var(--glass-border);
+    background: var(--glass-strong);
+    text-decoration: none;
+    white-space: nowrap;
+}
+.repo-newer-item:hover {
+    color: var(--ink);
+    border-color: var(--accent);
+}
+.repo-newer-item.is-pre {
+    color: #e8b45a; /* 预发布:琥珀色 */
+    border-color: rgba(232, 180, 90, 0.35);
+}
+.repo-newer-item.is-draft {
+    color: #9aa4b8; /* 草稿:灰蓝 */
+    opacity: 0.8;
+}
+
+/* 版本徽标:最新版本 / 有更新 / 本地版本 */
+.ver-badge {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    padding: 2px 9px;
+    border-radius: 999px;
+    font-size: calc(13.5px + var(--font-delta));
+    font-weight: 600;
+    white-space: nowrap;
+    text-decoration: none;
+    line-height: 1.5;
+}
+.ver-ok {
+    color: var(--down);
+    border: 1px solid rgba(61, 220, 132, 0.35);
+    background: rgba(61, 220, 132, 0.10);
+}
+.ver-update {
+    color: #fff;
+    border: 1px solid rgba(255, 77, 77, 0.55);
+    background: var(--grad);
+    box-shadow: 0 2px 8px rgba(255, 61, 90, 0.3);
+    cursor: pointer;
+    transition: filter 0.15s ease;
+}
+.ver-update:hover {
+    filter: brightness(1.1);
+}
+.ver-muted {
+    color: var(--muted);
+    border: 1px solid var(--glass-border);
+    background: var(--glass);
+}
+.ver-local {
+    margin-left: 4px;
+    color: var(--muted);
+    font-size: calc(13px + var(--font-delta));
+    text-decoration: underline dotted;
+    text-underline-offset: 2px;
+    cursor: pointer;
+    transition: color 0.15s ease;
+}
+.ver-local:hover {
+    color: var(--accent);
+}
+
+/* ver-ok / ver-muted 作为可点击链接时给出提示 */
+a.ver-ok,
+a.ver-muted {
+    cursor: pointer;
+    transition: filter 0.15s ease;
+}
+a.ver-ok:hover {
+    filter: brightness(1.1);
+}
+a.ver-muted:hover {
+    color: var(--ink);
+    border-color: var(--accent);
+}
+
+/* 检视按钮:有可用更新时出现,点击标记已检查 */
+.repo-check-btn {
+    padding: 2px 10px;
+    font-size: calc(13.5px + var(--font-delta));
+    line-height: 1.5;
+    white-space: nowrap;
+    border-radius: 999px;
+}
+.repo-check-btn:disabled {
+    opacity: 0.6;
+    cursor: default;
+}
+
+.repo-error {
+    color: var(--up);
+    font-size: calc(14.5px + var(--font-delta));
+}
+
+.commit-row {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 12px;
+    padding: 7px 10px;
+    border-radius: 8px;
+    color: var(--ink);
+    text-decoration: none;
+    transition: background 0.15s ease;
+}
+.commit-row:hover {
+    background: var(--glass-strong);
+}
+
+.commit-title {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: calc(14.5px + var(--font-delta));
+}
+
+.commit-time {
+    flex-shrink: 0;
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+    font-variant-numeric: tabular-nums;
+}
+
+/* ---------- 记录页 ---------- */
+.panel-record {
+    display: none;                          /* 未激活时隐藏 */
+}
+.panel-record.active {
+    display: flex;                          /* 激活时纵向布局:编辑框撑满 */
+    flex-direction: column;
+    gap: 10px;
+}
+
+.record-editor {
+    flex: 1;
+    min-height: 200px;
+    padding: 14px 16px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+    color: var(--ink);
+    font-size: calc(15.5px + var(--font-delta));
+    font-family: "Consolas", "Courier New", monospace;
+    line-height: 1.7;
+    resize: none;                           /* 尺寸由布局控制 */
+    outline: none;
+    -webkit-user-select: text;
+    user-select: text;
+}
+.record-editor:focus {
+    border-color: rgba(255, 77, 77, 0.55);
+}
+
+/* 记录页头部右侧:编辑/预览切换 + 保存按钮 */
+.record-head-actions {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+}
+.record-mode {
+    display: inline-flex;
+    padding: 2px;
+    border: 1px solid var(--glass-border);
+    border-radius: 8px;
+    background: var(--glass);
+}
+.record-mode-btn {
+    padding: 3px 14px;
+    border: none;
+    border-radius: 6px;
+    background: transparent;
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+    line-height: 1.4;
+    cursor: pointer;
+    transition: background 0.15s ease, color 0.15s ease;
+}
+.record-mode-btn:hover {
+    color: var(--ink);
+}
+.record-mode-btn.is-active {
+    background: var(--accent);
+    color: #fff;
+}
+
+/* ntfy 聊天室乒乓按钮:监听中突出显示 */
+#chat-toggle.is-active {
+    background: var(--accent);
+    color: #fff;
+    border-color: var(--accent);
+}
+
+/* 工具页古诗词朗读按钮:朗读中突出显示 */
+#poem-tool-read.is-active {
+    background: var(--accent);
+    color: #fff;
+    border-color: var(--accent);
+}
+
+/* Markdown 预览容器(记录页「预览」模式) */
+.record-preview {
+    display: none;                          /* 默认隐藏:编辑状态下不显示 markdown 渲染结果 */
+    flex: 1;
+    min-height: 200px;
+    padding: 14px 16px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+    overflow-y: auto;
+    -webkit-user-select: text;
+    user-select: text;
+}
+
+/* Markdown 渲染通用样式(记录预览 / Web 服务等) */
+.markdown-body {
+    font-size: calc(15px + var(--font-delta));
+    line-height: 1.75;
+    color: var(--ink);
+    word-break: break-word;
+}
+.markdown-body > :first-child { margin-top: 0; }
+.markdown-body > :last-child { margin-bottom: 0; }
+.markdown-body h1, .markdown-body h2, .markdown-body h3,
+.markdown-body h4, .markdown-body h5, .markdown-body h6 {
+    margin: 1.1em 0 0.5em;
+    font-weight: 700;
+    line-height: 1.3;
+}
+.markdown-body h1 { font-size: calc(22px + var(--font-delta)); }
+.markdown-body h2 { font-size: calc(19px + var(--font-delta)); }
+.markdown-body h3 { font-size: calc(17px + var(--font-delta)); }
+.markdown-body h4 { font-size: calc(15.5px + var(--font-delta)); }
+.markdown-body p { margin: 0.7em 0; }
+.markdown-body ul, .markdown-body ol { margin: 0.7em 0; padding-left: 1.6em; }
+.markdown-body li { margin: 0.25em 0; }
+.markdown-body a { color: var(--accent); text-decoration: underline; }
+.markdown-body blockquote {
+    margin: 0.8em 0;
+    padding: 2px 12px;
+    border-left: 3px solid var(--accent);
+    color: var(--muted);
+    background: var(--glass-strong);
+}
+.markdown-body code {
+    padding: 1px 5px;
+    border-radius: 4px;
+    background: var(--glass-strong);
+    font-family: "Consolas", "Courier New", monospace;
+    font-size: 0.92em;
+}
+.markdown-body pre {
+    margin: 0.8em 0;
+    padding: 10px 12px;
+    border-radius: var(--radius);
+    background: var(--glass-strong);
+    overflow-x: auto;
+}
+.markdown-body pre code { padding: 0; background: transparent; }
+.markdown-body table {
+    margin: 0.8em 0;
+    border-collapse: collapse;
+    width: 100%;
+}
+.markdown-body th, .markdown-body td {
+    padding: 6px 10px;
+    border: 1px solid var(--glass-border);
+    text-align: left;
+}
+.markdown-body th { background: var(--glass-strong); font-weight: 600; }
+.markdown-body hr {
+    margin: 1.2em 0;
+    border: none;
+    border-top: 1px solid var(--glass-border);
+}
+.markdown-body img { max-width: 100%; border-radius: 6px; }
+.markdown-body del { opacity: 0.7; }
+
+.record-status {
+    min-height: 16px;
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+}
+
+/* 记录文件路径说明(面板标题下方) */
+.record-path-desc {
+    margin-top: 2px;
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+}
+
+/* ---------- 待办页 ---------- */
+.panel-todo {
+    display: none;                          /* 未激活时隐藏 */
+}
+.panel-todo.active {
+    display: flex;                          /* 激活时纵向布局:列表撑满 */
+    flex-direction: column;
+    gap: 10px;
+}
+
+.todo-list {
+    flex: 1;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    padding: 2px;
+}
+
+.todo-row {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 8px 12px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+}
+
+.todo-check {
+    width: 16px;
+    height: 16px;
+    flex-shrink: 0;
+    cursor: pointer;
+    accent-color: var(--accent, #ff4d4d);
+}
+
+.todo-input {
+    flex: 1;
+    min-width: 0;
+    padding: 6px 8px;
+    border: 1px solid transparent;
+    border-radius: calc(var(--radius) - 2px);
+    background: transparent;
+    color: var(--ink);
+    font-size: calc(15.5px + var(--font-delta));
+    outline: none;
+    transition: border-color 0.15s;
+}
+.todo-input:focus {
+    border-color: rgba(255, 77, 77, 0.55);
+}
+
+/* 已完成任务:文字划线变淡 */
+.todo-input.todo-done {
+    text-decoration: line-through;
+    color: var(--muted);
+}
+
+.todo-del {
+    flex-shrink: 0;
+}
+
+/* 待办提醒时间选择器 */
+.todo-remind {
+    flex-shrink: 0;
+    width: 168px;
+    padding: 5px 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));
+    color-scheme: dark;
+    outline: none;
+    cursor: pointer;
+    transition: border-color 0.15s;
+}
+.todo-remind:hover {
+    border-color: rgba(255, 77, 77, 0.45);
+}
+.todo-remind:focus {
+    border-color: var(--accent);
+}
+:root[data-theme="light"] .todo-remind {
+    color-scheme: light;
+}
+
+/* 清除提醒时间按钮 */
+.todo-remind-clear {
+    flex-shrink: 0;
+    padding: 0 6px;
+    font-size: calc(16px + var(--font-delta));
+}
+.todo-remind-clear:disabled {
+    opacity: 0.3;
+    cursor: default;
+}
+
+.todo-actions {
+    display: flex;
+    justify-content: flex-start;
+}
+
+.todo-status {
+    min-height: 16px;
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+}
+
+/* 待办文件路径说明(面板标题下方) */
+.todo-path-desc {
+    margin-top: 2px;
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+}
+
+/* ---------- 主机页 ---------- */
+.host-cards {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+    gap: 12px;
+    margin-bottom: 18px;
+}
+.host-cards .card p {
+    font-variant-numeric: tabular-nums;
+    word-break: break-all;
+}
+
+/* 卡片标题行内嵌操作按钮(如 API 服务右上角的「停止/启动」) */
+.card-title-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+    margin-bottom: 6px;
+}
+.card-title-row .btn-ghost {
+    font-size: calc(12px + var(--font-delta));
+    padding: 4px 10px;
+}
+/* 服务运行中:按钮高亮(「停止服务」态) */
+#api-toggle.is-active {
+    color: var(--brand-text, #2d7d46);
+    border-color: currentColor;
+}
+
+.section-title {
+    margin: 0 0 10px;
+    color: var(--ink);
+    font-size: calc(16px + var(--font-delta));
+    font-weight: 650;
+}
+
+/* 标题行内嵌操作按钮(如 Gin 日志右侧的「清空日志」) */
+.section-title-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+}
+.section-title-row .btn-ghost {
+    font-size: calc(12px + var(--font-delta));
+    padding: 4px 10px;
+}
+
+/* 截屏预览区 */
+.screenshot-box {
+    margin-bottom: 18px;
+    padding: 12px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+}
+.screenshot-head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 10px;
+}
+.screenshot-head .card-title {
+    margin-bottom: 0;
+}
+.screenshot-hint {
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+    font-variant-numeric: tabular-nums;
+}
+.screenshot-box img {
+    display: block;
+    max-width: 100%;
+    max-height: 320px;
+    margin: 0 auto;
+    border-radius: 8px;
+    border: 1px solid var(--glass-border);
+    object-fit: contain;
+    background: rgba(0, 0, 0, 0.3);
+}
+
+/* ---------- 工具页:Web 服务 ---------- */
+.tool-card {
+    margin-bottom: 14px;
+    padding: 16px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+    /* 功能卡片间的区隔:上边缘用更明显分隔 */
+    position: relative;
+}
+.tool-card + .tool-card {
+    border-top: 1px solid var(--glass-border);
+}
+.tool-card-head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 8px;
+    cursor: pointer;
+    user-select: none;
+    border-radius: 8px;
+}
+.tool-card-head:focus-visible {
+    outline: 2px solid var(--accent);
+    outline-offset: 2px;
+}
+.tool-card-head:hover .tool-arrow {
+    color: var(--accent);
+}
+.tool-card-title {
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    color: var(--ink);
+    font-size: calc(17px + var(--font-delta));
+    font-weight: 650;
+}
+/* 折叠箭头:▸ 展开态旋转为 ▾ */
+.tool-arrow {
+    display: inline-block;
+    font-size: 12px;
+    line-height: 1;
+    color: var(--muted);
+    transition: transform 0.2s ease, color 0.15s ease;
+}
+.tool-card:not(.is-collapsed) .tool-arrow {
+    transform: rotate(90deg);
+}
+/* 折叠时隐藏功能主体 */
+.tool-card-body {
+    overflow: hidden;
+}
+.tool-card.is-collapsed .tool-card-body {
+    display: none;
+}
+/* 折叠时收起头部下边距 */
+.tool-card.is-collapsed .tool-card-head {
+    margin-bottom: 0;
+}
+.tool-card-title .dot {
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: var(--accent);
+}
+.tool-status {
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+    white-space: nowrap;
+}
+.tool-status.is-on {
+    color: #3ddc84;
+}
+.tool-status .tool-url {
+    color: #3ddc84;
+    text-decoration: underline;
+    text-underline-offset: 2px;
+    margin-left: 4px;
+}
+.tool-status .tool-url:hover {
+    color: #6ff0a8;
+}
+.tool-desc {
+    margin: 0 0 12px;
+    color: var(--muted);
+    font-size: calc(14.5px + var(--font-delta));
+}
+.tool-desc code {
+    padding: 1px 5px;
+    border-radius: 4px;
+    background: var(--glass-strong);
+    color: var(--ink);
+    font-family: Consolas, "Courier New", monospace;
+}
+.tool-row {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin-bottom: 12px;
+}
+.tool-row.tool-actions {
+    margin-bottom: 0;
+}
+.tool-dir {
+    flex: 1;
+    min-width: 0;
+}
+.tool-hint {
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+}
+.websvc-preview {
+    margin-top: 14px;
+    height: 320px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    overflow: hidden;
+    background: rgba(0, 0, 0, 0.25);
+}
+.websvc-preview iframe {
+    width: 100%;
+    height: 100%;
+    border: none;
+}
+
+/* 翻译工具:输入框 + 结果区 */
+.tr-input {
+    width: 100%;
+    resize: vertical;
+    min-height: 72px;
+    font-family: inherit;
+}
+.tr-input:focus {
+    outline: none;
+    border-color: var(--accent);
+    box-shadow: 0 0 0 3px rgba(255, 77, 77, 0.14);
+}
+.tr-result {
+    margin-top: 12px;
+    padding: 12px 14px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass-strong);
+    color: var(--ink);
+    font-size: calc(16px + var(--font-delta));
+    line-height: 1.7;
+    white-space: pre-wrap;
+    word-break: break-word;
+    user-select: text;
+}
+
+/* 二维码工具:图片展示区 + 下载按钮 */
+.qr-box {
+    margin-top: 12px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 10px;
+    padding: 14px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass-strong);
+}
+.qr-box img {
+    width: 240px;
+    height: 240px;
+    border-radius: 8px;
+    background: #fff;           /* 二维码白底,确保扫描清晰 */
+    padding: 8px;
+    cursor: pointer;
+}
+
+/* ntfy 聊天室:消息列表容器 + 单条消息 */
+.chat-box {
+    margin-top: 12px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass-strong);
+    overflow: hidden;
+}
+.chat-list {
+    max-height: 320px;
+    overflow-y: auto;
+    padding: 12px 10px;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+}
+/* QQ 风格:一条消息(头像 + 主体),他人靠左、自己(.mine)靠右 */
+.chat-msg {
+    display: flex;
+    align-items: flex-start;
+    gap: 8px;
+    max-width: 100%;
+}
+.chat-msg.mine {
+    flex-direction: row-reverse; /* 自己的消息头像在右 */
+}
+.chat-avatar {
+    flex: 0 0 auto;
+    width: 34px;
+    height: 34px;
+    border-radius: 50%;
+    background: linear-gradient(135deg, #4f8cff, #7b5cff);
+    color: #fff;
+    font-weight: 700;
+    font-size: calc(14px + var(--font-delta));
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    user-select: none;
+}
+.chat-msg.mine .chat-avatar {
+    background: linear-gradient(135deg, #23c88a, #0fa5c8); /* 自己的头像用绿色系 */
+}
+.chat-msg-main {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    max-width: 72%;
+}
+.chat-msg.mine .chat-msg-main {
+    align-items: flex-end; /* 自己的内容靠右对齐 */
+}
+.chat-msg-head {
+    display: flex;
+    align-items: baseline;
+    gap: 8px;
+    margin-bottom: 3px;
+    padding: 0 2px;
+}
+.chat-msg-title {
+    font-weight: 600;
+    font-size: calc(12px + var(--font-delta));
+    color: var(--muted);
+}
+.chat-msg-time {
+    font-size: calc(10px + var(--font-delta));
+    color: var(--muted);
+    opacity: 0.8;
+    white-space: nowrap;
+}
+/* 气泡本体:他人灰底、自己主题色底 */
+.chat-bubble {
+    padding: 8px 12px;
+    border-radius: 10px;
+    background: rgba(0, 0, 0, 0.12);
+    border: 1px solid var(--glass-border);
+    color: var(--text);
+    font-size: calc(13px + var(--font-delta));
+    line-height: 1.55;
+    white-space: pre-wrap;
+    word-break: break-word;
+    -webkit-user-select: text;
+    user-select: text;
+    border-top-left-radius: 2px; /* 靠近头像一角收尖,QQ 风格 */
+}
+.chat-msg.mine .chat-bubble {
+    background: var(--brand, #2d7d46);
+    border-color: transparent;
+    color: var(--brand-on, #fff);
+    border-top-right-radius: 2px;
+    border-top-left-radius: 10px;
+}
+/* 消息输入行 */
+.chat-input-row {
+    display: flex;
+    gap: 8px;
+    padding: 8px 10px;
+    border-top: 1px solid var(--glass-border);
+}
+.chat-input-row .input {
+    flex: 1;
+}
+
+/* 工具页「古诗词」卡片 */
+.poem-tool {
+    margin-top: 12px;
+    padding: 14px 16px;
+    border-radius: var(--radius);
+    background: var(--glass-strong);
+    border: 1px solid var(--glass-border);
+    text-align: center;
+}
+.poem-tool-title {
+    font-size: calc(16px + var(--font-delta));
+    font-weight: 700;
+    color: var(--poem-color, #c98a3a);
+    margin-bottom: 4px;
+}
+.poem-tool-author {
+    font-size: calc(12px + var(--font-delta));
+    color: var(--muted);
+    margin-bottom: 10px;
+}
+.poem-tool-content p {
+    margin: 2px 0;
+    font-size: calc(14px + var(--font-delta));
+    color: var(--text);
+    line-height: 1.7;
+    -webkit-user-select: text;
+    user-select: text;
+}
+
+/* 日志视图:等宽字体,固定高度内部滚动 */
+.log-view {
+    margin: 0;
+    padding: 12px 14px;
+    height: 300px;
+    overflow-y: auto;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: rgba(0, 0, 0, 0.25);
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
+    font-family: "Consolas", "Courier New", monospace;
+    line-height: 1.65;
+    white-space: pre-wrap;
+    word-break: break-all;
+    -webkit-user-select: text;
+    user-select: text;
+}
+:root[data-theme="light"] .log-view {
+    background: rgba(20, 26, 40, 0.06);
+}
+
+/* ---------- 设置页新增控件 ---------- */
+/* 纵向排列的设置行(文本域类) */
+.setting-row-col {
+    flex-direction: column;
+    align-items: stretch;
+}
+
+.textarea {
+    width: 100%;
+    height: auto;
+    padding: 10px 12px;
+    line-height: 1.6;
+    resize: vertical;
+    font-family: inherit;
+}
+
+/* 滚动条配置控件组:开关 + 字号 + 颜色 + 速度 */
+.ticker-controls {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-shrink: 0;
+    flex-wrap: wrap;
+    justify-content: flex-end;
+}
+
+.input-num {
+    width: 72px;
+    text-align: center;
+}
+
+/* 颜色选择器:与输入框同高的小色块 */
+.input-color {
+    width: 40px;
+    height: 32px;
+    padding: 2px;
+    border: 1px solid var(--glass-border);
+    border-radius: 8px;
+    background: rgba(127, 127, 127, 0.10);
+    cursor: pointer;
+}
+
+/* 数字输入 + 单位:一行内右对齐(刷新间隔等) */
+.num-group {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    flex-shrink: 0;
+}
+.num-group .input-num {
+    width: 84px;
+}
+.num-unit {
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+    white-space: nowrap;
+}
+
+/* 迷你勾选框(滚动条「透明」等):小号文字 + 原生 checkbox */
+.mini-label {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+    cursor: pointer;
+    white-space: nowrap;
+}
+.mini-label input[type="checkbox"] {
+    width: 14px;
+    height: 14px;
+    margin: 0;
+    accent-color: var(--accent);
+    cursor: pointer;
+}
+
+/* ---------- 主机:提供的功能列表 ---------- */
+.api-list {
+    margin: 0 0 20px;
+    padding: 4px 0 4px 0;
+    list-style: none;
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
+    gap: 8px 14px;
+}
+.api-list li {
+    display: flex;
+    align-items: baseline;
+    gap: 8px;
+    padding: 8px 10px;
+    border: 1px solid var(--glass-border);
+    border-radius: 8px;
+    background: var(--glass);
+    color: var(--muted);
+    font-size: calc(14px + var(--font-delta));
+    line-height: 1.5;
+}
+.api-list code {
+    flex-shrink: 0;
+    padding: 1px 6px;
+    border-radius: 5px;
+    background: rgba(127, 127, 127, 0.14);
+    color: var(--ink);
+    font-family: "Consolas", "Courier New", monospace;
+    font-size: calc(13px + var(--font-delta));
+    white-space: nowrap;
+}
+
+/* ---------- 刷新按钮加载动画(Github 等) ---------- */
+.btn-ghost.loading {
+    position: relative;
+    padding-left: 32px;                     /* 为转圈图标让位 */
+    color: var(--muted);
+    pointer-events: none;                   /* 加载中不可再次点击 */
+}
+.btn-ghost.loading::before {
+    content: "";
+    position: absolute;
+    left: 10px;
+    top: 50%;
+    width: 14px;
+    height: 14px;
+    margin-top: -7px;
+    border: 2px solid var(--muted);
+    border-top-color: transparent;          /* 顶部透明形成弧形缺口 */
+    border-radius: 50%;
+    animation: spin 0.8s linear infinite;
+}
+@keyframes spin {
+    to { transform: rotate(360deg); }
+}
+
+/* ============================== 移动端 ============================== */
+@media (max-width: 640px) {
+    .tab-panel {
+        padding: 18px 16px 20px;
+    }
+    /* 标签栏:窄屏横向滚动,容纳全部 tab(基础样式已统一定义,此处只收紧内边距) */
+    .tab-btn {
+        padding: 0 12px;
+    }
+    .setting-row {
+        flex-direction: column;             /* 窄屏设置项纵向排列 */
+        align-items: flex-start;
+    }
+    .input {
+        width: 100%;                        /* 窄屏输入框铺满 */
+    }
+    .hotkey-input {
+        width: 100%;                        /* 窄屏热键输入框同样铺满 */
+    }
+    .ticker-controls {
+        justify-content: flex-start;        /* 窄屏滚动条控件靠左换行 */
+    }
+    .num-group .input-num {
+        width: 72px;                        /* 窄屏数字输入收窄 */
+    }
+    .api-list {
+        grid-template-columns: 1fr;         /* 窄屏功能列表单列 */
+    }
+    .stock-row {
+        grid-template-columns: 1fr auto;    /* 窄屏精简为两列 */
+        row-gap: 4px;
+    }
+    .stock-change {
+        align-items: flex-end;
+    }
+    .stock-amount {
+        justify-self: start;                /* 窄屏成交额换行靠左 */
+    }
+    .news-row {
+        flex-wrap: wrap;                    /* 窄屏来源标签与标题可换行 */
+    }
+    .commit-time {
+        display: none;                      /* 窄屏隐藏提交时间,优先标题 */
+    }
+    /* 图表弹窗窄屏近全屏 */
+    .chart-dialog {
+        width: 96vw;
+        height: 84vh;
+        padding: 12px;
+    }
+    .chart-head {
+        flex-wrap: wrap;
+        gap: 8px;
+    }
+    .host-cards {
+        grid-template-columns: 1fr;         /* 窄屏主机卡片单列 */
+    }
+    .log-view {
+        height: 220px;
+        font-size: calc(12.5px + var(--font-delta));
+    }
+    .tab-panel h2 {
+        font-size: calc(20px + var(--font-delta));
+    }
+    .card-grid {
+        grid-template-columns: 1fr;         /* 窄屏单列 */
+        gap: 10px;
+    }
+    .footer {
+        font-size: calc(13px + var(--font-delta));
+        gap: 4px;
+    }
+    /* 面板头部:窄屏标题与按钮同排可换行 */
+    .panel-head {
+        flex-wrap: wrap;
+        gap: 10px;
+    }
+    /* 分组标题与设置行:窄屏收紧间距 */
+    .setting-group-title {
+        margin-top: 10px;
+    }
+    /* 新闻 / 股票列表:窄屏让出高度,避免底部被遮挡 */
+    .news-list, .repo-list, .stock-list {
+        max-height: calc(100dvh - 170px);
+    }
+    /* 步进器与分段控件:窄屏不挤压 */
+    .stepper, .segmented {
+        width: 100%;
+        justify-content: flex-end;
+    }
+    .segmented .seg-btn {
+        flex: 1;
+    }
+}

BIN
frontend/public/wails.png


+ 147 - 0
frontend/src/lunar.js

@@ -0,0 +1,147 @@
+// 农历(阴历)与节日计算:公历转农历采用 1900-2100 查表法,无需外部依赖。
+// 导出 getCalendarInfo(date):返回 { lunarText, festival },供 footer 显示用。
+
+// 农历数据表:每项表示 1900..2100 年当年的农历信息(闰月月份、大小月、闰月天数)
+const LUNAR_INFO = [
+    0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
+    0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,
+    0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,
+    0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,
+    0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,
+    0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0,
+    0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,
+    0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b5a0, 0x195a6,
+    0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,
+    0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0,
+    0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5,
+    0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,
+    0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530,
+    0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45,
+    0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0,
+    0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0,
+    0x092e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4,
+    0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0,
+    0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160,
+    0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252,
+    0x0d520,
+];
+
+// 农历月份名(正月..腊月)
+const LUNAR_MONTHS = ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'];
+// 农历日名(初一..三十)
+const LUNAR_DAYS = [
+    '初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',
+    '十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
+    '廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十',
+];
+
+// 农历节日:(月, 日) -> 名称
+const LUNAR_FESTIVALS = {
+    '1,1': '春节',
+    '1,15': '元宵节',
+    '2,2': '龙抬头',
+    '5,5': '端午节',
+    '7,7': '七夕',
+    '7,15': '中元节',
+    '8,15': '中秋节',
+    '9,9': '重阳节',
+    '12,8': '腊八节',
+    '12,23': '北方小年',
+    '12,24': '南方小年',
+};
+
+// 公历节日:(月, 日) -> 名称
+const SOLAR_FESTIVALS = {
+    '1,1': '元旦',
+    '2,14': '情人节',
+    '3,8': '妇女节',
+    '3,12': '植树节',
+    '4,1': '愚人节',
+    '5,1': '劳动节',
+    '5,4': '青年节',
+    '6,1': '儿童节',
+    '7,1': '建党节',
+    '8,1': '建军节',
+    '9,10': '教师节',
+    '10,1': '国庆节',
+    '12,24': '平安夜',
+    '12,25': '圣诞节',
+};
+
+// 农历某年是否闰年
+function leapMonth(y) { return LUNAR_INFO[y - 1900] & 0xf; }
+
+// 农历某年闰月天数(无闰月返回 0)
+function leapDays(y) { return leapMonth(y) ? ((LUNAR_INFO[y - 1900] & 0x10000) ? 30 : 29) : 0; }
+
+// 农历某年某月天数(m 为 1..12)
+function monthDays(y, m) { return (LUNAR_INFO[y - 1900] & (0x10000 >> m)) ? 30 : 29; }
+
+// 农历某年总天数
+function lYearDays(y) {
+    let sum = 348;
+    for (let i = 0x8000; i > 0x8; i >>= 1) sum += (LUNAR_INFO[y - 1900] & i) ? 1 : 0;
+    return sum + leapDays(y);
+}
+
+// 公历 Date -> 农历 { year, month, day, isLeap }
+function solar2lunar(date) {
+    // 用 UTC 构造纯日期,消除时分秒与本地时区对天数差的影响
+    const base = Date.UTC(1900, 0, 31); // 1900-01-31 为农历庚子年正月初一
+    const target = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
+    let offset = Math.round((target - base) / 86400000);
+    let temp = 0;
+    let year = 1900;
+    for (; year < 2101 && offset > 0; year++) {
+        temp = lYearDays(year);
+        offset -= temp;
+    }
+    if (offset < 0) { offset += temp; year--; }
+    const leap = leapMonth(year);
+    let isLeap = false;
+    let month;
+    for (month = 1; month < 13 && offset > 0; month++) {
+        if (leap > 0 && month === (leap + 1) && !isLeap) {
+            --month;
+            isLeap = true;
+            temp = leapDays(year);
+        } else {
+            temp = monthDays(year, month);
+        }
+        if (isLeap && month === (leap + 1)) isLeap = false;
+        offset -= temp;
+    }
+    if (offset === 0 && leap > 0 && month === leap + 1) {
+        if (isLeap) { isLeap = false; } else { isLeap = true; --month; }
+    }
+    if (offset < 0) { offset += temp; --month; }
+    return { year, month, day: offset + 1, isLeap };
+}
+
+// 返回该月最后一天(用于判断除夕)
+function lastDayOfMonth(y, m) { return monthDays(y, m); }
+
+// 计算某公历日期对应的农历文字与节日
+// date: Date 对象;返回 { lunarText, festival }
+export function getCalendarInfo(date) {
+    const y = date.getFullYear();
+    const m = date.getMonth() + 1; // 公历 1..12
+    const d = date.getDate();
+    const lunar = solar2lunar(date);
+
+    // 农历文字:如「九月廿二」,闰月加「闰」前缀
+    const lunarText = (lunar.isLeap ? '闰' : '') + LUNAR_MONTHS[lunar.month - 1] + '月' + LUNAR_DAYS[lunar.day - 1];
+
+    // 节日:先查农历节日,其次公历节日,均无则空
+    let festival = '';
+    const lkey = `${lunar.month},${lunar.day}`;
+    if (LUNAR_FESTIVALS[lkey]) {
+        festival = LUNAR_FESTIVALS[lkey];
+    } else if (lunar.month === 12 && lunar.day === lastDayOfMonth(lunar.year, 12)) {
+        festival = '除夕'; // 腊月最后一天
+    } else {
+        festival = SOLAR_FESTIVALS[`${m},${d}`] || '';
+    }
+
+    return { lunarText, festival };
+}

+ 2808 - 0
frontend/src/main.js

@@ -0,0 +1,2808 @@
+import {Events, WML, Window} from "@wailsio/runtime"; // 导入 Wails 运行时的事件系统、WML 工具与窗口控制 API
+import {SettingsService, AppSettings} from "../bindings/TengTuToolbox/index.js"; // 设置服务与配置模型
+import {getCalendarInfo} from "./lunar.js"; // 农历与节日计算
+
+WML.Enable(); // 为带 data-wml-openURL 属性的链接绑定打开行为(动态内容渲染后需调 WML.Reload())
+
+// 浏览器预览(非 Wails 环境)时的兜底配置,保证页面仍可正常浏览
+const FALLBACK_SETTINGS = {
+    theme: localStorage.getItem('tengtu-theme') || 'dark',
+    autostart: true,
+    alwaysOnTop: false,
+    hotkey: 'CmdOrCtrl+Shift+G',
+    notesUrl: 'https://m.scwy.net',
+    controlUrl: 'https://a7.scwy.net/4191',
+    blogUrl: 'https://i.scwy.net',
+    chatUrl: 'https://c.scwy.net',
+    tabOrder: ['notes', 'control', 'blog', 'chat', 'links', 'news', 'github', 'stock', 'record', 'hardware', 'host', 'settings'],
+    stockCodes: ['sh000001', 'sh600519', 'sz000001'],
+    links: [{name: '煎蛋', url: 'https://jandan.net/'}],
+    newsKeywords: '',
+    biliCookie: '',                    // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
+    githubRepos: [{url: 'https://github.com/wailsapp/wails', desc: 'Wails 框架'}],
+    customTabs: [],                   // 用户自定义 Tab 页面(名字 + 网址)
+    apiAddr: '127.0.0.1:17890',
+    recordPath: 'D:\\My\\腾图\\记录.txt',
+    todoPath: 'D:\\My\\腾图\\待办.txt',
+    hardwareUrl: 'http://192.168.100.190/',
+    stockUpColor: '#ff5a5a',      // 股票上涨颜色(红涨)
+    stockDownColor: '#4d8dff',    // 股票下跌颜色(蓝跌)
+    stockRefreshSecs: 30,         // 股市自动刷新间隔(秒)
+    stockClosedRefreshSecs: 7200, // 股市休市时自动刷新间隔(秒),默认 2 小时
+    newsRefreshMinutes: 120,      // 新闻自动刷新间隔(分钟)
+    newsCount: 30,                // 新闻页显示条数
+    githubRefreshMinutes: 120,    // Github 自动刷新间隔(分钟)
+    fontDelta: 0,                 // 全局字号偏移(px),+-3,默认 0(基准字号已含 +2)
+    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},
+};
+
+// HTML 转义:外部来源的新闻 / 提交标题插入页面前先转义,防止注入
+function escapeHtml(text) {
+    const div = document.createElement('div');
+    div.textContent = text ?? '';
+    return div.innerHTML;
+}
+
+// 取 iframe 应加载的地址:直接返回原 URL(已取消本地证书代理,https 直连)。
+// 之前版本对不受信 HTTPS 走本地反向代理绕开证书校验,现已移除该机制。
+function embedSrc(url) {
+    return url;
+}
+
+const timeElement = document.getElementById('time');         // 底部时间显示元素
+let tabButtons = document.querySelectorAll('.tab-btn');   // 所有 tab 按钮(含动态自定义 Tab,创建后刷新)
+let tabPanels = document.querySelectorAll('.tab-panel');  // 所有 tab 面板(含动态自定义 Tab,创建后刷新)
+
+// footer 说明 / 古诗滚动区元素
+const footerMarqueeEl = document.getElementById('footer-marquee'); // 滚动文字(古诗或 Tab 说明)
+const poemReadBtn = document.getElementById('poem-read');          // 朗读按钮(古诗显示时可见)
+
+// Tab 名称映射:id -> 显示名(用于顺序编辑与 footer 注释)
+const TAB_NAMES = {
+    notes: '笔记', control: '控制', blog: '博客', chat: '聊天',
+    links: '网址', news: '新闻', github: 'Github', stock: '股市',
+    record: '记录', todo: '待办', hardware: '硬件', tools: '工具', host: '服务', settings: '设置',
+};
+
+// Tab 注释映射:id -> 在 footer 显示的说明文字(切换时显示,不占页面空间)
+const TAB_DESCS = {
+    notes: '记录灵感与备忘,地址可在设置中修改。',
+    control: '远程控制中心,地址可在设置中修改。',
+    blog: '个人博客,地址可在设置中修改。',
+    chat: '聊天页面,地址可在设置中修改。',
+    links: '常用网址卡片,点击在浏览器打开,列表在设置中维护。',
+    news: '聚合煎蛋与头条热榜,命中关键词置顶高亮。',
+    github: '监听仓库提交与 release,本地版本低于最新版提示更新。',
+    stock: '自选股实时行情,点击行查看分时 / 日K。',
+    record: '记录零碎信息,文件路径可在设置中修改。',
+    todo: '每行一个待办任务,可勾选完成、编辑、删除,保存后写入本地文件。',
+    hardware: '硬件监控页面,地址可在设置中修改。',
+    tools: '开发/运维辅助工具:Web 服务(选目录开启浏览)、翻译(中英互译)、二维码生成。',
+    host: '内置 API 服务状态、电源命令与 Gin 日志。',
+    settings: '修改后点击保存立即生效,焦点离开时自动保存。',
+};
+
+let customTabs = [];
+
+// 取 Tab 显示名:优先内置 TAB_NAMES,其次自定义 Tab
+function getTabName(id) {
+    if (TAB_NAMES[id]) return TAB_NAMES[id];
+    const c = customTabs.find(t => t.id === id);
+    return c ? c.name : id;
+}
+
+// 取 Tab 说明:优先内置 TAB_DESCS,自定义 Tab 用页面地址
+function getTabDesc(id) {
+    if (TAB_DESCS[id]) return TAB_DESCS[id];
+    const c = customTabs.find(t => t.id === id);
+    return c ? `自定义页面:${c.url}` : '';
+}
+
+// 删除旧的自定义 Tab 按钮与面板,再按 settings.customTabs 重建(插入到「设置」Tab 之前)
+async function applyCustomTabs(tabs) {
+    const tabBar = document.querySelector('.tab-buttons');
+    const content = document.querySelector('.tab-content');
+    if (!tabBar || !content) return;
+    // 移除旧的自定义 Tab 按钮与面板
+    tabBar.querySelectorAll('.tab-btn[data-custom]').forEach(b => b.remove());
+    content.querySelectorAll('.tab-panel[data-custom]').forEach(p => p.remove());
+    // 重建自定义 Tab(按钮插入到设置按钮之前,面板插入到设置面板之前)
+    const settingsBtn = tabBar.querySelector('.tab-btn[data-tab="settings"]');
+    const settingsPanel = content.querySelector('#tab-settings');
+    customTabs = (tabs || []).map((t, i) => ({ id: `custom-${i}`, name: t.name, url: t.url }));
+    for (const t of customTabs) {
+        const btn = document.createElement('button');
+        btn.className = 'tab-btn';
+        btn.dataset.tab = t.id;
+        btn.dataset.custom = '1';
+        btn.textContent = t.name;
+        tabBar.insertBefore(btn, settingsBtn);
+        btn.addEventListener('click', () => switchTab(t.id)); // 点击切换
+
+        const panel = document.createElement('div');
+        panel.className = 'tab-panel panel-embed panel-custom';
+        panel.id = `tab-${t.id}`;
+        panel.dataset.custom = '1';
+        // 常驻工具条:在新窗口打开(内网自签名 / 禁止嵌入的站点可兜底打开)
+        const bar = document.createElement('div');
+        bar.className = 'custom-tab-bar';
+        const openBtn = document.createElement('button');
+        openBtn.className = 'btn-ghost';
+        openBtn.textContent = '在新窗口打开';
+        openBtn.title = t.url;
+        openBtn.addEventListener('click', (e) => {
+            e.stopPropagation(); // 不触发 Tab 切换
+            window.open(t.url, '_blank'); // 用系统浏览器打开(浏览器自会提示证书)
+        });
+        bar.appendChild(openBtn);
+        const frame = document.createElement('iframe');
+        frame.id = `frame-${t.id}`;
+        frame.src = embedSrc(t.url); // 直接使用原地址(已取消代理)
+        frame.title = t.name;
+        frame.setAttribute('loading', 'lazy');
+        panel.appendChild(bar);
+        panel.appendChild(frame);
+        content.insertBefore(panel, settingsPanel);
+    }
+    // 刷新按钮与面板引用,供 applyTabOrder / switchTab 使用
+    tabButtons = document.querySelectorAll('.tab-btn');
+    tabPanels = document.querySelectorAll('.tab-panel');
+    return customTabs.length > 0;
+}
+
+// 应用 Tab 显示顺序:按 settings.tabOrder 重排按钮与面板
+function applyTabOrder(order) {
+    const valid = new Set([...Object.keys(TAB_NAMES), ...customTabs.map(t => t.id)]); // 含内置与自定义 Tab
+    const ordered = [...new Set((order || []).filter(id => valid.has(id)))];
+    // 设置 Tab 恒在最后,其余未列出的(含自定义 Tab)追加到设置之前
+    const si = ordered.indexOf('settings');
+    if (si > -1) ordered.splice(si, 1);
+    [...valid].forEach(id => { if (id !== 'settings' && !ordered.includes(id)) ordered.push(id); });
+    ordered.push('settings'); // 设置在最后
+    // 重排按钮:先把右侧的 win-spacer / win-close 临时移出,避免被 appendChild 挤到末尾
+    const parent = tabButtons[0].parentNode;
+    const spacer = parent.querySelector('.win-spacer');
+    const closeBtn = parent.querySelector('.win-close');
+    if (spacer) spacer.remove();
+    if (closeBtn) closeBtn.remove();
+    const btnMap = {};
+    tabButtons.forEach(btn => { btnMap[btn.dataset.tab] = btn; });
+    ordered.forEach(id => { if (btnMap[id]) parent.appendChild(btnMap[id]); });
+    // 关闭按钮与占位空白放回最右(按钮全在左,关闭在右)
+    if (spacer) parent.appendChild(spacer);
+    if (closeBtn) parent.appendChild(closeBtn);
+    // 重排面板(追加顺序即 DOM 顺序,不影响显示)
+    const panelMap = {};
+    tabPanels.forEach(p => { panelMap[p.id.replace('tab-', '')] = p; });
+    const content = tabPanels[0].parentNode;
+    ordered.forEach(id => { if (panelMap[id]) content.appendChild(panelMap[id]); });
+    // 重排设置项:无 data-tab-group 的全局项归入「系统」组排最前,其余按 Tab 顺序分组
+    const settingList = document.querySelector('.setting-list');
+    if (settingList) {
+        const grouped = { system: [] }; // system 组 + 各 Tab 组
+        settingList.querySelectorAll('.setting-row').forEach(row => {
+            const g = row.dataset.tabGroup || 'system';
+            if (!grouped[g]) grouped[g] = [];
+            grouped[g].push(row);
+        });
+        // 分组标题:系统在前,其后按 Tab 顺序
+        const groupOrder = ['system', ...ordered];
+        settingList.querySelectorAll('.setting-group-title').forEach(el => el.remove());
+        settingList.querySelectorAll('.setting-row').forEach(row => row.remove());
+        const frag = document.createDocumentFragment();
+        groupOrder.forEach(g => {
+            const gRows = grouped[g];
+            if (!gRows || !gRows.length) return; // 该组无设置项则不显示标题
+            const title = document.createElement('div');
+            title.className = 'setting-group-title';
+            title.textContent = g === 'system' ? '系统' : getTabName(g); // 内置或自定义 Tab 名
+            frag.appendChild(title);
+            gRows.forEach(row => frag.appendChild(row));
+        });
+        settingList.appendChild(frag);
+    }
+}
+
+// ------------------------------------------------------------------------
+// Tab 切换:点击按钮,仅显示对应的内容面板
+// ------------------------------------------------------------------------
+const loadedOnce = new Set(); // 记录已做过首次加载的面板,避免重复请求
+
+function switchTab(tabId) {
+    const leavingTab = document.querySelector('.tab-btn.active')?.dataset.tab;
+    if (leavingTab === 'settings' && tabId !== 'settings') {
+        autoSaveSettings(); // 从设置页切走时自动保存未保存的修改
+    }
+    tabButtons.forEach(b => b.classList.remove('active'));
+    tabPanels.forEach(p => p.classList.remove('active'));
+
+    document.querySelector(`.tab-btn[data-tab="${tabId}"]`)?.classList.add('active'); // 激活对应按钮
+    document.getElementById(`tab-${tabId}`)?.classList.add('active');                // 显示对应面板
+    showTabDesc(tabId); // 点击 Tab 后 footer 显示该 Tab 的说明(替代古诗)
+
+    // 部分面板首次打开时才加载数据
+    if (!loadedOnce.has(tabId)) {
+        loadedOnce.add(tabId);
+        if (tabId === 'record') loadRecord();
+        if (tabId === 'todo') loadTodo();
+        if (tabId === 'host') loadHostPanel();
+        if (tabId === 'tools') loadToolsPanel();
+    }
+}
+
+tabButtons.forEach(btn => {
+    btn.addEventListener('click', () => {
+        switchTab(btn.dataset.tab); // 用 data-tab 值定位面板
+    });
+});
+
+// ------------------------------------------------------------------------
+// 主题切换:暗色 / 亮色,立即生效并记录在 localStorage
+// ------------------------------------------------------------------------
+const THEME_KEY = 'tengtu-theme';
+
+function applyTheme(theme, persist = true) {
+    document.documentElement.dataset.theme = theme; // 写入 data-theme,由 CSS 变量接管配色
+    document.querySelectorAll('#theme-seg .seg-btn').forEach(b => {
+        b.classList.toggle('active', b.dataset.themeValue === theme); // 同步分段按钮选中态(仅限主题分段,勿影响图表弹窗的分时/日K分段)
+    });
+    if (persist) {
+        localStorage.setItem(THEME_KEY, theme); // 记住用户选择
+    }
+}
+
+applyTheme(localStorage.getItem(THEME_KEY) || 'dark', false); // 启动时恢复上次主题(默认暗色)
+
+document.querySelectorAll('#theme-seg .seg-btn').forEach(btn => {
+    btn.addEventListener('click', () => {
+        applyTheme(btn.dataset.themeValue); // 点击即切换并持久化
+        markDirty();                        // 标记有未保存改动
+        redrawChart();                      // 图表弹窗打开时按新主题重绘
+    });
+});
+
+// ------------------------------------------------------------------------
+// 设置项元素
+// ------------------------------------------------------------------------
+const autostartToggle = document.getElementById('autostart');       // 开机启动开关
+const topToggle = document.getElementById('always-on-top');         // 窗口置顶开关
+const hotkeyInput = document.getElementById('hotkey');              // 热键输入框
+const notesUrlInput = document.getElementById('notes-url');         // 笔记页地址
+const controlUrlInput = document.getElementById('control-url');     // 控制页地址
+const blogUrlInput = document.getElementById('blog-url');           // 博客页地址
+const chatUrlInput = document.getElementById('chat-url');           // 聊天页地址
+const tabOrderText = document.getElementById('tab-order');          // Tab 显示顺序(每行一个 id)
+const hardwareUrlInput = document.getElementById('hardware-url');   // 硬件页地址
+const stockCodesInput = document.getElementById('stock-codes');     // 股票代码
+const linksText = document.getElementById('links-text');            // 常用网址(每行 名称|网址)
+const newsKeywordsInput = document.getElementById('news-keywords'); // 新闻关键词
+const biliCookieInput = document.getElementById('bili-cookie');      // B站登录 cookie
+const githubReposText = document.getElementById('github-repos');    // Github 项目(每行 地址|说明)
+const apiAddrInput = document.getElementById('api-addr');           // API 监听地址
+const recordPathInput = document.getElementById('record-path');     // 记录文件路径
+const todoPathInput = document.getElementById('todo-path');         // 待办(TODO)文件路径
+const customTabsText = document.getElementById('custom-tabs');      // 自定义 Tab(每行 名称|网址)
+const saveButton = document.getElementById('save-settings');        // 保存按钮
+const saveStatus = document.getElementById('save-status');          // 保存状态提示
+const stockList = document.getElementById('stock-list');            // 股市列表容器
+const stockRefreshBtn = document.getElementById('stock-refresh');   // 股市刷新按钮
+const stockRefreshTime = document.getElementById('stock-refresh-time'); // 最后一次成功刷新时间
+
+// 显示最后一次成功刷新的时间(持久化到 localStorage,重启后仍可显示)
+function showStockRefreshTime(ts) {
+    if (!stockRefreshTime) return;
+    localStorage.setItem('tengtu-stock-last-success', String(ts || Date.now()));
+    const d = new Date(ts || Date.now());
+    const p = n => String(n).padStart(2, '0');
+    stockRefreshTime.textContent = `最后刷新 ${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
+}
+
+// 初始化:优先从 localStorage 恢复上次成功时间
+(function initStockRefreshTime() {
+    if (!stockRefreshTime) return;
+    const saved = parseInt(localStorage.getItem('tengtu-stock-last-success'), 10);
+    if (saved) showStockRefreshTime(saved);
+})();
+
+// 切换股市刷新按钮的加载状态(转圈动画)
+function setStockLoading(loading) {
+    stockRefreshBtn.classList.toggle('loading', loading);
+    stockRefreshBtn.disabled = loading;
+}
+
+// 界面字号步进器
+const fontMinusBtn = document.getElementById('font-minus');         // 减小字号
+const fontPlusBtn = document.getElementById('font-plus');           // 增大字号
+const fontDeltaValue = document.getElementById('font-delta-value'); // 当前字号偏移显示
+
+// 滚动条配置控件(股票条 / 新闻条共用一组 id 前缀)
+const tickerInputs = {
+    stock: {
+        enabled: document.getElementById('stock-ticker-enabled'),
+        width: document.getElementById('stock-ticker-width'),
+        size: document.getElementById('stock-ticker-size'),
+        color: document.getElementById('stock-ticker-color'),
+        speed: document.getElementById('stock-ticker-speed'),
+        transparent: document.getElementById('stock-ticker-transparent'),
+    },
+    news: {
+        enabled: document.getElementById('news-ticker-enabled'),
+        width: document.getElementById('news-ticker-width'),
+        size: document.getElementById('news-ticker-size'),
+        color: document.getElementById('news-ticker-color'),
+        speed: document.getElementById('news-ticker-speed'),
+        transparent: document.getElementById('news-ticker-transparent'),
+    },
+};
+
+// 古诗配置控件(开关 / 颜色 / 字号 / TTS / 换诗间隔)
+const poemInputs = {
+    enabled: document.getElementById('poem-enabled'),
+    color: document.getElementById('poem-color'),
+    size: document.getElementById('poem-size'),
+    tts: document.getElementById('poem-tts'),
+    speed: document.getElementById('poem-speed'),
+};
+
+// 股市 / 新闻 / Github 刷新间隔与涨跌色控件
+const stockUpColorInput = document.getElementById('stock-up-color');
+const stockDownColorInput = document.getElementById('stock-down-color');
+const stockRefreshSecsInput = document.getElementById('stock-refresh-secs');
+const stockClosedRefreshSecsInput = document.getElementById('stock-closed-refresh-secs');
+const newsRefreshMinsInput = document.getElementById('news-refresh-mins');
+const newsCountInput = document.getElementById('news-count');
+const githubRefreshMinsInput = document.getElementById('github-refresh-mins');
+
+// 各面板自动刷新的定时器句柄(设置保存后按新间隔重建)
+let stockTimer = null;
+let newsTimer = null;
+let githubTimer = null;
+
+let currentSettings = {...FALLBACK_SETTINGS}; // 当前生效的配置
+let isDirty = false;   // 设置页是否有未保存的修改
+let isSaving = false;  // 是否正在保存,防止自动保存与手动保存并发
+let marketClosed = false;   // 当前是否休市(用于切换刷新间隔);提前声明避免模块加载早期访问时的 TDZ 错误
+
+function markDirty() {
+    isDirty = true;
+    saveStatus.textContent = '有未保存的修改';
+    saveStatus.classList.remove('is-error', 'is-ok');
+}
+
+// 把配置回填到界面(iframe 地址仅在变化时更新,避免重复加载)
+async function syncForm(settings) {
+    autostartToggle.checked = settings.autostart;
+    topToggle.checked = settings.alwaysOnTop;
+    hotkeyInput.value = settings.hotkey;
+    notesUrlInput.value = settings.notesUrl;
+    controlUrlInput.value = settings.controlUrl;
+    blogUrlInput.value = settings.blogUrl;
+    chatUrlInput.value = settings.chatUrl;
+    tabOrderText.value = (settings.tabOrder || []).map(id => getTabName(id)).join(', '); // 顺序编辑显示 Tab 名称(逗号分隔)
+    hardwareUrlInput.value = settings.hardwareUrl;
+    stockCodesInput.value = settings.stockCodes.join(', ');
+    linksText.value = (settings.links || []).map(l => `${l.name}|${l.url}`).join('\n');
+    newsKeywordsInput.value = settings.newsKeywords || '';
+    biliCookieInput.value = settings.biliCookie || '';
+    githubReposText.value = (settings.githubRepos || []).map(r => `${r.url}|${r.desc}|${r.version || ''}`).join('\n');
+    customTabsText.value = (settings.customTabs || []).map(t => `${t.name}|${t.url}`).join('\n');
+    apiAddrInput.value = settings.apiAddr;
+    recordPathInput.value = settings.recordPath;
+    todoPathInput.value = settings.todoPath;
+    stockUpColorInput.value = settings.stockUpColor || FALLBACK_SETTINGS.stockUpColor;
+    stockDownColorInput.value = settings.stockDownColor || FALLBACK_SETTINGS.stockDownColor;
+    stockRefreshSecsInput.value = settings.stockRefreshSecs || FALLBACK_SETTINGS.stockRefreshSecs;
+    stockClosedRefreshSecsInput.value = settings.stockClosedRefreshSecs || FALLBACK_SETTINGS.stockClosedRefreshSecs;
+    newsRefreshMinsInput.value = settings.newsRefreshMinutes || FALLBACK_SETTINGS.newsRefreshMinutes;
+    newsCountInput.value = settings.newsCount || FALLBACK_SETTINGS.newsCount;
+    githubRefreshMinsInput.value = settings.githubRefreshMinutes || FALLBACK_SETTINGS.githubRefreshMinutes;
+    syncTickerForm('stock', settings.stockTicker);
+    syncTickerForm('news', settings.newsTicker);
+    syncPoemForm(settings.poem);
+    applyTheme(settings.theme, false);
+    applyFontDelta(settings.fontDelta ?? FALLBACK_SETTINGS.fontDelta); // 应用全局字号偏移(默认 +2)
+    await applyCustomTabs(settings.customTabs);   // 重建自定义 Tab(按钮 + 面板)
+    applyTabOrder(settings.tabOrder); // 按设置重排 Tab 顺序
+}
+
+// 应用全局字号偏移:写入 CSS 变量并同步步进器显示
+function applyFontDelta(delta) {
+    const d = Math.max(-3, Math.min(3, Math.round(delta ?? FALLBACK_SETTINGS.fontDelta)));
+    document.documentElement.style.setProperty('--font-delta', d + 'px');
+    if (fontDeltaValue) fontDeltaValue.textContent = String(d);
+    if (fontMinusBtn) fontMinusBtn.disabled = d <= -3;
+    if (fontPlusBtn) fontPlusBtn.disabled = d >= 3;
+}
+
+// 界面字号 +- 步进:立即预览并标记待保存(元素定义后绑定)
+if (fontMinusBtn && fontPlusBtn) {
+    const stepFont = (dir) => {
+        const cur = parseInt(fontDeltaValue.textContent, 10) || 0;
+        applyFontDelta(cur + dir);
+        markDirty(); // 有未保存改动
+    };
+    fontMinusBtn.addEventListener('click', () => stepFont(-1));
+    fontPlusBtn.addEventListener('click', () => stepFont(1));
+}
+
+// 滚动条配置回填到对应控件
+function syncTickerForm(kind, cfg) {
+    const inputs = tickerInputs[kind];
+    cfg = cfg || {};
+    inputs.enabled.checked = !!cfg.enabled;
+    inputs.width.value = cfg.width || 1400;
+    inputs.size.value = cfg.fontSize || 14;
+    inputs.color.value = cfg.color || '#ff4d4d';
+    inputs.speed.value = cfg.speed || 80;
+    if (inputs.transparent) inputs.transparent.checked = cfg.transparent !== false; // 默认透明开启
+}
+
+// 古诗配置回填到对应控件
+function syncPoemForm(cfg) {
+    cfg = cfg || {};
+    poemInputs.enabled.checked = !!cfg.enabled;
+    poemInputs.color.value = cfg.color || '#c98a3a';
+    poemInputs.size.value = cfg.fontSize || 16;
+    poemInputs.tts.checked = !!cfg.tts;
+    poemInputs.speed.value = cfg.speed || 18;
+}
+
+async function applyEmbedUrls(settings) {
+    const frames = [
+        ['frame-notes', settings.notesUrl],
+        ['frame-control', settings.controlUrl],
+        ['frame-blog', settings.blogUrl],
+        ['frame-chat', settings.chatUrl],
+        ['frame-hardware', settings.hardwareUrl],
+    ];
+    await Promise.all(frames.map(async ([id, url]) => {
+        const frame = document.getElementById(id);
+        if (!frame) return;
+        const eff = embedSrc(url); // 直接使用原地址(已取消代理)
+        if (frame.dataset.src !== eff) {
+            frame.dataset.src = eff; // 记录已应用的地址
+            frame.src = eff;         // 地址变化才重新加载
+        }
+    }));
+}
+
+// ------------------------------------------------------------------------
+// 热键录入:点击输入框后按下组合键
+// ------------------------------------------------------------------------
+const MODIFIER_KEYS = ['Control', 'Shift', 'Alt', 'Meta'];
+
+hotkeyInput.addEventListener('keydown', (event) => {
+    event.preventDefault(); // 阻止输入框写入原始字符
+
+    if (MODIFIER_KEYS.includes(event.key)) {
+        return; // 只按下修饰键时,等待主键
+    }
+
+    const parts = [];
+    if (event.ctrlKey || event.metaKey) parts.push('CmdOrCtrl'); // Windows 下对应 Ctrl
+    if (event.altKey) parts.push('Alt');
+    if (event.shiftKey) parts.push('Shift');
+    parts.push(event.key.length === 1 ? event.key.toUpperCase() : event.key); // 主键统一大写
+
+    hotkeyInput.value = parts.join('+');
+    markDirty();
+});
+
+hotkeyInput.addEventListener('focus', () => {
+    hotkeyInput.select(); // 聚焦时全选,方便重新录入
+});
+
+// ------------------------------------------------------------------------
+// 读取 / 保存配置
+// ------------------------------------------------------------------------
+async function loadSettings() {
+    try {
+        currentSettings = await SettingsService.GetSettings();
+    } catch (err) {
+        console.warn('读取设置失败,使用默认配置:', err); // 浏览器预览时无后端
+        currentSettings = {...FALLBACK_SETTINGS};
+    }
+    await syncForm(currentSettings);
+    applyEmbedUrls(currentSettings);
+    renderLinks(currentSettings.links || []);
+    initFooterPoem(currentSettings.poem);  // footer 古诗按配置显示并加载
+    restartAutoTimers(); // 按真实设置的间隔重建自动刷新定时器
+}
+
+// 解析「每行 键|值」格式的文本域为对象数组
+function parsePipeLines(text, keyName, valueName) {
+    return text.split('\n')
+        .map(line => line.trim())
+        .filter(Boolean)
+        .map(line => {
+            const idx = line.indexOf('|');
+            if (idx < 0) return {[keyName]: line, [valueName]: ''}; // 无分隔符时整行作为第一字段
+            return {
+                [keyName]: line.slice(0, idx).trim(),
+                [valueName]: line.slice(idx + 1).trim(),
+            };
+        });
+}
+
+// 解析 地址|说明|版本 三列行(Github 仓库),支持缺省列
+function parsePipeLines3(text, keyName, valueName, thirdName) {
+    return text.split('\n')
+        .map(line => line.trim())
+        .filter(Boolean)
+        .map(line => {
+            const parts = line.split('|');
+            return {
+                [keyName]: (parts[0] || '').trim(),
+                [valueName]: (parts[1] || '').trim(),
+                [thirdName]: (parts[2] || '').trim(), // 第 3 列为本地版本,可为空
+            };
+        });
+}
+
+function collectForm() {
+    // 数组字段:表单为空时回退到当前生效配置,避免保存时用空值覆盖已有数据。
+    // 用户可能在设置页修改单个字段后保存,此时未触碰的 textarea 若为空不能清空原配置。
+    const fallbackArr = (input, key) => {
+        const v = input.value.trim();
+        if (v) return v; // 表单有内容,用表单值
+        // 表单为空:回退到 currentSettings 的已有值,其次默认值
+        const cur = currentSettings[key];
+        if (Array.isArray(cur) && cur.length) return cur.map(item => {
+            if (typeof item === 'string') return item;
+            // LinkItem / RepoItem / CustomTab 对象:转回 "a|b" 格式行
+            if (key === 'links') return `${item.name}|${item.url}`;
+            if (key === 'githubRepos') return `${item.url}|${item.desc}|${item.version || ''}`;
+            if (key === 'customTabs') return `${item.name}|${item.url}`;
+            return '';
+        }).join('\n');
+        return '';
+    };
+
+    const stockCodes = stockCodesInput.value
+        .split(/[,,\s]+/)                 // 支持中英文逗号与空格分隔
+        .map(code => code.trim())
+        .filter(Boolean);
+
+    // 颜色字段:回退到 currentSettings 或默认色,避免 color input 默认黑覆盖
+    const fallbackColor = (input, curVal, defVal) => {
+        const v = input.value.trim().toLowerCase();
+        if (v && v !== '#000000') return v;       // 有效且非浏览器默认黑
+        if (curVal && curVal.toLowerCase() !== '#000000') return curVal; // 用当前生效值
+        return defVal; // 兜底默认色
+    };
+
+    return {
+        theme: document.documentElement.dataset.theme || 'dark',
+        autostart: autostartToggle.checked,
+        alwaysOnTop: topToggle.checked,
+        hotkey: hotkeyInput.value.trim() || FALLBACK_SETTINGS.hotkey,
+        notesUrl: notesUrlInput.value.trim(),
+        controlUrl: controlUrlInput.value.trim(),
+        blogUrl: blogUrlInput.value.trim(),
+        chatUrl: chatUrlInput.value.trim(),
+        tabOrder: collectTabOrder(),
+        hardwareUrl: hardwareUrlInput.value.trim(),
+        stockCodes,
+        links: parsePipeLines(fallbackArr(linksText, 'links'), 'name', 'url').filter(l => l.name && l.url),
+        newsKeywords: newsKeywordsInput.value.trim(),
+        biliCookie: biliCookieInput.value.trim(),
+        githubRepos: parsePipeLines3(fallbackArr(githubReposText, 'githubRepos'), 'url', 'desc', 'version').filter(r => r.url),
+        customTabs: parsePipeLines(fallbackArr(customTabsText, 'customTabs'), 'name', 'url').filter(t => t.name && t.url),
+        apiAddr: apiAddrInput.value.trim(),
+        recordPath: recordPathInput.value.trim(),
+        todoPath: todoPathInput.value.trim(),
+        stockUpColor: fallbackColor(stockUpColorInput, currentSettings.stockUpColor, FALLBACK_SETTINGS.stockUpColor),
+        stockDownColor: fallbackColor(stockDownColorInput, currentSettings.stockDownColor, FALLBACK_SETTINGS.stockDownColor),
+        stockRefreshSecs: parseInt(stockRefreshSecsInput.value, 10) || FALLBACK_SETTINGS.stockRefreshSecs,
+        stockClosedRefreshSecs: parseInt(stockClosedRefreshSecsInput.value, 10) || FALLBACK_SETTINGS.stockClosedRefreshSecs,
+        newsRefreshMinutes: parseInt(newsRefreshMinsInput.value, 10) || FALLBACK_SETTINGS.newsRefreshMinutes,
+        newsCount: parseInt(newsCountInput.value, 10) || FALLBACK_SETTINGS.newsCount,
+        githubRefreshMinutes: parseInt(githubRefreshMinsInput.value, 10) || FALLBACK_SETTINGS.githubRefreshMinutes,
+        fontDelta: parseInt(fontDeltaValue.textContent, 10) || 0,
+        stockTicker: collectTicker('stock'),
+        newsTicker: collectTicker('news'),
+        poem: collectPoem(),
+    };
+}
+
+// 从控件收集滚动条配置;位置(x/y)由拖动自动保存,表单不修改
+function collectTicker(kind) {
+    const inputs = tickerInputs[kind];
+    const prev = (kind === 'stock' ? currentSettings.stockTicker : currentSettings.newsTicker) || {};
+    // 颜色:color input 若为浏览器默认黑(#000000),回退到当前生效色或默认色,避免覆盖
+    const colorVal = inputs.color.value.trim().toLowerCase();
+    const prevColor = (prev.color || '').toLowerCase();
+    const defColor = kind === 'stock' ? '#ff4d4d' : '#3ddc84';
+    const color = (colorVal && colorVal !== '#000000') ? colorVal
+        : (prevColor && prevColor !== '#000000') ? prevColor : defColor;
+    return {
+        enabled: inputs.enabled.checked,
+        width: parseInt(inputs.width.value, 10) || 1400, // 窗口宽度(px)
+        fontSize: parseInt(inputs.size.value, 10) || 14,
+        color,
+        speed: parseInt(inputs.speed.value, 10) || 80,
+        transparent: inputs.transparent ? inputs.transparent.checked : true, // 背景透明开关
+        x: prev.x ?? -1, // 保留拖动保存的位置
+        y: prev.y ?? -1,
+    };
+}
+
+// 从控件收集古诗配置
+function collectPoem() {
+    const prev = currentSettings.poem || {};
+    // 颜色:color input 若为浏览器默认黑(#000000),回退到当前生效色或默认色,避免覆盖
+    const colorVal = poemInputs.color.value.trim().toLowerCase();
+    const prevColor = (prev.color || '').toLowerCase();
+    const color = (colorVal && colorVal !== '#000000') ? colorVal
+        : (prevColor && prevColor !== '#000000') ? prevColor : '#c98a3a';
+    return {
+        enabled: poemInputs.enabled.checked,
+        color,
+        fontSize: parseInt(poemInputs.size.value, 10) || 16,
+        tts: poemInputs.tts.checked,
+        speed: parseInt(poemInputs.speed.value, 10) || 18,
+    };
+}
+
+// ------------------------------------------------------------------------
+// footer 说明区:启动时在版本后面水平滚动显示一首古诗;
+// 点击任意 Tab 后该区显示当前 Tab 的说明文字;古诗不自动刷新(手动点击换一首)
+// ------------------------------------------------------------------------
+let poemSpeaking = false;  // 是否正在朗读
+let currentPoem = null;    // 当前显示的古诗数据
+let footerMode = 'desc';   // 当前 footer 显示模式:'poem'(古诗)| 'desc'(Tab 说明)
+let footerScrollRAF = null;
+
+// 检测文字是否超出容器宽度:超出则用双份文本 + 水平滚动动画(无缝衔接)
+function updateFooterMarquee() {
+    if (!footerMarqueeEl) return;
+    if (footerScrollRAF) { cancelAnimationFrame(footerScrollRAF); footerScrollRAF = null; }
+    footerMarqueeEl.classList.remove('scrolling');
+    const inner = footerMarqueeEl._inner;
+    if (!inner || !inner.textContent) return;
+    // 下一帧测量,确保布局完成
+    footerScrollRAF = requestAnimationFrame(() => {
+        const single = inner.dataset.text || '';
+        inner.textContent = single; // 先按单份测量一份文本宽度
+        const singleW = inner.scrollWidth;
+        if (singleW > footerMarqueeEl.clientWidth + 4) {
+            inner.textContent = single + single; // 双份文本:-50% 位移正好滚完一份并接上下一份
+            footerMarqueeEl.classList.add('scrolling');
+        }
+    });
+}
+
+// 设置 footer 说明区内容:文本 + 是否显示朗读按钮;color/fontSize 仅古诗显示时应用
+function setFooterText(text, opts = {}) {
+    if (!footerMarqueeEl) return;
+    const safeText = text || '';
+    footerMarqueeEl.title = safeText;
+    footerMarqueeEl.style.color = opts.color || '';
+    footerMarqueeEl.style.fontSize = opts.fontSize ? opts.fontSize + 'px' : '';
+    // 滚动速度:设置 CSS 变量(秒/轮),供 footerMarquee 动画时长引用
+    if (opts.speed) {
+        footerMarqueeEl.style.setProperty('--poem-speed', opts.speed + 's');
+    }
+    if (poemReadBtn) poemReadBtn.hidden = !opts.showRead;
+    // 内层 span:静态显示单份文本;超宽时再复制一份实现无缝滚动
+    let inner = footerMarqueeEl._inner;
+    if (!inner) {
+        inner = document.createElement('span');
+        inner.className = 'footer-marquee-inner';
+        footerMarqueeEl.appendChild(inner);
+        footerMarqueeEl._inner = inner;
+    }
+    inner.dataset.text = safeText; // 保存原始文本,供测量 / 双份拼接
+    inner.textContent = safeText;
+    updateFooterMarquee();
+}
+
+// 古诗朗读(TTS);再点停止
+function togglePoemRead() {
+    if (!poemReadBtn) return;
+    if (poemSpeaking) {
+        speechSynthesis.cancel();
+        poemSpeaking = false;
+        poemReadBtn.classList.remove('is-active');
+        return;
+    }
+    if (!currentPoem) return;
+    const lines = [currentPoem.title, currentPoem.author, ...(currentPoem.content || [])]
+        .filter(s => s && s.trim())
+        .join(',');
+    const utter = new SpeechSynthesisUtterance(lines);
+    utter.lang = 'zh-CN';
+    utter.rate = 0.9;
+    utter.onend = () => {
+        poemSpeaking = false;
+        poemReadBtn.classList.remove('is-active');
+    };
+    utter.onerror = () => {
+        poemSpeaking = false;
+        poemReadBtn.classList.remove('is-active');
+    };
+    speechSynthesis.speak(utter);
+    poemSpeaking = true;
+    poemReadBtn.classList.add('is-active');
+}
+
+// 把一首古诗渲染到 footer 说明区(滚动显示,带朗读按钮)
+function renderPoem(poem) {
+    currentPoem = poem;
+    const title = poem?.title || '';
+    const author = [poem?.dynasty, poem?.author].filter(Boolean).join(' · ');
+    // 完整古诗:题目、作者、全部诗句(单行拼接滚动显示)
+    const lines = (poem?.content || []).filter(s => s && s.trim());
+    const text = [title, author, ...lines].filter(Boolean).join(' ');
+    setFooterText(text || '暂无古诗', {
+        color: currentSettings?.poem?.color || '#c98a3a',
+        fontSize: currentSettings?.poem?.fontSize || 16,
+        showRead: !!currentSettings?.poem?.tts,
+        speed: currentSettings?.poem?.speed || 18,
+    });
+}
+
+// 拉取一首随机古诗并显示(失败时静默,避免清空说明区)
+async function loadFooterPoem(showHint = false) {
+    if (footerMode !== 'poem') return; // 已切到 Tab 说明,不再显示古诗
+    try {
+        const poem = await SettingsService.GetRandomPoem();
+        renderPoem(poem);
+        updatePoemTool(poem); // 同步显示到工具页「古诗词」卡片
+        return poem;
+    } catch (err) {
+        if (showHint) {
+            setFooterText(`古诗加载失败:${err?.message || err}`, { showRead: false });
+        }
+        return null;
+    }
+}
+
+// 启动时进入古诗模式:在版本后面滚动显示一首古诗(不自动刷新)
+function showFooterPoem(cfg) {
+    footerMode = 'poem';
+    const enabled = !!cfg?.enabled;
+    if (!enabled) { // 未启用:回退显示当前 Tab 说明
+        const cur = document.querySelector('.tab-btn.active')?.dataset.tab || 'notes';
+        showTabDesc(cur);
+        return;
+    }
+    loadFooterPoem(false); // 加载一首古诗并滚动显示
+}
+
+// 切换到 Tab 说明模式:显示指定 Tab 的说明文字(替代古诗)
+function showTabDesc(tabId) {
+    footerMode = 'desc';
+    if (poemSpeaking) { // 切走时停止朗读
+        speechSynthesis.cancel();
+        poemSpeaking = false;
+        poemReadBtn?.classList.remove('is-active');
+    }
+    setFooterText(getTabDesc(tabId), { showRead: false });
+}
+
+// 初始化 footer 说明区:绑定古诗点击换诗 / 朗读事件
+function initFooterPoem(cfg) {
+    if (poemReadBtn) poemReadBtn.addEventListener('click', togglePoemRead);
+    if (footerMarqueeEl) footerMarqueeEl.addEventListener('click', () => {
+        if (footerMode === 'poem') loadFooterPoem(true); // 古诗模式下点击换一首
+    });
+    window.addEventListener('resize', updateFooterMarquee); // 窗口尺寸变化时重测滚动
+    showFooterPoem(cfg); // 启动默认显示古诗
+}
+
+// 从控件收集 Tab 顺序:逗号(中英文)分隔的名称,转回 tabId(含内置与自定义);未知名称忽略
+function collectTabOrder() {
+    const nameToId = {};
+    Object.entries(TAB_NAMES).forEach(([id, name]) => { nameToId[name] = id; });
+    customTabs.forEach(t => { nameToId[t.name] = t.id; });
+    return tabOrderText.value.split(/[,,\s]+/)
+        .map(s => s.trim())
+        .filter(Boolean)
+        .map(name => nameToId[name])
+        .filter(Boolean);
+}
+
+async function saveSettings() {
+    if (isSaving) return; // 正在保存时跳过重复触发(自动保存与点击可能同时发生)
+    isSaving = true;
+    saveButton.disabled = true;
+    saveStatus.textContent = '保存中…';
+    saveStatus.classList.remove('is-error', 'is-ok');
+
+    try {
+        const saved = await SettingsService.SaveSettings(new AppSettings(collectForm()));
+        currentSettings = saved;
+        await syncForm(saved);
+        applyEmbedUrls(saved);
+        renderLinks(saved.links || []);
+        // 古诗配置变化:重新进入古诗模式(若启用则加载一首,未启用回退 Tab 说明)
+        showFooterPoem(saved.poem);
+        loadStocks(); // 代码变化后立即刷新行情
+        restartAutoTimers(); // 按新设置的间隔重建各面板定时器
+
+        isDirty = false; // 保存成功,清除未保存标记
+        saveStatus.textContent = '已保存并生效';
+        saveStatus.classList.add('is-ok');
+    } catch (err) {
+        saveStatus.textContent = '保存失败:' + (err?.message || err);
+        saveStatus.classList.add('is-error');
+    } finally {
+        isSaving = false;
+        saveButton.disabled = false;
+    }
+}
+
+// 焦点离开设置界面时自动保存(仅在有改动时触发)
+async function autoSaveSettings() {
+    if (!isDirty) return;
+    await saveSettings();
+}
+
+saveButton.addEventListener('click', saveSettings);
+
+// 所有设置输入控件统一监听:任何修改都标记未保存
+document.querySelectorAll('#tab-settings .input, #tab-settings input[type="checkbox"], #tab-settings input[type="color"]').forEach(input => {
+    input.addEventListener('input', markDirty);  // 文本 / 数字 / 颜色输入
+    input.addEventListener('change', markDirty); // 开关切换
+});
+
+// 焦点移出设置面板(点击面板外区域 / 切换 tab)时自动保存
+const settingsPanel = document.getElementById('tab-settings');
+settingsPanel.addEventListener('focusout', (event) => {
+    if (!settingsPanel.contains(event.relatedTarget)) {
+        autoSaveSettings();
+    }
+});
+
+// ------------------------------------------------------------------------
+// 常用网址:卡片网格,点击在浏览器打开
+// ------------------------------------------------------------------------
+const linksGrid = document.getElementById('links-grid');
+
+function renderLinks(links) {
+    if (!links || links.length === 0) {
+        linksGrid.innerHTML = `
+            <div class="empty-state">
+                <div class="empty-mark">◈</div>
+                <div class="empty-title">暂无常用网址</div>
+                <div class="empty-text">在「设置」中按 名称|网址 每行一条添加</div>
+            </div>`;
+        return;
+    }
+    linksGrid.innerHTML = links.map(link => `
+        <a class="card link-card" href="${escapeHtml(link.url)}" target="_blank" data-wml-openURL="${escapeHtml(link.url)}">
+            <div class="card-title"><span class="dot"></span>${escapeHtml(link.name)}</div>
+            <p>${escapeHtml(link.url)}</p>
+        </a>`).join('');
+    WML.Reload(); // 动态插入的链接需要重新绑定打开行为
+}
+
+// ------------------------------------------------------------------------
+// 新闻:煎蛋 + 头条热榜,命中关键词置顶高亮
+// ------------------------------------------------------------------------
+const newsList = document.getElementById('news-list');
+const newsRefreshBtn = document.getElementById('news-refresh');
+const newsRefreshTime = document.getElementById('news-refresh-time'); // 最后一次成功刷新时间徽标
+const VIEWED_NEWS_KEY = 'tengtu-viewed-news'; // 已查看新闻 URL 集合的存储键
+
+// 显示最后一次成功刷新的时间(持久化到 localStorage,重启后仍可显示)
+function showNewsRefreshTime(ts) {
+    if (!newsRefreshTime) return;
+    localStorage.setItem('tengtu-news-last-success', String(ts || Date.now()));
+    const d = new Date(ts || Date.now());
+    const p = n => String(n).padStart(2, '0');
+    newsRefreshTime.textContent = `最后刷新 ${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
+}
+
+// 初始化:优先从 localStorage 恢复上次成功时间
+(function initNewsRefreshTime() {
+    if (!newsRefreshTime) return;
+    const saved = parseInt(localStorage.getItem('tengtu-news-last-success'), 10);
+    if (saved) showNewsRefreshTime(saved);
+})();
+
+// 切换刷新按钮的加载状态(转圈动画)
+function setNewsLoading(loading) {
+    newsRefreshBtn.classList.toggle('loading', loading);
+    newsRefreshBtn.disabled = loading;
+}
+
+// 读取已查看新闻 URL 集合
+function getViewedNews() {
+    try {
+        return new Set(JSON.parse(localStorage.getItem(VIEWED_NEWS_KEY) || '[]'));
+    } catch {
+        return new Set();
+    }
+}
+
+// 记录某条新闻为已查看(最多保留 500 条,防 localStorage 膨胀)
+function markNewsViewed(url) {
+    if (!url) return;
+    const viewed = getViewedNews();
+    viewed.add(url);
+    const arr = [...viewed].slice(-500);
+    try { localStorage.setItem(VIEWED_NEWS_KEY, JSON.stringify(arr)); } catch { /* 存储满则忽略 */ }
+}
+
+let currentNews = []; // 当前显示的新闻列表,供 TTS 阅读使用
+
+async function loadNews() {
+    setNewsLoading(true); // 显示刷新过程(转圈动画,按钮不可再点)
+    try {
+        // 并行抓取:普通新闻 + B站动态(后者失败不阻断新闻展示,单独显示提示)
+        const [news, bili] = await Promise.all([
+            SettingsService.GetNews(),
+            SettingsService.GetBiliDynamic().catch(err => ({ __error: err?.message || String(err) })),
+        ]);
+
+        const viewed = getViewedNews(); // 已查看集合,用于灰显
+        const rowHtml = n => `
+            <a class="news-row ${n.matched ? 'matched' : ''} ${viewed.has(n.url) ? 'viewed' : ''}" href="${escapeHtml(n.url)}" target="_blank" data-wml-openURL="${escapeHtml(n.url)}">
+                <span class="news-src">${escapeHtml(n.source)}</span>
+                <span class="news-title">${escapeHtml(n.title)}</span>
+            </a>`;
+
+        // B站动态区块(放在新闻列表上方,独立分组)
+        let biliHtml = '';
+        if (bili && bili.__error) {
+            biliHtml = `<div class="news-group-label"><span class="news-group-dot"></span>B站动态</div>
+                <div class="news-bili-hint">${escapeHtml(bili.__error)}</div>`;
+        } else if (bili && bili.length) {
+            const biliShown = bili.slice(0, 8); // B站动态最多显示 8 条,避免喧宾夺主
+            biliHtml = `<div class="news-group-label"><span class="news-group-dot"></span>B站关注动态(${biliShown.length})</div>`;
+            biliHtml += biliShown.map(rowHtml).join('');
+            biliHtml += `<div class="news-divider"></div>`;
+        }
+
+        let newsHtml = '';
+        if (!news || news.length === 0) {
+            newsHtml = `<div class="empty-state">
+                <div class="empty-mark">◈</div>
+                <div class="empty-title">暂无新闻</div>
+                <div class="empty-text">请检查网络后点击右上角「刷新」</div>
+            </div>`;
+        } else {
+            const maxCount = Math.max(5, currentSettings.newsCount || FALLBACK_SETTINGS.newsCount);
+            const shown = news.slice(0, maxCount); // 按设置中的条数截断
+            currentNews = shown;                   // 记录当前列表供 TTS 阅读
+            const matchedRows = shown.filter(n => n.matched);
+            const otherRows = shown.filter(n => !n.matched);
+            if (matchedRows.length) {
+                newsHtml += `<div class="news-group-label"><span class="news-group-dot"></span>关注关键词(${matchedRows.length})</div>`;
+                newsHtml += matchedRows.map(rowHtml).join('');
+            }
+            if (matchedRows.length && otherRows.length) {
+                newsHtml += `<div class="news-divider"></div>`;
+            }
+            if (otherRows.length) {
+                newsHtml += `<div class="news-group-label news-group-other"><span class="news-group-dot"></span>其他(${otherRows.length})</div>`;
+                newsHtml += otherRows.map(rowHtml).join('');
+            }
+        }
+
+        newsList.innerHTML = biliHtml + newsHtml;
+        WML.Reload();
+        showNewsRefreshTime(Date.now()); // 刷新成功:更新最后刷新时间
+    } catch (err) {
+        console.warn('获取新闻失败:', err);
+        // 刷新失败:列表保持原样,仅提示
+        newsList.innerHTML = `<div class="empty-state">
+            <div class="empty-mark">◈</div>
+            <div class="empty-title">获取新闻失败</div>
+            <div class="empty-text">${escapeHtml(err?.message || String(err))}</div>
+        </div>`;
+    } finally {
+        setNewsLoading(false); // 结束加载状态
+    }
+}
+
+// 点击新闻链接时标记为已查看(用捕获阶段确保记录后仍正常跳转)
+newsList.addEventListener('click', e => {
+    const row = e.target.closest('a.news-row');
+    if (row) markNewsViewed(row.getAttribute('href'));
+}, true);
+
+newsRefreshBtn.addEventListener('click', loadNews); // 手动刷新
+
+// ------------------------------------------------------------------------
+// 新闻 TTS 阅读:朗读未点击过的新闻标题,当前朗读的行高亮
+// ------------------------------------------------------------------------
+const newsReadBtn = document.getElementById('news-read');
+
+// 高亮当前正在朗读的新闻行(清除其他行的高亮)
+function setReadingRow(html) {
+    newsList.querySelectorAll('.news-row.reading').forEach(el => el.classList.remove('reading'));
+    const row = newsList.querySelector(`.news-row[href="${CSS.escape(html)}"]`);
+    if (row) row.classList.add('reading');
+}
+
+function readUnviewedNews() {
+    if (typeof speechSynthesis === 'undefined' || !('SpeechSynthesisUtterance' in window)) {
+        alert('当前环境不支持语音朗读(TTS)'); // 无 TTS 能力时提示
+        return;
+    }
+    if (speechSynthesis.speaking) {
+        speechSynthesis.cancel(); // 正在朗读则停止(再次点击即停止)
+        newsReadBtn.textContent = '阅读'; // 显式复位按钮文案,避免 cancel 不触发 onend 时停留在「停止」
+        newsReadBtn.classList.remove('is-active');
+        setReadingRow(''); // 清除高亮
+        return;
+    }
+    const viewed = getViewedNews(); // 已查看集合
+    const unread = currentNews.filter(n => !viewed.has(n.url)); // 仅未点击过的
+    if (!unread.length) {
+        alert('没有未阅读的新闻'); // 全部已读
+        return;
+    }
+    // 逐条朗读(每条一句),当前朗读的行高亮显示
+    unread.forEach((n, i) => {
+        const utter = new SpeechSynthesisUtterance(`${n.title}`);
+        utter.lang = 'zh-CN';
+        utter.rate = 1.0;
+        utter.onstart = () => setReadingRow(n.url); // 开始朗读时高亮对应行
+        utter.onend = () => { if (i === unread.length - 1) setReadingRow(''); }; // 最后一条读完清除高亮
+        speechSynthesis.speak(utter);
+    });
+    newsReadBtn.textContent = '停止'; // 朗读中按钮显示「停止」
+    newsReadBtn.classList.add('is-active');
+    speechSynthesis.onend = () => { newsReadBtn.textContent = '阅读'; newsReadBtn.classList.remove('is-active'); setReadingRow(''); };
+}
+
+newsReadBtn.addEventListener('click', readUnviewedNews);
+
+// ------------------------------------------------------------------------
+// Github:监听仓库最新提交(带刷新动画,解析出错 30 秒后自动重试)
+// ------------------------------------------------------------------------
+const repoList = document.getElementById('repo-list');
+const githubRefreshBtn = document.getElementById('github-refresh');
+const githubRefreshTime = document.getElementById('github-refresh-time'); // 最后一次成功刷新时间徽标
+
+// 显示最后一次成功刷新的时间(持久化到 localStorage,重启后仍可显示)
+function showGithubRefreshTime(ts) {
+    if (!githubRefreshTime) return;
+    localStorage.setItem('tengtu-github-last-success', String(ts || Date.now()));
+    const d = new Date(ts || Date.now());
+    const p = n => String(n).padStart(2, '0');
+    githubRefreshTime.textContent = `最后刷新 ${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
+}
+
+// 初始化:优先从 localStorage 恢复上次成功时间
+(function initGithubRefreshTime() {
+    if (!githubRefreshTime) return;
+    const saved = parseInt(localStorage.getItem('tengtu-github-last-success'), 10);
+    if (saved) showGithubRefreshTime(saved);
+})();
+
+let githubRetryTimer = null; // 失败 / 无数据后的 30 秒重试定时器
+
+// 调度 30 秒后的自动重试(与设置的刷新间隔无关,仅用于异常恢复)
+function scheduleGithubRetry() {
+    clearTimeout(githubRetryTimer);
+    githubRetryTimer = setTimeout(() => {
+        githubRetryTimer = null;
+        loadGithub();
+    }, 30000);
+}
+
+// 切换刷新按钮的加载状态(转圈动画)
+function setGithubLoading(loading) {
+    githubRefreshBtn.classList.toggle('loading', loading);
+    githubRefreshBtn.disabled = loading;
+}
+
+async function loadGithub() {
+    setGithubLoading(true); // 显示刷新过程
+    try {
+        const feeds = await SettingsService.GetGithubFeeds();
+        clearTimeout(githubRetryTimer); // 先取消可能挂起的重试
+        githubRetryTimer = null;
+
+        if (!feeds || feeds.length === 0) {
+            repoList.innerHTML = `
+                <div class="empty-state">
+                    <div class="empty-mark">◈</div>
+                    <div class="empty-title">暂无关注项目</div>
+                    <div class="empty-text">在「设置」中按 仓库地址|项目说明 每行一条添加</div>
+                </div>`;
+            return; // 未配置仓库不算错误,不重试
+        }
+
+        repoList.innerHTML = feeds.map(feed => {
+            // 版本徽标:有更新则高亮可点击下载;本地已最新则直接勾选本地版本(不再显示「最新」标签)
+            let versionBadge = '';
+            // 「本地 xxx」徽标也做成可点击链接,跳转到仓库版本列表页(releases),
+            // 便于查看本地版本对应与相邻的所有版本;feed.repo 为 owner/repo 短名。
+            const releasesURL = feed.repo
+                ? 'https://github.com/' + feed.repo + '/releases'
+                : (feed.url || '#');
+            if (feed.latest) {
+                if (feed.hasUpdate) {
+                    // 发现新版本:高亮可点击下载 + 检视按钮
+                    const badgeText = `发现新版本 ${escapeHtml(feed.latest)}`;
+                    versionBadge = `<a class="ver-badge ver-update" href="${escapeHtml(feed.releaseURL || feed.url)}" target="_blank" data-wml-openURL="${escapeHtml(feed.releaseURL || feed.url)}" title="点击前往下载">● ${badgeText}</a>`;
+                    if (feed.version) versionBadge += `<a class="ver-local" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表">本地 ${escapeHtml(feed.version)}</a>`;
+                    versionBadge += `<button class="btn-ghost repo-check-btn" data-url="${escapeHtml(feed.url)}" data-latest="${escapeHtml(feed.latest)}">检视</button>`;
+                } else if (feed.version) {
+                    // 本地已是最新版本:勾选本地版本号,不再显示「最新 xxx」标签
+                    versionBadge = `<a class="ver-badge ver-ok" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表">✓ 本地 ${escapeHtml(feed.version)}</a>`;
+                } else {
+                    // 无本地版本可勾选时,仍显示最新版本徽标
+                    versionBadge = `<span class="ver-badge ver-ok">✓ 最新 ${escapeHtml(feed.latest)}</span>`;
+                }
+            } else if (feed.version) {
+                versionBadge = `<a class="ver-badge ver-muted" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表">本地 ${escapeHtml(feed.version)}</a>`;
+            }
+
+            // Latest 之后的版本(Pre-release 等):仅列出展示,不参与更新比较
+            let newerReleases = '';
+            if (feed.newer && feed.newer.length > 0) {
+                newerReleases = `<div class="repo-newer">` +
+                    feed.newer.map(r => {
+                        // 优先显示 release 名称(标题),无名称时回退到版本标签
+                        const displayName = r.name && r.name.trim() ? r.name : r.tagName;
+                        return `<a class="repo-newer-item${r.isPrerelease ? ' is-pre' : ''}${r.isDraft ? ' is-draft' : ''}" href="${escapeHtml(r.url)}" target="_blank" data-wml-openURL="${escapeHtml(r.url)}" title="发布于 ${escapeHtml(r.publishedAt || '未知')}">${escapeHtml(displayName)}${r.isPrerelease ? ' · 预发布' : ''}${r.isDraft ? ' · 草稿' : ''}</a>`;
+                    }).join('') +
+                    `</div>`;
+            }
+
+            return `
+                <div class="repo-card">
+                    <div class="repo-head">
+                        <a class="repo-name" href="${escapeHtml(feed.url)}" target="_blank" data-wml-openURL="${escapeHtml(feed.url)}">${escapeHtml(feed.repo || feed.url)}</a>
+                        ${feed.desc ? `<span class="repo-desc">${escapeHtml(feed.desc)}</span>` : ''}
+                        ${versionBadge}
+                    </div>
+                    ${newerReleases}
+                </div>`;
+        }).join('');
+        WML.Reload();
+
+        // 「检视」按钮:标记已检查,把本地版本更新为最新版,消除更新提示
+        repoList.querySelectorAll('.repo-check-btn').forEach(btn => {
+            btn.addEventListener('click', async () => {
+                btn.disabled = true; // 防止重复点击
+                btn.textContent = '处理中…';
+                try {
+                    await SettingsService.MarkGithubRepoChecked(btn.dataset.url, btn.dataset.latest);
+                    await loadGithub(); // 重新拉取,更新徽标状态
+                } catch (err) {
+                    console.warn('标记检视失败:', err);
+                    btn.disabled = false;
+                    btn.textContent = '检视';
+                }
+            });
+        });
+
+        // 任一仓库抓取失败 → 30 秒后自动重试(提交列表已不显示,无数据不再触发轮询)
+        const anyError = feeds.some(f => f.error);
+        // 至少一个仓库抓取成功才算一次「成功刷新」,更新最后成功时间
+        if (feeds.some(f => !f.error)) {
+            showGithubRefreshTime(Date.now());
+        }
+        if (anyError) {
+            scheduleGithubRetry();
+        }
+    } catch (err) {
+        console.warn('获取仓库动态失败:', err);
+        scheduleGithubRetry(); // 异常:30 秒后自动重试
+    } finally {
+        setGithubLoading(false);
+    }
+}
+
+githubRefreshBtn.addEventListener('click', loadGithub); // 手动刷新
+
+// ------------------------------------------------------------------------
+// Github 热门仓库:抓取 github.com/trending,英文简介已由后端翻译为中文
+// ------------------------------------------------------------------------
+const trendingList = document.getElementById('trending-list');
+const trendingRefreshBtn = document.getElementById('trending-refresh');
+const trendingRefreshTime = document.getElementById('trending-refresh-time'); // 最后一次成功刷新时间
+const trendingBlock = document.querySelector('.trending-block'); // 热门仓库整块(失败时整块隐藏)
+
+// 显示最后一次成功刷新的时间(持久化到 localStorage,重启后仍可显示)
+function showTrendingRefreshTime(ts) {
+    if (!trendingRefreshTime) return;
+    localStorage.setItem('tengtu-trending-last-success', String(ts || Date.now()));
+    const d = new Date(ts || Date.now());
+    const p = n => String(n).padStart(2, '0');
+    trendingRefreshTime.textContent = `最后刷新 ${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
+}
+
+// 初始化:优先从 localStorage 恢复上次成功时间
+(function initTrendingRefreshTime() {
+    if (!trendingRefreshTime) return;
+    const saved = parseInt(localStorage.getItem('tengtu-trending-last-success'), 10);
+    if (saved) showTrendingRefreshTime(saved);
+})();
+
+// 切换热门仓库刷新按钮的加载状态(转圈动画)
+function setTrendingLoading(loading) {
+    trendingRefreshBtn.classList.toggle('loading', loading);
+    trendingRefreshBtn.disabled = loading;
+}
+
+async function loadTrending() {
+    if (!trendingList) return;
+    setTrendingLoading(true); // 显示刷新过程(转圈动画,按钮不可再点)
+    try {
+        const repos = await SettingsService.GetTrendingRepos();
+        if (!repos || repos.length === 0) {
+            // 获取不到:整块隐藏,不显示占位提示
+            trendingBlock.style.display = 'none';
+            return;
+        }
+        trendingBlock.style.display = ''; // 有数据时恢复显示
+        trendingList.innerHTML = repos.map(r => `
+            <a class="trending-card" href="${escapeHtml(r.url)}" target="_blank" data-wml-openURL="${escapeHtml(r.url)}">
+                <div class="trending-card-head">
+                    <span class="trending-repo">${escapeHtml(r.name)}</span>
+                    ${r.lang ? `<span class="trending-lang">${escapeHtml(r.lang)}</span>` : ''}
+                </div>
+                ${r.desc ? `<div class="trending-desc">${escapeHtml(r.desc)}</div>` : ''}
+                <div class="trending-meta">
+                    ${r.todayStars ? `<span class="trending-stat trending-today">★ 今日 +${escapeHtml(r.todayStars)}</span>` : ''}
+                    ${r.stars ? `<span class="trending-stat">★ ${escapeHtml(r.stars)}</span>` : ''}
+                    ${r.forks ? `<span class="trending-stat">⑂ ${escapeHtml(r.forks)}</span>` : ''}
+                </div>
+            </a>`).join('');
+        WML.Reload();
+        showTrendingRefreshTime(Date.now()); // 刷新成功:更新最后刷新时间
+    } catch (err) {
+        console.warn('获取热门仓库失败:', err);
+        // 获取失败:整块隐藏(用户要求「无法获取则不显示」)
+        trendingBlock.style.display = 'none';
+    } finally {
+        setTrendingLoading(false); // 结束加载状态
+    }
+}
+
+trendingRefreshBtn.addEventListener('click', loadTrending); // 手动刷新
+
+// ------------------------------------------------------------------------
+// 记录:编辑记录文件
+// ------------------------------------------------------------------------
+const recordEditor = document.getElementById('record-editor');   // 记录编辑框
+const recordStatus = document.getElementById('record-status');   // 记录状态提示
+const recordSaveBtn = document.getElementById('record-save');    // 记录保存按钮
+const recordPathDesc = document.getElementById('record-path-desc'); // 显示当前记录文件路径
+const recordPreview = document.getElementById('record-preview'); // Markdown 预览容器
+const recordModeEdit = document.getElementById('record-mode-edit');   // 编辑模式按钮
+const recordModePreview = document.getElementById('record-mode-preview'); // 预览模式按钮
+
+// 记录页显示模式:'edit' 源码编辑 / 'preview' Markdown 预览
+let recordMode = 'edit';
+
+async function loadRecord() {
+    recordPathDesc.textContent = `记录零碎信息,当前文件:${currentSettings.recordPath || FALLBACK_SETTINGS.recordPath}`;
+    try {
+        recordEditor.value = await SettingsService.ReadRecord();
+        recordStatus.textContent = '';
+        refreshRecordPreview(); // 首次加载后刷新预览
+    } catch (err) {
+        recordStatus.textContent = '读取失败:' + (err?.message || err);
+    }
+}
+
+// 把当前编辑内容渲染到 Markdown 预览区(调用后端 gomarkdown)
+async function refreshRecordPreview() {
+    const md = recordEditor.value;
+    try {
+        recordPreview.innerHTML = await SettingsService.RenderMarkdown(md);
+    } catch (err) {
+        recordPreview.textContent = '预览失败:' + (err?.message || err);
+    }
+}
+
+// 切换记录页显示模式(编辑 / 预览),并给两个按钮标记激活态
+function setRecordMode(mode) {
+    recordMode = mode;
+    const isEdit = mode === 'edit';
+    recordModeEdit.classList.toggle('is-active', isEdit);
+    recordModePreview.classList.toggle('is-active', !isEdit);
+    recordEditor.style.display = isEdit ? '' : 'none';
+    recordPreview.style.display = isEdit ? 'none' : '';
+    if (!isEdit) refreshRecordPreview(); // 进入预览时渲染一次
+}
+
+recordModeEdit.addEventListener('click', () => setRecordMode('edit'));
+recordModePreview.addEventListener('click', () => setRecordMode('preview'));
+
+// 编辑内容变化时,若正处于预览模式则实时刷新(简单去抖)
+let recordPreviewTimer = null;
+recordEditor.addEventListener('input', () => {
+    if (recordMode !== 'preview') return;
+    clearTimeout(recordPreviewTimer);
+    recordPreviewTimer = setTimeout(refreshRecordPreview, 250);
+});
+
+recordSaveBtn.addEventListener('click', async () => {
+    recordStatus.textContent = '保存中…';
+    try {
+        await SettingsService.WriteRecord(recordEditor.value);
+        recordStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
+    } catch (err) {
+        recordStatus.textContent = '保存失败:' + (err?.message || err);
+    }
+});
+
+// ------------------------------------------------------------------------
+// 待办:每行记录一个待完成任务,可勾选完成 / 编辑 / 删除,保存到本地文件
+// ------------------------------------------------------------------------
+const todoList = document.getElementById('todo-list');           // 待办任务列表容器
+const todoSaveBtn = document.getElementById('todo-save');        // 保存按钮
+const todoAddBtn = document.getElementById('todo-add');          // 添加任务按钮
+const todoStatus = document.getElementById('todo-status');       // 保存状态提示
+const todoPathDesc = document.getElementById('todo-path-desc');  // 显示当前待办文件路径
+let todoDirty = false; // 是否有未保存修改
+
+// 解析待办行:分离任务文本与提醒时间。
+// 行格式:任务 @ 2026-10-31 17:30 或 任务 @ 17:30(当天)。
+// 返回 {text, reminder},reminder 为 datetime-local 可用的值(YYYY-MM-DDTHH:mm)或空。
+function parseTodoLine(line) {
+    const m = line.match(/\s*@\s*((\d{4}-\d{2}-\d{2})\s+)?(\d{1,2}):(\d{2})\s*$/);
+    if (!m) return { text: line.trim(), reminder: '' };
+    let reminder = '';
+    const hh = m[3].padStart(2, '0');
+    const mm = m[4].padStart(2, '0');
+    if (m[2]) {
+        reminder = `${m[2]}T${hh}:${mm}`; // 带日期
+    } else {
+        // 无日期:默认今天
+        const today = new Date();
+        const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
+        reminder = `${ymd}T${hh}:${mm}`;
+    }
+    return { text: line.replace(/\s*@\s*((\d{4}-\d{2}-\d{2})\s+)?(\d{1,2}):(\d{2})\s*$/, '').trim(), reminder };
+}
+
+// 把 datetime-local 值转为待办行标记(YYYY-MM-DD HH:MM)
+function todoReminderMark(value) {
+    if (!value) return '';
+    // value 形如 2026-10-31T17:30,转为 2026-10-31 17:30
+    return value.replace('T', ' ');
+}
+
+// 渲染一行待办:复选框(完成状态)+ 文本输入框 + 提醒时间 + 删除按钮
+function renderTodoRow(text, done, reminder) {
+    const row = document.createElement('div');
+    row.className = 'todo-row';
+
+    const check = document.createElement('input');
+    check.type = 'checkbox';
+    check.className = 'todo-check';
+    check.checked = !!done;
+    check.title = '标记完成';
+    check.addEventListener('change', () => {
+        input.classList.toggle('todo-done', check.checked); // 勾选后文字划线
+        todoDirty = true;
+    });
+
+    const input = document.createElement('input');
+    input.type = 'text';
+    input.className = 'todo-input';
+    input.value = text;
+    input.placeholder = '输入待办任务…';
+    input.spellcheck = false;
+    if (done) input.classList.add('todo-done');
+    input.addEventListener('input', () => { todoDirty = true; });
+    input.addEventListener('keydown', (e) => {
+        if (e.key === 'Enter') { e.preventDefault(); addTodoRow(); } // 回车快速添加下一行
+    });
+
+    // 提醒时间选择器(datetime-local):设置后到点弹窗提醒
+    const remind = document.createElement('input');
+    remind.type = 'datetime-local';
+    remind.className = 'todo-remind';
+    remind.value = reminder || '';
+    remind.title = '设置提醒时间,到点弹窗提醒';
+    remind.addEventListener('change', () => { todoDirty = true; });
+    remind.addEventListener('input', () => { todoDirty = true; });
+
+    // 清除提醒时间按钮
+    const clearRemind = document.createElement('button');
+    clearRemind.className = 'btn-ghost todo-remind-clear';
+    clearRemind.textContent = '×';
+    clearRemind.title = '清除提醒时间';
+    clearRemind.disabled = !reminder;
+    clearRemind.addEventListener('click', () => {
+        remind.value = '';
+        clearRemind.disabled = true;
+        todoDirty = true;
+    });
+    remind.addEventListener('change', () => {
+        clearRemind.disabled = !remind.value;
+    });
+
+    const del = document.createElement('button');
+    del.className = 'btn-ghost todo-del';
+    del.textContent = '删除';
+    del.title = '删除此行';
+    del.addEventListener('click', () => {
+        row.remove();
+        todoDirty = true;
+    });
+
+    row.appendChild(check);
+    row.appendChild(input);
+    row.appendChild(remind);
+    row.appendChild(clearRemind);
+    row.appendChild(del);
+    return row;
+}
+
+// 添加一行空的待办(聚焦输入框)
+function addTodoRow() {
+    const row = renderTodoRow('', false);
+    todoList.appendChild(row);
+    row.querySelector('.todo-input').focus();
+    todoDirty = true;
+}
+
+// 从后端读取待办并渲染:每行一个任务,行首 [x] 表示已完成,行尾 @ 时间表示提醒
+async function loadTodo() {
+    todoPathDesc.textContent = `待办任务,当前文件:${currentSettings.todoPath || FALLBACK_SETTINGS.todoPath}`;
+    todoList.innerHTML = '';
+    try {
+        const raw = await SettingsService.ReadTodo();
+        const lines = (raw || '').split(/\r?\n/);
+        let hasContent = false;
+        for (const line of lines) {
+            if (!line.trim()) continue; // 跳过空行
+            hasContent = true;
+            const done = /^\[x\]\s*/i.test(line); // 行首 [x] 标记完成
+            const body = line.replace(/^\[[ x]\]\s*/i, ''); // 去掉 [x] 前缀
+            const parsed = parseTodoLine(body);             // 分离文本与提醒时间
+            todoList.appendChild(renderTodoRow(parsed.text, done, parsed.reminder));
+        }
+        if (!hasContent) addTodoRow(); // 无任务时给一行空输入
+        todoDirty = false;
+        todoStatus.textContent = '';
+    } catch (err) {
+        todoStatus.textContent = '读取失败:' + (err?.message || err);
+    }
+}
+
+// 保存所有待办:每行输出 "[x] 任务 @ 提醒时间" 或 "任务 @ 提醒时间",跳过空行
+async function saveTodo() {
+    const lines = [];
+    todoList.querySelectorAll('.todo-row').forEach(row => {
+        const input = row.querySelector('.todo-input');
+        const done = row.querySelector('.todo-check').checked;
+        const remind = row.querySelector('.todo-remind').value;
+        const text = input.value.trim();
+        if (!text) return; // 空行不保存
+        const mark = todoReminderMark(remind); // 提醒标记(YYYY-MM-DD HH:MM)
+        const body = mark ? `${text} @ ${mark}` : text;
+        lines.push(done ? `[x] ${body}` : body);
+    });
+    todoStatus.textContent = '保存中…';
+    try {
+        await SettingsService.WriteTodo(lines.join('\n'));
+        todoStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
+        todoDirty = false;
+    } catch (err) {
+        todoStatus.textContent = '保存失败:' + (err?.message || err);
+    }
+}
+
+todoSaveBtn.addEventListener('click', saveTodo); // 保存全部待办
+todoAddBtn.addEventListener('click', addTodoRow); // 添加一行
+
+// ------------------------------------------------------------------------
+// 待办提醒:后端到点广播 todoReminder 事件,前端弹出置顶消息窗
+// ------------------------------------------------------------------------
+const remindModal = document.getElementById('remind-modal');
+const remindText = document.getElementById('remind-text');
+const remindTime = document.getElementById('remind-time');
+const remindOk = document.getElementById('remind-ok');
+const remindMask = document.getElementById('remind-mask');   // 弹窗遮罩,点击可关闭
+let remindOpen = false;
+
+// 显示提醒弹窗:payload 为 "任务文本\nYYYY-MM-DD HH:MM"
+function showTodoReminder(payload) {
+    const lines = String(payload || '').split('\n');
+    remindText.textContent = lines[0] || '';
+    remindTime.textContent = lines[1] ? '提醒时间:' + lines[1] : '';
+    remindModal.hidden = false;
+    remindOpen = true;
+    // 窗口可能隐藏,唤回前台(后端已 showMainWindow,此处确保聚焦)
+    Window.Show();
+    Window.Focus();
+}
+
+// 关闭提醒弹窗
+function closeTodoReminder() {
+    remindModal.hidden = true;
+    remindOpen = false;
+}
+
+remindOk.addEventListener('click', closeTodoReminder);
+remindMask.addEventListener('click', closeTodoReminder);
+
+// 订阅后端待办提醒事件
+Events.On('todoReminder', (event) => {
+    showTodoReminder(event.data);
+});
+
+// 从待办页切走时若有未保存修改,自动保存(与设置页一致)
+const todoPanel = document.getElementById('tab-todo');
+if (todoPanel) {
+    new MutationObserver(() => {
+        if (todoDirty) saveTodo(); // 列表变化后自动保存
+    }).observe(todoList, { childList: true, subtree: false });
+}
+
+// ------------------------------------------------------------------------
+// 主机:API 服务状态、电源命令状态与 Gin 日志
+// ------------------------------------------------------------------------
+const apiStatusEl = document.getElementById('api-status'); // API 服务状态文本
+const apiToggleBtn = document.getElementById('api-toggle'); // API 服务停止/启动按钮
+const pcStatusEl = document.getElementById('pc-status');   // 电源命令状态文本
+const logView = document.getElementById('log-view');       // Gin 日志视图
+const logClearBtn = document.getElementById('log-clear');  // 清空日志按钮
+const screenshotCaptureBtn = document.getElementById('shot-capture'); // 截屏按钮(工具页)
+const screenshotBox = document.getElementById('screenshot-box');           // 截屏预览容器
+const screenshotImg = document.getElementById('screenshot-img');           // 截屏图片
+const screenshotHint = document.getElementById('screenshot-hint');         // 截屏提示(尺寸/时间)
+const shotStatusEl = document.getElementById('shot-status');               // 截屏状态徽标
+const shotHintEl = document.getElementById('shot-hint');                   // 截屏提示文字
+
+// 截屏并显示预览:先隐藏本程序窗口,截取全屏后恢复显示
+async function captureScreenshot() {
+    if (!screenshotCaptureBtn) return;
+    screenshotCaptureBtn.classList.add('loading'); // 转圈反馈
+    screenshotCaptureBtn.disabled = true;
+    shotHintEl.textContent = '';
+    shotStatusEl.textContent = '截屏中…';
+    try {
+        // 1) 隐藏本程序窗口,避免截到自身;等窗口真正隐藏后再截
+        Window.Hide();
+        await new Promise(r => setTimeout(r, 400));
+
+        // 2) 截取全部屏幕
+        const dataURI = await SettingsService.CaptureScreen(-1); // -1 全部屏幕
+        if (!dataURI) throw new Error('未返回图片数据');
+
+        // 3) 恢复显示本程序窗口并聚焦
+        Window.Show();
+        Window.Focus();
+
+        // 4) 显示预览
+        screenshotImg.src = dataURI;
+        screenshotBox.hidden = false;
+        // 显示截图尺寸与时间
+        screenshotImg.onload = () => {
+            const d = new Date();
+            const p = n => String(n).padStart(2, '0');
+            screenshotHint.textContent = `${screenshotImg.naturalWidth}×${screenshotImg.naturalHeight} | ${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
+        };
+        shotStatusEl.textContent = '已截取';
+        shotHintEl.textContent = '截图已更新(本程序窗口已自动隐藏避免入镜)';
+    } catch (err) {
+        console.warn('截屏失败:', err);
+        // 失败也要恢复窗口,避免窗口保持隐藏
+        Window.Show();
+        Window.Focus();
+        screenshotBox.hidden = false;
+        screenshotImg.removeAttribute('src');
+        screenshotHint.textContent = '截屏失败:' + (err?.message || String(err));
+        shotStatusEl.textContent = '截屏失败';
+        shotHintEl.textContent = '';
+    } finally {
+        screenshotCaptureBtn.classList.remove('loading');
+        screenshotCaptureBtn.disabled = false;
+    }
+}
+
+if (screenshotCaptureBtn) screenshotCaptureBtn.addEventListener('click', captureScreenshot); // 点击截屏
+
+async function loadHostPanel() {
+    try {
+        const status = await SettingsService.GetAPIStatus();
+        apiStatusEl.textContent = status.running
+            ? `运行中 | http://${status.addr} | 启动于 ${status.startedAt}`
+            : `未运行${status.addr ? '(' + status.addr + ')' : ''}${status.error ? ':' + status.error : ''}`;
+        // 按钮乒乓:运行中→「停止服务」,未运行→「启动服务」
+        apiToggleBtn.textContent = status.running ? '停止服务' : '启动服务';
+        apiToggleBtn.classList.toggle('is-active', status.running);
+
+        const pc = await SettingsService.GetPCStatus();
+        pcStatusEl.textContent = pc.requested
+            ? (pc.pending
+                ? `待执行:${pc.command},剩余 ${pc.remainingSecs} 秒(来自 ${pc.clientIp})`
+                : `最近请求:${pc.command || '-'}(${pc.requestTime || '-'},来自 ${pc.clientIp || '-'})`)
+            : '暂无请求';
+
+        const logs = await SettingsService.GetAPILogs();
+        logView.textContent = logs && logs.length ? logs.join('\n') : '暂无日志';
+        logView.scrollTop = logView.scrollHeight; // 滚动到最新一行
+    } catch (err) {
+        apiStatusEl.textContent = '状态获取失败(浏览器预览模式无后端)';
+        console.warn('主机面板加载失败:', err);
+    }
+}
+
+// 乒乓切换 API 服务:运行中→停止,未运行→启动
+async function toggleAPIService() {
+    apiToggleBtn.disabled = true;
+    try {
+        const status = await SettingsService.GetAPIStatus();
+        if (status.running) {
+            await SettingsService.StopAPIService();
+        } else {
+            await SettingsService.StartAPIService();
+        }
+        await loadHostPanel(); // 刷新状态与日志
+    } catch (err) {
+        console.warn('API 服务切换失败:', err);
+        apiStatusEl.textContent = '操作失败:' + (err?.message || String(err));
+    } finally {
+        apiToggleBtn.disabled = false;
+    }
+}
+
+apiToggleBtn.addEventListener('click', toggleAPIService);
+
+// 后端实时推送的日志行:追加显示,保持滚动到底部
+Events.On('apilog', (event) => {
+    if (logView.textContent === '暂无日志') logView.textContent = '';
+    const nearBottom = logView.scrollTop + logView.clientHeight >= logView.scrollHeight - 20;
+    logView.textContent += '\n' + event.data;
+    if (nearBottom) logView.scrollTop = logView.scrollHeight; // 已在底部时跟随滚动
+});
+
+logClearBtn.addEventListener('click', async () => {
+    try {
+        await SettingsService.ClearAPILogs();
+    } catch (err) { /* 预览模式忽略 */ }
+    logView.textContent = '暂无日志';
+});
+
+// ------------------------------------------------------------------------
+// 工具页:功能卡片折叠——点击标题切换展开/收起,默认全部折叠
+// ------------------------------------------------------------------------
+function initToolCollapse() {
+    document.querySelectorAll('.tool-card').forEach(card => {
+        const head = card.querySelector('.tool-card-head');
+        if (!head) return;
+        const toggle = () => {
+            const collapsed = card.classList.toggle('is-collapsed');
+            head.setAttribute('aria-expanded', String(!collapsed));
+        };
+        head.addEventListener('click', toggle);
+        head.addEventListener('keydown', e => {
+            if (e.key === 'Enter' || e.key === ' ') {
+                e.preventDefault();
+                toggle();
+            }
+        });
+    });
+}
+initToolCollapse();
+
+// ------------------------------------------------------------------------
+// 工具页:Web 服务——选择目录,开启本地浏览服务
+// ------------------------------------------------------------------------
+const websvcStatusEl = document.getElementById('websvc-status');  // 运行状态徽标
+const websvcDirInput = document.getElementById('websvc-dir');     // 目录输入框
+const websvcPickBtn = document.getElementById('websvc-pick');     // 选择目录按钮
+const websvcStartBtn = document.getElementById('websvc-start');   // 开启服务按钮
+const websvcStopBtn = document.getElementById('websvc-stop');     // 停止服务按钮
+const websvcHintEl = document.getElementById('websvc-hint');      // 提示文字
+const websvcPreview = document.getElementById('websvc-preview');  // 预览容器
+const websvcFrame = document.getElementById('websvc-frame');      // 预览 iframe
+
+let websvcDir = ''; // 当前选中的目录(持久化于 localStorage)
+
+// 渲染 Web 服务状态(运行中 / 未运行)
+function renderWebSvc(info) {
+    if (info && info.running) {
+        // URL 做成可点击链接:点击用系统浏览器打开
+        websvcStatusEl.innerHTML = `● 运行中 <a class="tool-url" href="${escapeHtml(info.url)}" target="_blank" data-wml-openURL="${escapeHtml(info.url)}" title="点击在浏览器打开">${escapeHtml(info.url)}</a>`;
+        websvcStatusEl.className = 'tool-status is-on';
+        websvcStartBtn.disabled = true;
+        websvcStopBtn.disabled = false;
+        websvcHintEl.textContent = info.indexName
+            ? `默认页 ${info.indexName}|${info.connections} 次请求`
+            : `未发现 index.html 或 readme.md,显示目录列表|${info.connections} 次请求`;
+        // 预览:加载服务首页
+        websvcPreview.hidden = false;
+        websvcFrame.src = info.url;
+    } else {
+        websvcStatusEl.textContent = '未运行';
+        websvcStatusEl.className = 'tool-status';
+        websvcStartBtn.disabled = false;
+        websvcStopBtn.disabled = true;
+        websvcHintEl.textContent = websvcDir ? '点击「开启浏览服务」开始' : '请先选择目录';
+        websvcPreview.hidden = true;
+        websvcFrame.src = 'about:blank';
+    }
+}
+
+// 首次进入工具页:恢复上次目录与运行状态
+async function loadToolsPanel() {
+    websvcDir = localStorage.getItem('tengtu-websvc-dir') || '';
+    if (websvcDir) websvcDirInput.value = websvcDir;
+    try {
+        const info = await SettingsService.WebServiceStatus();
+        if (info && info.running && info.dir) {
+            websvcDir = info.dir;
+            websvcDirInput.value = info.dir;
+            renderWebSvc(info);
+            return;
+        }
+    } catch (err) { /* 预览模式忽略 */ }
+    renderWebSvc(null);
+}
+
+// 选择目录
+websvcPickBtn.addEventListener('click', async () => {
+    try {
+        const dir = await SettingsService.PickWebDir();
+        if (!dir) return; // 用户取消
+        websvcDir = dir;
+        websvcDirInput.value = dir;
+        localStorage.setItem('tengtu-websvc-dir', dir);
+        renderWebSvc(null); // 重新渲染(若在运行则提示先停止)
+        const info = await SettingsService.WebServiceStatus();
+        if (info.running) renderWebSvc(info); // 仍运行则恢复状态
+    } catch (err) {
+        console.warn('选择目录失败:', err);
+        websvcHintEl.textContent = '选择目录失败:' + (err?.message || String(err));
+    }
+});
+
+// 开启浏览服务
+websvcStartBtn.addEventListener('click', async () => {
+    if (!websvcDir) {
+        websvcHintEl.textContent = '请先选择目录';
+        return;
+    }
+    websvcStartBtn.classList.add('loading');
+    websvcStartBtn.disabled = true;
+    try {
+        const info = await SettingsService.StartWebService(websvcDir);
+        renderWebSvc(info);
+    } catch (err) {
+        console.warn('开启服务失败:', err);
+        websvcHintEl.textContent = '开启失败:' + (err?.message || String(err));
+    } finally {
+        websvcStartBtn.classList.remove('loading');
+    }
+});
+
+// 停止浏览服务
+websvcStopBtn.addEventListener('click', async () => {
+    websvcStopBtn.classList.add('loading');
+    websvcStopBtn.disabled = true;
+    try {
+        await SettingsService.StopWebService();
+        renderWebSvc(null);
+    } catch (err) {
+        console.warn('停止服务失败:', err);
+        websvcHintEl.textContent = '停止失败:' + (err?.message || String(err));
+    } finally {
+        websvcStopBtn.classList.remove('loading');
+    }
+});
+
+// ------------------------------------------------------------------------
+// 翻译工具:中文→英文 / 英文→中文,自动检测方向(MyMemory 免费接口)
+// ------------------------------------------------------------------------
+const trStatusEl = document.getElementById('tr-status');    // 状态徽标
+const trSourceEl = document.getElementById('tr-source');    // 输入框
+const trGoBtn = document.getElementById('tr-go');           // 翻译按钮
+const trHintEl = document.getElementById('tr-hint');        // 提示文字
+const trResultEl = document.getElementById('tr-result');    // 结果区
+
+// 检测输入是否含中文:含 CJK 字符视为中文(→译英),否则视为英文(→译中)
+function isChineseText(text) {
+    return /[\u4e00-\u9fff]/.test(text);
+}
+
+// 翻译:调用后端 Translate,展示结果或错误
+async function doTranslate() {
+    const text = trSourceEl.value.trim();
+    if (!text) {
+        trHintEl.textContent = '请输入要翻译的内容';
+        return;
+    }
+    trGoBtn.classList.add('loading');
+    trGoBtn.disabled = true;
+    trHintEl.textContent = '';
+    trStatusEl.textContent = '翻译中…';
+    try {
+        const result = await SettingsService.Translate(text);
+        const dir = isChineseText(text) ? '中文 → 英文' : '英文 → 中文';
+        trResultEl.textContent = result.text;
+        trResultEl.hidden = false;
+        trStatusEl.textContent = dir;
+    } catch (err) {
+        console.warn('翻译失败:', err);
+        trHintEl.textContent = '翻译失败:' + (err?.message || String(err));
+        trStatusEl.textContent = '翻译失败';
+    } finally {
+        trGoBtn.classList.remove('loading');
+        trGoBtn.disabled = false;
+    }
+}
+
+trGoBtn.addEventListener('click', doTranslate);
+
+// 输入框内按 Ctrl/Cmd+Enter 触发翻译
+trSourceEl.addEventListener('keydown', (e) => {
+    if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+        e.preventDefault();
+        doTranslate();
+    }
+});
+
+// ------------------------------------------------------------------------
+// 二维码生成工具:输入字符串生成 PNG 二维码
+// ------------------------------------------------------------------------
+const qrStatusEl = document.getElementById('qr-status');   // 状态徽标
+const qrInputEl = document.getElementById('qr-input');     // 输入框
+const qrGoBtn = document.getElementById('qr-go');          // 生成按钮
+const qrHintEl = document.getElementById('qr-hint');       // 提示文字
+const qrBoxEl = document.getElementById('qr-box');         // 图片容器
+const qrImgEl = document.getElementById('qr-img');         // 二维码图片
+const qrSaveBtn = document.getElementById('qr-save');      // 下载按钮
+
+let qrDataUri = ''; // 最近一次生成的二维码 data URI(供下载)
+
+// 生成二维码:调用后端 GenerateQRCode
+async function doGenerateQR() {
+    const text = qrInputEl.value.trim();
+    if (!text) {
+        qrHintEl.textContent = '请输入要生成二维码的内容';
+        return;
+    }
+    qrGoBtn.classList.add('loading');
+    qrGoBtn.disabled = true;
+    qrHintEl.textContent = '';
+    qrStatusEl.textContent = '生成中…';
+    try {
+        const uri = await SettingsService.GenerateQRCode(text, 0);
+        qrDataUri = uri;
+        qrImgEl.src = uri;
+        qrBoxEl.hidden = false;
+        qrStatusEl.textContent = '已生成';
+    } catch (err) {
+        console.warn('生成二维码失败:', err);
+        qrHintEl.textContent = '生成失败:' + (err?.message || String(err));
+        qrStatusEl.textContent = '生成失败';
+    } finally {
+        qrGoBtn.classList.remove('loading');
+        qrGoBtn.disabled = false;
+    }
+}
+
+qrGoBtn.addEventListener('click', doGenerateQR);
+
+// 输入框内按 Ctrl/Cmd+Enter 触发生成
+qrInputEl.addEventListener('keydown', (e) => {
+    if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+        e.preventDefault();
+        doGenerateQR();
+    }
+});
+
+// 点击图片或下载按钮:把 data URI 转成 PNG 文件下载
+function downloadQR() {
+    if (!qrDataUri) return;
+    const a = document.createElement('a');
+    a.href = qrDataUri;
+    a.download = 'qrcode.png';
+    document.body.appendChild(a);
+    a.click();
+    a.remove();
+}
+qrImgEl.addEventListener('click', downloadQR);
+qrSaveBtn.addEventListener('click', downloadQR);
+
+// ------------------------------------------------------------------------
+// ntfy 消息推送工具:输入主题 / 标题 / 内容,推送到 ntfy 频道
+// ------------------------------------------------------------------------
+const ntfyStatusEl = document.getElementById('ntfy-status');   // 状态徽标
+const ntfyTopicEl = document.getElementById('ntfy-topic');     // 主题输入框
+const ntfyTitleEl = document.getElementById('ntfy-title');     // 标题输入框
+const ntfyMessageEl = document.getElementById('ntfy-message'); // 内容输入框
+const ntfyGoBtn = document.getElementById('ntfy-go');          // 发送按钮
+const ntfyHintEl = document.getElementById('ntfy-hint');       // 提示文字
+const ntfyTopicList = document.getElementById('ntfy-topic-list'); // 历史主题下拉建议
+
+// 加载已记忆的主题历史,填入 datalist 供输入时选择
+async function loadNtfyTopics() {
+    try {
+        const topics = await SettingsService.GetNtfyTopics();
+        ntfyTopicList.innerHTML = (topics || []).map(t =>
+            `<option value="${t.replace(/&/g, '&amp;').replace(/"/g, '&quot;')}">`).join('');
+    } catch (err) {
+        console.warn('加载 ntfy 主题历史失败:', err);
+    }
+}
+
+// 发送 ntfy 消息:调用后端 SendNtfyMessage,成功后刷新主题历史
+async function doSendNtfy() {
+    const topic = ntfyTopicEl.value.trim();
+    const title = ntfyTitleEl.value.trim();
+    const message = ntfyMessageEl.value.trim();
+    if (!topic) {
+        ntfyHintEl.textContent = '请输入推送主题(Topic)';
+        return;
+    }
+    if (!message) {
+        ntfyHintEl.textContent = '请输入消息内容';
+        return;
+    }
+    ntfyGoBtn.classList.add('loading');
+    ntfyGoBtn.disabled = true;
+    ntfyHintEl.textContent = '';
+    ntfyStatusEl.textContent = '发送中…';
+    try {
+        await SettingsService.SendNtfyMessage(topic, title, message);
+        ntfyStatusEl.textContent = '已发送';
+        ntfyHintEl.textContent = `已推送到 ${topic}`;
+        ntfyMessageEl.value = ''; // 发送成功清空内容(保留主题/标题便于连续发送)
+        await loadNtfyTopics();   // 刷新历史下拉
+    } catch (err) {
+        console.warn('ntfy 发送失败:', err);
+        ntfyHintEl.textContent = '发送失败:' + (err?.message || String(err));
+        ntfyStatusEl.textContent = '发送失败';
+    } finally {
+        ntfyGoBtn.classList.remove('loading');
+        ntfyGoBtn.disabled = false;
+    }
+}
+
+ntfyGoBtn.addEventListener('click', doSendNtfy);
+
+// 内容输入框内按 Ctrl/Cmd+Enter 发送
+ntfyMessageEl.addEventListener('keydown', (e) => {
+    if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+        e.preventDefault();
+        doSendNtfy();
+    }
+});
+
+loadNtfyTopics(); // 初始化时加载历史主题
+
+// ------------------------------------------------------------------------
+// ntfy 聊天室:输入昵称与主题,订阅并实时收发消息(QQ 气泡风格)
+// ------------------------------------------------------------------------
+const chatNicknameEl = document.getElementById('chat-nickname');   // 昵称输入框
+const chatTopicEl = document.getElementById('chat-topic');          // 主题输入框
+const chatToggleBtn = document.getElementById('chat-toggle');       // 乒乓按钮(开始监听/停止)
+const chatBox = document.getElementById('chat-box');                // 消息列表容器
+const chatListEl = document.getElementById('chat-list');            // 消息列表
+const chatStatusEl = document.getElementById('chat-status');        // 状态徽标
+const chatMessageEl = document.getElementById('chat-message');      // 消息输入框
+const chatSendBtn = document.getElementById('chat-send');           // 发送按钮
+
+let chatListening = false; // 是否正在监听
+let chatAutoScroll = true; // 用户上滚时暂停自动滚动到底部
+let chatNickname = '';     // 当前昵称(发送方标识,用于区分左右气泡)
+
+// 近期本地发送的消息(topic/title/message/at),用于去重 ntfy 的 SSE 回声:
+// 自己发送的消息会被 ntfy 经 SSE 回传一次,若不加去重会在本地显示后再追加一条。
+let chatLocalRecent = [];
+const CHAT_ECHO_WINDOW = 8000; // 8 秒内的同名同内容消息视为回声
+
+// 追加一条聊天消息为 QQ 气泡(自己的消息靠右,他人靠左)
+function appendChatMessage(msg) {
+    const mine = !!msg.title && msg.title === chatNickname && chatNickname !== '';
+    const row = document.createElement('div');
+    row.className = 'chat-msg' + (mine ? ' mine' : '');
+
+    const avatar = document.createElement('div');
+    avatar.className = 'chat-avatar';
+    avatar.textContent = (msg.title || msg.topic || '?').trim().charAt(0).toUpperCase();
+
+    const main = document.createElement('div');
+    main.className = 'chat-msg-main';
+    const head = document.createElement('div');
+    head.className = 'chat-msg-head';
+    const title = document.createElement('span');
+    title.className = 'chat-msg-title';
+    title.textContent = msg.title || msg.topic;
+    const time = document.createElement('span');
+    time.className = 'chat-msg-time';
+    time.textContent = msg.time || '';
+    head.append(title, time);
+
+    const body = document.createElement('div');
+    body.className = 'chat-bubble';
+    body.textContent = msg.message || '';
+
+    main.append(head, body);
+    // 自己的消息:头像在右,内容靠右;他人:头像在左
+    if (mine) {
+        row.append(main, avatar);
+    } else {
+        row.append(avatar, main);
+    }
+    chatListEl.appendChild(row);
+
+    if (chatAutoScroll) {
+        chatListEl.scrollTop = chatListEl.scrollHeight; // 滚动到底部
+    }
+}
+
+// 乒乓切换:未监听→开始监听;监听中→停止
+async function toggleChat() {
+    if (chatListening) {
+        await doStopChat();
+    } else {
+        await doStartChat();
+    }
+}
+
+// 开始监听:调用后端设置主题,成功后显示消息区域并切换按钮为「停止」
+async function doStartChat() {
+    const topic = chatTopicEl.value.trim();
+    if (!topic) {
+        chatStatusEl.textContent = '请输入主题(留空则不监听)';
+        return;
+    }
+    chatNickname = chatNicknameEl.value.trim(); // 记录当前昵称,用于区分左右气泡
+    chatToggleBtn.classList.add('loading');
+    chatToggleBtn.disabled = true;
+    try {
+        await SettingsService.SetNtfyChatTopic(topic);
+        chatListening = true;
+        chatToggleBtn.textContent = '停止监听';
+        chatToggleBtn.classList.add('is-active'); // 激活态:突出显示正在监听
+        chatStatusEl.textContent = '监听中:' + topic;
+        chatBox.hidden = false;
+        chatListEl.innerHTML = ''; // 清空旧消息,开始新会话
+        await loadNtfyTopics();    // 刷新历史下拉
+    } catch (err) {
+        console.warn('ntfy 聊天室启动失败:', err);
+        chatStatusEl.textContent = '启动失败:' + (err?.message || String(err));
+    } finally {
+        chatToggleBtn.classList.remove('loading');
+        chatToggleBtn.disabled = false;
+    }
+}
+
+// 停止监听:调用后端清空主题,隐藏消息区域并切换按钮为「开始监听」
+async function doStopChat() {
+    chatToggleBtn.classList.add('loading');
+    chatToggleBtn.disabled = true;
+    try {
+        await SettingsService.SetNtfyChatTopic(''); // 空主题 = 不监听
+        chatListening = false;
+        chatToggleBtn.textContent = '开始监听';
+        chatToggleBtn.classList.remove('is-active');
+        chatStatusEl.textContent = '';
+        chatBox.hidden = true;
+        chatListEl.innerHTML = '';
+    } catch (err) {
+        console.warn('ntfy 聊天室停止失败:', err);
+        chatStatusEl.textContent = '停止失败:' + (err?.message || String(err));
+    } finally {
+        chatToggleBtn.classList.remove('loading');
+        chatToggleBtn.disabled = false;
+    }
+}
+
+// 发送一条聊天消息:调用后端 SendNtfyChat(昵称作为标题),成功后追加到列表
+async function doSendChat() {
+    const topic = chatTopicEl.value.trim();
+    const nickname = chatNicknameEl.value.trim();
+    const message = chatMessageEl.value.trim();
+    if (!nickname) {
+        chatStatusEl.textContent = '请输入昵称';
+        return;
+    }
+    if (!topic) {
+        chatStatusEl.textContent = '请输入主题(聊天室名)';
+        return;
+    }
+    if (!message) {
+        chatStatusEl.textContent = '请输入消息内容';
+        return;
+    }
+    chatSendBtn.classList.add('loading');
+    chatSendBtn.disabled = true;
+    try {
+        await SettingsService.SendNtfyChat(topic, nickname, message);
+        chatNickname = nickname;                       // 更新昵称,用于区分左右气泡
+        chatMessageEl.value = '';                      // 清空输入
+        appendChatMessage({ topic, title: nickname, message, time: new Date().toLocaleTimeString('zh-CN', { hour12: false }) });
+        // 记录本地发送,用于去重 ntfy 的 SSE 回声
+        chatLocalRecent.push({ topic, title: nickname, message, at: Date.now() });
+        if (chatLocalRecent.length > 20) chatLocalRecent.shift();
+        await loadNtfyNicknames();                     // 刷新昵称历史下拉
+    } catch (err) {
+        console.warn('ntfy 聊天发送失败:', err);
+        chatStatusEl.textContent = '发送失败:' + (err?.message || String(err));
+    } finally {
+        chatSendBtn.classList.remove('loading');
+        chatSendBtn.disabled = false;
+    }
+}
+
+// 加载昵称历史到 datalist
+async function loadNtfyNicknames() {
+    try {
+        const nicknames = await SettingsService.GetNtfyNicknames();
+        const list = document.getElementById('ntfy-nickname-list');
+        if (list) {
+            list.innerHTML = '';
+            (nicknames || []).forEach(n => {
+                const opt = document.createElement('option');
+                opt.value = n;
+                list.appendChild(opt);
+            });
+        }
+        // 昵称为空时自动填入最近使用的昵称
+        if (!chatNicknameEl.value.trim() && nicknames && nicknames.length) {
+            chatNicknameEl.value = nicknames[0];
+            chatNickname = nicknames[0];
+        }
+    } catch (err) {
+        console.warn('加载 ntfy 昵称历史失败:', err);
+    }
+}
+
+chatToggleBtn.addEventListener('click', toggleChat);
+
+// 主题输入框按 Enter 开始监听
+chatTopicEl.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter') {
+        e.preventDefault();
+        doStartChat();
+    }
+});
+
+// 昵称输入框按 Enter 聚焦主题(快捷流转)
+chatNicknameEl.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter') {
+        e.preventDefault();
+        chatTopicEl.focus();
+    }
+});
+
+// 消息输入框按 Enter 发送
+chatMessageEl.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter' && !e.shiftKey) {
+        e.preventDefault();
+        doSendChat();
+    }
+});
+
+// 发送按钮点击发送
+chatSendBtn.addEventListener('click', doSendChat);
+
+// 用户滚动列表时记录是否仍在底部(决定是否自动跟随新消息)
+chatListEl.addEventListener('scroll', () => {
+    chatAutoScroll = chatListEl.scrollHeight - chatListEl.scrollTop - chatListEl.clientHeight < 24;
+});
+
+// 订阅后端 ntfyChat 事件:收到消息时追加到列表
+Events.On('ntfyChat', (event) => {
+    let msg;
+    try {
+        msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
+    } catch (err) {
+        console.warn('ntfyChat 消息解析失败:', event.data, err);
+        return;
+    }
+    // 去重 SSE 回声:自己刚发的消息会被 ntfy 回传,若 8 秒内同主题、
+    // 同内容则判定为回声,跳过(本地已显示)。
+    // 注意:只按 topic+message 匹配,不依赖 title——ntfy 回传时标题可能
+    // 缺失或与昵称不一致(后端会回退为主题名),避免去重失效。
+    const now = Date.now();
+    const idx = chatLocalRecent.findIndex(m =>
+        now - m.at < CHAT_ECHO_WINDOW &&
+        m.topic === msg.topic && m.message === msg.message);
+    if (idx > -1) {
+        chatLocalRecent.splice(idx, 1); // 消费该回声记录,避免后续重复命中
+        return;
+    }
+    appendChatMessage(msg);
+});
+
+loadNtfyNicknames(); // 初始化时加载昵称历史并自动填入最近使用的昵称
+
+// ------------------------------------------------------------------------
+// 古诗词工具:显示 footer 最近获取的古诗,可刷新获取新诗
+// ------------------------------------------------------------------------
+const poemToolBox = document.getElementById('poem-tool-box');         // 古诗内容容器
+const poemToolTitleEl = document.getElementById('poem-tool-title');   // 题目
+const poemToolAuthorEl = document.getElementById('poem-tool-author'); // 作者
+const poemToolContentEl = document.getElementById('poem-tool-content'); // 诗句
+const poemToolRefreshBtn = document.getElementById('poem-tool-refresh'); // 刷新按钮
+const poemToolReadBtn = document.getElementById('poem-tool-read');     // 朗读按钮
+const poemToolHintEl = document.getElementById('poem-tool-hint');     // 提示
+const poemToolStatusEl = document.getElementById('poem-status');      // 状态徽标
+
+let toolPoemSpeaking = false; // 工具页古诗是否正在朗读
+let toolCurrentPoem = null;   // 工具页当前显示的古诗(供朗读)
+
+// 把一首古诗渲染到工具页卡片(与 footer 同步)
+function updatePoemTool(poem) {
+    if (!poem || !poemToolBox) return;
+    toolCurrentPoem = poem; // 记录当前古诗,供朗读
+    poemToolTitleEl.textContent = poem.title || '未命名';
+    // 标题颜色与 footer 古诗配置联动
+    poemToolTitleEl.style.color = currentSettings?.poem?.color || '#c98a3a';
+    poemToolAuthorEl.textContent = [poem.dynasty, poem.author].filter(Boolean).join(' · ');
+    const lines = (poem.content || []).filter(s => s && s.trim());
+    poemToolContentEl.innerHTML = ''; // 清空后按行渲染,保留换行
+    lines.forEach(line => {
+        const p = document.createElement('p');
+        p.textContent = line;
+        poemToolContentEl.appendChild(p);
+    });
+    poemToolBox.hidden = false;
+}
+
+// 工具页古诗朗读(TTS);再点停止。与 footer 朗读相互独立(各自管理自己的语音状态)
+function toggleToolPoemRead() {
+    if (!poemToolReadBtn) return;
+    if (toolPoemSpeaking) {
+        speechSynthesis.cancel(); // 停止所有语音(footer 与工具页共享 speechSynthesis,取消会同时停掉)
+        toolPoemSpeaking = false;
+        poemToolReadBtn.classList.remove('is-active');
+        poemToolReadBtn.textContent = '朗读';
+        return;
+    }
+    if (!toolCurrentPoem) {
+        poemToolHintEl.textContent = '请先获取一首古诗';
+        return;
+    }
+    const lines = [toolCurrentPoem.title, toolCurrentPoem.author, ...(toolCurrentPoem.content || [])]
+        .filter(s => s && s.trim())
+        .join(',');
+    const utter = new SpeechSynthesisUtterance(lines);
+    utter.lang = 'zh-CN';
+    utter.rate = 0.9;
+    utter.onend = () => {
+        toolPoemSpeaking = false;
+        poemToolReadBtn.classList.remove('is-active');
+        poemToolReadBtn.textContent = '朗读';
+    };
+    utter.onerror = () => {
+        toolPoemSpeaking = false;
+        poemToolReadBtn.classList.remove('is-active');
+        poemToolReadBtn.textContent = '朗读';
+    };
+    speechSynthesis.speak(utter);
+    toolPoemSpeaking = true;
+    poemToolReadBtn.classList.add('is-active');
+    poemToolReadBtn.textContent = '停止';
+}
+
+// 工具页「刷新获取」:拉取新诗,同时同步到 footer(若处于古诗模式)
+async function doRefreshToolPoem() {
+    poemToolRefreshBtn.classList.add('loading');
+    poemToolRefreshBtn.disabled = true;
+    poemToolHintEl.textContent = '';
+    try {
+        const poem = await SettingsService.GetRandomPoem();
+        updatePoemTool(poem);
+        // 同步到 footer:若当前是古诗模式则更新显示,否则仅记录供下次切回
+        if (footerMode === 'poem') {
+            renderPoem(poem);
+        }
+        poemToolHintEl.textContent = `已获取《${poem.title || '未命名'}》`;
+        poemToolStatusEl.textContent = '已刷新';
+    } catch (err) {
+        console.warn('工具页古诗刷新失败:', err);
+        poemToolHintEl.textContent = '获取失败:' + (err?.message || String(err));
+        poemToolStatusEl.textContent = '获取失败';
+    } finally {
+        poemToolRefreshBtn.classList.remove('loading');
+        poemToolRefreshBtn.disabled = false;
+    }
+}
+
+if (poemToolRefreshBtn) poemToolRefreshBtn.addEventListener('click', doRefreshToolPoem);
+if (poemToolReadBtn) poemToolReadBtn.addEventListener('click', toggleToolPoemRead);
+
+// ------------------------------------------------------------------------
+// 股市行情
+// ------------------------------------------------------------------------
+function renderStockEmpty(title, text) {
+    stockList.innerHTML = `
+        <div class="empty-state">
+            <div class="empty-mark">◈</div>
+            <div class="empty-title">${title}</div>
+            <div class="empty-text">${text}</div>
+        </div>`;
+}
+
+let lastQuotes = []; // 最近一次行情快照,点击行打开图表时按代码查找
+
+// 从设置取涨跌色,未配置时回退默认值
+function upColor() { return currentSettings.stockUpColor || FALLBACK_SETTINGS.stockUpColor; }
+function downColor() { return currentSettings.stockDownColor || FALLBACK_SETTINGS.stockDownColor; }
+
+// ------------------------------------------------------------------------
+// 交易 / 休市 状态徽标:按 A 股交易时间实时判定
+// ------------------------------------------------------------------------
+const marketStatusEl = document.getElementById('market-status');
+
+function updateMarketStatus() {
+    const now = new Date();
+    const day = now.getDay();
+    const isWeekday = day >= 1 && day <= 5;                    // 周一至周五
+    if (!isWeekday) {
+        marketClosed = true;
+        setMarketStatus('closed', '休市 · 周末');
+        return;
+    }
+    const minutes = now.getHours() * 60 + now.getMinutes();    // 当天分钟数
+    const inMorning = minutes >= 570 && minutes < 690;         // 9:30 ~ 11:30(含 9:15 集合竞价前)
+    const inAfternoon = minutes >= 780 && minutes < 900;       // 13:00 ~ 15:00
+    if (inMorning || inAfternoon) {
+        marketClosed = false;
+        setMarketStatus('open', '交易中');
+    } else {
+        marketClosed = true;
+        setMarketStatus('closed', '休市 · 非交易时段');
+    }
+}
+
+function setMarketStatus(state, text) {
+    if (!marketStatusEl) return;
+    marketStatusEl.className = 'market-status' + (state === 'open' ? ' is-open' : state === 'closed' ? ' is-closed' : '');
+    marketStatusEl.innerHTML = `<span class="dot"></span>${text}`;
+}
+
+async function loadStocks() {
+    setStockLoading(true); // 显示刷新过程(转圈动画,按钮不可再点)
+    try {
+        const quotes = await SettingsService.GetStocks();
+        lastQuotes = quotes || [];
+        if (!quotes || quotes.length === 0) {
+            renderStockEmpty('暂无股票', '请在「设置」中添加股票代码');
+            return;
+        }
+
+        stockList.innerHTML = quotes.map(q => {
+            const rising = q.change >= 0;                              // 涨跌方向
+            const sign = rising ? '+' : '';                           // 正数补加号
+            const color = rising ? upColor() : downColor();           // 用设置中的涨跌颜色
+            return `
+                <div class="stock-row" data-code="${q.code}" title="点击查看分时 / 日K">
+                    <div class="stock-main">
+                        <span class="stock-name">${escapeHtml(q.name)}</span>
+                        <span class="stock-code">${q.code.toUpperCase()}</span>
+                    </div>
+                    <div class="stock-price" style="color:${color}">${q.price.toFixed(2)}</div>
+                    <div class="stock-change" style="color:${color}">
+                        <span>${sign}${q.change.toFixed(2)}</span>
+                        <span>${sign}${q.changePercent.toFixed(2)}%</span>
+                    </div>
+                    <div class="stock-amount">成交 ${formatAmount(q.amount)}</div>
+                </div>`;
+        }).join('');
+        showStockRefreshTime(Date.now()); // 刷新成功:更新最后刷新时间
+    } catch (err) {
+        console.warn('获取行情失败:', err);
+        renderStockEmpty('无法获取行情', '请确认代码有效且网络可访问行情接口');
+    } finally {
+        setStockLoading(false); // 结束加载状态
+    }
+}
+
+stockRefreshBtn.addEventListener('click', loadStocks); // 手动刷新
+
+// 成交额格式化:接口单位为万元,超过 1 亿用「亿」表示
+function formatAmount(wan) {
+    if (wan >= 10000) return (wan / 10000).toFixed(2) + '亿';
+    return wan.toFixed(0) + '万';
+}
+
+// 点击股市行:打开该票的分时 / 日K 弹窗
+stockList.addEventListener('click', (event) => {
+    const row = event.target.closest('.stock-row');
+    if (!row) return;
+    const quote = lastQuotes.find(q => q.code === row.dataset.code);
+    if (quote) openChart(quote);
+});
+
+// ------------------------------------------------------------------------
+// 窗口关闭按钮(右上角 ×):Go 端 Hook 拦截后隐藏到系统托盘
+// ------------------------------------------------------------------------
+document.getElementById('win-close')?.addEventListener('click', () => {
+    Window.Close(); // 触发关闭流程,实际效果为最小化到托盘
+});
+
+// ------------------------------------------------------------------------
+// 监听后端 "time" 事件(仅在分钟变化时广播),更新底部时间:
+// 农历 + 公历年月日时分 + 节日
+// ------------------------------------------------------------------------
+// 组装 footer 时间文本:阴历、公历(年月日 时分)、节日(若有)
+function formatFooterTime(now) {
+    const y = now.getFullYear();
+    const mo = String(now.getMonth() + 1).padStart(2, '0');
+    const d = String(now.getDate()).padStart(2, '0');
+    const hh = String(now.getHours()).padStart(2, '0');
+    const mm = String(now.getMinutes()).padStart(2, '0');
+    const {lunarText, festival} = getCalendarInfo(now); // 农历文字与节日
+    let text = `${lunarText} | ${y}年${mo}月${d}日 ${hh}:${mm}`;
+    if (festival) text += ` | ${festival}`; // 有节日则附加
+    return text;
+}
+
+Events.On('time', (time) => {
+    if (!timeElement) return;
+    // 后端仅跨分钟广播一次;从事件时间解析出 Date 后按需组装
+    const now = new Date(time.data);
+    if (isNaN(now)) return;
+    timeElement.innerText = formatFooterTime(now);
+});
+
+// 页面加载后立即显示当前时间(不等下一次分钟变化广播)
+(function () {
+    if (!timeElement) return;
+    timeElement.innerText = formatFooterTime(new Date());
+})();
+
+// ------------------------------------------------------------------------
+// 自动刷新定时器:按设置中的间隔重建(股市秒 / 新闻分钟 / Github 分钟)
+// ------------------------------------------------------------------------
+function restartAutoTimers() {
+    if (stockTimer) clearInterval(stockTimer);
+    if (newsTimer) clearInterval(newsTimer);
+    if (githubTimer) clearInterval(githubTimer);
+
+    const stockSecs = Math.max(5, (marketClosed ? (currentSettings.stockClosedRefreshSecs || FALLBACK_SETTINGS.stockClosedRefreshSecs)
+                                                 : (currentSettings.stockRefreshSecs || FALLBACK_SETTINGS.stockRefreshSecs)));
+    const newsMins = Math.max(5, currentSettings.newsRefreshMinutes || FALLBACK_SETTINGS.newsRefreshMinutes);
+    const githubMins = Math.max(5, currentSettings.githubRefreshMinutes || FALLBACK_SETTINGS.githubRefreshMinutes);
+
+    stockTimer = setInterval(loadStocks, stockSecs * 1000);         // 股市(秒,休市用较长间隔)
+    newsTimer = setInterval(loadNews, newsMins * 60000);            // 新闻(分钟)
+    githubTimer = setInterval(() => { loadGithub(); loadTrending(); }, githubMins * 60000); // Github(分钟,含热门仓库)
+}
+
+// ------------------------------------------------------------------------
+// 启动:读取配置并首次拉取行情 / 新闻 / 仓库动态
+// ------------------------------------------------------------------------
+// 移动端(窄视口)检测:隐藏主机 Tab 并通知后端禁止滚动条
+const MOBILE_QUERY = window.matchMedia('(max-width: 640px)');
+
+function applyMobileMode() {
+    const mobile = MOBILE_QUERY.matches;
+    // 主机 Tab:移动端隐藏按钮与面板(需求2)
+    document.querySelectorAll('.tab-btn[data-tab="host"]').forEach(b => { b.style.display = mobile ? 'none' : ''; });
+    const hostPanel = document.getElementById('tab-host');
+    if (hostPanel) hostPanel.style.display = mobile ? 'none' : '';
+    // 通知后端:移动端禁止滚动文本(需求2)
+    if (typeof SettingsService.SetMobileMode === 'function') {
+        SettingsService.SetMobileMode(mobile).catch?.(() => {});
+    }
+}
+
+MOBILE_QUERY.addEventListener?.('change', applyMobileMode); // 视口变化时切换
+
+// 加载应用版本号并显示在 footer:调用后端 AppVersion(),浏览器预览时回退本地构建号
+async function loadVersion() {
+    const el = document.getElementById('footer-version-text');
+    if (!el) return;
+    try {
+        const v = await SettingsService.AppVersion();
+        el.textContent = v ? `v${v}` : '';
+    } catch (err) {
+        // 浏览器预览(无 Wails 后端)时从 index.html 的构建号或留空
+        console.warn('获取版本号失败:', err);
+    }
+}
+
+loadVersion();
+loadSettings();
+loadStocks();
+loadNews();
+loadGithub();
+loadTrending();             // 首次拉取热门仓库(GitHub Trending)
+applyMobileMode();          // 启动即应用移动端适配
+restartAutoTimers();        // 启动自动刷新定时器
+updateMarketStatus();       // 首次判定交易 / 休市
+setInterval(() => {
+    const prev = marketClosed;
+    updateMarketStatus();
+    if (prev !== marketClosed) restartAutoTimers(); // 交易/休市切换时重建股市定时器
+}, 60000); // 每分钟刷新一次状态(交易时段边界变化)
+
+// Tab 栏横向滚动:滚轮在标签栏上滚动时转为横向滚动(Tab 过多时)
+const tabBarEl = document.querySelector('.tab-buttons');
+if (tabBarEl) {
+    tabBarEl.addEventListener('wheel', (e) => {
+        // 仅当内容横向溢出时接管滚轮,避免干扰窗口滚动
+        if (tabBarEl.scrollWidth > tabBarEl.clientWidth) {
+            e.preventDefault(); // 阻止默认(Wails 拖拽 / 页面滚动)
+            tabBarEl.scrollLeft += (e.deltaY || e.deltaX);
+        }
+    }, {passive: false});
+}
+
+// ------------------------------------------------------------------------
+// 股票图表弹窗:分时图 / 日K 图(canvas 手绘,随主题换色)
+// ------------------------------------------------------------------------
+const chartModal = document.getElementById('chart-modal');   // 弹窗容器
+const chartCanvas = document.getElementById('chart-canvas'); // 绘图画布
+const chartTitle = document.getElementById('chart-title');   // 标题:名称 + 代码
+const chartSub = document.getElementById('chart-sub');       // 副标题:现价与涨跌幅
+const chartLegend = document.getElementById('chart-legend'); // 图例 / 状态行
+
+// 弹窗当前状态:code 股票代码,kind 图表类型(minute/kline),data 已加载的数据
+const chartState = {code: '', kind: 'minute', data: null};
+
+function cssVar(name) {
+    return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); // 读取当前主题的 CSS 变量
+}
+
+// 打开弹窗并默认加载分时图
+function openChart(quote) {
+    chartState.code = quote.code;
+    chartTitle.textContent = `${quote.name} ${quote.code.toUpperCase()}`;
+    const sign = quote.change >= 0 ? '+' : '';
+    chartSub.textContent = `现价 ${quote.price.toFixed(2)}  ${sign}${quote.change.toFixed(2)} (${sign}${quote.changePercent.toFixed(2)}%)`;
+    chartModal.hidden = false;
+    setChartKind('minute');
+}
+
+function closeChart() {
+    chartModal.hidden = true;
+    chartState.data = null;
+}
+
+// 切换分时 / 日K 并加载对应数据
+function setChartKind(kind) {
+    chartState.kind = kind;
+    document.querySelectorAll('#chart-kind .seg-btn').forEach(b => {
+        b.classList.toggle('active', b.dataset.chartKind === kind);
+    });
+    loadChartData();
+}
+
+async function loadChartData() {
+    chartLegend.textContent = '加载中…';
+    try {
+        if (chartState.kind === 'minute') {
+            chartState.data = await SettingsService.GetMinuteChart(chartState.code); // 分时数据
+        } else {
+            chartState.data = await SettingsService.GetKLine(chartState.code, 120);  // 最近 120 根日K
+        }
+        drawChart();
+    } catch (err) {
+        chartState.data = null;
+        chartLegend.textContent = '加载失败:' + (err?.message || err);
+        clearCanvas();
+    }
+}
+
+// 主题切换或窗口尺寸变化时重绘(仅在弹窗打开且已有数据时)
+function redrawChart() {
+    if (chartModal && !chartModal.hidden && chartState.data) drawChart();
+}
+
+function drawChart() {
+    if (chartState.kind === 'minute') {
+        drawMinute(chartState.data);
+    } else {
+        drawKLine(chartState.data);
+    }
+}
+
+// 按设备像素比初始化画布,返回 2d 上下文与 CSS 尺寸
+function prepareCanvas() {
+    const dpr = window.devicePixelRatio || 1;
+    const rect = chartCanvas.getBoundingClientRect();
+    chartCanvas.width = Math.max(1, Math.round(rect.width * dpr));
+    chartCanvas.height = Math.max(1, Math.round(rect.height * dpr));
+    const ctx = chartCanvas.getContext('2d');
+    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+    return {ctx, w: rect.width, h: rect.height};
+}
+
+function clearCanvas() {
+    const {ctx, w, h} = prepareCanvas();
+    ctx.clearRect(0, 0, w, h);
+}
+
+// 绘制分时图:价格线 + 均价线 + 昨收虚线
+function drawMinute(chart) {
+    const {ctx, w, h} = prepareCanvas();
+    ctx.clearRect(0, 0, w, h);
+    const points = chart.points || [];
+    if (points.length === 0) {
+        chartLegend.textContent = '暂无分时数据';
+        return;
+    }
+
+    const padL = 48, padR = 12, padT = 14, padB = 22; // 绘图区内边距
+    const plotW = w - padL - padR;
+    const plotH = h - padT - padB;
+
+    // 价格范围需覆盖价格、均价与昨收,保证基准线可见
+    let min = chart.prevClose, max = chart.prevClose;
+    points.forEach(p => {
+        min = Math.min(min, p.price, p.avg);
+        max = Math.max(max, p.price, p.avg);
+    });
+    if (max - min < 0.01) { max += 0.01; min -= 0.01; } // 全天一字时避免除零
+    const span = max - min;
+
+    const xOf = i => padL + (plotW * i) / (points.length - 1);
+    const yOf = v => padT + plotH * (1 - (v - min) / span);
+
+    const muted = cssVar('--muted');
+    const upColorVal = upColor();   // 用设置中的涨跌色(改名避免遮蔽外层同名函数)
+    const downColorVal = downColor();
+    const lastPrice = points[points.length - 1].price;
+    const priceColor = lastPrice >= chart.prevClose ? upColorVal : downColorVal; // 相对昨收定红绿
+
+    // 水平网格线 + 左侧价格刻度
+    ctx.font = '10px sans-serif';
+    ctx.fillStyle = muted;
+    ctx.strokeStyle = 'rgba(127,127,127,0.18)';
+    ctx.lineWidth = 1;
+    for (let i = 0; i <= 4; i++) {
+        const v = min + (span * i) / 4;
+        const y = yOf(v);
+        ctx.beginPath();
+        ctx.moveTo(padL, y);
+        ctx.lineTo(w - padR, y);
+        ctx.stroke();
+        ctx.textAlign = 'right';
+        ctx.fillText(v.toFixed(2), padL - 6, y + 3);
+    }
+
+    // 昨收基准虚线
+    const yPrev = yOf(chart.prevClose);
+    ctx.save();
+    ctx.setLineDash([5, 4]);
+    ctx.strokeStyle = muted;
+    ctx.beginPath();
+    ctx.moveTo(padL, yPrev);
+    ctx.lineTo(w - padR, yPrev);
+    ctx.stroke();
+    ctx.restore();
+
+    // 底部时间刻度:开盘 / 中午 / 收盘
+    ctx.fillStyle = muted;
+    const timeIdx = [0, Math.floor(points.length / 2), points.length - 1];
+    timeIdx.forEach((idx, i) => {
+        ctx.textAlign = i === 0 ? 'left' : (i === 2 ? 'right' : 'center');
+        const x = i === 0 ? padL : (i === 2 ? w - padR : xOf(idx));
+        ctx.fillText(points[idx].time, x, h - 6);
+    });
+
+    // 均价线
+    ctx.strokeStyle = muted;
+    ctx.lineWidth = 1;
+    ctx.beginPath();
+    points.forEach((p, i) => {
+        const x = xOf(i), y = yOf(p.avg);
+        i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
+    });
+    ctx.stroke();
+
+    // 价格线
+    ctx.strokeStyle = priceColor;
+    ctx.lineWidth = 1.6;
+    ctx.beginPath();
+    points.forEach((p, i) => {
+        const x = xOf(i), y = yOf(p.price);
+        i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
+    });
+    ctx.stroke();
+
+    chartLegend.innerHTML =
+        `<span class="lg" style="color:${priceColor}"><i></i>价格 ${lastPrice.toFixed(2)}</span>` +
+        `<span class="lg" style="color:${muted}"><i></i>均价</span>` +
+        `<span>昨收 ${chart.prevClose.toFixed(2)}</span>`;
+}
+
+// 绘制日 K 图:蜡烛 + 底部成交量
+function drawKLine(bars) {
+    const {ctx, w, h} = prepareCanvas();
+    ctx.clearRect(0, 0, w, h);
+    if (!bars || bars.length === 0) {
+        chartLegend.textContent = '暂无日K数据';
+        return;
+    }
+
+    const padL = 48, padR = 12, padT = 14, padB = 22;
+    const volH = (h - padT - padB) * 0.18;            // 成交量区域高度
+    const plotH = h - padT - padB - volH - 8;         // K 线区域高度(底部留 8px 间隔)
+    const plotW = w - padL - padR;
+
+    let min = Infinity, max = -Infinity, maxVol = 0;
+    bars.forEach(b => {
+        min = Math.min(min, b.low);
+        max = Math.max(max, b.high);
+        maxVol = Math.max(maxVol, b.volume);
+    });
+    if (max - min < 0.01) { max += 0.01; min -= 0.01; }
+
+    const step = plotW / bars.length;                 // 每根 K 线占位宽
+    const bodyW = Math.max(1, step * 0.6);            // 蜡烛实体宽
+    const xOf = i => padL + step * (i + 0.5);
+    const yOf = v => padT + plotH * (1 - (v - min) / (max - min));
+
+    const muted = cssVar('--muted');
+    const upColorVal = upColor();   // 用设置中的涨跌色(改名避免遮蔽外层同名函数)
+    const downColorVal = downColor();
+
+    // 价格网格与左侧刻度
+    ctx.font = '10px sans-serif';
+    ctx.fillStyle = muted;
+    ctx.strokeStyle = 'rgba(127,127,127,0.18)';
+    ctx.lineWidth = 1;
+    for (let i = 0; i <= 4; i++) {
+        const v = min + ((max - min) * i) / 4;
+        const y = yOf(v);
+        ctx.beginPath();
+        ctx.moveTo(padL, y);
+        ctx.lineTo(w - padR, y);
+        ctx.stroke();
+        ctx.textAlign = 'right';
+        ctx.fillText(v.toFixed(2), padL - 6, y + 3);
+    }
+
+    // 日期刻度:首 / 中 / 尾(仅显示月-日)
+    const dateIdx = [0, Math.floor(bars.length / 2), bars.length - 1];
+    dateIdx.forEach((idx, i) => {
+        ctx.textAlign = i === 0 ? 'left' : (i === 2 ? 'right' : 'center');
+        const x = i === 0 ? padL : (i === 2 ? w - padR : xOf(idx));
+        ctx.fillText(bars[idx].date.slice(5), x, h - 6);
+    });
+
+    // 蜡烛与成交量柱
+    bars.forEach((b, i) => {
+        const rising = b.close >= b.open;             // 红涨绿跌
+        const color = rising ? upColorVal : downColorVal;
+        const x = xOf(i);
+
+        ctx.strokeStyle = color;
+        ctx.beginPath();                              // 上下影线
+        ctx.moveTo(x, yOf(b.high));
+        ctx.lineTo(x, yOf(b.low));
+        ctx.stroke();
+
+        ctx.fillStyle = color;
+        const yOpen = yOf(b.open), yClose = yOf(b.close);
+        const top = Math.min(yOpen, yClose);
+        ctx.fillRect(x - bodyW / 2, top, bodyW, Math.max(1, Math.abs(yClose - yOpen))); // 实体
+
+        const barH = maxVol > 0 ? (b.volume / maxVol) * volH : 0;
+        ctx.globalAlpha = 0.5;                        // 成交量柱半透明,避免喧宾夺主
+        ctx.fillRect(x - bodyW / 2, h - padB - barH, bodyW, barH);
+        ctx.globalAlpha = 1;
+    });
+
+    const lastBar = bars[bars.length - 1];
+    chartLegend.innerHTML =
+        `<span class="lg" style="color:${upColorVal}"><i></i>上涨</span>` +
+        `<span class="lg" style="color:${downColorVal}"><i></i>下跌</span>` +
+        `<span>${bars.length} 根 | 最新 ${lastBar.date} 收 ${lastBar.close.toFixed(2)}</span>`;
+}
+
+// 弹窗交互:分段切换、关闭按钮、遮罩点击、Esc
+document.querySelectorAll('#chart-kind .seg-btn').forEach(btn => {
+    btn.addEventListener('click', () => setChartKind(btn.dataset.chartKind));
+});
+document.getElementById('chart-close').addEventListener('click', closeChart);
+document.getElementById('chart-mask').addEventListener('click', closeChart);
+document.addEventListener('keydown', (event) => {
+    if (event.key === 'Escape' && !chartModal.hidden) closeChart();
+});
+
+// 窗口尺寸变化时重绘图表(防抖 150ms)
+let resizeTimer = null;
+window.addEventListener('resize', () => {
+    clearTimeout(resizeTimer);
+    resizeTimer = setTimeout(redrawChart, 150);
+});

+ 190 - 0
frontend/src/ticker.js

@@ -0,0 +1,190 @@
+import {Events, WML, Window} from "@wailsio/runtime"; // Wails 运行时:事件、WML 链接、窗口控制
+import {SettingsService} from "../bindings/TengTuToolbox/index.js"; // 后端服务绑定
+
+WML.Enable(); // 绑定 data-wml-openURL 链接的打开行为(动态内容渲染后需调 WML.Reload())
+
+// 从 URL 参数识别滚动条类型:stock=股票条,news=新闻条
+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'); // 关闭滚动条按钮
+
+let tickerCfg = {fontSize: 14, color: '#ff4d4d', speed: 80, transparent: true}; // 当前滚动条配置
+let upColor = '#ff5a5a', downColor = '#4d8dff'; // 股票涨跌颜色(红涨蓝跌)
+
+// ------------------------------------------------------------------------
+// 配置加载:字号 / 颜色 / 速度 / 透明应用到页面
+// ------------------------------------------------------------------------
+let stockRefreshSecs = 30;   // 股市刷新间隔(秒)
+let newsRefreshMins = 120;   // 新闻刷新间隔(分钟)
+
+async function loadConfig() {
+    try {
+        const settings = await SettingsService.GetSettings();
+        tickerCfg = kind === 'stock' ? settings.stockTicker : settings.newsTicker;
+        upColor = settings.stockUpColor || upColor;
+        downColor = settings.stockDownColor || downColor;
+        stockRefreshSecs = settings.stockRefreshSecs || 30;
+        newsRefreshMins = settings.newsRefreshMinutes || 120;
+    } catch (err) {
+        console.warn('读取滚动条配置失败,使用默认值:', err);
+    }
+    document.body.style.fontSize = tickerCfg.fontSize + 'px';
+    document.body.style.color = tickerCfg.color;
+    document.getElementById('ticker').classList.toggle('transparent', !!tickerCfg.transparent); // 透明背景开关
+}
+
+// ------------------------------------------------------------------------
+// 数据渲染:内容复制两份实现无缝循环;动画时长 = 半程宽度 / 速度
+// ------------------------------------------------------------------------
+function renderItems(html) {
+    track.innerHTML = html + html; // 两份内容,CSS 动画平移 50% 即无缝循环
+    WML.Reload();                  // 动态插入的链接需要重新绑定打开行为
+
+    requestAnimationFrame(() => {
+        const half = track.scrollWidth / 2;                 // 半程宽度
+        const speed = Math.max(20, tickerCfg.speed || 80);  // px/s
+        track.style.animationDuration = (half / speed).toFixed(1) + 's';
+    });
+}
+
+function renderLoading(text) {
+    track.innerHTML = `<span class="ticker-item">${text}</span>`;
+    track.style.animationDuration = '0s';
+}
+
+// 股票条内容:名称 现价 涨跌幅(红涨绿跌)
+async function loadStock() {
+    try {
+        const quotes = await SettingsService.GetStocks();
+        if (!quotes || quotes.length === 0) {
+            renderLoading('未配置股票代码(在设置的「股市代码」中添加)');
+            return;
+        }
+        renderItems(quotes.map(q => {
+            const rising = q.change >= 0;
+            const sign = rising ? '+' : '';
+            const color = rising ? upColor : downColor; // 用设置中的涨跌颜色
+            return `<span class="ticker-item">${q.name} <b style="color:${color}">${q.price.toFixed(2)}</b> <span style="color:${color}">${sign}${q.changePercent.toFixed(2)}%</span></span>`;
+        }).join(''));
+    } catch (err) {
+        renderLoading('行情获取失败:' + (err?.message || err));
+    }
+}
+
+// 新闻条内容:来源标签 + 可点击标题(点击在浏览器打开详情页)
+let tickerNews = []; // 当前新闻列表,供 TTS 逐条朗读
+async function loadNews() {
+    try {
+        const news = await SettingsService.GetNews();
+        if (!news || news.length === 0) {
+            renderLoading('暂无新闻');
+            return;
+        }
+        tickerNews = news; // 记录当前列表
+        renderItems(news.map(n =>
+            `<span class="ticker-item"><span class="src">[${n.source}]</span><a data-wml-openURL="${encodeURI(n.url)}">${n.title}</a></span>`
+        ).join(''));
+    } catch (err) {
+        renderLoading('新闻获取失败:' + (err?.message || err));
+    }
+}
+
+// 朗读新闻标题:逐条朗读,当前条目高亮;仅新闻条显示该按钮
+let reading = false; // 是否正在朗读
+
+function readTickerNews() {
+    if (typeof speechSynthesis === 'undefined' || !('SpeechSynthesisUtterance' in window)) {
+        alert('当前环境不支持语音朗读(TTS)');
+        return;
+    }
+    if (speechSynthesis.speaking) {
+        speechSynthesis.cancel(); // 再点一次停止
+        reading = false;
+        btnRead.textContent = '读';
+        btnRead.classList.remove('is-active');
+        track.querySelectorAll('.ticker-item.speaking').forEach(el => el.classList.remove('speaking'));
+        return;
+    }
+    if (!tickerNews.length) {
+        alert('暂无新闻可朗读');
+        return;
+    }
+    const items = track.querySelectorAll('.ticker-item'); // 第一份内容(前半程),与 tickerNews 一一对应
+    reading = true;
+    btnRead.textContent = '停';
+    btnRead.classList.add('is-active');
+    // 逐条朗读:开始时高亮当前条目,结束后清除
+    tickerNews.forEach((n, i) => {
+        const utter = new SpeechSynthesisUtterance(n.title);
+        utter.lang = 'zh-CN';
+        utter.rate = 1.0;
+        utter.onstart = () => items[i]?.classList.add('speaking');
+        utter.onend = () => items[i]?.classList.remove('speaking');
+        speechSynthesis.speak(utter);
+    });
+    // 全部朗读完成后复位
+    speechSynthesis.onend = () => {
+        reading = false;
+        btnRead.textContent = '读';
+        btnRead.classList.remove('is-active');
+        track.querySelectorAll('.ticker-item.speaking').forEach(el => el.classList.remove('speaking'));
+    };
+}
+
+// 关闭滚动条窗口:调用后端保存关闭状态(置 Enabled=false),随后关闭窗口
+async function closeTicker() {
+    try {
+        const kind2 = kind === 'stock' ? 'stock' : 'news';
+        await SettingsService.SaveTickerEnabled(kind2, false); // 更新设置,滚动条不再显示
+    } catch (err) {
+        console.warn('关闭滚动条保存失败:', err);
+    }
+    try {
+        window.close(); // 关闭当前窗口
+    } catch (e) {
+        // 忽略:部分环境下 window.close 无效,由后端窗口管理接管
+    }
+}
+
+// 按钮仅在新闻条显示(股票条不提供 TTS 阅读)
+if (kind === 'news') {
+    btnRead.addEventListener('click', readTickerNews);
+} else {
+    actions.style.display = 'none'; // 股票条隐藏右侧按钮区
+}
+btnClose.addEventListener('click', closeTicker);
+
+const loadData = kind === 'stock' ? loadStock : loadNews;
+
+// ------------------------------------------------------------------------
+// 位置持久化:轮询窗口位置,拖动停止后保存到设置
+// ------------------------------------------------------------------------
+let lastX = -1, lastY = -1;
+
+async function pollPosition() {
+    try {
+        const pos = await Window.Position();
+        if (lastX >= 0 && (pos.x !== lastX || pos.y !== lastY)) {
+            await SettingsService.SaveTickerPos(kind, pos.x, pos.y); // 位置变化时保存
+        }
+        lastX = pos.x;
+        lastY = pos.y;
+    } catch (err) {
+        // 忽略轮询错误(窗口关闭途中等)
+    }
+}
+
+// ------------------------------------------------------------------------
+// 启动:读配置 → 加载数据 → 定时刷新与位置轮询
+// ------------------------------------------------------------------------
+async function boot() {
+    await loadConfig();  // 先读配置拿到刷新间隔
+    loadData();
+    const intervalMs = kind === 'stock' ? stockRefreshSecs * 1000 : newsRefreshMins * 60000; // 按设置的间隔
+    setInterval(loadData, intervalMs);
+}
+boot();
+setInterval(pollPosition, 2000);

+ 118 - 0
frontend/ticker.html

@@ -0,0 +1,118 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+    <title>腾图滚动条</title>
+    <style>
+        /* 滚动条窗口:整行可拖动,透明背景 */
+        * { box-sizing: border-box; }
+        html, body {
+            margin: 0;
+            height: 100%;
+            overflow: hidden;
+            background: transparent;
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+            -webkit-user-select: none;
+            user-select: none;
+        }
+        /* 容器:整行拖动窗口;默认半透明底,透明模式完全透明不遮挡内容 */
+        .ticker {
+            display: flex;
+            align-items: center;
+            height: 100vh;
+            overflow: hidden;
+            border-radius: 8px;
+            background: rgba(10, 12, 24, 0.72);
+            backdrop-filter: blur(8px);
+            -webkit-backdrop-filter: blur(8px);
+            --wails-draggable: drag;         /* 整行可拖动窗口 */
+            cursor: move;
+        }
+        .ticker.transparent {
+            background: transparent;         /* 背景透明,不遮挡下方内容 */
+            backdrop-filter: none;
+            -webkit-backdrop-filter: none;
+            text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); /* 文字加投影保证可读 */
+        }
+        /* 滚动视口:占满剩余宽度,超出部分隐藏(轨道动画在内部平移) */
+        .ticker-viewport {
+            flex: 1;
+            min-width: 0;
+            height: 100%;
+            overflow: hidden;
+        }
+        /* 滚动轨道:内容复制两份实现无缝循环,动画时长由 JS 按速度计算 */
+        .ticker-track {
+            display: inline-flex;
+            align-items: center;
+            white-space: nowrap;
+            animation: ticker-scroll linear infinite;
+            will-change: transform;
+        }
+        @keyframes ticker-scroll {
+            from { transform: translateX(0); }
+            to   { transform: translateX(-50%); } /* 内容两份,平移一半即循环 */
+        }
+        .ticker-item {
+            display: inline-flex;
+            align-items: center;
+            gap: 6px;
+            padding: 0 18px;
+            line-height: 1;
+            font-variant-numeric: tabular-nums;
+        }
+        .ticker-item a {
+            color: inherit;
+            text-decoration: none;
+            cursor: pointer;
+            --wails-draggable: no-drag;      /* 链接可点击,不触发拖动 */
+        }
+        .ticker-item a:hover { text-decoration: underline; }
+        .up { color: #ff5a5a; }              /* 红涨 */
+        .down { color: #3ddc84; }            /* 绿跌 */
+        .src { opacity: 0.55; font-size: 0.85em; } /* 新闻来源小标签 */
+        /* 右侧控制按钮:阅读(TTS 朗读新闻标题)与关闭(停止滚动条) */
+        .ticker-actions {
+            display: flex;
+            align-items: center;
+            gap: 4px;
+            flex-shrink: 0;
+            padding: 0 8px;
+            --wails-draggable: no-drag;  /* 按钮可点击,不触发拖动 */
+        }
+        .ticker-actions button {
+            display: inline-flex;
+            align-items: center;
+            justify-content: center;
+            min-width: 22px;
+            height: 22px;
+            padding: 0 6px;
+            border: 1px solid rgba(255, 255, 255, 0.18);
+            border-radius: 6px;
+            background: rgba(255, 255, 255, 0.10);
+            color: #fff;
+            font-size: 13px;
+            line-height: 1;
+            cursor: pointer;
+            transition: background 0.15s ease;
+        }
+        .ticker-actions button:hover { background: rgba(255, 255, 255, 0.22); }
+        .ticker-actions button.is-active { background: rgba(255, 77, 77, 0.55); border-color: rgba(255, 77, 77, 0.8); }
+        /* 新闻标题行当前被朗读:高亮 */
+        .ticker-item.speaking { color: #ffe08a; text-shadow: 0 0 6px rgba(255, 224, 138, 0.6); }
+    </style>
+</head>
+<body>
+    <div class="ticker" id="ticker">
+        <div class="ticker-viewport" id="viewport">
+            <div class="ticker-track" id="track"></div>
+        </div>
+        <div class="ticker-actions" id="actions">
+            <button id="btn-read" title="朗读新闻标题">读</button>
+            <button id="btn-close" title="关闭滚动条">×</button>
+        </div>
+    </div>
+<script type="module" src="/src/ticker.js"></script>
+</body>
+</html>

+ 25 - 0
frontend/tsconfig.json

@@ -0,0 +1,25 @@
+/** 
+ * This file tells your IDE where the root of your JavaScript project is, and sets some
+ * options that it can use to provide autocompletion and other features.  
+ */
+{
+  "compilerOptions": {
+    "allowJs": true,
+    "moduleResolution": "bundler",
+    /**
+     * The target and module can be set to ESNext to allow writing modern JavaScript, 
+     * and Vite will compile down to the level of "build.target" specified in the vite config file.
+     * Builds will error if you use a feature that cannot be compiled down to the target level.
+     */
+    "target": "ESNext",
+    "module": "ESNext",
+    "resolveJsonModule": true,
+    /**
+     * Enable checkJs if you'd like type checking in `.js` files.
+     */
+    "checkJs": false,
+    "strict": true,
+    "skipLibCheck": true,
+  },
+  "include": ["src", "bindings"]
+}

+ 20 - 0
frontend/vite.config.js

@@ -0,0 +1,20 @@
+import { defineConfig } from "vite";
+import wails from "@wailsio/runtime/plugins/vite";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  server: {
+    host: "127.0.0.1",
+    port: Number(process.env.WAILS_VITE_PORT) || 9245,
+    strictPort: true,
+  },
+  build: {
+    rollupOptions: {
+      input: {
+        main: "index.html",     // 主窗口
+        ticker: "ticker.html",  // 置顶滚动条窗口(股票条 / 新闻条)
+      },
+    },
+  },
+  plugins: [wails("./bindings")],
+});

+ 261 - 0
githubwatch.go

@@ -0,0 +1,261 @@
+// ============================================================================
+// githubwatch.go — GitHub 仓库监控
+//
+// 用 GitHub REST API 监听配置仓库的最新提交与 release;
+// 本地版本低于最新版时提示更新,并列出 Latest 之后的版本(仅展示)。
+// ============================================================================
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+)
+
+// CommitItem 仓库的一条提交记录
+type CommitItem struct {
+	Title   string `json:"title"`   // 提交标题(首行)
+	URL     string `json:"url"`     // 提交详情页地址
+	Updated string `json:"updated"` // 提交时间(MM-DD HH:MM)
+}
+
+// ReleaseItem 一个 GitHub release 的简略信息,用于列出 Latest 之后的版本(Pre-release 等)
+type ReleaseItem struct {
+	TagName      string `json:"tagName"`      // 版本标签,如 v1.3.0-beta.1
+	Name         string `json:"name"`         // release 显示名称(标题),可能比 tag 更完整,如 "Rolling: v2.1.0-..."
+	URL          string `json:"url"`          // release 详情页地址
+	IsPrerelease bool   `json:"isPrerelease"` // 是否预发布版本
+	IsDraft      bool   `json:"isDraft"`      // 是否草稿版本
+	PublishedAt  string `json:"publishedAt"`  // 发布时间(MM-DD HH:MM)
+}
+
+// RepoFeed 一个 GitHub 仓库的最新提交摘要
+type RepoFeed struct {
+	URL        string        `json:"url"`        // 仓库地址(设置中配置)
+	Desc       string        `json:"desc"`       // 项目说明
+	Repo       string        `json:"repo"`       // owner/repo 短名
+	Version    string        `json:"version"`    // 本地已安装版本(设置中配置)
+	Latest     string        `json:"latest"`     // 最新正式 release 版本号,无正式 release 时为空
+	HasUpdate  bool          `json:"hasUpdate"`  // 是否存在可用更新(本地版本低于最新正式 release)
+	ReleaseURL string        `json:"releaseURL"` // 最新正式 release 的下载 / 详情页地址
+	Newer      []ReleaseItem `json:"newer"`      // Latest 之后的版本(Pre-release 等,仅列出不参与更新比较)
+	Commits    []CommitItem  `json:"commits"`    // 最近提交(最多 5 条)
+	Error      string        `json:"error"`      // 抓取失败原因,为空表示正常
+}
+
+// githubCommit GitHub REST API 返回的单条提交(只取需要的字段)
+type githubCommit struct {
+	Commit struct {
+		Message string `json:"message"` // 提交说明
+		Author  struct {
+			Date string `json:"date"` // 提交时间(RFC3339)
+		} `json:"author"`
+	} `json:"commit"`
+	HTMLURL string `json:"html_url"` // 提交详情页地址
+}
+
+// githubRelease GitHub REST API 返回的一个 release(只取需要的字段)
+type githubRelease struct {
+	TagName     string `json:"tag_name"`     // 版本标签,如 v1.2.3
+	Name        string `json:"name"`         // release 显示名称(标题),可能比 tag 更完整
+	HTMLURL     string `json:"html_url"`     // release 详情页(含下载入口)
+	Draft       bool   `json:"draft"`        // 草稿不算正式版本
+	Prerelease  bool   `json:"prerelease"`   // 预发布版本
+	PublishedAt string `json:"published_at"` // 发布时间(RFC3339)
+}
+
+// fetchGithubFeeds 并行抓取所有配置仓库的提交动态
+func fetchGithubFeeds(repos []RepoItem) []RepoFeed {
+	feeds := make([]RepoFeed, len(repos))
+
+	var wg sync.WaitGroup
+	for i, repo := range repos {
+		wg.Add(1)
+		go func(idx int, item RepoItem) {
+			defer wg.Done()
+			feeds[idx] = fetchRepoFeed(item) // 单个仓库失败不影响其他
+		}(i, repo)
+	}
+	wg.Wait()
+	return feeds
+}
+
+// fetchRepoFeed 抓取单个仓库的提交动态与最新 release 版本
+func fetchRepoFeed(item RepoItem) RepoFeed {
+	feed := RepoFeed{URL: item.URL, Desc: item.Desc, Version: item.Version, Commits: []CommitItem{}}
+
+	repo := parseGithubRepo(item.URL)
+	if repo == "" {
+		feed.Error = "无法从地址解析 owner/repo"
+		return feed
+	}
+	feed.Repo = repo
+
+	// 用 GitHub REST API 获取提交(api.github.com 通常比 github.com/commits.atom 更稳定可达)
+	apiURL := fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=5", repo)
+	var commits []githubCommit
+	if err := fetchGithubJSON(apiURL, &commits); err != nil {
+		feed.Error = err.Error()
+		return feed
+	}
+	if len(commits) == 0 {
+		feed.Error = "暂无提交记录"
+		return feed
+	}
+
+	for i, c := range commits {
+		if i >= 5 {
+			break // 只保留最近 5 条
+		}
+		feed.Commits = append(feed.Commits, CommitItem{
+			Title:   firstLine(c.Commit.Message),
+			URL:     c.HTMLURL,
+			Updated: formatAtomTime(c.Commit.Author.Date),
+		})
+	}
+
+	// 最新 release:拉取失败(如仓库无 release)不阻塞提交显示,仅记录
+	feed.applyLatestRelease(repo)
+	return feed
+}
+
+// applyLatestRelease 拉取仓库 release 列表,识别 Latest 正式版与之后的版本(Pre-release 等)。
+// 更新比较(HasUpdate)只基于 Latest 正式版;Latest 之后的版本仅列出展示,不参与比较。
+func (feed *RepoFeed) applyLatestRelease(repo string) {
+	// 拉取最近的 release 列表(含 pre-release / draft),按创建时间倒序返回
+	releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=30", repo)
+	var rels []githubRelease
+	if err := fetchGithubJSON(releaseURL, &rels); err != nil {
+		return // 无 release / 404 / 限流:不填 Latest,视为无版本信息
+	}
+
+	// 第一个非草稿、非预发布的版本即为 Latest(GitHub 按时间倒序,先到先得)
+	latestIdx := -1
+	for i, r := range rels {
+		if r.TagName != "" && !r.Draft && !r.Prerelease {
+			latestIdx = i
+			break
+		}
+	}
+	if latestIdx >= 0 {
+		rel := rels[latestIdx]
+		feed.Latest = rel.TagName
+		feed.ReleaseURL = rel.HTMLURL
+		feed.HasUpdate = versionLess(feed.Version, feed.Latest) // 只比较 Latest 正式版
+	}
+
+	// Latest 之后的版本(在列表中排在 Latest 之前的,即更新/更新的预发布、草稿):仅列出
+	for i, r := range rels {
+		if i >= latestIdx {
+			break // 已到 Latest 或更旧的版本,停止
+		}
+		if r.TagName == "" {
+			continue
+		}
+		feed.Newer = append(feed.Newer, ReleaseItem{
+			TagName:      r.TagName,
+			Name:         r.Name,
+			URL:          r.HTMLURL,
+			IsPrerelease: r.Prerelease,
+			IsDraft:      r.Draft,
+			PublishedAt:  formatAtomTime(r.PublishedAt),
+		})
+	}
+}
+
+// versionLess 判断本地版本 a 是否低于远端版本 b(忽略 v 前缀,按数值段比较)
+func versionLess(a, b string) bool {
+	na := normalizeVersion(a)
+	nb := normalizeVersion(b)
+	if na == "" || nb == "" {
+		return false // 任一侧无有效版本号时不判定更新
+	}
+	return na < nb
+}
+
+// normalizeVersion 去掉 v 前缀并补零对齐成可比对的数值(如 1.2.3 -> 1.2.3,1.2 -> 1.2.0)
+func normalizeVersion(v string) string {
+	v = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(v), "v"))
+	v = strings.TrimPrefix(v, "V")
+	if v == "" {
+		return ""
+	}
+	// 仅保留数字与点,非版本字符串视为无效
+	clean := ""
+	for _, r := range v {
+		if (r >= '0' && r <= '9') || r == '.' {
+			clean += string(r)
+		}
+	}
+	if clean == "" {
+		return ""
+	}
+	parts := strings.Split(clean, ".")
+	for len(parts) < 3 {
+		parts = append(parts, "0") // 补足三位,保证可比性
+	}
+	for i, p := range parts {
+		for len(p) < 3 {
+			p = "0" + p // 每段补零对齐,如 1.10 与 1.9
+		}
+		parts[i] = p
+	}
+	return strings.Join(parts, ".")
+}
+
+// fetchGithubJSON 抓取 GitHub REST API 并解析 JSON(带鉴权友好的请求头)
+func fetchGithubJSON(url string, target any) error {
+	resp, err := githubClient(url)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode > 299 {
+		return fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status) // 403 限流 / 404 仓库不存在等
+	}
+	return json.NewDecoder(resp.Body).Decode(target)
+}
+
+// githubClient 发起带 GitHub 友好请求头的 GET(api.github.com 需要规范的 UA 与 Accept)
+func githubClient(url string) (*http.Response, error) {
+	req, err := http.NewRequest(http.MethodGet, url, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("User-Agent", "TengTuToolbox") // GitHub API 要求非空 UA
+	req.Header.Set("Accept", "application/vnd.github+json")
+	req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
+	client := &http.Client{Timeout: 15 * time.Second} // API 响应略大,放宽超时
+	return client.Do(req)
+}
+
+// parseGithubRepo 从仓库地址解析 owner/repo,支持 https://github.com/owner/repo[/...] 与 owner/repo
+func parseGithubRepo(raw string) string {
+	raw = strings.TrimSpace(strings.TrimSuffix(raw, ".git"))
+	raw = strings.TrimPrefix(raw, "https://")
+	raw = strings.TrimPrefix(raw, "http://")
+	raw = strings.TrimPrefix(raw, "github.com/")
+	parts := strings.Split(strings.Trim(raw, "/"), "/")
+	if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
+		return ""
+	}
+	return parts[0] + "/" + parts[1]
+}
+
+// firstLine 取提交标题的第一行并去除首尾空白(atom 标题常以换行开头,先整体 trim 再切行)
+func firstLine(text string) string {
+	line, _, _ := strings.Cut(strings.TrimSpace(text), "\n")
+	return strings.TrimSpace(line)
+}
+
+// formatAtomTime 把 RFC3339 时间格式化为 01-02 15:04
+func formatAtomTime(raw string) string {
+	t, err := time.Parse(time.RFC3339, raw)
+	if err != nil {
+		return raw
+	}
+	return t.Local().Format("01-02 15:04")
+}

+ 60 - 0
go.mod

@@ -0,0 +1,60 @@
+module TengTuToolbox
+
+go 1.27.3
+
+require (
+	github.com/gen2brain/beeep v0.11.3
+	github.com/gin-gonic/gin v1.12.0
+	github.com/gomarkdown/markdown v0.0.0-20261026184151-79e163b7b632
+	github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
+	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
+)
+
+require (
+	git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
+	github.com/adrg/xdg v0.5.3 // indirect
+	github.com/bytedance/gopkg v0.1.3 // indirect
+	github.com/bytedance/sonic v1.15.0 // indirect
+	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/esiqveland/notify v0.13.3 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.12 // indirect
+	github.com/gen2brain/shm v0.1.0 // indirect
+	github.com/gin-contrib/sse v1.1.0 // indirect
+	github.com/go-ole/go-ole v1.3.0 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
+	github.com/go-playground/validator/v10 v10.30.1 // indirect
+	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/jackmordaunt/icns/v3 v3.0.1 // indirect
+	github.com/jezek/xgb v1.1.1 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+	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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // 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/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
+	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+	github.com/ugorji/go/codec v1.3.1 // indirect
+	go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+	golang.org/x/arch v0.22.0 // indirect
+	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
+	google.golang.org/protobuf v1.36.10 // indirect
+)

+ 138 - 0
go.sum

@@ -0,0 +1,138 @@
+git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
+git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
+github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
+github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
+github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
+github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
+github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
+github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
+github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
+github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+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/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=
+github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gen2brain/beeep v0.11.3 h1:mZp2s0S1ZQuqb9ECUYMWb/kAcwGjww7dy58eRaqSq7o=
+github.com/gen2brain/beeep v0.11.3/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=
+github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
+github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
+github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
+github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
+github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
+github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
+github.com/gomarkdown/markdown v0.0.0-20261026184151-79e163b7b632 h1:kycupqEwsSTMkgv8OB0mbaWw47OAIc0cXfnM106CSVs=
+github.com/gomarkdown/markdown v0.0.0-20261026184151-79e163b7b632/go.mod h1:zLCvXLlUnS5tvOi4hpJgzzpJsq0+dliwn85VbPh1JbQ=
+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/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=
+github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
+github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
+github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
+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/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/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=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+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/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=
+github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
+github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
+github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/wailsapp/wails/v3 v3.0.0 h1:lBe89kFu23bVz1dc1H9v0srkHQgWsy7BAKpH4sLQuDw=
+github.com/wailsapp/wails/v3 v3.0.0/go.mod h1:U3p3/QfSer2XxXM3XWhwis9O7LvvuNKN/AcGoEFD+K8=
+go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
+go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
+golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+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/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
+golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
+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/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
+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=

+ 32 - 0
greetservice.go

@@ -0,0 +1,32 @@
+// ============================================================================
+// greetservice.go — Wails 示例服务
+//
+// GreetService 用于验证 Go↔JS 绑定链路,提供 Greet / GreetMany 示例方法。
+// ============================================================================
+package main
+
+import "strings"
+
+// GreetService 是一个暴露给前端调用的示例服务。
+// 在 main.go 中通过 application.NewService(&GreetService{}) 注册后,
+// Wails 会自动为其生成 JS/TS 绑定(frontend/bindings 目录),
+// 前端即可像调用本地函数一样直接调用 Greet 方法(底层通过 IPC 桥接)。
+type GreetService struct{}
+
+// Greet 返回对 name 的问候语。
+//
+// 调用链:前端 main.js 的 doGreet() → GreetService.Greet(name) → 本方法。
+// 前端调用示例:GreetService.Greet("张三") → 返回 "您好 张三!"
+// 参数与返回值由 Wails 自动完成 Go 与 JS 之间的序列化/反序列化。
+func (g *GreetService) Greet(name string) string {
+	return "您好 " + name + "!" // 拼接并返回问候语,由 Wails 自动序列化后回传给前端
+}
+
+// GreetMany 返回对一批 name 的问候语(示例方法,演示切片参数的绑定与序列化)。
+func (g *GreetService) GreetMany(names []string) string {
+	greetings := make([]string, len(names))
+	for i, name := range names {
+		greetings[i] = name
+	}
+	return "您们好 " + strings.Join(greetings, ", ") + " !"
+}

+ 224 - 0
main.go

@@ -0,0 +1,224 @@
+// ============================================================================
+// main.go — 应用入口
+//
+// 创建 Wails 应用、主窗口与系统托盘,注册服务与窗口事件;
+// 启动每秒时间广播与待办提醒调度协程,随后进入事件循环。
+// ============================================================================
+package main
+
+import (
+	"embed" // Go 内嵌资源包:将前端静态文件打包进可执行文件
+	"fmt"   // 格式化 I/O:用于控制台打印调试信息
+	"image/color"
+	"log"  // 日志:记录并输出错误
+	"time" // 时间处理:用于每秒定时发送事件
+
+	"github.com/wailsapp/wails/v3/pkg/application" // Wails3 核心:应用/窗口/菜单/对话框/事件/托盘
+	"github.com/wailsapp/wails/v3/pkg/events"      // Wails3 事件常量:窗口关闭、文件拖放等
+	"github.com/wailsapp/wails/v3/pkg/services/dock"
+)
+
+// ============================================================================
+// 前端资源嵌入
+// ============================================================================
+// Wails 使用 Go 的 `embed` 包将前端文件嵌入到二进制文件中。
+// frontend/dist 目录下的所有文件都会被嵌入到可执行文件里,
+// 并由 Wails 内置的资源服务器(Asset Server)提供给 WebView 前端访问。
+
+//go:embed all:frontend/dist
+var assets embed.FS // assets 持有 frontend/dist 下全部前端文件的只读文件系统
+
+//go:embed frontend/public/wails.png
+var trayIcon []byte // 嵌入系统托盘图标(PNG 格式)
+
+// 全局运行时对象:服务、API、托盘菜单都要用到应用实例与主窗口
+var (
+	app        *application.App           // 应用实例
+	mainWindow *application.WebviewWindow // 主窗口
+
+	// 窗口隐藏前的位置,恢复显示时还原
+	savedX, savedY int
+	hasSavedPos    bool
+)
+
+// showMainWindow 统一显示入口:先还原位置,再显示并聚焦。
+// 托盘、热键、API 接口都通过它唤出窗口。
+func showMainWindow() {
+	if mainWindow == nil {
+		return
+	}
+	if hasSavedPos {
+		mainWindow.SetPosition(savedX, savedY) // 还原隐藏前的窗口坐标
+	}
+	mainWindow.Show()  // 显示窗口
+	mainWindow.Focus() // 聚焦到前台
+}
+
+// init 在 main 函数之前自动执行,用于应用启动前的注册工作。
+func init() {
+	application.RegisterEvent[string]("time")   // 注册 string 类型的自定义事件,绑定生成器据此为前端生成强类型事件 API
+	application.RegisterEvent[string]("apilog") // Gin 日志行实时推送(「主机」页订阅)
+	application.RegisterEvent[string]("todoReminder") // 待办提醒:到点时向前端广播提醒内容
+	application.RegisterEvent[string]("ntfyChat")     // ntfy 聊天室:订阅主题后收到消息广播给前端
+}
+
+// main 是应用程序的入口,整体流程如下:
+//  1. 创建 Wails 应用实例(注册服务、配置资源服务器)
+//  2. 创建主窗口
+//  3. 应用已保存的配置(热键、置顶、开机启动、API 服务、滚动条窗口)
+//  4. 创建系统托盘(关闭时隐藏到托盘,双击恢复显示)
+//  5. 注册窗口事件(文件拖放、关闭→隐藏托盘、显示时还原位置)
+//  6. 启动后台协程,运行应用(阻塞直到退出)
+func main() {
+
+	// 创建 dock service 实例,自定义 badge 选项
+	dockService := dock.NewWithOptions(dock.BadgeOptions{
+		TextColour:       color.RGBA{255, 255, 255, 255}, // 白色文字
+		BackgroundColour: color.RGBA{0, 0, 255, 255},     // 蓝色背景
+		FontName:         "consolab.ttf",                 // 粗体 Consolas 字体
+		FontSize:         20,                             // 单字符字号
+		SmallFontSize:    14,                             // 多字符字号
+	})
+
+	// ------------------------------------------------------------------------
+	// 第 1 步:创建 Wails 应用实例
+	// ------------------------------------------------------------------------
+	app = application.New(application.Options{
+		Name:        "TengTu Toolbox",
+		Description: "TengTu Toolbox for OS",
+		Services: []application.Service{
+			application.NewService(&GreetService{}),    // 注册 GreetService,前端可通过 bindings 直接调用其 Greet 方法
+			application.NewService(&SettingsService{}), // 注册 SettingsService,供设置页读写配置、拉取行情
+			application.NewService(dockService),        // 注册 dockService,前端可通过 bindings 直接调用其 SetBadge 方法
+		},
+		Assets: application.AssetOptions{
+			Handler: application.AssetFileServerFS(assets), // 使用嵌入的前端文件作为资源服务器处理器
+		},
+		Mac: application.MacOptions{
+			ApplicationShouldTerminateAfterLastWindowClosed: false, // 最后一个窗口关闭后不退出应用(托盘常驻)
+		},
+	})
+
+	// ------------------------------------------------------------------------
+	// 第 2 步:创建主窗口
+	// ------------------------------------------------------------------------
+	win := app.Window.NewWithOptions(application.WebviewWindowOptions{
+		Title:  "腾图工具箱", // 窗口标题
+		Width:  1000,    // 窗口宽度(与高度构成黄金比例 1000/618≈1.618)
+		Height: 618,     // 窗口高度
+		Mac: application.MacWindow{
+			InvisibleTitleBarHeight: 50,                                 // 顶部 50px 为不可见但可拖拽的标题栏区域
+			Backdrop:                application.MacBackdropTranslucent, // 半透明背景材质
+			TitleBar:                application.MacTitleBarHiddenInset, // 隐藏标题栏但保留红绿灯按钮
+		},
+		Windows: application.WindowsWindow{
+			DisableFramelessWindowDecorations: false, // 禁用无边框窗口装饰(标题栏、边框等)
+		},
+		BackgroundColour: application.NewRGB(6, 7, 15), // 窗口背景色(深色)
+		URL:              "/",                          // WebView 加载的页面路径("/" 即前端首页 index.html)
+
+		DevToolsEnabled:            false, // 关闭开发者工具(生产环境不开放)
+		DefaultContextMenuDisabled: true,  // 禁用默认上下文菜单,防止右键点击打开菜单
+		UseApplicationMenu:         true,  // 启用应用菜单
+		EnableFileDrop:             true,  // 启用文件拖拽,需在 HTML 中添加 data-file-drop-target 标签
+		ContentProtectionEnabled:   true,  // 启用内容保护,防止截取窗口内容
+
+		Permissions: map[application.PermissionType]application.Permission{
+			application.PermissionMicrophone:    application.PermissionAllow,
+			application.PermissionNotifications: application.PermissionAllow,
+			application.PermissionCamera:        application.PermissionAllow,
+			application.PermissionGeolocation:   application.PermissionAllow,
+			application.PermissionClipboardRead: application.PermissionAllow,
+		},
+
+		Frameless:      true,                                  // 无边框窗口
+		BackgroundType: application.BackgroundTypeTransparent, // 透明背景
+	})
+	mainWindow = win // 暴露给热键、托盘、API 使用
+
+	// ------------------------------------------------------------------------
+	// 第 3 步:应用已保存的配置(热键、置顶、开机启动)
+	// ------------------------------------------------------------------------
+	settings := loadSettings() // 读取用户配置,首次运行返回默认值(开机启动默认开启)
+	applySettings(settings)    // 注册热键、设置置顶、同步开机启动
+
+	// ------------------------------------------------------------------------
+	// 第 4 步:创建系统托盘
+	// ------------------------------------------------------------------------
+	// 关闭窗口时隐藏到托盘;双击托盘图标恢复窗口显示
+	// 托盘菜单提供「显示 / 退出」两个入口
+	tray := app.SystemTray.New()
+	tray.SetIcon(trayIcon)   // 设置托盘图标(嵌入的 wails.png)
+	tray.SetTooltip("腾图工具箱") // 设置鼠标悬停提示
+	tray.AttachWindow(win)   // 绑定窗口:点击托盘图标自动切换窗口显隐
+
+	// 构建托盘右键菜单
+	trayMenu := application.NewMenu()
+	trayMenu.Add("显示主窗口").OnClick(func(_ *application.Context) {
+		showMainWindow() // 显示窗口到隐藏前的位置并聚焦
+	})
+	trayMenu.Add("退出").OnClick(func(_ *application.Context) {
+		app.Quit() // 彻底退出应用
+	})
+	tray.SetMenu(trayMenu) // 设置托盘右键菜单
+
+	// ------------------------------------------------------------------------
+	// 第 5 步:注册窗口事件处理
+	// ------------------------------------------------------------------------
+
+	// 监听「文件拖放完成」事件,打印拖放目标详情与所有被拖入的文件
+	win.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
+		files := event.Context().DroppedFiles()        // 获取拖入的文件路径列表
+		details := event.Context().DropTargetDetails() // 获取拖放目标位置等详情
+		fmt.Println("Drop target details:", details)
+		// 遍历并打印每个被拖入的文件
+		for _, file := range files {
+			fmt.Println("Dropped:", file)
+		}
+	})
+
+	// 注册「窗口关闭前」勾子:记录位置后取消关闭,改为隐藏到托盘
+	win.RegisterHook(events.Common.WindowClosing, func(event *application.WindowEvent) {
+		savedX, savedY = win.Position() // 记录隐藏前的窗口坐标
+		hasSavedPos = true              // 标记位置已记录
+		event.Cancel()                  // 拦截关闭事件,阻止窗口被系统销毁
+		win.Hide()                      // 隐藏窗口到托盘
+	})
+
+	// 窗口重新显示时(含托盘图标点击恢复),还原到隐藏前的位置
+	win.OnWindowEvent(events.Common.WindowShow, func(_ *application.WindowEvent) {
+		if hasSavedPos {
+			win.SetPosition(savedX, savedY) // 还原窗口位置
+		}
+	})
+
+	// 监听「窗口正在关闭」事件,执行清理逻辑
+	win.OnWindowEvent(events.Common.WindowClosing, func(event *application.WindowEvent) {
+		fmt.Printf("Window %s is closing\n", win.Name()) // 清理代码写在这里,此处仅打印关闭日志
+	})
+
+	// API 服务与滚动条窗口已在 applySettings(第 3 步)中按配置启动
+
+	// 时间事件:footer 只需显示到分,故仅在分钟变化时广播,避免每秒唤醒前端
+	lastMinute := ""
+	go func() {
+		for {
+			now := time.Now().Format(time.DateTime) // 格式化为 DateTime 时间字符串
+			minute := now[:len(now)-3]              // 去掉 ":SS" 秒部分,取到分
+			if minute != lastMinute {               // 仅跨分钟时发送一次
+				app.Event.Emit("time", now)         // 向所有前端窗口广播 "time" 事件
+				lastMinute = minute
+			}
+			time.Sleep(time.Second) // 每秒检查一次分钟边界
+		}
+	}()
+
+	// 待办提醒调度:每 15 秒扫描一次待办文件,到点的提醒通过事件广播给前端弹窗。
+	// 放在后台协程,即使窗口隐藏/最小化也能触发。
+	go startTodoReminderLoop()
+
+	// 运行应用(阻塞,直到退出)
+	if err := app.Run(); err != nil {
+		log.Fatal(err) // 运行过程中发生错误则记录日志并退出进程
+	}
+}

+ 17 - 0
main_android.go

@@ -0,0 +1,17 @@
+//go:build android
+
+// 本文件仅在 Android 平台参与编译(构建标签 //go:build android)。
+//
+// 背景:Android 应用的启动由 Java/Kotlin 层的 Activity 生命周期驱动,
+// 不能像桌面平台那样由运行时直接调用 main 函数开始执行。
+// 因此需要把 main 函数注册给 Wails 的 Android 运行时,
+// 待 Android 生命周期就绪后再回调执行 main()。
+package main
+
+import "github.com/wailsapp/wails/v3/pkg/application"
+
+// init 在包加载时自动执行,把 main 函数交给 Android 运行时托管;
+// 后续流程(创建应用、窗口、服务等)与桌面端完全一致。
+func init() {
+	application.RegisterAndroidMain(main) // 注册 Android 平台的应用入口
+}

+ 19 - 0
mousealert_other.go

@@ -0,0 +1,19 @@
+//go:build !windows
+
+// ============================================================================
+// mousealert_other.go — 系统级鼠标提醒(非 Windows 空实现)
+//
+// 当前平台不支持系统级鼠标提醒,提供空实现占位以保证跨平台编译;
+// Windows 实现在 mousealert_windows.go。
+// ============================================================================
+package main
+
+// triggerMouseAlert 非 Windows 平台为空实现(无系统级鼠标提醒能力)。
+func triggerMouseAlert(text string) {
+	_ = text // 忽略
+}
+
+// stopMouseAlert 非 Windows 平台为空实现。
+func stopMouseAlert() {
+	// 无操作
+}

+ 569 - 0
mousealert_windows.go

@@ -0,0 +1,569 @@
+//go:build windows
+
+// ============================================================================
+// 系统级鼠标提醒(Windows 原生实现)
+//
+// 需求:平时鼠标是正常样式;有消息来(通过 API 触发)时,鼠标图标闪烁,
+// 且光标下方浮出简短的半透明提示文字,可覆盖任意窗口(系统级)。
+//
+// 实现原理:
+//   1. 全局低层鼠标钩子 WH_MOUSE_LL:捕获系统级鼠标移动,获取屏幕坐标。
+//   2. 一个置顶、透明、点击穿透的覆盖层窗口(WS_EX_LAYERED|WS_EX_TRANSPARENT
+//      |WS_EX_TOOLWINDOW|WS_EX_NOACTIVATE),用 GDI UpdateLayeredWindow 在鼠标
+//      位置绘制闪烁光标环与下方半透明文字。
+//   3. 定时器驱动闪烁:普通光标样式 <-> 提醒光标样式交替。
+//   4. 提醒可自动消退(默认 8 秒),也可再调 API 停止。
+//
+// 说明:本文件仅在 Windows 编译。其他平台见 mousealert_other.go 空实现。
+// ============================================================================
+package main
+
+import (
+	"sync"
+	"syscall"
+	"time"
+	"unsafe"
+)
+
+// Windows API 常量
+const (
+	whMouseLL = 14 // WH_MOUSE_LL 全局低层鼠标钩子
+
+	wmTimer     = 0x0113
+	wmDestroy   = 0x0002
+	wmNcDestroy = 0x0082
+
+	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 不抢焦点
+
+	swpNoActivate = 0x0010 // SWP_NOACTIVATE
+	swpShowWindow = 0x0040 // SWP_SHOWWINDOW
+
+	biRgb = 0 // BI_RGB
+
+	timerFlash = 1 // 闪烁定时器 ID
+
+	overlayWidth  = 220
+	overlayHeight = 90
+	textTopY      = 26 // 文字起始 Y 坐标(覆盖层内),用于区分半透明文字区
+
+	alertDuration   = 8 * time.Second
+	flashIntervalMs = 250 // 闪烁周期 250ms
+)
+
+// 光标提示样式:普通状态 vs 提醒状态(闪烁交替)
+const (
+	flashColorNormal = 0x00FFFFFF // 普通:白
+	flashColorAlert  = 0x00FF5050 // 提醒:红
+)
+
+// hwndTopMost HWND_TOPMOST = (HWND)-1
+const hwndTopMost uintptr = ^uintptr(0)
+
+// ------------------------------------------------------------------------
+// Windows 结构体与函数指针声明(用 syscall 直接调用,避免引入额外依赖)
+// ------------------------------------------------------------------------
+
+type point struct {
+	X int32
+	Y int32
+}
+
+type msg struct {
+	HWND    uintptr
+	Message uint32
+	WParam  uintptr
+	LParam  uintptr
+	Time    uint32
+	Pt      point
+	LPrivate uint32
+}
+
+type msllhookstruct struct {
+	Pt         point
+	MouseData  uint32
+	Flags      uint32
+	Time       uint32
+	DWExtraInfo uintptr
+}
+
+type wndclassex struct {
+	CbSize        uint32
+	Style         uint32
+	LpfnWndProc   uintptr
+	CbClsExtra    int32
+	CbWndExtra    int32
+	HInstance     uintptr
+	HIcon         uintptr
+	HCursor       uintptr
+	HbrBackground uintptr
+	LpszMenuName  *uint16
+	LpszClassName *uint16
+	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
+}
+
+type blendfunction struct {
+	BlendOp             byte
+	BlendFlags          byte
+	SourceConstantAlpha byte
+	AlphaFormat         byte
+}
+
+// 延迟加载的 user32.dll / gdi32.dll / kernel32.dll 函数
+var (
+	user32   = syscall.NewLazyDLL("user32.dll")
+	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")
+	procRegisterClassEx     = user32.NewProc("RegisterClassExW")
+	procCreateWindowEx      = user32.NewProc("CreateWindowExW")
+	procDefWindowProc       = user32.NewProc("DefWindowProcW")
+	procDestroyWindow       = user32.NewProc("DestroyWindow")
+	procSetTimer            = user32.NewProc("SetTimer")
+	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")
+
+	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")
+
+	procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW")
+)
+
+// ------------------------------------------------------------------------
+// 鼠标提醒运行时状态
+// ------------------------------------------------------------------------
+
+// mouseAlertState 鼠标提醒的运行时状态(单实例)
+type mouseAlertState struct {
+	mu sync.Mutex
+
+	started     bool   // 工作线程是否已启动(持久化,仅启动一次)
+	hook        uintptr
+	overlayWnd  uintptr
+	active      bool
+	text        string
+	flashOn     bool
+	lastX, lastY int32
+	stopTime    time.Time
+	hookProc    uintptr
+	wndProc     uintptr
+	classOK     bool
+}
+
+var mouseAlert = &mouseAlertState{}
+
+// triggerMouseAlert 触发鼠标提醒:显示闪烁光标 + 光标下方透明文字。
+// 文字为空时用默认提示。重复触发会刷新提醒计时并立即显示。
+func triggerMouseAlert(text string) {
+	mouseAlert.mu.Lock()
+	if text == "" {
+		text = "有新消息提醒"
+	}
+	mouseAlert.text = text
+	mouseAlert.active = true
+	mouseAlert.flashOn = true
+	mouseAlert.stopTime = time.Now().Add(alertDuration)
+	started := mouseAlert.started
+	mouseAlert.started = true
+	overlay := mouseAlert.overlayWnd
+	mouseAlert.mu.Unlock()
+
+	// 首次触发:启动常驻工作线程(幂等,之后不再重复创建)
+	if !started {
+		go mouseAlertLoop()
+		return
+	}
+	// 已启动:立即显示窗口(位置由钩子回调或定时器补充)
+	if overlay != 0 {
+		procShowWindow.Call(overlay, 5) // SW_SHOW
+	}
+}
+
+// stopMouseAlert 立即停止鼠标提醒(隐藏覆盖层;钩子保持常驻)
+func stopMouseAlert() {
+	mouseAlert.mu.Lock()
+	mouseAlert.active = false
+	overlay := mouseAlert.overlayWnd
+	mouseAlert.mu.Unlock()
+	if overlay != 0 {
+		procShowWindow.Call(overlay, 0) // SW_HIDE
+	}
+}
+
+// mouseAlertLoop 常驻工作线程:注册窗口类、安装全局钩子、创建覆盖层窗口,
+// 进入消息循环。低层钩子必须有消息循环,故全程运行在独立线程。
+func mouseAlertLoop() {
+	hInst, _, _ := procGetModuleHandleW.Call(0)
+
+	mouseAlert.mu.Lock()
+	className, _ := syscall.UTF16PtrFromString("TengTuMouseAlertOverlay")
+	wc := wndclassex{
+		CbSize:        uint32(unsafe.Sizeof(wndclassex{})),
+		LpfnWndProc:   mouseAlert.wndProcAddr(),
+		HInstance:     hInst,
+		LpszClassName: className,
+		HCursor:       0,
+	}
+	atom, _, _ := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc)))
+	if atom != 0 {
+		mouseAlert.classOK = true
+	}
+	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()
+	}
+
+	// 创建覆盖层窗口(初始 220x90,位置随后由钩子/定时器设置)
+	style := uintptr(wndExLayered | wndExTrans | wndExTool | wndExTopMost | wndExNoActivate)
+	overlay, _, _ := procCreateWindowEx.Call(
+		style,
+		uintptr(unsafe.Pointer(className)),
+		0,
+		0, 0, overlayWidth, overlayHeight, 0,
+		0, 0, hInst, 0,
+	)
+	mouseAlert.mu.Lock()
+	mouseAlert.overlayWnd = overlay
+	mouseAlert.mu.Unlock()
+
+	// 启动闪烁定时器
+	if overlay != 0 {
+		procSetTimer.Call(overlay, timerFlash, flashIntervalMs, 0)
+	}
+
+	// 消息循环(常驻,直到线程退出)
+	var m msg
+	for {
+		ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
+		if ret == 0 {
+			break // WM_QUIT
+		}
+		procTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
+		procDispatchMessage.Call(uintptr(unsafe.Pointer(&m)))
+	}
+
+	// 清理
+	if overlay != 0 {
+		procDestroyWindow.Call(overlay)
+	}
+	if hHook != 0 {
+		procUnhookWindowsHook.Call(hHook)
+	}
+	mouseAlert.mu.Lock()
+	mouseAlert.hook = 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 返回覆盖层窗口过程地址
+func (s *mouseAlertState) wndProcAddr() uintptr {
+	if s.wndProc != 0 {
+		return s.wndProc
+	}
+	s.wndProc = syscall.NewCallback(s.wndProcCallback)
+	return s.wndProc
+}
+
+// hookCallback 全局低层鼠标钩子回调:跟踪鼠标位置、驱动自动消退与重绘。
+// 注意:此回调在钩子线程上下文执行,必须快速返回。
+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 操作均为同线程。
+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)
+
+	s.drawLayered(overlay, text, flashOn)
+}
+
+// drawLayered 用 32 位 DIB + UpdateLayeredWindow 绘制闪烁光标环与半透明文字。
+func (s *mouseAlertState) drawLayered(overlay uintptr, text string, flashOn bool) {
+	const w = overlayWidth
+	const h = overlayHeight
+
+	hdcScreen, _, _ := procGetDC.Call(0)
+	if hdcScreen == 0 {
+		return
+	}
+	defer procReleaseDC.Call(0, hdcScreen)
+
+	hdcMem, _, _ := procCreateCompatibleDC.Call(hdcScreen)
+	if hdcMem == 0 {
+		return
+	}
+	defer procDeleteDC.Call(hdcMem)
+
+	// 32 位 DIB 位图(负高度:自顶向下)
+	bi := bitmapinfoheader{}
+	bi.BiSize = 40 // sizeof(BITMAPINFOHEADER)
+	bi.BiWidth = w
+	bi.BiHeight = -h
+	bi.BiPlanes = 1
+	bi.BiBitCount = 32
+	bi.BiCompression = biRgb
+
+	var bits uintptr
+	hBitmap, _, _ := procCreateDIBSection.Call(hdcScreen,
+		uintptr(unsafe.Pointer(&bi)), 0, uintptr(unsafe.Pointer(&bits)), 0, 0)
+	if hBitmap == 0 {
+		return
+	}
+	defer procDeleteObject.Call(hBitmap)
+
+	oldBmp, _, _ := procSelectObject.Call(hdcMem, hBitmap)
+
+	// 清空为全透明
+	clearPixels(bits, w, h)
+
+	// 光标环中心放在覆盖层 (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)
+
+	// UpdateLayeredWindow:pptDst 传 nil 保持 SetWindowPos 设定的位置
+	var blend blendfunction
+	blend.BlendOp = 0        // AC_SRC_OVER
+	blend.AlphaFormat = 0x01 // AC_SRC_ALPHA
+	blend.SourceConstantAlpha = 255
+	procUpdateLayeredWindow.Call(overlay, hdcScreen,
+		0, 0, // pptDst=nil, psize=nil
+		hdcMem, 0, 0, uintptr(unsafe.Pointer(&blend)), 0x00000002 /*ULW_ALPHA*/)
+}
+
+// clearPixels 把 DIB 位图全部置为透明(ARGB=0)
+func clearPixels(bits uintptr, w, h int) {
+	if bits == 0 {
+		return
+	}
+	total := uintptr(w) * uintptr(h) * 4
+	for i := uintptr(0); i+8 <= total; i += 8 {
+		*(*uint64)(unsafe.Pointer(bits + i)) = 0
+	}
+}
+
+// setAlphaFromLuma 按亮度补全 alpha:非透明像素的 alpha = max(r,g,b),
+// 文字区(y >= textTopY)乘以 3/4 实现半透明。
+func setAlphaFromLuma(bits uintptr, w, h int) {
+	if bits == 0 {
+		return
+	}
+	for y := 0; y < h; y++ {
+		for x := 0; x < w; x++ {
+			p := (*uint32)(unsafe.Pointer(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
+		}
+	}
+}
+
+// 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))
+}
+
+// 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
+	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 用系统默认字体
+	)
+	if hFont == 0 {
+		return
+	}
+	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 {
+			s.mu.Lock()
+			if s.active {
+				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()
+		}
+		return 0
+	case wmDestroy, wmNcDestroy:
+		procKillTimer.Call(hwnd, timerFlash)
+		return 0
+	}
+	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
+	}
+	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
+	}
+	for r < -3.14159265 {
+		r += 6.28318530
+	}
+	r2 := r * r
+	return r - r2*r/6 + r2*r2*r/120 - r2*r2*r2*r/5040
+}

+ 390 - 0
newsfeed.go

@@ -0,0 +1,390 @@
+// ============================================================================
+// newsfeed.go — 新闻聚合
+//
+// 多来源新闻抓取(煎蛋、头条热榜、腾讯科技/财经、Solidot),
+// 支持关键词命中置顶与已查看状态。
+// ============================================================================
+package main
+
+import (
+	"crypto/md5"
+	"crypto/sha1"
+	"encoding/json"
+	"encoding/xml"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"regexp"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+)
+
+// 新闻来源接口地址
+const (
+	jandanURL  = "https://jandan.net/"                                            // 煎蛋首页(HTML)
+	toutiaoURL = "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc" // 头条热榜(JSON)
+	tencentURL = "https://r.inews.qq.com/web_feed/getPCList"                      // 腾讯新闻频道列表(JSON)
+	// 腾讯频道 id:来自频道页 channelNav 配置(news_news_tech / news_news_finance)
+	tencentTechID     = "news_news_tech"
+	tencentFinanceID  = "news_news_finance"
+	tencentSourceName = "腾讯"
+	solidotURL        = "https://www.solidot.org/index.rss" // 奇客 Solidot 全文 RSS(XML)
+	solidotSourceName = "Solidot"
+	cailianSourceName = "财联社"                        // 财联社来源显示名
+	cailianDepthID    = "1000"                          // 深度频道 id(对应 /depth?id=1000)
+	cailianDepthURL   = "https://www.cls.cn/depth?id=1000" // 深度频道页面地址
+)
+
+// NewsItem 一条新闻标题,供「新闻」页与新闻滚动条使用
+type NewsItem struct {
+	Source  string `json:"source"`  // 来源:煎蛋 / 头条 / 腾讯
+	Title   string `json:"title"`   // 标题
+	URL     string `json:"url"`     // 详情页地址
+	Matched bool   `json:"matched"` // 是否命中关注关键词
+}
+
+// fetchNews 并行抓取各来源新闻,按关键词标记后返回(命中的排前面)
+func fetchNews(keywords string) ([]NewsItem, error) {
+	var mu sync.Mutex
+	var wg sync.WaitGroup
+	items := make([]NewsItem, 0, 60)
+	errs := make([]string, 0, 5)
+
+	// 各来源并发抓取,失败只记录不中断
+	for _, job := range []struct {
+		source string
+		fetch  func() ([]NewsItem, error)
+	}{
+		{"煎蛋", fetchJandan},
+		{"头条", fetchToutiao},
+		{"腾讯科技", func() ([]NewsItem, error) { return fetchTencentChannel(tencentTechID) }},
+		{"腾讯财经", func() ([]NewsItem, error) { return fetchTencentChannel(tencentFinanceID) }},
+		{"Solidot", fetchSolidot},
+		{"财联社", fetchCailian},
+	} {
+		wg.Add(1)
+		go func(source string, fetch func() ([]NewsItem, error)) {
+			defer wg.Done()
+			list, err := fetch()
+			mu.Lock()
+			defer mu.Unlock()
+			if err != nil {
+				errs = append(errs, fmt.Sprintf("%s: %v", source, err))
+				return
+			}
+			items = append(items, list...)
+		}(job.source, job.fetch)
+	}
+	wg.Wait()
+
+	markNewsMatched(items, keywords) // 按关键词标记命中项
+	sortNews(items)                  // 命中项排在前面
+
+	if len(items) == 0 && len(errs) > 0 {
+		return nil, fmt.Errorf("全部来源抓取失败(%s)", strings.Join(errs, ";"))
+	}
+	return items, nil
+}
+
+// markNewsMatched 按逗号 / 空格分隔的关键词标记标题命中项(不区分大小写)
+func markNewsMatched(items []NewsItem, keywords string) {
+	words := strings.FieldsFunc(keywords, func(r rune) bool {
+		return r == ',' || r == ',' || r == ' '
+	})
+	for i := range items {
+		lower := strings.ToLower(items[i].Title)
+		for _, w := range words {
+			if w = strings.TrimSpace(w); w != "" && strings.Contains(lower, strings.ToLower(w)) {
+				items[i].Matched = true
+				break
+			}
+		}
+	}
+}
+
+// sortNews 稳定排序:命中关键词的排前面,其余保持原顺序
+func sortNews(items []NewsItem) {
+	matched := make([]NewsItem, 0, len(items))
+	others := make([]NewsItem, 0, len(items))
+	for _, item := range items {
+		if item.Matched {
+			matched = append(matched, item)
+		} else {
+			others = append(others, item)
+		}
+	}
+	copy(items, append(matched, others...))
+}
+
+// 煎蛋首页文章标题:<h2 class="post-title"><a href="/p/123/">标题</a></h2>
+var jandanTitleRe = regexp.MustCompile(`<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)</a>\s*</h2>`)
+var htmlTagRe = regexp.MustCompile(`<[^>]+>`) // 剥掉标题里残留的内联标签
+
+// fetchJandan 抓取煎蛋首页文章标题(取前 20 条)
+func fetchJandan() ([]NewsItem, error) {
+	body, err := httpGetText(jandanURL) // 煎蛋页面为 UTF-8
+	if err != nil {
+		return nil, err
+	}
+
+	matches := jandanTitleRe.FindAllStringSubmatch(body, 40)
+	items := make([]NewsItem, 0, len(matches))
+	seen := make(map[string]bool)
+	for _, m := range matches {
+		title := strings.TrimSpace(htmlTagRe.ReplaceAllString(m[2], ""))
+		url := strings.TrimSpace(m[1])
+		if strings.HasPrefix(url, "//") {
+			url = "https:" + url // 协议相对地址补全
+		}
+		if strings.HasPrefix(url, "/") {
+			url = "https://jandan.net" + url // 站内相对地址补全
+		}
+		if title == "" || seen[url] {
+			continue
+		}
+		seen[url] = true
+		items = append(items, NewsItem{Source: "煎蛋", Title: title, URL: url})
+		if len(items) >= 20 {
+			break
+		}
+	}
+	if len(items) == 0 {
+		return nil, fmt.Errorf("未解析到文章标题")
+	}
+	return items, nil
+}
+
+// toutiaoPayload 头条热榜接口的响应结构
+type toutiaoPayload struct {
+	Data []struct {
+		Title string `json:"Title"`
+		URL   string `json:"Url"`
+	} `json:"data"`
+}
+
+// fetchToutiao 抓取头条热榜(取前 30 条)
+func fetchToutiao() ([]NewsItem, error) {
+	var payload toutiaoPayload
+	if err := httpGetJSON2(toutiaoURL, &payload); err != nil {
+		return nil, err
+	}
+
+	items := make([]NewsItem, 0, len(payload.Data))
+	for _, entry := range payload.Data {
+		if entry.Title == "" {
+			continue
+		}
+		items = append(items, NewsItem{Source: "头条", Title: entry.Title, URL: entry.URL})
+		if len(items) >= 30 {
+			break
+		}
+	}
+	if len(items) == 0 {
+		return nil, fmt.Errorf("热榜为空")
+	}
+	return items, nil
+}
+
+// tencentPayload 腾讯新闻频道接口的响应结构
+type tencentPayload struct {
+	Data []struct {
+		Title    string `json:"title"` // 标题
+		LinkInfo struct {
+			ShareURL string `json:"share_url"` // 详情页地址
+		} `json:"link_info"`
+	} `json:"data"`
+}
+
+// fetchTencentChannel 抓取腾讯新闻指定频道(取前 20 条)
+// 接口需要 base_req[from]=pc 形式的嵌套参数,否则返回 empty from
+func fetchTencentChannel(channelID string) ([]NewsItem, error) {
+	url := fmt.Sprintf("%s?base_req%%5Bfrom%%5D=pc&channel_id=%s&flush_num=1&item_count=20",
+		tencentURL, channelID)
+	var payload tencentPayload
+	if err := httpGetJSON2(url, &payload); err != nil {
+		return nil, err
+	}
+
+	items := make([]NewsItem, 0, len(payload.Data))
+	for _, entry := range payload.Data {
+		title := strings.TrimSpace(entry.Title)
+		link := strings.TrimSpace(entry.LinkInfo.ShareURL)
+		if title == "" || link == "" {
+			continue
+		}
+		items = append(items, NewsItem{Source: tencentSourceName, Title: title, URL: link})
+		if len(items) >= 20 {
+			break
+		}
+	}
+	if len(items) == 0 {
+		return nil, fmt.Errorf("频道列表为空")
+	}
+	return items, nil
+}
+
+// solidotRSS 奇客 Solidot RSS 的 XML 结构
+type solidotRSS struct {
+	Channel struct {
+		Items []struct {
+			Title string `xml:"title"` // 标题
+			Link  string `xml:"link"`  // 详情页地址
+		} `xml:"item"`
+	} `xml:"channel"`
+}
+
+// fetchSolidot 抓取奇客 Solidot 的 RSS 新闻(取前 20 条)
+func fetchSolidot() ([]NewsItem, error) {
+	body, err := httpGetText(solidotURL)
+	if err != nil {
+		return nil, err
+	}
+	var rss solidotRSS
+	if err := xml.Unmarshal([]byte(body), &rss); err != nil {
+		return nil, fmt.Errorf("解析 RSS 失败: %w", err)
+	}
+	items := make([]NewsItem, 0, 20)
+	for _, entry := range rss.Channel.Items {
+		title := strings.TrimSpace(entry.Title)
+		link := strings.TrimSpace(entry.Link)
+		if title == "" || link == "" {
+			continue
+		}
+		items = append(items, NewsItem{Source: solidotSourceName, Title: title, URL: link})
+		if len(items) >= 20 {
+			break
+		}
+	}
+	if len(items) == 0 {
+		return nil, fmt.Errorf("RSS 为空")
+	}
+	return items, nil
+}
+
+// ------------------------------------------------------------------------
+// 财联社深度频道
+// ------------------------------------------------------------------------
+// 签名算法(逆向自前端 _app.js 模块 36498 + 37371 + 68908 + 81383):
+//   sign = MD5( hex(SHA1("k=v&k=v")) )
+// 其中参数按 key 默认字典序(.sort())排列,财联社深度接口固定参数为
+// {os:"web", sv:"8.7.9", app:"CailianpressWeb"},与路径 / 时间无关,可稳定复现。
+func cailianSign() string {
+	params := map[string]string{"os": "web", "sv": "8.7.9", "app": "CailianpressWeb"}
+	keys := make([]string, 0, len(params))
+	for k := range params {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys) // 默认字典序,与前端 Object.keys().sort() 一致
+
+	parts := make([]string, 0, len(keys))
+	for _, k := range keys {
+		parts = append(parts, k+"="+params[k])
+	}
+	raw := strings.Join(parts, "&")
+
+	// 先 SHA-1(得到 20 字节原始摘要),再取其 hex 字符串(40 个 ASCII 字符)做 MD5
+	sha1Bytes := sha1.Sum([]byte(raw))
+	sha1Hex := fmt.Sprintf("%x", sha1Bytes) // 40 字符小写 hex
+	return fmt.Sprintf("%x", md5.Sum([]byte(sha1Hex)))
+}
+
+// cailianPayload 财联社深度接口响应结构(只取需要的字段)
+type cailianPayload struct {
+	Errno int `json:"errno"`
+	Data  struct {
+		DepthList []struct {
+			ID    int64  `json:"id"`    // 文章 id,用于拼详情页地址
+			Title string `json:"title"` // 标题
+		} `json:"depth_list"`
+	} `json:"data"`
+}
+
+// fetchCailian 抓取财联社深度频道文章(取前 20 条)
+func fetchCailian() ([]NewsItem, error) {
+	sign := cailianSign()
+	apiURL := fmt.Sprintf("https://www.cls.cn/v3/depth/home/assembled/%s?app=%s&os=%s&sv=%s&sign=%s",
+		cailianDepthID, url.QueryEscape("CailianpressWeb"), "web", "8.7.9", sign)
+
+	body, err := httpGetText(apiURL) // 该接口返回 JSON 文本
+	if err != nil {
+		return nil, err
+	}
+	var payload cailianPayload
+	if err := json.Unmarshal([]byte(body), &payload); err != nil {
+		return nil, fmt.Errorf("解析响应失败: %w", err)
+	}
+	if payload.Errno != 0 {
+		return nil, fmt.Errorf("接口返回 errno=%d", payload.Errno)
+	}
+
+	items := make([]NewsItem, 0, len(payload.Data.DepthList))
+	for _, entry := range payload.Data.DepthList {
+		title := strings.TrimSpace(entry.Title)
+		if title == "" {
+			continue
+		}
+		items = append(items, NewsItem{
+			Source: cailianSourceName,
+			Title:  title,
+			URL:    fmt.Sprintf("https://www.cls.cn/detail/%d", entry.ID),
+		})
+		if len(items) >= 20 {
+			break
+		}
+	}
+	if len(items) == 0 {
+		return nil, fmt.Errorf("深度列表为空")
+	}
+	return items, nil
+}
+
+// httpGetText 发起 GET 请求并返回响应文本(检查状态码,非 2xx 或空 body 均返回错误)
+func httpGetText(url string) (string, error) {
+	resp, err := newsClient(url)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode < 200 || resp.StatusCode > 299 {
+		return "", fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status) // 限流 / 鉴权失败等,避免误报为解析错误
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", err
+	}
+	if len(body) == 0 {
+		return "", fmt.Errorf("响应为空")
+	}
+	return string(body), nil
+}
+
+// httpGetJSON2 发起 GET 请求并解析 JSON
+func httpGetJSON2(url string, target any) error {
+	resp, err := newsClient(url)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+	return json.Unmarshal(body, target)
+}
+
+// newsClient 发起带 UA 的 GET 请求(部分站点校验 User-Agent)
+func newsClient(url string) (*http.Response, error) {
+	client := &http.Client{Timeout: 10 * time.Second}
+	req, err := http.NewRequest(http.MethodGet, url, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
+	return client.Do(req)
+}

+ 500 - 0
ntfy.go

@@ -0,0 +1,500 @@
+// ============================================================================
+// ntfy.go — 工具页 ntfy 消息推送
+//
+// 把消息通过 ntfy 推送服务(默认 https://ntfy.sh)发送到指定主题(频道),
+// 用于给监听该主题的手机 / 浏览器 / 其他设备推送通知。
+// 主题输入会记忆最近使用过的主题(独立文件持久化),便于下拉选择。
+// ============================================================================
+package main
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+)
+
+// ntfy 默认推送服务器(ntfy.sh 公共实例)
+const ntfyServer = "https://ntfy.sh"
+
+// ntfy 主题历史最多记忆条数
+const ntfyTopicHistoryLimit = 20
+
+// ntfy 昵称历史最多记忆条数
+const ntfyNicknameHistoryLimit = 20
+
+// ntfyTopicsFile 返回主题历史文件路径:<用户配置目录>/TengTuToolbox/ntfy_topics.json
+func ntfyTopicsFile() (string, error) {
+	dir, err := os.UserConfigDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(dir, "TengTuToolbox", "ntfy_topics.json"), nil
+}
+
+// loadNtfyTopics 读取已记忆的主题列表(文件不存在或损坏时返回空列表)
+func loadNtfyTopics() []string {
+	path, err := ntfyTopicsFile()
+	if err != nil {
+		return nil
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil
+	}
+	var topics []string
+	if err := json.Unmarshal(data, &topics); err != nil {
+		return nil
+	}
+	return topics
+}
+
+// saveNtfyTopics 把主题列表写回历史文件(目录不存在时自动创建)
+func saveNtfyTopics(topics []string) error {
+	path, err := ntfyTopicsFile()
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return err
+	}
+	data, err := json.MarshalIndent(topics, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0o644)
+}
+
+var ntfyMu sync.Mutex // 保护主题历史的并发读写
+
+// ntfyNicknamesFile 返回昵称历史文件路径:<用户配置目录>/TengTuToolbox/ntfy_nicknames.json
+func ntfyNicknamesFile() (string, error) {
+	dir, err := os.UserConfigDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(dir, "TengTuToolbox", "ntfy_nicknames.json"), nil
+}
+
+// loadNtfyNicknames 读取已记忆的昵称列表(文件不存在或损坏时返回空列表)
+func loadNtfyNicknames() []string {
+	path, err := ntfyNicknamesFile()
+	if err != nil {
+		return nil
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil
+	}
+	var nicknames []string
+	if err := json.Unmarshal(data, &nicknames); err != nil {
+		return nil
+	}
+	return nicknames
+}
+
+// saveNtfyNicknames 把昵称列表写回历史文件(目录不存在时自动创建)
+func saveNtfyNicknames(nicknames []string) error {
+	path, err := ntfyNicknamesFile()
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return err
+	}
+	data, err := json.MarshalIndent(nicknames, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0o644)
+}
+
+// AddNtfyNickname 记录一个昵称:去重、置顶、上限 ntfyNicknameHistoryLimit 条,返回更新后列表。
+// 昵称可为空(不记忆空昵称)。
+func AddNtfyNickname(nickname string) []string {
+	nickname = strings.TrimSpace(nickname)
+	if nickname == "" {
+		return loadNtfyNicknames()
+	}
+
+	ntfyMu.Lock()
+	defer ntfyMu.Unlock()
+
+	nicknames := loadNtfyNicknames()
+	next := []string{nickname}
+	for _, n := range nicknames {
+		if n != nickname {
+			next = append(next, n)
+		}
+	}
+	if len(next) > ntfyNicknameHistoryLimit {
+		next = next[:ntfyNicknameHistoryLimit]
+	}
+	if err := saveNtfyNicknames(next); err != nil {
+		return nicknames
+	}
+	return next
+}
+
+// GetNtfyNicknames 返回已记忆的昵称列表(用于前端下拉建议)
+func (s *SettingsService) GetNtfyNicknames() []string {
+	ntfyMu.Lock()
+	defer ntfyMu.Unlock()
+	return loadNtfyNicknames()
+}
+
+// SendNtfyChat 向聊天室(主题)发送一条消息,nickname 作为标题发送。
+// 与 SendNtfyMessage 的区别:昵称固定作为标题,便于聊天室按昵称区分发言者。
+func (s *SettingsService) SendNtfyChat(topic, nickname, message string) error {
+	nickname = strings.TrimSpace(nickname)
+	message = strings.TrimSpace(message)
+	err := s.SendNtfyMessage(topic, nickname, message)
+	if err != nil {
+		return err
+	}
+	AddNtfyNickname(nickname)   // 发送成功后记忆昵称
+	rememberLocalChat(topic, message) // 记录本地发送,用于去重 SSE 回声
+	return nil
+}
+
+// SendNtfyMessage 向指定主题推送一条 ntfy 消息。
+// topic 为推送主题(频道名),title 可为空(使用主题名),message 为消息内容。
+func (s *SettingsService) SendNtfyMessage(topic, title, message string) error {
+	topic = strings.TrimSpace(topic)
+	message = strings.TrimSpace(message)
+	if topic == "" {
+		return errors.New("请输入推送主题(Topic)")
+	}
+	if message == "" {
+		return errors.New("请输入消息内容")
+	}
+	if len(topic) > 64 || len(message) > 4000 {
+		return errors.New("主题过长或内容过长(主题 ≤64、内容 ≤4000 字符)")
+	}
+
+	// 校验主题为合法 URL 路径片段(允许字母数字、- _ .)
+	for _, r := range topic {
+		if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
+			return errors.New("主题只能包含字母、数字、-、_、. 字符")
+		}
+	}
+
+	body := bytes.NewBufferString(message)
+	req, err := http.NewRequest(http.MethodPost, ntfyServer+"/"+url.PathEscape(topic), body)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "text/plain; charset=utf-8")
+	if strings.TrimSpace(title) != "" {
+		req.Header.Set("Title", strings.TrimSpace(title)) // 通知标题,缺省用主题名
+	}
+
+	client := &http.Client{Timeout: 15 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return errors.New("推送请求失败:" + err.Error())
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
+		return errors.New("推送失败,状态 " + resp.Status + ":" + strings.TrimSpace(string(raw)))
+	}
+
+	// 发送成功后记忆主题(去重置顶)
+	AddNtfyTopic(topic)
+	return nil
+}
+
+// GetNtfyTopics 返回已记忆的主题列表(用于前端下拉建议)
+func (s *SettingsService) GetNtfyTopics() []string {
+	ntfyMu.Lock()
+	defer ntfyMu.Unlock()
+	return loadNtfyTopics()
+}
+
+// AddNtfyTopic 记录一个主题:去重、置顶、上限 ntfyTopicHistoryLimit 条,返回更新后列表。
+// 发送失败的主题不记录;仅在成功发送时调用。
+func AddNtfyTopic(topic string) []string {
+	topic = strings.TrimSpace(topic)
+	if topic == "" {
+		return loadNtfyTopics()
+	}
+
+	ntfyMu.Lock()
+	defer ntfyMu.Unlock()
+
+	topics := loadNtfyTopics()
+	next := []string{topic}
+	for _, t := range topics {
+		if t != topic {
+			next = append(next, t)
+		}
+	}
+	if len(next) > ntfyTopicHistoryLimit {
+		next = next[:ntfyTopicHistoryLimit] // 只保留最近 N 条
+	}
+	if err := saveNtfyTopics(next); err != nil {
+		// 持久化失败不影响本次返回(仅记录历史)
+		return topics
+	}
+	return next
+}
+
+// ============================================================================
+// ntfy 聊天室:订阅一个主题,实时监听该主题收到的消息并广播给前端
+// ============================================================================
+
+// NtfyChatMessage 聊天室收到的一条消息(JSON 序列化后经 ntfyChat 事件发给前端)
+type NtfyChatMessage struct {
+	Topic   string `json:"topic"`   // 所属主题(聊天室名)
+	Title   string `json:"title"`   // 通知标题(可为空)
+	Message string `json:"message"` // 消息内容
+	Time    string `json:"time"`    // 收到时间(本地时区 HH:MM:SS)
+}
+
+// ntfyEchoRecent 记录近期本地发送的消息,用于去重 ntfy 的 SSE 回声:
+// 自己发送的消息会被 ntfy 经 SSE 回传一次,若不加去重会在前端重复显示。
+type ntfyEchoRecord struct {
+	Topic   string
+	Message string
+	At      time.Time
+}
+
+var (
+	ntfyEchoMu      sync.Mutex
+	ntfyEchoRecent  []ntfyEchoRecord
+	ntfyEchoWindow  = 8 * time.Second // 8 秒内的同主题同内容消息视为回声
+	ntfyEchoMax     = 20              // 最多记忆条数
+)
+
+// rememberLocalChat 记录一次本地发送(由 SendNtfyChat 成功发送后调用)。
+func rememberLocalChat(topic, message string) {
+	ntfyEchoMu.Lock()
+	defer ntfyEchoMu.Unlock()
+	now := time.Now()
+	// 先清理过期记录,再追加
+	valid := ntfyEchoRecent[:0]
+	for _, r := range ntfyEchoRecent {
+		if now.Sub(r.At) < ntfyEchoWindow {
+			valid = append(valid, r)
+		}
+	}
+	valid = append(valid, ntfyEchoRecord{Topic: topic, Message: message, At: now})
+	if len(valid) > ntfyEchoMax {
+		valid = valid[len(valid)-ntfyEchoMax:]
+	}
+	ntfyEchoRecent = valid
+}
+
+// consumeEchoChat 检查一条收到的 SSE 消息是否为本地发送的回声。
+// 若是(8 秒内同主题同内容),消费并返回 true,调用方应丢弃该消息。
+func consumeEchoChat(topic, message string) bool {
+	ntfyEchoMu.Lock()
+	defer ntfyEchoMu.Unlock()
+	now := time.Now()
+	for i, r := range ntfyEchoRecent {
+		if now.Sub(r.At) < ntfyEchoWindow && r.Topic == topic && r.Message == message {
+			// 移除该条,避免后续重复命中
+			ntfyEchoRecent = append(ntfyEchoRecent[:i], ntfyEchoRecent[i+1:]...)
+			return true
+		}
+	}
+	return false
+}
+
+// ntfyChatState 聊天室监听状态:当前订阅的主题与取消函数
+type ntfyChatState struct {
+	mu     sync.Mutex
+	topic  string   // 当前监听的主题,空表示未监听
+	cancel func()   // 取消当前监听(关闭 HTTP 响应体)
+}
+
+var ntfyChat = &ntfyChatState{}
+
+// SetNtfyChatTopic 设置聊天室要监听的主题。
+// 传入空字符串则停止监听(无主题不监听)。
+// 若与当前监听主题相同则不做任何事;不同则先停止旧监听再启动新监听。
+func (s *SettingsService) SetNtfyChatTopic(topic string) error {
+	topic = strings.TrimSpace(topic)
+	if topic == "" {
+		StopNtfyChat()
+		return nil
+	}
+
+	// 主题合法性校验(与发送一致)
+	for _, r := range topic {
+		if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
+			return errors.New("主题只能包含字母、数字、-、_、. 字符")
+		}
+	}
+
+	ntfyChat.mu.Lock()
+	defer ntfyChat.mu.Unlock()
+
+	if ntfyChat.topic == topic {
+		return nil // 已在监听该主题,无需重启
+	}
+
+	// 停止旧的监听
+	if ntfyChat.cancel != nil {
+		ntfyChat.cancel()
+		ntfyChat.cancel = nil
+	}
+
+	// 启动新监听
+	ctx, cancel := context.WithCancel(context.Background())
+	ntfyChat.topic = topic
+	ntfyChat.cancel = cancel
+	go listenNtfyChat(ctx, topic)
+	return nil
+}
+
+// StopNtfyChat 停止聊天室监听(清空主题并取消请求)
+func StopNtfyChat() {
+	ntfyChat.mu.Lock()
+	defer ntfyChat.mu.Unlock()
+	if ntfyChat.cancel != nil {
+		ntfyChat.cancel()
+		ntfyChat.cancel = nil
+	}
+	ntfyChat.topic = ""
+}
+
+// GetNtfyChatTopic 返回当前监听的聊天室主题(空表示未监听)
+func (s *SettingsService) GetNtfyChatTopic() string {
+	ntfyChat.mu.Lock()
+	defer ntfyChat.mu.Unlock()
+	return ntfyChat.topic
+}
+
+// listenNtfyChat 用 SSE 长连接订阅 ntfy 主题,循环读取消息并广播 ntfyChat 事件。
+// ctx 取消时关闭请求退出;连接中断后按 backoff 自动重连,直到被取消。
+func listenNtfyChat(ctx context.Context, topic string) {
+	client := &http.Client{}
+	backoff := time.Second
+	for {
+		select {
+		case <-ctx.Done():
+			return // 被取消,退出
+		default:
+		}
+
+		err := ntfyChatOnce(client, ctx, topic, backoff)
+		// 连接结束(错误或中断),增大重连间隔,若已被取消则退出
+		select {
+		case <-ctx.Done():
+			return
+		default:
+		}
+		if backoff < 30*time.Second {
+			backoff += 5 * time.Second // 逐步增大重连间隔
+		}
+		_ = err
+	}
+}
+
+// ntfyChatOnce 建立一次 SSE 连接并阻塞读取消息,直到连接结束或被取消。
+// 成功接收任意一条消息后重置 backoff(由调用方在收到消息时处理)。
+func ntfyChatOnce(client *http.Client, ctx context.Context, topic string, backoff time.Duration) error {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, ntfyServer+"/"+url.PathEscape(topic)+"/sse", nil)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Accept", "text/event-stream")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	// 非 2xx:不重试(如主题非法 / 服务拒绝),直接返回错误
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
+		return fmt.Errorf("subscribe failed: %s %s", resp.Status, strings.TrimSpace(string(raw)))
+	}
+
+	scanner := bufio.NewScanner(resp.Body)
+	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 单行最大 1MB,容纳长消息
+
+	// SSE 字段:ntfy.sh 只发 data: 行(内含 JSON,事件类型在 JSON 的 event 键),
+	// 没有独立的 event: 字段;空行分隔一条事件。keepalive 为注释行(: ping),不含 data:。
+	var data string
+	for scanner.Scan() {
+		line := scanner.Text()
+		if line == "" {
+			// 一条 SSE 事件结束
+			if data != "" {
+				emitNtfyChat(topic, data)
+			}
+			data = ""
+			continue
+		}
+		if strings.HasPrefix(line, "data:") {
+			data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+		}
+	}
+	return scanner.Err()
+}
+
+// emitNtfyChat 解析一条消息 data 并广播 ntfyChat 事件给前端。
+// ntfy 的 SSE data 为 JSON,内含 event 字段:只处理 message 事件,忽略 open/keepalive 等。
+func emitNtfyChat(topic, data string) {
+	// data 可能是 JSON(发布时带标题 / event 字段)或纯文本。
+	// 注意:ntfy JSON 的 time 字段是数字,必须用 map[string]any 解析,
+	// 若用 map[string]string 会因 number→string 失败导致解析整个失败,
+	// 从而把整条原始 JSON 当作消息回退显示(表现为「直接显示 ntfy 消息」)。
+	var title, msg string
+	showTime := time.Now().Format("15:04:05") // 默认用收到时间
+	var raw map[string]any
+	if json.Unmarshal([]byte(data), &raw) == nil {
+		// 仅处理 message 事件;无 event 字段时按纯文本消息处理
+		if ev, ok := raw["event"].(string); ok && ev != "message" {
+			return // open / keepalive 等非消息事件,忽略
+		}
+		if v, ok := raw["message"].(string); ok {
+			msg = v
+		}
+		if v, ok := raw["title"].(string); ok {
+			title = v
+		}
+		// ntfy 的 time 为 Unix 时间戳(秒),转本地时间显示发送时刻
+		if ts, ok := raw["time"].(float64); ok && ts > 0 {
+			showTime = time.Unix(int64(ts), 0).Format("15:04:05")
+		}
+	}
+	if msg == "" {
+		msg = data // 纯文本消息
+	}
+	if title == "" {
+		title = topic // 无标题时显示主题名
+	}
+
+	// 去重 SSE 回声:自己刚发送的消息会被 ntfy 回传一次,
+	// 若 8 秒内同主题同内容则判定为回声,直接丢弃(本地已显示)。
+	if consumeEchoChat(topic, msg) {
+		return
+	}
+
+	payload := NtfyChatMessage{
+		Topic:   topic,
+		Title:   title,
+		Message: msg,
+		Time:    showTime,
+	}
+	b, _ := json.Marshal(payload)
+	if app != nil {
+		app.Event.Emit("ntfyChat", string(b))
+	}
+}

+ 288 - 0
pccontrol.go

@@ -0,0 +1,288 @@
+// ============================================================================
+// pccontrol.go — 电源控制
+//
+// 关机 / 重启 / 休眠 / 锁屏命令(Windows 下用 shutdown 与 rundll32),
+// 支持延迟执行与取消,并提供当前命令状态查询。
+// ============================================================================
+package main
+
+import (
+	"fmt"
+	"net/http"
+	"os/exec"
+	"runtime"
+	"strconv"
+	"sync"
+	"time"
+
+	"github.com/gen2brain/beeep"
+	"github.com/gin-gonic/gin"
+)
+
+// 电源命令默认延迟执行时间(Windows 下交给 shutdown /t 系统计时,服务崩溃倒计时仍有效)
+const pcShutdownDelay = 30 * time.Second
+
+// pcCommand 电源命令类型
+type pcCommand string
+
+const (
+	pcPoweroff pcCommand = "poweroff" // 关机
+	pcRestart  pcCommand = "restart"  // 重启
+	pcSleep    pcCommand = "sleep"    // 休眠
+	pcLock     pcCommand = "lock"     // 锁屏
+)
+
+// PCStatus 电源命令状态快照,供 API 与「主机」页查询
+type PCStatus struct {
+	Command       string `json:"command"`        // 最近一次命令类型
+	Pending       bool   `json:"pending"`        // 是否有待执行命令
+	ExecuteAt     string `json:"executeAt"`      // 计划执行时间
+	RemainingSecs int    `json:"remainingSecs"`  // 剩余秒数
+	ClientIP      string `json:"clientIp"`       // 最近一次请求的客户端 IP
+	RequestTime   string `json:"requestTime"`    // 最近一次请求时间
+	Requested     bool   `json:"requested"`      // 是否收到过请求
+}
+
+var (
+	pcMu         sync.Mutex
+	pcTimer      *time.Timer // 休眠 / 锁屏等不支持系统计时的命令用 Go 计时
+	pcExecuteAt  time.Time
+	pcPending    bool
+	pcPendingCmd pcCommand
+	pcSystemTimer bool // 是否已交给系统计时(shutdown /t)
+
+	pcLastClientIP string
+	pcLastTime     time.Time
+	pcLastCmd      pcCommand
+	pcRequested    bool
+)
+
+// pcSystemCommand 构造系统命令;返回 nil 表示该命令需 Go 计时后立即执行
+func pcSystemCommand(cmd pcCommand, delaySec int) *exec.Cmd {
+	switch runtime.GOOS {
+	case "windows":
+		switch cmd {
+		case pcPoweroff:
+			return exec.Command("shutdown", "/s", "/t", strconv.Itoa(delaySec), "/f")
+		case pcRestart:
+			return exec.Command("shutdown", "/r", "/t", strconv.Itoa(delaySec), "/f")
+		}
+	case "darwin":
+		switch cmd {
+		case pcPoweroff:
+			return exec.Command("sudo", "shutdown", "-h", "+0")
+		case pcRestart:
+			return exec.Command("sudo", "shutdown", "-r", "+0")
+		}
+	case "linux":
+		switch cmd {
+		case pcPoweroff:
+			return exec.Command("shutdown", "-h", "+0")
+		case pcRestart:
+			return exec.Command("shutdown", "-r", "+0")
+		}
+	}
+	return nil
+}
+
+// pcExecuteNow 立即执行休眠 / 锁屏(这类命令无系统计时,直接执行)
+func pcExecuteNow(cmd pcCommand) {
+	var execCmd *exec.Cmd
+	switch cmd {
+	case pcSleep:
+		switch runtime.GOOS {
+		case "windows":
+			execCmd = exec.Command("rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0")
+		case "darwin":
+			execCmd = exec.Command("pmset", "sleepnow")
+		default:
+			execCmd = exec.Command("systemctl", "suspend")
+		}
+	case pcLock:
+		switch runtime.GOOS {
+		case "windows":
+			execCmd = exec.Command("rundll32.exe", "user32.dll,LockWorkStation")
+		default:
+			execCmd = nil
+		}
+	}
+	if execCmd == nil {
+		fmt.Fprintf(apiLogRing, "[SYS] 不支持的命令 %s (OS: %s)\n", cmd, runtime.GOOS)
+		return
+	}
+	if output, err := execCmd.CombinedOutput(); err != nil {
+		fmt.Fprintf(apiLogRing, "[SYS] 命令 %s 执行失败: %v | %s\n", cmd, err, string(output))
+	} else {
+		fmt.Fprintf(apiLogRing, "[SYS] 命令 %s 执行成功\n", cmd)
+	}
+}
+
+// pcAbortShutdown 取消系统关机 / 重启倒计时
+func pcAbortShutdown() {
+	if runtime.GOOS == "windows" {
+		if output, err := exec.Command("shutdown", "/a").CombinedOutput(); err != nil {
+			fmt.Fprintf(apiLogRing, "[SYS] 取消关机失败: %v | %s\n", err, string(output))
+		} else {
+			fmt.Fprintln(apiLogRing, "[SYS] 系统关机已取消")
+		}
+	} else {
+		_ = exec.Command("shutdown", "-c").Run()
+	}
+}
+
+// getPCStatus 返回当前电源命令状态快照
+func getPCStatus() PCStatus {
+	pcMu.Lock()
+	defer pcMu.Unlock()
+
+	status := PCStatus{
+		Command:   string(pcLastCmd),
+		Pending:   pcPending,
+		ClientIP:  pcLastClientIP,
+		Requested: pcRequested,
+	}
+	if !pcLastTime.IsZero() {
+		status.RequestTime = pcLastTime.Format("01-02 15:04:05")
+	}
+	if pcPending {
+		status.ExecuteAt = pcExecuteAt.Format("01-02 15:04:05")
+		remaining := int(time.Until(pcExecuteAt).Seconds())
+		if remaining < 0 {
+			remaining = 0
+		}
+		status.RemainingSecs = remaining
+	}
+	return status
+}
+
+// handleDelayedCommand 通用延迟命令处理:优先交给系统计时,不支持则用 Go 计时
+func handleDelayedCommand(c *gin.Context, cmd pcCommand, delay time.Duration, title string) {
+	clientIP := c.ClientIP()
+	delaySec := int(delay.Seconds())
+
+	pcMu.Lock()
+	pcLastClientIP = clientIP
+	pcLastTime = time.Now()
+	pcLastCmd = cmd
+	pcRequested = true
+
+	if pcPending {
+		remaining := time.Until(pcExecuteAt).Round(time.Second)
+		pendingCmd := pcPendingCmd
+		pcMu.Unlock()
+		c.String(http.StatusOK, "命令 %s 已在待执行,剩余 %v\n", pendingCmd, remaining)
+		return
+	}
+
+	pcPending = true
+	pcPendingCmd = cmd
+	pcExecuteAt = time.Now().Add(delay)
+
+	sysCmd := pcSystemCommand(cmd, delaySec)
+	if sysCmd != nil {
+		pcSystemTimer = true
+		pcMu.Unlock()
+
+		// 系统计时:立即下发 shutdown /t N,由系统自己倒计时
+		if output, err := sysCmd.CombinedOutput(); err != nil {
+			pcMu.Lock()
+			pcPending = false
+			pcPendingCmd = ""
+			pcExecuteAt = time.Time{}
+			pcSystemTimer = false
+			pcMu.Unlock()
+			fmt.Fprintf(apiLogRing, "[SYS] 命令 %s 下发失败: %v | %s\n", cmd, err, string(output))
+			c.String(http.StatusInternalServerError, "命令执行失败: %v\n", err)
+			return
+		}
+		fmt.Fprintf(apiLogRing, "[SYS] 命令 %s 交由系统计时 | %ds | 客户端 %s\n", cmd, delaySec, clientIP)
+	} else {
+		// Go 计时:延迟到点后立即执行
+		pcSystemTimer = false
+		cmdToExec := cmd
+		pcTimer = time.AfterFunc(delay, func() {
+			pcMu.Lock()
+			pcPending = false
+			pcPendingCmd = ""
+			pcMu.Unlock()
+			pcExecuteNow(cmdToExec)
+		})
+		pcMu.Unlock()
+		fmt.Fprintf(apiLogRing, "[SYS] 命令 %s 使用 Go 计时 | %ds | 客户端 %s\n", cmd, delaySec, clientIP)
+	}
+
+	_ = beeep.Notify("⚠ "+title, fmt.Sprintf("来自: %s\n将在 %d 秒后执行\n时间: %s", clientIP, delaySec, time.Now().Format("15:04:05")), "")
+	c.String(http.StatusOK, "OK: %s\n", title)
+}
+
+// handlePoweroff 处理关机请求:延迟 pcShutdownDelay 秒后关机
+func handlePoweroff(c *gin.Context) {
+	handleDelayedCommand(c, pcPoweroff, pcShutdownDelay, "收到关机请求")
+}
+
+// handleRestart 处理重启请求:延迟 pcShutdownDelay 秒后重启
+func handleRestart(c *gin.Context) {
+	handleDelayedCommand(c, pcRestart, pcShutdownDelay, "收到重启请求")
+}
+
+// handleSleep 处理休眠请求:延迟 pcShutdownDelay 秒后休眠
+func handleSleep(c *gin.Context) {
+	handleDelayedCommand(c, pcSleep, pcShutdownDelay, "收到休眠请求")
+}
+
+// handleLock 锁屏不需要倒计时,直接执行
+func handleLock(c *gin.Context) {
+	pcMu.Lock()
+	pcLastClientIP = c.ClientIP()
+	pcLastTime = time.Now()
+	pcLastCmd = pcLock
+	pcRequested = true
+	pcMu.Unlock()
+	go pcExecuteNow(pcLock)
+	c.String(http.StatusOK, "OK: 已锁屏\n")
+}
+
+// handleCancel 取消待执行命令:系统计时调 shutdown /a,Go 计时停定时器
+func handleCancel(c *gin.Context) {
+	pcMu.Lock()
+	if !pcPending {
+		pcMu.Unlock()
+		c.String(http.StatusOK, "无待执行命令\n")
+		return
+	}
+
+	cancelled := pcPendingCmd
+	wasSystemTimer := pcSystemTimer
+
+	pcPending = false
+	pcPendingCmd = ""
+	pcExecuteAt = time.Time{}
+	pcSystemTimer = false
+	if pcTimer != nil {
+		pcTimer.Stop()
+		pcTimer = nil
+	}
+	pcMu.Unlock()
+
+	if wasSystemTimer {
+		pcAbortShutdown() // 系统计时的倒计时由 shutdown /a 取消
+	}
+
+	fmt.Fprintf(apiLogRing, "[SYS] 命令 %s 已取消 | 客户端 %s\n", cancelled, c.ClientIP())
+	_ = beeep.Notify("✅ 命令已取消", fmt.Sprintf("%s 由 %s 取消", cancelled, c.ClientIP()), "")
+	c.String(http.StatusOK, "命令已取消\n")
+}
+
+// handlePCStatus 返回当前命令状态(JSON)
+func handlePCStatus(c *gin.Context) {
+	c.JSON(http.StatusOK, getPCStatus())
+}
+
+// handleTimeSync 手动触发时间同步,返回具体结果
+func handleTimeSync(c *gin.Context) {
+	if err := syncSystemTime(); err != nil {
+		c.String(http.StatusInternalServerError, "时间同步失败: %v\n", err)
+		return
+	}
+	c.String(http.StatusOK, "时间同步成功\n")
+}

+ 114 - 0
poem.go

@@ -0,0 +1,114 @@
+// ============================================================================
+// poem.go — 随机古诗
+//
+// 主窗口首个 Tab(笔记页)footer 内水平滚动显示一首随机古诗;
+// 数据来自 poetry.palemoky.com 的随机接口,颜色 / 字号 / 刷新间隔可配置。
+// ============================================================================
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+)
+
+const (
+	PoemAPIURL          = "https://poetry.palemoky.com/api/poems/random" // 随机古诗接口
+	DefaultPoemColor    = "#c98a3a"                                      // 默认文字颜色(深琥珀,暗底明显可读)
+	DefaultPoemFontSize = 16                                             // 默认字号(px)
+	DefaultPoemInterval = 60                                             // 默认随机换诗间隔(秒),0 表示不自动换
+	DefaultPoemSpeed    = 18                                             // 默认滚动一轮时长(秒)
+)
+
+// PoemConfig 古诗显示的配置
+type PoemConfig struct {
+	Enabled  bool   `json:"enabled"`  // 是否启用古诗显示
+	Color    string `json:"color"`    // 文字颜色
+	FontSize int    `json:"fontSize"` // 字号(px)
+	TTS      bool   `json:"tts"`      // 允许 TTS 阅读(显示朗读按钮)
+	Interval int    `json:"interval"` // 自动换诗间隔(秒),0 表示不自动换
+	Speed    int    `json:"speed"`    // 滚动速度(秒/轮),即滚动一个文本周期所需秒数,越小越快
+}
+
+// PoemData 一首古诗的完整数据(从接口解析)
+type PoemData struct {
+	Title   string   `json:"title"`   // 题目
+	Author  string   `json:"author"`  // 作者
+	Dynasty string   `json:"dynasty"` // 朝代
+	Content []string `json:"content"` // 每句诗(数组,按行显示)
+	Type    string   `json:"type"`    // 体裁,如 五言律诗
+}
+
+// poemAPIResponse 接口返回的外层结构
+type poemAPIResponse struct {
+	Data struct {
+		ID      int      `json:"id"`
+		Title   string   `json:"title"`
+		Content []string `json:"content"`
+		Author  struct {
+			ID   int    `json:"id"`
+			Name string `json:"name"`
+		} `json:"author"`
+		Dynasty struct {
+			ID   int    `json:"id"`
+			Name string `json:"name"`
+		} `json:"dynasty"`
+		Type struct {
+			ID   int    `json:"id"`
+			Name string `json:"name"`
+		} `json:"type"`
+	} `json:"data"`
+	Lang string `json:"lang"`
+}
+
+// fetchRandomPoem 从接口拉取一首随机古诗;失败时返回错误
+func fetchRandomPoem() (PoemData, error) {
+	client := &http.Client{Timeout: 10 * time.Second}
+	resp, err := client.Get(PoemAPIURL)
+	if err != nil {
+		return PoemData{}, fmt.Errorf("请求古诗接口失败: %w", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return PoemData{}, fmt.Errorf("古诗接口返回状态 %d", resp.StatusCode)
+	}
+	var out poemAPIResponse
+	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+		return PoemData{}, fmt.Errorf("解析古诗数据失败: %w", err)
+	}
+	p := PoemData{
+		Title:   strings.TrimSpace(out.Data.Title),
+		Author:  strings.TrimSpace(out.Data.Author.Name),
+		Dynasty: strings.TrimSpace(out.Data.Dynasty.Name),
+		Content: out.Data.Content,
+		Type:    strings.TrimSpace(out.Data.Type.Name),
+	}
+	if p.Title == "" && len(p.Content) == 0 {
+		return PoemData{}, fmt.Errorf("古诗接口返回空数据")
+	}
+	return p, nil
+}
+
+// GetRandomPoem 返回一首随机古诗(供主窗口前端加载 / 手动刷新调用)
+func (s *SettingsService) GetRandomPoem() (PoemData, error) {
+	return fetchRandomPoem()
+}
+
+// normalizePoemConfig 校正古诗配置:颜色默认、字号限制、间隔限制
+func normalizePoemConfig(cfg PoemConfig) PoemConfig {
+	if strings.TrimSpace(cfg.Color) == "" {
+		cfg.Color = DefaultPoemColor
+	}
+	if cfg.FontSize < 12 || cfg.FontSize > 48 {
+		cfg.FontSize = DefaultPoemFontSize
+	}
+	if cfg.Interval < 0 || cfg.Interval > 3600 {
+		cfg.Interval = DefaultPoemInterval
+	}
+	if cfg.Speed < 5 || cfg.Speed > 120 {
+		cfg.Speed = DefaultPoemSpeed
+	}
+	return cfg
+}

+ 41 - 0
qrcode.go

@@ -0,0 +1,41 @@
+// ============================================================================
+// qrcode.go — 工具页二维码生成
+//
+// 用 go-qrcode 把任意字符串生成 PNG 二维码,返回 data URI 供前端预览/下载。
+// ============================================================================
+package main
+
+import (
+	"encoding/base64"
+	"errors"
+	"strings"
+
+	qrcode "github.com/skip2/go-qrcode"
+)
+
+// GenerateQRCode 把输入字符串生成 PNG 二维码,返回 data URI 供前端 <img> 直接展示。
+// size 为图片边长(像素),<=0 时用默认值 300。
+func GenerateQRCode(text string, size int) (string, error) {
+	text = strings.TrimSpace(text)
+	if text == "" {
+		return "", errors.New("请输入要生成二维码的内容")
+	}
+	if len(text) > 1000 {
+		return "", errors.New("内容过长,请控制在 1000 字符以内")
+	}
+	if size <= 0 {
+		size = 300
+	}
+	if size < 128 {
+		size = 128 // 最小尺寸,避免模糊
+	}
+	if size > 1024 {
+		size = 1024 // 最大尺寸
+	}
+
+	png, err := qrcode.Encode(text, qrcode.Medium, size)
+	if err != nil {
+		return "", err
+	}
+	return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
+}

+ 39 - 0
qrcode_test.go

@@ -0,0 +1,39 @@
+package main
+
+import (
+	"strings"
+	"testing"
+)
+
+// 验证二维码生成:返回 data URI,PNG 可解码
+func TestGenerateQRCode(t *testing.T) {
+	uri, err := GenerateQRCode("https://example.com", 0)
+	if err != nil {
+		t.Fatalf("生成失败: %v", err)
+	}
+	if !strings.HasPrefix(uri, "data:image/png;base64,") {
+		t.Fatalf("应返回 data URI,得到前缀: %v", uri[:30])
+	}
+	if len(uri) < 100 {
+		t.Fatalf("data URI 过短: %d", len(uri))
+	}
+}
+
+// 验证空输入与超长输入
+func TestGenerateQRCodeInvalid(t *testing.T) {
+	if _, err := GenerateQRCode("", 0); err == nil {
+		t.Fatal("空输入应报错")
+	}
+	if _, err := GenerateQRCode(strings.Repeat("a", 1001), 0); err == nil {
+		t.Fatal("超长输入应报错")
+	}
+}
+
+// 验证不同尺寸
+func TestGenerateQRCodeSize(t *testing.T) {
+	for _, size := range []int{0, 128, 300, 1024} {
+		if _, err := GenerateQRCode("test", size); err != nil {
+			t.Fatalf("size=%d 生成失败: %v", size, err)
+		}
+	}
+}

+ 61 - 0
screenshot.go

@@ -0,0 +1,61 @@
+// ============================================================================
+// screenshot.go — 屏幕截图
+//
+// 截取全部或指定显示器,多屏拼接为一张图,返回 PNG base64 data URI。
+// ============================================================================
+package main
+
+import (
+	"bytes"
+	"encoding/base64"
+	"fmt"
+	"image"
+	"image/png"
+
+	"github.com/kbinani/screenshot"
+)
+
+// captureScreen 截取整个主屏幕(或指定显示器),返回 PNG 的 base64 数据 URI。
+// displayIndex 为显示器索引,-1 表示全部屏幕拼接;越界自动回退主屏。
+func captureScreen(displayIndex int) (string, error) {
+	n := screenshot.NumActiveDisplays()
+	if n <= 0 {
+		return "", fmt.Errorf("未检测到可用显示器")
+	}
+
+	bounds := image.Rect(0, 0, 0, 0)
+	if displayIndex >= 0 && displayIndex < n {
+		bounds = screenshot.GetDisplayBounds(displayIndex)
+	} else {
+		// 全部屏幕拼接:合并所有显示器的边界
+		for i := 0; i < n; i++ {
+			b := screenshot.GetDisplayBounds(i)
+			if b.Min.X < bounds.Min.X {
+				bounds.Min.X = b.Min.X
+			}
+			if b.Min.Y < bounds.Min.Y {
+				bounds.Min.Y = b.Min.Y
+			}
+			if b.Max.X > bounds.Max.X {
+				bounds.Max.X = b.Max.X
+			}
+			if b.Max.Y > bounds.Max.Y {
+				bounds.Max.Y = b.Max.Y
+			}
+		}
+	}
+	if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
+		return "", fmt.Errorf("显示器尺寸无效")
+	}
+
+	img, err := screenshot.CaptureRect(bounds)
+	if err != nil {
+		return "", fmt.Errorf("截屏失败: %w", err)
+	}
+
+	var buf bytes.Buffer
+	if err := png.Encode(&buf, img); err != nil {
+		return "", fmt.Errorf("编码 PNG 失败: %w", err)
+	}
+	return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
+}

+ 394 - 0
settings.go

@@ -0,0 +1,394 @@
+// ============================================================================
+// settings.go — 应用配置
+//
+// AppSettings 结构体(含各 Tab 设置项)、默认值、字段校验(normalizeSettings)、
+// 配置读写(loadSettings/saveSettings)与运行时应用(applySettings)。
+// ============================================================================
+package main
+
+import (
+	"encoding/json"
+	"log"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+)
+
+// 默认配置:首次运行或字段为空时使用
+const (
+	DefaultHotkey               = "CmdOrCtrl+Shift+G"        // 默认呼出窗口热键
+	DefaultNotesURL             = "https://m.scwy.net"       // 默认笔记页地址
+	DefaultControlURL           = "https://a7.scwy.net/4191" // 默认控制页地址
+	DefaultBlogURL              = "https://i.scwy.net"       // 默认博客页地址
+	DefaultChatURL              = "https://c.scwy.net"       // 默认聊天页地址
+	DefaultAPIAddr              = "127.0.0.1:17890"          // 内置 Gin API 服务默认监听地址(仅本机)
+	DefaultRecordPath           = `D:\My\腾图\记录.txt`          // 默认记录文件路径
+	DefaultTodoPath             = `D:\My\腾图\待办.txt`          // 默认待办(TODO)文件路径
+	DefaultHardwareURL          = "http://192.168.100.190/"  // 默认硬件页地址
+	DefaultStockUpColor         = "#ff5a5a"                  // 股票上涨颜色(红涨)
+	DefaultStockDownColor       = "#4d8dff"                  // 股票下跌颜色(蓝跌)
+	DefaultStockRefreshSecs     = 30                         // 股市自动刷新间隔(秒)
+	DefaultStockClosedRefreshSecs = 7200                    // 股市休市时自动刷新间隔(秒),默认 2 小时
+	DefaultNewsRefreshMinutes   = 120                        // 新闻自动刷新间隔(分钟)
+	DefaultNewsCount            = 30                         // 新闻页显示条数
+	DefaultGithubRefreshMinutes = 120                        // Github 自动刷新间隔(分钟)
+	AppVersion                  = "0.1.0"                    // 应用自身版本(与 build/windows/info.json 的 ProductVersion 一致),用于仓库更新提示
+)
+
+// LinkItem 常用网址条目
+type LinkItem struct {
+	Name string `json:"name"` // 网站名称
+	URL  string `json:"url"`  // 网站地址
+}
+
+// CustomTab 用户自定义 Tab 页面
+type CustomTab struct {
+	Name string `json:"name"` // 页面名称(Tab 按钮显示文字)
+	URL  string `json:"url"`  // 页面地址(iframe 加载)
+}
+
+// RepoItem 关注的 GitHub 仓库条目
+type RepoItem struct {
+	URL     string `json:"url"`     // 仓库地址,如 https://github.com/wailsapp/wails
+	Desc    string `json:"desc"`    // 项目说明
+	Version string `json:"version"` // 本地已安装版本(用于与最新 release 比较),可为空
+}
+
+// TickerConfig 置顶滚动条(股票 / 新闻)的显示配置
+type TickerConfig struct {
+	Enabled     bool   `json:"enabled"`     // 是否显示滚动条窗口
+	Width       int    `json:"width"`       // 窗口宽度(px),0 表示默认 1400
+	FontSize    int    `json:"fontSize"`    // 字号(px)
+	Color       string `json:"color"`       // 文字颜色
+	Speed       int    `json:"speed"`       // 滚动速度(px/s)
+	Transparent bool   `json:"transparent"` // 背景透明,不遮挡下方内容
+	X           int    `json:"x"`           // 窗口位置,-1 表示未设置(系统默认位置)
+	Y           int    `json:"y"`           // 窗口位置,-1 表示未设置
+}
+
+// AppSettings 保存全部可自定义配置,序列化为 JSON 存于用户配置目录
+type AppSettings struct {
+	Theme        string       `json:"theme"`        // 界面主题:dark / light
+	Autostart    bool         `json:"autostart"`    // 系统启动时运行
+	AlwaysOnTop  bool         `json:"alwaysOnTop"`  // 窗口置顶
+	Hotkey       string       `json:"hotkey"`       // 呼出(显示)窗口的全局热键
+	NotesURL     string       `json:"notesUrl"`     // 笔记页地址
+	ControlURL   string       `json:"controlUrl"`   // 控制页地址
+	BlogURL      string       `json:"blogUrl"`      // 博客页地址
+	ChatURL      string       `json:"chatUrl"`      // 聊天页地址
+	TabOrder     []string     `json:"tabOrder"`     // Tab 显示顺序(tabId 列表,未列出的追加到末尾)
+	StockCodes   []string     `json:"stockCodes"`   // 股市页展示的股票代码列表
+	Links        []LinkItem   `json:"links"`        // 常用网址列表
+	NewsKeywords  string       `json:"newsKeywords"` // 新闻关注关键词,逗号分隔
+	BiliCookie    string       `json:"biliCookie"`   // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
+	GithubRepos   []RepoItem   `json:"githubRepos"`  // 关注的 GitHub 仓库列表
+	CustomTabs   []CustomTab  `json:"customTabs"`   // 用户自定义 Tab 页面(名字 + 网址)
+	APIAddr      string       `json:"apiAddr"`      // 内置 API 服务监听地址
+	RecordPath   string       `json:"recordPath"`   // 记录文件路径
+	TodoPath     string       `json:"todoPath"`     // 待办(TODO)文件路径
+	HardwareURL  string       `json:"hardwareUrl"`  // 硬件页地址
+	StockTicker  TickerConfig `json:"stockTicker"`  // 股票滚动条配置
+	NewsTicker   TickerConfig `json:"newsTicker"`   // 新闻滚动条配置
+	Poem         PoemConfig   `json:"poem"`         // 随机古诗显示配置
+
+	StockUpColor         string `json:"stockUpColor"`         // 股票上涨颜色
+	StockDownColor       string `json:"stockDownColor"`       // 股票下跌颜色
+	StockRefreshSecs     int    `json:"stockRefreshSecs"`       // 股市自动刷新间隔(秒)
+	StockClosedRefreshSecs int  `json:"stockClosedRefreshSecs"` // 股市休市时自动刷新间隔(秒)
+	NewsRefreshMinutes   int    `json:"newsRefreshMinutes"`     // 新闻自动刷新间隔(分钟)
+	NewsCount            int    `json:"newsCount"`              // 新闻页显示条数
+	GithubRefreshMinutes int    `json:"githubRefreshMinutes"`   // Github 自动刷新间隔(分钟)
+	FontDelta            int    `json:"fontDelta"`              // 全局字号偏移(px),+-3 范围内调整
+}
+
+// 当前已注册的热键(用于保存设置时先注销旧热键)
+var (
+	hotkeyMu     sync.Mutex
+	activeHotkey string
+)
+
+// defaultSettings 返回默认配置:开机启动默认开启,热键与页面地址使用内置值
+func defaultSettings() AppSettings {
+	return AppSettings{
+		Theme:       "dark",
+		Autostart:   true,
+		AlwaysOnTop: false,
+		Hotkey:      DefaultHotkey,
+		NotesURL:    DefaultNotesURL,
+		ControlURL:  DefaultControlURL,
+		BlogURL:     DefaultBlogURL,
+		ChatURL:     DefaultChatURL,
+		TabOrder:    []string{"notes", "control", "blog", "chat", "links", "news", "github", "stock", "record", "todo", "hardware", "tools", "host", "settings"}, // 默认 Tab 顺序(工具在硬件与主机之间)
+		StockCodes:  []string{"sh000001", "sh600519", "sz000001"},
+		Links: []LinkItem{
+			{Name: "煎蛋", URL: "https://jandan.net/"},
+			{Name: "头条", URL: "https://www.toutiao.com/"},
+			{Name: "GitHub", URL: "https://github.com/"},
+		},
+		NewsKeywords: "",
+		GithubRepos: []RepoItem{
+			{URL: "https://github.com/wailsapp/wails", Desc: "Wails:Go + Web 桌面应用框架"},
+		},
+		APIAddr:     DefaultAPIAddr,
+		RecordPath:  DefaultRecordPath,
+		TodoPath:    DefaultTodoPath,
+		HardwareURL: DefaultHardwareURL,
+		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},
+
+		StockUpColor:         DefaultStockUpColor,
+		StockDownColor:       DefaultStockDownColor,
+		StockRefreshSecs:     DefaultStockRefreshSecs,
+		StockClosedRefreshSecs: DefaultStockClosedRefreshSecs,
+		NewsRefreshMinutes:   DefaultNewsRefreshMinutes,
+		NewsCount:            DefaultNewsCount,
+		GithubRefreshMinutes: DefaultGithubRefreshMinutes,
+		FontDelta:            0, // 全局字号偏移默认 0
+	}
+}
+
+// settingsFilePath 返回配置文件路径:<用户配置目录>/TengTuToolbox/settings.json
+func settingsFilePath() (string, error) {
+	dir, err := os.UserConfigDir() // 例如 C:\Users\<用户名>\AppData\Roaming
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(dir, "TengTuToolbox", "settings.json"), nil
+}
+
+// loadSettings 读取配置文件;文件不存在或损坏时回退到默认配置
+func loadSettings() AppSettings {
+	defaults := defaultSettings()
+
+	path, err := settingsFilePath()
+	if err != nil {
+		return defaults
+	}
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return defaults // 首次运行:无配置文件,使用默认值
+	}
+
+	settings := defaults
+	if err := json.Unmarshal(data, &settings); err != nil {
+		return defaults // 文件损坏:回退默认值,避免启动失败
+	}
+	return normalizeSettings(settings)
+}
+
+// saveSettings 将配置写入磁盘(目录不存在时自动创建)
+func saveSettings(settings AppSettings) error {
+	path, err := settingsFilePath()
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(settings, "", "  ") // 缩进保存,便于手动查看
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0o644)
+}
+
+// normalizeSettings 校正配置字段:空值回填默认、主题取值限定、股票代码规范化
+func normalizeSettings(settings AppSettings) AppSettings {
+	if settings.Theme != "light" {
+		settings.Theme = "dark" // 只允许 dark / light
+	}
+	if strings.TrimSpace(settings.Hotkey) == "" {
+		settings.Hotkey = DefaultHotkey
+	}
+	if strings.TrimSpace(settings.NotesURL) == "" {
+		settings.NotesURL = DefaultNotesURL
+	}
+	if strings.TrimSpace(settings.ControlURL) == "" {
+		settings.ControlURL = DefaultControlURL
+	}
+	if strings.TrimSpace(settings.BlogURL) == "" {
+		settings.BlogURL = DefaultBlogURL
+	}
+	if strings.TrimSpace(settings.ChatURL) == "" {
+		settings.ChatURL = DefaultChatURL
+	}
+	// Tab 顺序:只保留已存在的 tabId,去重;未列出的追加到末尾,保证全部 Tab 都能显示
+	validTabs := map[string]bool{"notes": true, "control": true, "blog": true, "chat": true, "links": true,
+		"news": true, "github": true, "stock": true, "record": true, "todo": true, "hardware": true, "tools": true, "host": true, "settings": true}
+	seen := map[string]bool{}
+	ordered := make([]string, 0, len(validTabs))
+	for _, tab := range settings.TabOrder {
+		if validTabs[tab] && !seen[tab] {
+			seen[tab] = true
+			ordered = append(ordered, tab)
+		}
+	}
+	for _, tab := range []string{"notes", "control", "blog", "chat", "links", "news", "github", "stock", "record", "todo", "hardware", "tools", "host", "settings"} {
+		if !seen[tab] {
+			ordered = append(ordered, tab) // 设置中未列出的 Tab 追加到末尾
+		}
+	}
+	settings.TabOrder = ordered
+	if strings.TrimSpace(settings.APIAddr) == "" {
+		settings.APIAddr = DefaultAPIAddr
+	}
+	if strings.TrimSpace(settings.RecordPath) == "" {
+		settings.RecordPath = DefaultRecordPath
+	}
+	if strings.TrimSpace(settings.TodoPath) == "" {
+		settings.TodoPath = DefaultTodoPath
+	}
+	if strings.TrimSpace(settings.HardwareURL) == "" {
+		settings.HardwareURL = DefaultHardwareURL
+	}
+	settings.StockTicker = normalizeTicker(settings.StockTicker, "#ff4d4d", 80) // 股票条默认红字
+	settings.NewsTicker = normalizeTicker(settings.NewsTicker, "#3ddc84", 60)   // 新闻条默认绿字
+	settings.Poem = normalizePoemConfig(settings.Poem)                          // 古诗显示配置校正
+
+	if !isHexColor(settings.StockUpColor) {
+		settings.StockUpColor = DefaultStockUpColor // 涨色回填默认红
+	}
+	if !isHexColor(settings.StockDownColor) {
+		settings.StockDownColor = DefaultStockDownColor // 跌色回填默认蓝
+	}
+	if settings.StockRefreshSecs < 5 || settings.StockRefreshSecs > 3600 {
+		settings.StockRefreshSecs = DefaultStockRefreshSecs // 股市刷新间隔限定 5 秒 ~ 1 小时
+	}
+	if settings.StockClosedRefreshSecs < 60 || settings.StockClosedRefreshSecs > 86400 {
+		settings.StockClosedRefreshSecs = DefaultStockClosedRefreshSecs // 休市刷新间隔限定 1 分钟 ~ 1 天
+	}
+	if settings.NewsRefreshMinutes < 5 || settings.NewsRefreshMinutes > 1440 {
+		settings.NewsRefreshMinutes = DefaultNewsRefreshMinutes // 新闻刷新间隔限定 5 分钟 ~ 1 天
+	}
+	if settings.NewsCount < 5 || settings.NewsCount > 200 {
+		settings.NewsCount = DefaultNewsCount // 新闻显示条数限定 5 ~ 200 条
+	}
+	if settings.GithubRefreshMinutes < 5 || settings.GithubRefreshMinutes > 1440 {
+		settings.GithubRefreshMinutes = DefaultGithubRefreshMinutes // Github 刷新间隔限定 5 分钟 ~ 1 天
+	}
+	if settings.FontDelta < -3 || settings.FontDelta > 3 {
+		settings.FontDelta = 0 // 全局字号偏移限定 +-3 px
+	}
+
+	if settings.StockCodes == nil {
+		settings.StockCodes = defaultSettings().StockCodes // 未设置时用默认自选股
+	}
+	if settings.Links == nil {
+		settings.Links = defaultSettings().Links // 未设置时用默认网址
+	}
+	if settings.GithubRepos == nil {
+		settings.GithubRepos = defaultSettings().GithubRepos // 未设置时用默认仓库
+	}
+
+	links := make([]LinkItem, 0, len(settings.Links))
+	for _, link := range settings.Links {
+		link.Name = strings.TrimSpace(link.Name)
+		link.URL = strings.TrimSpace(link.URL)
+		if link.Name != "" && link.URL != "" {
+			links = append(links, link)
+		}
+	}
+	settings.Links = links
+
+	repos := make([]RepoItem, 0, len(settings.GithubRepos))
+	for _, repo := range settings.GithubRepos {
+		repo.URL = strings.TrimSpace(repo.URL)
+		repo.Desc = strings.TrimSpace(repo.Desc)
+		repo.Version = strings.TrimSpace(repo.Version) // 本地版本,可为空
+		if repo.Version == "" {
+			repo.Version = AppVersion // 未填版本时自动用应用自身版本,用于分析并提示新版本
+		}
+		if repo.URL != "" {
+			repos = append(repos, repo)
+		}
+	}
+	settings.GithubRepos = repos
+
+	tabs := make([]CustomTab, 0, len(settings.CustomTabs))
+	for _, tab := range settings.CustomTabs {
+		tab.Name = strings.TrimSpace(tab.Name)
+		tab.URL = strings.TrimSpace(tab.URL)
+		if tab.Name != "" && tab.URL != "" { // 名字与网址都非空才保留
+			tabs = append(tabs, tab)
+		}
+	}
+	settings.CustomTabs = tabs
+
+	codes := make([]string, 0, len(settings.StockCodes))
+	for _, raw := range settings.StockCodes {
+		code := normalizeStockCode(raw) // 补全 sh / sz / bj 前缀
+		if code != "" {
+			codes = append(codes, code)
+		}
+	}
+	settings.StockCodes = codes
+	return settings
+}
+
+// normalizeTicker 校正滚动条配置:宽度、字号、速度下限,颜色默认值
+func normalizeTicker(cfg TickerConfig, defaultColor string, defaultSpeed int) TickerConfig {
+	if cfg.Width < 200 || cfg.Width > 6000 {
+		cfg.Width = 0 // 宽度限定在 200~6000px,0 表示用默认 1400
+	}
+	if cfg.FontSize < 10 || cfg.FontSize > 40 {
+		cfg.FontSize = 14 // 字号限定在 10~40px
+	}
+	if strings.TrimSpace(cfg.Color) == "" {
+		cfg.Color = defaultColor
+	}
+	if cfg.Speed < 20 || cfg.Speed > 400 {
+		cfg.Speed = defaultSpeed // 速度限定在 20~400 px/s
+	}
+	return cfg
+}
+
+// isHexColor 判断是否为 #RGB / #RRGGBB 形式的颜色值
+func isHexColor(value string) bool {
+	value = strings.TrimSpace(value)
+	if !strings.HasPrefix(value, "#") {
+		return false
+	}
+	return len(value) == 4 || len(value) == 7
+}
+
+// mergeTickerPositions 保留磁盘上已保存的滚动条位置:
+// 设置表单不提交位置,保存其他设置时不能覆盖拖动后记录的最新坐标
+func mergeTickerPositions(settings AppSettings) AppSettings {
+	prev := loadSettings()
+	settings.StockTicker.X, settings.StockTicker.Y = prev.StockTicker.X, prev.StockTicker.Y
+	settings.NewsTicker.X, settings.NewsTicker.Y = prev.NewsTicker.X, prev.NewsTicker.Y
+	return settings
+}
+
+// applySettings 把配置应用到运行时:重新注册热键、设置窗口置顶、同步开机启动
+func applySettings(settings AppSettings) {
+	hotkeyMu.Lock()
+	if settings.Hotkey != activeHotkey {
+		if activeHotkey != "" {
+			_ = app.GlobalShortcut.Unregister(activeHotkey) // 注销旧热键,避免冲突
+		}
+		if err := app.GlobalShortcut.Register(settings.Hotkey, showMainWindow); err != nil {
+			log.Printf("register hotkey %q failed: %v", settings.Hotkey, err)
+		} else {
+			activeHotkey = settings.Hotkey // 记录当前生效的热键
+		}
+	}
+	hotkeyMu.Unlock()
+
+	if mainWindow != nil {
+		mainWindow.SetAlwaysOnTop(settings.AlwaysOnTop) // 窗口置顶开关
+	}
+
+	if settings.Autostart {
+		_ = app.Autostart.Enable() // 写入系统开机启动项
+	} else {
+		_ = app.Autostart.Disable() // 移除开机启动项
+	}
+
+	ensureAPIServer(settings.APIAddr) // API 地址变化时重启内置服务
+	syncTickerWindows(settings)       // 按配置创建 / 关闭 / 刷新滚动条窗口
+}

+ 247 - 0
settingsservice.go

@@ -0,0 +1,247 @@
+// ============================================================================
+// settingsservice.go — 设置页后端能力绑定
+//
+// SettingsService 暴露给前端调用的核心服务:配置读写、新闻抓取、GitHub 监听、
+// 行情查询、翻译、二维码、截屏、Web 服务、待办读写、Markdown 渲染、API 状态等。
+// ============================================================================
+package main
+
+import (
+	"os"
+	"path/filepath"
+
+	"github.com/gomarkdown/markdown"
+	"github.com/gomarkdown/markdown/html"
+	"github.com/gomarkdown/markdown/parser"
+)
+
+// SettingsService 暴露「设置」页需要的后端能力给前端调用。
+// 前端通过 bindings 调用 GetSettings / SaveSettings / GetStocks 等方法。
+type SettingsService struct{}
+
+// AppVersion 返回应用版本号,供 footer 展示。
+// 版本常量在 settings.go 的 AppVersion(与 build/windows/info.json 的 ProductVersion 一致)。
+func (s *SettingsService) AppVersion() string {
+	return AppVersion
+}
+
+// GetSettings 返回当前配置(读取配置文件,不存在则返回默认值)
+func (s *SettingsService) GetSettings() AppSettings {
+	return loadSettings()
+}
+
+// SetMobileMode 前端在窗口尺寸变化时调用:移动端(窄屏)隐藏主机 Tab 并禁止滚动条
+func (s *SettingsService) SetMobileMode(mobile bool) {
+	SetMobileMode(mobile)
+}
+
+// SaveSettings 保存配置并立即生效:热键、窗口置顶、开机启动、页面地址、股票代码等
+func (s *SettingsService) SaveSettings(settings AppSettings) (AppSettings, error) {
+	settings = normalizeSettings(settings)    // 校正空值与代码格式
+	settings = mergeTickerPositions(settings) // 保留拖动保存的滚动条位置
+	if err := saveSettings(settings); err != nil {
+		return settings, err
+	}
+	applySettings(settings) // 应用到运行时
+	return settings, nil
+}
+
+// GetStocks 按已保存的股票代码拉取最新行情
+func (s *SettingsService) GetStocks() ([]StockQuote, error) {
+	return fetchStockQuotes(loadSettings().StockCodes)
+}
+
+// CaptureScreen 截取屏幕并返回 PNG 数据 URI,供「主机」页预览。
+// display 为显示器索引,-1 表示全部屏幕。
+func (s *SettingsService) CaptureScreen(display int) (string, error) {
+	return captureScreen(display)
+}
+
+// PickWebDir 弹出目录选择对话框,返回选中的目录(取消返回空串)。
+func (s *SettingsService) PickWebDir() (string, error) {
+	return pickWebDir()
+}
+
+// StartWebService 启动静态文件服务(默认页 index.html 或 readme.md)。
+func (s *SettingsService) StartWebService(dir string) (WebServiceInfo, error) {
+	return StartWebService(dir)
+}
+
+// StopWebService 停止静态文件服务。
+func (s *SettingsService) StopWebService() {
+	StopWebService()
+}
+
+// WebServiceStatus 返回静态文件服务当前状态。
+func (s *SettingsService) WebServiceStatus() WebServiceInfo {
+	return WebServiceStatus()
+}
+
+// Translate 翻译文本:输入中文翻译为英文,输入英文翻译为中文(自动检测方向)。
+// 使用 MyMemory 免费 API,有每日配额限制。
+func (s *SettingsService) Translate(text string) (TranslateResult, error) {
+	return translateMyMemory(text)
+}
+
+// GenerateQRCode 把输入字符串生成 PNG 二维码,返回 data URI 供前端展示。
+// size 为图片边长(像素),<=0 时用默认值 300。
+func (s *SettingsService) GenerateQRCode(text string, size int) (string, error) {
+	return GenerateQRCode(text, size)
+}
+
+// GetMinuteChart 拉取指定股票的分时数据(点击股市行打开分时图时调用)
+func (s *SettingsService) GetMinuteChart(code string) (MinuteChart, error) {
+	return fetchMinuteChart(normalizeStockCode(code))
+}
+
+// GetKLine 拉取指定股票的日 K 数据,count 为交易日数量
+func (s *SettingsService) GetKLine(code string, count int) ([]KLineBar, error) {
+	return fetchKLine(normalizeStockCode(code), count)
+}
+
+// ShowWindow 供前端主动呼出窗口(例如通知点击后回到主界面)
+func (s *SettingsService) ShowWindow() {
+	showMainWindow()
+}
+
+// GetNews 抓取新闻标题,按设置的关键词标记命中项并优先排序
+func (s *SettingsService) GetNews() ([]NewsItem, error) {
+	return fetchNews(loadSettings().NewsKeywords)
+}
+
+// GetBiliDynamic 抓取 B站「关注的人最新动态」。
+// 需要设置里配置 B站登录 cookie(含 SESSDATA);cookie 为空或失效时回退热门视频。
+func (s *SettingsService) GetBiliDynamic() ([]BiliDynamicItem, error) {
+	items, _, err := fetchBiliFeed(loadSettings().BiliCookie)
+	return items, err
+}
+
+// GetGithubFeeds 抓取设置中所有 GitHub 仓库的最新提交
+func (s *SettingsService) GetGithubFeeds() []RepoFeed {
+	return fetchGithubFeeds(loadSettings().GithubRepos)
+}
+
+// GetTrendingRepos 抓取 GitHub Trending 页的热门仓库列表(英文简介已自动翻译为中文)
+func (s *SettingsService) GetTrendingRepos() ([]TrendingRepo, error) {
+	return fetchTrendingRepos()
+}
+
+// MarkGithubRepoChecked 标记某仓库已检视:把本地版本更新为最新 release 版本,消除「发现新版本」提示
+func (s *SettingsService) MarkGithubRepoChecked(url, latest string) error {
+	settings := loadSettings()
+	for i := range settings.GithubRepos {
+		if settings.GithubRepos[i].URL == url {
+			settings.GithubRepos[i].Version = latest
+			break
+		}
+	}
+	return saveSettings(settings)
+}
+
+// ReadRecord 读取记录文件内容;文件不存在时返回空内容
+func (s *SettingsService) ReadRecord() (string, error) {
+	data, err := os.ReadFile(loadSettings().RecordPath)
+	if os.IsNotExist(err) {
+		return "", nil // 首次使用:文件尚未创建,返回空白
+	}
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+// WriteRecord 把记录内容写回记录文件(目录不存在时自动创建)
+func (s *SettingsService) WriteRecord(content string) error {
+	path := loadSettings().RecordPath
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return err
+	}
+	return os.WriteFile(path, []byte(content), 0o644)
+}
+
+// RenderMarkdown 把 Markdown 文本渲染为安全 HTML 片段(用于「记录」页预览)。
+// 使用 gomarkdown 渲染,并启用 CommonMark 扩展(表格、任务列表、围栏代码等)。
+func (s *SettingsService) RenderMarkdown(md string) string {
+	p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
+	renderer := html.NewRenderer(html.RendererOptions{
+		Flags: html.CommonFlags | html.HrefTargetBlank, // 链接在新窗口打开
+	})
+	return string(markdown.ToHTML([]byte(md), p, renderer))
+}
+
+// ReadTodo 读取待办文件内容(每行一个任务);文件不存在时返回空
+func (s *SettingsService) ReadTodo() (string, error) {
+	data, err := os.ReadFile(loadSettings().TodoPath)
+	if os.IsNotExist(err) {
+		return "", nil // 首次使用:文件尚未创建,返回空白
+	}
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+// WriteTodo 把待办内容写回待办文件(目录不存在时自动创建)
+func (s *SettingsService) WriteTodo(content string) error {
+	path := loadSettings().TodoPath
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return err
+	}
+	return os.WriteFile(path, []byte(content), 0o644)
+}
+
+// GetAPILogs 返回 Gin 日志环形缓冲的全部内容
+func (s *SettingsService) GetAPILogs() []string {
+	return apiLogRing.Snapshot()
+}
+
+// ClearAPILogs 清空 Gin 日志缓冲
+func (s *SettingsService) ClearAPILogs() {
+	apiLogRing.Clear()
+}
+
+// GetAPIStatus 返回内置 API 服务运行状态
+func (s *SettingsService) GetAPIStatus() APIStatus {
+	return getAPIStatus()
+}
+
+// StopAPIService 停止内置 API 服务(服务页手动停止)
+func (s *SettingsService) StopAPIService() {
+	StopAPIServer()
+}
+
+// StartAPIService 启动 / 重启内置 API 服务(服务页手动启动)
+func (s *SettingsService) StartAPIService() {
+	StartAPIServer()
+}
+
+// GetPCStatus 返回电源控制命令状态
+func (s *SettingsService) GetPCStatus() PCStatus {
+	return getPCStatus()
+}
+
+// SaveTickerPos 保存滚动条窗口拖动后的位置(只更新配置,不重建窗口)
+func (s *SettingsService) SaveTickerPos(kind string, x, y int) error {
+	settings := loadSettings()
+	if kind == "stock" {
+		settings.StockTicker.X, settings.StockTicker.Y = x, y
+	} else {
+		settings.NewsTicker.X, settings.NewsTicker.Y = x, y
+	}
+	return saveSettings(settings) // 不调 applySettings,避免滚动条因保存位置而重建
+}
+
+// SaveTickerEnabled 保存滚动条开关状态(新闻条 / 股票条右侧「关闭」按钮调用)
+func (s *SettingsService) SaveTickerEnabled(kind string, enabled bool) error {
+	settings := loadSettings()
+	if kind == "stock" {
+		settings.StockTicker.Enabled = enabled
+	} else {
+		settings.NewsTicker.Enabled = enabled
+	}
+	if err := saveSettings(settings); err != nil {
+		return err
+	}
+	syncTickerWindows(settings) // 同步滚动条窗口:关闭时销毁
+	return nil
+}

+ 309 - 0
stockquote.go

@@ -0,0 +1,309 @@
+// ============================================================================
+// stockquote.go — 股票行情
+//
+// 拉取腾讯行情、分时与日 K 数据;区分交易时段 / 休市刷新间隔。
+// ============================================================================
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"golang.org/x/text/encoding/simplifiedchinese" // 腾讯行情接口返回 GBK,需要转码
+	"golang.org/x/text/transform"
+)
+
+// 行情接口地址
+const (
+	quoteAPIURL  = "https://qt.gtimg.cn/q="                                    // 实时行情
+	minuteAPIURL = "https://web.ifzq.gtimg.cn/appstock/app/minute/query?code=" // 分时数据
+	klineAPIURL  = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=" // 日 K 数据
+)
+
+// StockQuote 单只股票的行情快照,供前端「股市」页展示
+type StockQuote struct {
+	Code          string  `json:"code"`          // 带市场前缀的代码,如 sh600519
+	Name          string  `json:"name"`          // 股票名称
+	Price         float64 `json:"price"`         // 当前价
+	Change        float64 `json:"change"`        // 涨跌额
+	ChangePercent float64 `json:"changePercent"` // 涨跌幅(%)
+	Amount        float64 `json:"amount"`        // 成交额(万元)
+	Time          string  `json:"time"`          // 行情时间 HH:MM:SS
+}
+
+// MinutePoint 分时图上的一个点
+type MinutePoint struct {
+	Time  string  `json:"time"`  // HH:MM
+	Price float64 `json:"price"` // 当前价
+	Avg   float64 `json:"avg"`   // 均价
+}
+
+// MinuteChart 分时图数据
+type MinuteChart struct {
+	Code      string        `json:"code"`      // 股票代码
+	PrevClose float64       `json:"prevClose"` // 昨收,作为分时基准线
+	Points    []MinutePoint `json:"points"`    // 分时点序列
+}
+
+// KLineBar 日 K 的一根柱子
+type KLineBar struct {
+	Date   string  `json:"date"`   // 交易日
+	Open   float64 `json:"open"`   // 开盘价
+	Close  float64 `json:"close"`  // 收盘价
+	High   float64 `json:"high"`   // 最高价
+	Low    float64 `json:"low"`    // 最低价
+	Volume float64 `json:"volume"` // 成交量(手)
+}
+
+// normalizeStockCode 规范化股票代码:纯数字自动补全市场前缀
+func normalizeStockCode(raw string) string {
+	code := strings.ToLower(strings.TrimSpace(raw))
+	if code == "" {
+		return ""
+	}
+	if strings.HasPrefix(code, "sh") || strings.HasPrefix(code, "sz") || strings.HasPrefix(code, "bj") {
+		return code // 已带前缀,直接使用
+	}
+
+	switch {
+	case strings.HasPrefix(code, "6"), strings.HasPrefix(code, "5"), strings.HasPrefix(code, "9"):
+		return "sh" + code // 沪市:60/68 等,也包含 5 开头基金、9 开头 B 股
+	case strings.HasPrefix(code, "0"), strings.HasPrefix(code, "3"):
+		return "sz" + code // 深市:00 主板、30 创业板
+	case strings.HasPrefix(code, "4"), strings.HasPrefix(code, "8"):
+		return "bj" + code // 北交所:43/83/87 等
+	}
+	return code
+}
+
+// fetchStockQuotes 从腾讯行情接口批量拉取股票行情
+func fetchStockQuotes(codes []string) ([]StockQuote, error) {
+	if len(codes) == 0 {
+		return []StockQuote{}, nil // 未配置股票时返回空列表
+	}
+
+	url := quoteAPIURL + strings.Join(codes, ",") // 批量查询,逗号分隔
+	client := &http.Client{Timeout: 8 * time.Second}
+
+	req, err := http.NewRequest(http.MethodGet, url, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") // 部分接口会校验 UA
+	req.Header.Set("Referer", "https://gu.qq.com/")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	// 腾讯接口为 GBK 编码,先整体转成 UTF-8 再解析
+	body, err := io.ReadAll(transform.NewReader(resp.Body, simplifiedchinese.GBK.NewDecoder()))
+	if err != nil {
+		return nil, err
+	}
+
+	quotes := make([]StockQuote, 0, len(codes))
+	for _, line := range strings.Split(string(body), ";") { // 每只股票一行,以分号分隔
+		quote, ok := parseQuoteLine(line)
+		if ok {
+			quotes = append(quotes, quote)
+		}
+	}
+	return quotes, nil
+}
+
+// parseQuoteLine 解析单行行情:v_sh600519="1~贵州茅台~600519~价格~...";
+func parseQuoteLine(line string) (StockQuote, bool) {
+	line = strings.TrimSpace(line)
+	idx := strings.Index(line, "=\"")
+	if idx < 0 {
+		return StockQuote{}, false
+	}
+
+	payload := strings.Trim(strings.TrimSpace(line[idx+2:]), `";`) // 去掉包裹的引号与分号
+	fields := strings.Split(payload, "~")
+	if len(fields) < 38 {
+		return StockQuote{}, false // 字段不足,视为无效行
+	}
+
+	return StockQuote{
+		Code:          fields[2],
+		Name:          fields[1],
+		Price:         parseQuoteFloat(fields[3]),
+		Change:        parseQuoteFloat(fields[31]),
+		ChangePercent: parseQuoteFloat(fields[32]),
+		Amount:        parseQuoteFloat(fields[37]), // 成交额,单位:万元
+		Time:          formatQuoteTime(fields[30]),
+	}, true
+}
+
+// fetchMinuteChart 拉取分时数据:time 分值 price avg
+func fetchMinuteChart(code string) (MinuteChart, error) {
+	chart := MinuteChart{Code: code, Points: []MinutePoint{}}
+
+	// 先取报价,用「现价 - 涨跌额」得到昨收,作为分时基准线
+	quote, err := fetchSingleQuote(code)
+	if err != nil {
+		return chart, err
+	}
+	chart.PrevClose = quote.Price - quote.Change
+
+	var payload minutePayload
+	if err := httpGetJSON(minuteAPIURL+code, &payload); err != nil {
+		return chart, err
+	}
+
+	item, ok := payload.Data[code]
+	if !ok {
+		return chart, fmt.Errorf("未获取到 %s 的分时数据", code)
+	}
+
+	var totalVolume, totalAmount float64
+	for _, line := range item.Data.Data {
+		fields := strings.Fields(line) // 形如:0930 1349.99 1486 200608514.00
+		if len(fields) < 4 {
+			continue
+		}
+
+		price := parseQuoteFloat(fields[1])
+		volume := parseQuoteFloat(fields[2]) // 成交量(手)
+		amount := parseQuoteFloat(fields[3]) // 成交额(元)
+		totalVolume += volume * 100          // 手 → 股
+		totalAmount += amount                // 累计成交额
+
+		avg := 0.0
+		if totalVolume > 0 {
+			avg = totalAmount / totalVolume // 均价 = 累计成交额 / 累计成交量
+		}
+		chart.Points = append(chart.Points, MinutePoint{
+			Time:  fields[0][0:2] + ":" + fields[0][2:4], // HHMM → HH:MM
+			Price: price,
+			Avg:   avg,
+		})
+	}
+	return chart, nil
+}
+
+// fetchKLine 拉取日 K 数据(前复权),返回最近 count 根
+func fetchKLine(code string, count int) ([]KLineBar, error) {
+	if count <= 0 {
+		count = 120 // 默认 120 个交易日
+	}
+	url := fmt.Sprintf("%s%s,day,,,%d,qfq", klineAPIURL, code, count)
+
+	var payload klinePayload
+	if err := httpGetJSON(url, &payload); err != nil {
+		return nil, err
+	}
+
+	item, ok := payload.Data[code]
+	if !ok {
+		return nil, fmt.Errorf("未获取到 %s 的日K数据", code)
+	}
+
+	rows := item.QfqDay // 前复权数据优先,没有则退回原始日线
+	if len(rows) == 0 {
+		rows = item.Day
+	}
+
+	bars := make([]KLineBar, 0, len(rows))
+	for _, row := range rows {
+		if len(row) < 6 {
+			continue // 数据不完整则跳过
+		}
+		// 前 6 项为 [日期, 开, 收, 高, 低, 量] 字符串;除权信息等附加字段忽略
+		vals := make([]string, 6)
+		for i := 0; i < 6; i++ {
+			if err := json.Unmarshal(row[i], &vals[i]); err != nil {
+				vals[i] = "" // 字段非字符串时置空,交给 parseQuoteFloat 归零
+			}
+		}
+		bars = append(bars, KLineBar{
+			Date:   vals[0],
+			Open:   parseQuoteFloat(vals[1]),
+			Close:  parseQuoteFloat(vals[2]),
+			High:   parseQuoteFloat(vals[3]),
+			Low:    parseQuoteFloat(vals[4]),
+			Volume: parseQuoteFloat(vals[5]),
+		})
+	}
+	return bars, nil
+}
+
+// fetchSingleQuote 取单只股票报价(用于分时基准线)
+func fetchSingleQuote(code string) (StockQuote, error) {
+	quotes, err := fetchStockQuotes([]string{code})
+	if err != nil {
+		return StockQuote{}, err
+	}
+	if len(quotes) == 0 {
+		return StockQuote{}, fmt.Errorf("未获取到 %s 的报价", code)
+	}
+	return quotes[0], nil
+}
+
+// httpGetJSON 发起 GET 请求并解析 JSON(腾讯 web 接口为 UTF-8)
+func httpGetJSON(url string, target any) error {
+	client := &http.Client{Timeout: 8 * time.Second}
+	req, err := http.NewRequest(http.MethodGet, url, nil)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
+	req.Header.Set("Referer", "https://gu.qq.com/")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+	return json.Unmarshal(body, target)
+}
+
+// minutePayload 分时接口的响应结构(按股票代码动态嵌套)
+type minutePayload struct {
+	Data map[string]struct {
+		Data struct {
+			Data []string `json:"data"` // "HHMM 价格 成交量 成交额"
+		} `json:"data"`
+	} `json:"data"`
+}
+
+// klinePayload 日 K 接口的响应结构
+type klinePayload struct {
+	Data map[string]struct {
+		// 前复权 / 原始日线。每行前 6 项固定为 [日期, 开, 收, 高, 低, 量] 字符串,
+		// 除权除息日会追加第 7 个对象字段(除权信息),故用 RawMessage 以兼容任意类型。
+		QfqDay [][]json.RawMessage `json:"qfqday"`
+		Day    [][]json.RawMessage `json:"day"`
+	} `json:"data"`
+}
+
+// parseQuoteFloat 解析浮点字段,失败时返回 0
+func parseQuoteFloat(raw string) float64 {
+	value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
+	if err != nil {
+		return 0
+	}
+	return value
+}
+
+// formatQuoteTime 将 20231028160001 格式化为 16:00:01
+func formatQuoteTime(raw string) string {
+	if len(raw) < 14 {
+		return raw
+	}
+	return raw[8:10] + ":" + raw[10:12] + ":" + raw[12:14]
+}

+ 84 - 0
ticker.go

@@ -0,0 +1,84 @@
+// ============================================================================
+// ticker.go — 滚动条窗口
+//
+// 股票条 / 新闻条:无边框、透明、置顶、可拖动的屏幕顶部一行窗口;
+// 位置拖动后延迟保存,重启恢复。
+// ============================================================================
+package main
+
+import (
+	"github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// 滚动条窗口(股票条 / 新闻条):无边框、透明、置顶、可拖动的一行窗口
+var (
+	stockTickerWin *application.WebviewWindow // 股票滚动条窗口
+	newsTickerWin  *application.WebviewWindow // 新闻滚动条窗口
+	mobileMode     bool                       // 移动端(窄屏)模式:禁止创建滚动条窗口
+)
+
+// SetMobileMode 由前端在窗口尺寸变化时调用:移动端隐藏主机 Tab 并禁止滚动条
+func SetMobileMode(mobile bool) {
+	mobileMode = mobile
+	syncTickerWindows(loadSettings()) // 立即按新模式刷新滚动条
+}
+
+// syncTickerWindows 按配置创建 / 关闭 / 刷新两个滚动条窗口
+func syncTickerWindows(settings AppSettings) {
+	stockTickerWin = syncTickerWindow("stock", settings.StockTicker, stockTickerWin)
+	newsTickerWin = syncTickerWindow("news", settings.NewsTicker, newsTickerWin)
+}
+
+// syncTickerWindow 同步单个滚动条窗口状态:
+// 关闭 → 已开启则创建;配置变化 → 重载页面(ticker.js 启动时读取最新配置);已开启 → 关闭
+func syncTickerWindow(kind string, cfg TickerConfig, win *application.WebviewWindow) *application.WebviewWindow {
+	if mobileMode { // 移动端禁止滚动文本:已开则关闭,不创建
+		if win != nil {
+			win.Close()
+		}
+		return nil
+	}
+	if !cfg.Enabled {
+		if win != nil {
+			win.Close() // 配置关闭时销毁窗口
+		}
+		return nil
+	}
+	if win != nil {
+		width := cfg.Width
+		if width < 200 {
+			width = 1400 // 未配置时用默认宽度
+		}
+		win.SetSize(width, cfg.FontSize+18) // 宽度 / 字号变化时同步窗口尺寸
+		win.Reload()                        // 让页面重新读取颜色 / 速度等配置
+		return win
+	}
+
+	x, y := cfg.X, cfg.Y
+	if x < 0 || y < 0 {
+		x, y = 100, 0 // 首次显示默认放在屏幕顶部
+	}
+	width := cfg.Width
+	if width < 200 {
+		width = 1400 // 未配置时用默认宽度
+	}
+
+	return app.Window.NewWithOptions(application.WebviewWindowOptions{
+		Name:                       "ticker-" + kind,
+		Title:                      "腾图滚动条",
+		Width:                      width,
+		Height:                     cfg.FontSize + 18, // 一行文字高度
+		X:                          x,
+		Y:                          y,
+		AlwaysOnTop:                true,                                  // 置顶显示
+		DisableResize:              true,                                  // 固定尺寸
+		Frameless:                  true,                                  // 无边框
+		BackgroundType:             application.BackgroundTypeTransparent, // 透明背景
+		URL:                        "/ticker.html?kind=" + kind,
+		DevToolsEnabled:            false,
+		DefaultContextMenuDisabled: true,
+		Windows: application.WindowsWindow{
+			HiddenOnTaskbar: true, // 不占任务栏
+		},
+	})
+}

+ 96 - 0
timesync.go

@@ -0,0 +1,96 @@
+// ============================================================================
+// timesync.go — 系统时间同步
+//
+// 从 NTP 服务器读取标准时间,调用平台实现校准系统时钟(Windows 用 SetLocalTime)。
+// ============================================================================
+package main
+
+import (
+	"encoding/binary"
+	"fmt"
+	"net"
+	"time"
+
+	"github.com/gen2brain/beeep"
+)
+
+// ntpEpoch NTP 时间戳起始时间(1900-01-01)
+var ntpEpoch = time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC)
+
+// queryNTP 向 NTP 服务器查询,返回服务器 UTC 时间
+func queryNTP(server string) (time.Time, error) {
+	conn, err := net.DialTimeout("udp", server+":123", 3*time.Second)
+	if err != nil {
+		return time.Time{}, err
+	}
+	defer conn.Close()
+	conn.SetDeadline(time.Now().Add(3 * time.Second))
+
+	// 48 字节请求包: LI=0, VN=4, Mode=3(client)
+	req := make([]byte, 48)
+	req[0] = 0x1B
+	if _, err := conn.Write(req); err != nil {
+		return time.Time{}, err
+	}
+
+	resp := make([]byte, 48)
+	if _, err := conn.Read(resp); err != nil {
+		return time.Time{}, err
+	}
+
+	secs := binary.BigEndian.Uint32(resp[40:44])
+	frac := binary.BigEndian.Uint32(resp[44:48])
+	return ntpEpoch.Add(time.Duration(secs)*time.Second +
+		time.Duration(frac)*1e9/(1<<32)), nil
+}
+
+// syncSystemTime 从 NTP 获取 UTC 时间,按系统当前时区换算后校准本地时钟(校准需管理员权限)
+func syncSystemTime() error {
+	before := time.Now()
+	fmt.Fprintf(apiLogRing, "[SYS] 开始同步系统时间 | 当前 %s\n", before.Format("2006-01-02 15:04:05.000"))
+
+	var serverTime time.Time
+	var lastErr error
+	for _, server := range []string{"ntp.aliyun.com", "ntp.tencent.com", "time.windows.com"} {
+		t, err := queryNTP(server)
+		if err == nil {
+			serverTime = t
+			fmt.Fprintf(apiLogRing, "[SYS] NTP 服务器 %s 响应成功\n", server)
+			break
+		}
+		lastErr = err
+	}
+	if serverTime.IsZero() {
+		fmt.Fprintf(apiLogRing, "[SYS] 所有 NTP 服务器均不可达: %v\n", lastErr)
+		return fmt.Errorf("无法连接 NTP 服务器: %v", lastErr)
+	}
+
+	// 关键:按系统当前时区(如 China Standard Time UTC+8)换算为本地时间
+	serverLocal := serverTime.In(before.Location())
+	drift := serverLocal.Sub(before)
+	absDrift := drift
+	if absDrift < 0 {
+		absDrift = -absDrift
+	}
+
+	if absDrift < time.Second {
+		fmt.Fprintf(apiLogRing, "[SYS] 时钟偏差 %v,无需校准\n", absDrift.Round(time.Millisecond))
+		_ = beeep.Notify("🕐 时间已精准", fmt.Sprintf("时钟偏差 %v,无需校准", absDrift.Round(time.Millisecond)), "")
+		return nil
+	}
+
+	if err := setLocalTime(serverLocal); err != nil {
+		fmt.Fprintf(apiLogRing, "[SYS] 校准失败: %v\n", err)
+		return err
+	}
+
+	after := time.Now()
+	fmt.Fprintf(apiLogRing, "[SYS] 校准成功 | 偏差 %v | %s → %s\n",
+		drift.Round(time.Millisecond),
+		before.Format("15:04:05"), after.Format("15:04:05"))
+	_ = beeep.Notify("🕐 时间同步成功",
+		fmt.Sprintf("偏差: %v\n校准前: %s\n校准后: %s",
+			drift.Round(time.Millisecond),
+			before.Format("15:04:05"), after.Format("15:04:05")), "")
+	return nil
+}

+ 18 - 0
timesync_other.go

@@ -0,0 +1,18 @@
+//go:build !windows
+
+// ============================================================================
+// timesync_other.go — 系统时间同步(非 Windows 空实现)
+//
+// 当前平台不支持校准系统时钟,返回明确错误提示。
+// ============================================================================
+package main
+
+import (
+	"fmt"
+	"time"
+)
+
+// setLocalTime 非 Windows 平台暂不支持校准系统时钟
+func setLocalTime(t time.Time) error {
+	return fmt.Errorf("当前平台不支持校准系统时间")
+}

+ 117 - 0
timesync_windows.go

@@ -0,0 +1,117 @@
+//go:build windows
+
+// ============================================================================
+// timesync_windows.go — 系统时间同步(Windows 平台)
+//
+// 启用 SeSystemtimePrivilege 特权后用 SetLocalTime 校准系统时间;
+// 直接调用 syscall 声明 user32/kernel32 函数,不引入额外依赖。
+// ============================================================================
+package main
+
+import (
+	"fmt"
+	"syscall"
+	"time"
+	"unsafe"
+)
+
+// systemTime Windows SYSTEMTIME 结构
+type systemTime struct {
+	Year, Month, DayOfWeek, Day, Hour, Minute, Second, Milliseconds uint16
+}
+
+type luid struct {
+	LowPart  uint32
+	HighPart int32
+}
+
+type luidAndAttributes struct {
+	Luid       luid
+	Attributes uint32
+}
+
+type tokenPrivileges struct {
+	PrivilegeCount uint32
+	Privileges     [1]luidAndAttributes
+}
+
+// enablePrivilege 启用当前进程的指定特权(SeSystemtimePrivilege 默认禁用,
+// 必须先 AdjustTokenPrivileges 启用,否则 SetLocalTime 会失败)
+func enablePrivilege(name string) error {
+	const (
+		tokenQuery            = 0x0008
+		tokenAdjustPrivileges = 0x0020
+		sePrivilegeEnabled    = 0x00000002
+		errNotAllAssigned     = syscall.Errno(1300) // ERROR_NOT_ALL_ASSIGNED
+	)
+
+	advapi32 := syscall.NewLazyDLL("advapi32.dll")
+	procOpenProcessToken := advapi32.NewProc("OpenProcessToken")
+	procLookupPrivilegeValue := advapi32.NewProc("LookupPrivilegeValueW")
+	procAdjustTokenPrivileges := advapi32.NewProc("AdjustTokenPrivileges")
+
+	currentProc, _ := syscall.GetCurrentProcess()
+	var token syscall.Handle
+	ret, _, err := procOpenProcessToken.Call(
+		uintptr(currentProc),
+		tokenQuery|tokenAdjustPrivileges,
+		uintptr(unsafe.Pointer(&token)),
+	)
+	if ret == 0 {
+		return fmt.Errorf("OpenProcessToken: %v", err)
+	}
+	defer syscall.CloseHandle(token)
+
+	var luidVal luid
+	namePtr, _ := syscall.UTF16PtrFromString(name)
+	ret, _, err = procLookupPrivilegeValue.Call(
+		0,
+		uintptr(unsafe.Pointer(namePtr)),
+		uintptr(unsafe.Pointer(&luidVal)),
+	)
+	if ret == 0 {
+		return fmt.Errorf("LookupPrivilegeValue(%s): %v", name, err)
+	}
+
+	tp := tokenPrivileges{
+		PrivilegeCount: 1,
+		Privileges: [1]luidAndAttributes{
+			{Luid: luidVal, Attributes: sePrivilegeEnabled},
+		},
+	}
+	ret, _, err = procAdjustTokenPrivileges.Call(
+		uintptr(token), 0,
+		uintptr(unsafe.Pointer(&tp)), 0, 0, 0,
+	)
+	if ret == 0 {
+		return fmt.Errorf("AdjustTokenPrivileges: %v", err)
+	}
+	if err == errNotAllAssigned {
+		return fmt.Errorf("进程不具备特权 %s(需以管理员身份运行)", name)
+	}
+	return nil
+}
+
+// setLocalTime 按系统当前时区设置本地时间(需管理员权限)
+func setLocalTime(t time.Time) error {
+	if err := enablePrivilege("SeSystemtimePrivilege"); err != nil {
+		return err
+	}
+
+	st := systemTime{
+		Year:         uint16(t.Year()),
+		Month:        uint16(t.Month()),
+		DayOfWeek:    uint16(t.Weekday()),
+		Day:          uint16(t.Day()),
+		Hour:         uint16(t.Hour()),
+		Minute:       uint16(t.Minute()),
+		Second:       uint16(t.Second()),
+		Milliseconds: uint16(t.Nanosecond() / 1e6),
+	}
+	ret, _, err := syscall.NewLazyDLL("kernel32.dll").NewProc("SetLocalTime").
+		Call(uintptr(unsafe.Pointer(&st)))
+	if ret == 0 {
+		return fmt.Errorf("SetLocalTime: %v", err)
+	}
+	return nil
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác