Quellcode durchsuchen

完善“记录”功能。增加上传功能。

soease vor 1 Woche
Ursprung
Commit
eeab343ede

+ 3 - 0
.gitignore

@@ -16,6 +16,9 @@ build/android/
 # --- 备份快照(bak 目录,按日期存放) ---
 # --- 备份快照(bak 目录,按日期存放) ---
 bak/
 bak/
 
 
+# --- 独立语音唤醒模块(voicewake,独立 Go 子模块,不纳入主项目) ---
+voicewake/
+
 # --- 前端构建产物与依赖 ---
 # --- 前端构建产物与依赖 ---
 frontend/dist/
 frontend/dist/
 frontend/node_modules/
 frontend/node_modules/

+ 343 - 0
apiserver.go

@@ -7,12 +7,19 @@
 package main
 package main
 
 
 import (
 import (
+	"archive/zip"
 	"context"
 	"context"
+	"crypto/rand"
+	"crypto/subtle"
+	"encoding/base64"
+	"encoding/binary"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"log"
 	"log"
 	"net/http"
 	"net/http"
+	"net/url"
 	"os"
 	"os"
+	"path/filepath"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
@@ -171,6 +178,8 @@ func runAPIServer(addr string) {
 	router.GET("/health", func(c *gin.Context) {
 	router.GET("/health", func(c *gin.Context) {
 		c.JSON(http.StatusOK, gin.H{"status": "ok"}) // 健康检查
 		c.JSON(http.StatusOK, gin.H{"status": "ok"}) // 健康检查
 	})
 	})
+	router.GET("/upload", handleUploadPage)       // 客户端上传页(手机等设备浏览器访问)
+	router.GET("/api/qr", handleQRImage)          // 生成二维码图片(供客户端上传页/桌面端展示)
 
 
 	api := router.Group("/api")
 	api := router.Group("/api")
 	api.POST("/notify", handleNotify)          // 弹出系统托盘通知
 	api.POST("/notify", handleNotify)          // 弹出系统托盘通知
@@ -180,6 +189,8 @@ func runAPIServer(addr string) {
 	api.POST("/settings", handleSaveSettings)  // 保存配置
 	api.POST("/settings", handleSaveSettings)  // 保存配置
 	api.GET("/stocks", handleGetStocks)        // 获取股票行情
 	api.GET("/stocks", handleGetStocks)        // 获取股票行情
 	api.GET("/screenshot", handleScreenshot)   // 截取屏幕,返回 PNG 数据 URI
 	api.GET("/screenshot", handleScreenshot)   // 截取屏幕,返回 PNG 数据 URI
+	api.POST("/upload", handleUpload)          // 上传文件(需密码)
+	api.GET("/download/:filename", handleDownload) // 下载已上传文件(需密码)
 
 
 	pc := api.Group("/pc")              // 主机控制接口(移植自主机服务)
 	pc := api.Group("/pc")              // 主机控制接口(移植自主机服务)
 	pc.GET("/poweroff", handlePoweroff) // 30 秒后关机
 	pc.GET("/poweroff", handlePoweroff) // 30 秒后关机
@@ -334,3 +345,335 @@ func handleScreenshot(c *gin.Context) {
 	}
 	}
 	c.JSON(http.StatusOK, gin.H{"image": dataURI, "display": idx})
 	c.JSON(http.StatusOK, gin.H{"image": dataURI, "display": idx})
 }
 }
+
+// ------------------------------------------------------------------------
+// 文件上传 / 下载(需密码)
+//
+// 上传:POST /api/upload  表单字段 password(密码)、file(文件)。
+//       密码正确才保存到 uploadDir 目录;返回文件 URL 供二维码展示。
+// 下载:GET /api/download/:filename?password=xxx  校验密码后返回文件内容。
+// ------------------------------------------------------------------------
+
+// uploadDirPath 返回上传保存目录:配置为空时回退到用户配置目录下 uploads/。
+func uploadDirPath() string {
+	dir := strings.TrimSpace(loadSettings().UploadDir)
+	if dir != "" {
+		return dir
+	}
+	cfg, err := os.UserConfigDir()
+	if err != nil {
+		return "." // 兜底:当前目录
+	}
+	return filepath.Join(cfg, "TengTuToolbox", "uploads")
+}
+
+// checkUploadPassword 校验上传密码(常数时间比较,防时序侧信道)。
+// 未设置密码时允许访问(与「空密码=不启用」语义一致)。
+func checkUploadPassword(pwd string) bool {
+	expected := loadSettings().UploadPassword
+	if strings.TrimSpace(expected) == "" {
+		return true // 未设置密码:默认放行(用户未启用该功能)
+	}
+	return subtle.ConstantTimeCompare([]byte(pwd), []byte(expected)) == 1
+}
+
+// handleUpload 处理文件上传:密码正确才落盘,返回下载 URL 与文件信息。
+func handleUpload(c *gin.Context) {
+	// 1) 校验密码(表单字段 password)
+	pwd := c.PostForm("password")
+	if !checkUploadPassword(pwd) {
+		c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
+		return
+	}
+
+	// 2) 读取上传文件
+	fileHeader, err := c.FormFile("file")
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "缺少文件字段 file"})
+		return
+	}
+	if fileHeader.Size <= 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "文件为空"})
+		return
+	}
+
+	// 3) 校验文件大小:上限由配置「上传大小上限」决定(默认 512MB)
+	maxMB := loadSettings().UploadMaxSize
+	if maxMB <= 0 {
+		maxMB = 512
+	}
+	maxUploadSize := int64(maxMB) << 20 // MB -> 字节
+	if fileHeader.Size > maxUploadSize {
+		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("文件过大,上限 %dMB", maxMB)})
+		return
+	}
+
+	// 4) 构造保存路径:仅用文件名(去路径),避免路径穿越
+	dir := uploadDirPath()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "无法创建上传目录: " + err.Error()})
+		return
+	}
+	origBase := filepath.Base(strings.ReplaceAll(fileHeader.Filename, "\\", "/"))
+	if origBase == "." || origBase == "" {
+		origBase = "upload.bin"
+	}
+
+	// 5) 落盘:存储文件名用「时间戳+随机数+原扩展名」,不可推测且天然唯一,避免覆盖与遍历。
+	//    zip 内条目保留原文件名,下载者解压后仍能看到原始文件名。
+	storedName := newStoredName(origBase)
+	final := filepath.Join(dir, storedName)
+	if err := c.SaveUploadedFile(fileHeader, final); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败: " + err.Error()})
+		return
+	}
+
+	// 6) 安全处理:把上传的原文件压缩为 zip(zip 内保留原文件名,文件名同样用时间戳+随机数),
+	//    压缩成功后删除原文件,之后仅提供 zip 下载,避免可执行文件等被直接下载。
+	zipName := newStoredName(origBase + ".zip")
+	zipPath := filepath.Join(dir, zipName)
+	if err := zipFile(final, zipPath, origBase); err != nil {
+		// 压缩失败:删除原文件并报错,避免遗留未压缩文件
+		os.Remove(final)
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "压缩文件失败: " + err.Error()})
+		return
+	}
+	os.Remove(final) // 压缩成功,删除原文件
+
+	// 7) 返回下载 URL(指向 zip,相对路径,前端拼完整地址)与文件信息
+	savedName := filepath.Base(zipPath)
+	downloadURL := "/api/download/" + url.PathEscape(savedName)
+	fmt.Fprintf(apiLogRing, "[SYS] 文件已上传并压缩: %s -> %s (%d 字节)\n", origBase, savedName, fileHeader.Size)
+	c.JSON(http.StatusOK, gin.H{
+		"ok":          true,
+		"filename":    origBase,
+		"size":        fileHeader.Size,
+		"downloadUrl": downloadURL,
+	})
+}
+
+// newStoredName 生成存储文件名:时间戳_6位随机数+原扩展名。
+// 扩展名做白名单过滤(仅保留字母数字,最长 10),防止可疑后缀。
+func newStoredName(orig string) string {
+	ext := filepath.Ext(orig)
+	ext = strings.Map(func(r rune) rune {
+		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' {
+			return r
+		}
+		return -1
+	}, ext)
+	if len(ext) > 10 {
+		ext = ext[:10]
+	}
+	ts := time.Now().Format("20060102_150405")
+	rnd := fmt.Sprintf("%06d", cryptoRandInt(1000000))
+	return fmt.Sprintf("%s_%s%s", ts, rnd, ext)
+}
+
+// cryptoRandInt 返回 [0, n) 的随机整数(crypto/rand),失败时回退 time 纳秒。
+func cryptoRandInt(n int) int {
+	var b [8]byte
+	if _, err := rand.Read(b[:]); err != nil {
+		return int(time.Now().UnixNano() % int64(n))
+	}
+	v := int64(binary.BigEndian.Uint64(b[:]))
+	if v < 0 {
+		v = -v
+	}
+	return int(v % int64(n))
+}
+
+// zipFile 把 src 文件压缩为 dst zip,zip 内条目名用 name(一般取原文件名)。
+func zipFile(src, dst, name string) error {
+	in, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+
+	out, err := os.Create(dst)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	zw := zip.NewWriter(out)
+	defer zw.Close()
+
+	w, err := zw.Create(name)
+	if err != nil {
+		return err
+	}
+	_, err = io.Copy(w, in)
+	return err
+}
+
+// handleDownload 处理文件下载:密码正确才返回文件内容。
+func handleDownload(c *gin.Context) {
+	if !checkUploadPassword(c.Query("password")) {
+		c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
+		return
+	}
+	name := filepath.Base(strings.ReplaceAll(c.Param("filename"), "\\", "/"))
+	if name == "." || name == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "文件名无效"})
+		return
+	}
+	path := filepath.Join(uploadDirPath(), name)
+	if _, err := os.Stat(path); err != nil {
+		c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
+		return
+	}
+	c.FileAttachment(path, name)
+}
+
+// ------------------------------------------------------------------------
+// 客户端上传页:手机/其他设备在浏览器中访问 http://<服务地址>/upload,
+// 输入密码并选择文件即可上传到本机,上传成功显示下载二维码。
+// ------------------------------------------------------------------------
+
+const clientUploadHTML = `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<title>腾图工具箱 · 文件上传</title>
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  body {
+    min-height: 100vh; display: flex; align-items: center; justify-content: center;
+    font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
+    background: linear-gradient(160deg, #0b0e1d 0%, #1a1030 100%); color: #e9ecf5; padding: 20px;
+  }
+  .card {
+    width: 100%; max-width: 400px; background: rgba(255,255,255,0.05);
+    border: 1px solid rgba(255,255,255,0.12); border-radius: 16px; padding: 26px 24px;
+    box-shadow: 0 10px 40px rgba(0,0,0,0.45); backdrop-filter: blur(6px);
+  }
+  h1 { font-size: 20px; text-align: center; margin-bottom: 6px; }
+  .sub { text-align: center; color: #8b93a7; font-size: 13px; margin-bottom: 20px; }
+  label { display: block; font-size: 13px; color: #b8bfd0; margin: 14px 0 6px; }
+  input[type=password], input[type=file] {
+    width: 100%; padding: 11px 12px; border-radius: 10px;
+    border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.06);
+    color: #e9ecf5; font-size: 15px;
+  }
+  input[type=file] { color: #8b93a7; padding: 8px; }
+  button {
+    width: 100%; margin-top: 20px; padding: 12px; border: 0; border-radius: 10px;
+    background: linear-gradient(135deg, #ff5a4d, #ff2d72); color: #fff; font-size: 16px; font-weight: 600;
+    cursor: pointer;
+  }
+  button:disabled { opacity: 0.6; cursor: default; }
+  .progress { height: 6px; border-radius: 3px; background: rgba(255,255,255,0.12); margin-top: 16px; overflow: hidden; display: none; }
+  .progress .bar { height: 100%; width: 0; background: linear-gradient(90deg, #ff5a4d, #ff2d72); transition: width .15s; }
+  .result { margin-top: 18px; text-align: center; display: none; }
+  .result img { width: 200px; height: 200px; border-radius: 12px; background: #fff; padding: 8px; margin-top: 14px; }
+  .result .fname { margin-top: 10px; font-size: 14px; word-break: break-all; }
+  .result .dllink {
+    display: inline-block; margin-top: 8px; padding: 9px 18px; border-radius: 8px;
+    background: rgba(255,255,255,0.10); border: 1px solid rgba(255,255,255,0.18);
+    color: #4f8cff; font-size: 14px; text-decoration: none; word-break: break-all; max-width: 100%;
+  }
+  .result .dllink:hover { background: rgba(255,255,255,0.16); }
+  .msg { margin-top: 12px; font-size: 13px; text-align: center; color: #ff9a9a; min-height: 18px; }
+</style>
+</head>
+<body>
+<div class="card">
+  <h1>文件上传</h1>
+  <div class="sub">腾图工具箱 · 客户端上传</div>
+  <label>上传密码</label>
+  <input type="password" id="pwd" placeholder="请输入上传密码" autocomplete="off">
+  <label>选择文件</label>
+  <input type="file" id="file">
+  <button id="btn">上传</button>
+  <div class="progress"><div class="bar" id="bar"></div></div>
+  <div class="msg" id="msg"></div>
+  <div class="result" id="result">
+    <div class="fname" id="fname"></div>
+    <a class="dllink" id="dllink" href="#" target="_blank" rel="noopener"></a>
+    <img id="qr" alt="下载二维码">
+  </div>
+</div>
+<script>
+  var pwd = document.getElementById('pwd');
+  var file = document.getElementById('file');
+  var btn = document.getElementById('btn');
+  var bar = document.getElementById('bar');
+  var msg = document.getElementById('msg');
+  var result = document.getElementById('result');
+  var qr = document.getElementById('qr');
+  var fname = document.getElementById('fname');
+  var dllink = document.getElementById('dllink');
+
+  function showMsg(t, err) { msg.textContent = t; msg.style.color = err ? '#ff9a9a' : '#3ddc84'; }
+
+  btn.addEventListener('click', function () {
+    var f = file.files && file.files[0];
+    if (!f) { showMsg('请先选择文件', true); return; }
+    btn.disabled = true; btn.textContent = '上传中…';
+    result.style.display = 'none'; showMsg('', false);
+    var progress = document.querySelector('.progress'); progress.style.display = 'block'; bar.style.width = '0%';
+    var fd = new FormData(); fd.append('password', pwd.value.trim()); fd.append('file', f);
+    var xhr = new XMLHttpRequest();
+    xhr.open('POST', '/api/upload');
+    xhr.upload.onprogress = function (e) { if (e.lengthComputable) bar.style.width = Math.round(e.loaded / e.total * 100) + '%'; };
+    xhr.onload = function () {
+      btn.disabled = false; btn.textContent = '上传';
+      var data = {};
+      try { data = JSON.parse(xhr.responseText); } catch (_) {}
+      if (xhr.status >= 200 && xhr.status < 300 && data.ok) {
+        var dl = '/api/download/' + encodeURIComponent(data.filename) + '?password=' + encodeURIComponent(pwd.value.trim());
+        var full = location.origin + dl;
+        fname.textContent = data.filename + '(' + data.size + ' 字节)';
+        dllink.href = full;
+        dllink.textContent = full; // 下载地址
+        qr.src = '/api/qr?text=' + encodeURIComponent(full) + '&size=256';
+        result.style.display = 'block';
+        showMsg('上传成功', false);
+      } else {
+        showMsg(data.error || ('上传失败(HTTP ' + xhr.status + ')'), true);
+      }
+    };
+    xhr.onerror = function () { btn.disabled = false; btn.textContent = '上传'; showMsg('网络错误,上传失败', true); };
+    xhr.send(fd);
+  });
+</script>
+</body>
+</html>
+`
+
+// handleUploadPage 返回客户端上传页 HTML(供手机等设备访问)
+func handleUploadPage(c *gin.Context) {
+	c.Header("Content-Type", "text/html; charset=utf-8")
+	c.String(http.StatusOK, clientUploadHTML)
+}
+
+// handleQRImage 生成二维码图片:GET /api/qr?text=xxx&size=300
+// 返回真实 PNG 图片字节(供客户端上传页 <img src> 直接加载)。
+func handleQRImage(c *gin.Context) {
+	text := c.Query("text")
+	if text == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 text 参数"})
+		return
+	}
+	size, _ := strconv.Atoi(c.Query("size"))
+	if size <= 0 {
+		size = 300
+	}
+	uri, err := GenerateQRCode(text, size)
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+	// GenerateQRCode 返回 data URI,剥离前缀后得到原始 PNG 字节
+	const prefix = "data:image/png;base64,"
+	if strings.HasPrefix(uri, prefix) {
+		if raw, derr := base64.StdEncoding.DecodeString(strings.TrimPrefix(uri, prefix)); derr == nil {
+			c.Data(http.StatusOK, "image/png", raw)
+			return
+		}
+	}
+	c.String(http.StatusOK, uri)
+}

+ 2 - 0
build/Taskfile.yml

@@ -181,8 +181,10 @@ tasks:
     sources:
     sources:
       - "**/*.[jt]s"
       - "**/*.[jt]s"
       - exclude: frontend/**/*
       - exclude: frontend/**/*
+      - exclude: voicewake/**/*   # 独立语音唤醒模块,不参与 Wails 构建/监听
       - frontend/bindings/**/*  # Rerun when switching between dev/production mode causes changes in output
       - frontend/bindings/**/*  # Rerun when switching between dev/production mode causes changes in output
       - "**/*.go"
       - "**/*.go"
+      - exclude: voicewake/**/*   # 独立语音唤醒模块,不参与 Wails 构建/监听
       - go.mod
       - go.mod
       - go.sum
       - go.sum
     generates:
     generates:

+ 369 - 0
edgetts.go

@@ -0,0 +1,369 @@
+// ============================================================================
+// edgetts.go — 微软 Edge 在线 TTS(Edge-TTS 协议,WebSocket,标准库实现)
+//
+// 直接实现微软 Edge 浏览器的「朗读」WebSocket 协议,用神经网络中文语音
+// (晓晓/云希/云健等)合成音频,返回 mp3 字节。零外部依赖(只用标准库),
+// 与项目现有「纯 Go 调用系统 API」的风格一致。
+//
+// 参考 edge-tts(Python)的协议逆向实现。
+// ============================================================================
+package main
+
+import (
+	"bufio"
+	"crypto/rand"
+	"crypto/tls"
+	"encoding/base64"
+	"encoding/binary"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"sync"
+	"time"
+)
+
+// EdgeTTS 客户端:一次合成 = 一个连接。
+type EdgeTTS struct {
+	conn *wsConn
+	// 累积的音频数据(mp3)
+	audio []byte
+	mu    sync.Mutex
+}
+
+// edgeTrustedToken 微软 Edge 朗读服务的固定令牌(公开常量,非机密)
+const edgeTrustedToken = "6A5AA1D4EAFF4E9FB37E23D78491D6F3"
+
+// edgeTTSURL 合成端点模板
+const edgeTTSURL = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
+
+// EdgeVoice 可选的中文神经网络语音(用于古诗词吟诵/朗读)
+var edgeVoiceDefault = "zh-CN-YunxiNeural" // 云希:男声,沉稳,适古诗
+
+// edgeVoiceMap 音色名规范化:前端传友好名 → 实际 voice
+var edgeVoiceMap = map[string]string{
+	"xiaoxiao": "zh-CN-XiaoxiaoNeural", // 晓晓:女声,清亮
+	"yunxi":    "zh-CN-YunxiNeural",    // 云希:男声,沉稳
+	"yunjian":  "zh-CN-YunjianNeural",  // 云健:男声,浑厚
+	"xiaoyi":   "zh-CN-XiaoyiNeural",   // 晓伊:女声,甜美
+	"yunyang":  "zh-CN-YunyangNeural",  // 云扬:男声,新闻腔
+	"liaoning": "zh-CN-liaoning-XiaobeiNeural",
+	"shaanxi":  "zh-CN-shaanxi-XiaoniNeural",
+}
+
+// TTSResult 合成结果
+type TTSResult struct {
+	Audio   []byte `json:"audio"`   // mp3 字节
+	Voice   string `json:"voice"`   // 实际使用的音色
+	DurMs   int    `json:"durMs"`   // 预估时长(毫秒)
+}
+
+// Speak 用 Edge-TTS 合成文本为 mp3。voice 可为空(用默认云希)。
+func SpeakEdge(text, voice string) (TTSResult, error) {
+	if strings.TrimSpace(text) == "" {
+		return TTSResult{}, errors.New("文本为空")
+	}
+	if voice == "" {
+		voice = edgeVoiceDefault
+	} else if v, ok := edgeVoiceMap[strings.ToLower(voice)]; ok {
+		voice = v
+	}
+
+	// 生成连接 ID
+	connID := randomHex(16)
+
+	// 建立 WebSocket 连接
+	wsURL := fmt.Sprintf("%s?TrustedClientToken=%s&ConnectionId=%s",
+		edgeTTSURL, edgeTrustedToken, connID)
+	conn, err := dialWS(wsURL, "edge.microsoft.com")
+	if err != nil {
+		return TTSResult{}, fmt.Errorf("连接 Edge-TTS 失败: %w", err)
+	}
+	defer conn.Close()
+
+	t := &EdgeTTS{conn: conn}
+
+	// 1. 发送 speech.config(配置音频格式)
+	if err := t.sendSpeechConfig(); err != nil {
+		return TTSResult{}, err
+	}
+	// 2. 发送合成请求(SSML)
+	if err := t.sendSynthesis(text, voice, connID); err != nil {
+		return TTSResult{}, err
+	}
+
+	// 3. 读取直到 turn.end
+	dur := t.readUntilDone()
+
+	if len(t.audio) == 0 {
+		return TTSResult{}, errors.New("未收到音频数据")
+	}
+	return TTSResult{Audio: t.audio, Voice: voice, DurMs: dur}, nil
+}
+
+// sendSpeechConfig 发送配置消息
+func (t *EdgeTTS) sendSpeechConfig() error {
+	msg := "X-Timestamp:" + time.Now().UTC().Format("2006-01-02T15:04:05.999Z") +
+		"\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n" +
+		`{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":false,"wordBoundaryEnabled":false},"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}`
+	return t.conn.WriteText(msg)
+}
+
+// sendSynthesis 发送 SSML 合成请求
+func (t *EdgeTTS) sendSynthesis(text, voice, connID string) error {
+	// 转义 SSML 特殊字符
+	ssmlText := escapeXML(text)
+	ssml := fmt.Sprintf(
+		`<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="zh-CN">`+
+			`<voice name="%s"><prosody pitch="+0Hz" rate="+0%%" volume="+0%%">%s</prosody></voice></speak>`,
+		voice, ssmlText)
+	payload := map[string]interface{}{
+		"context": map[string]interface{}{
+			"layout": "audiooutput",
+			"voiceCategory": "Cortana",
+		},
+		"version": "5.1.11",
+		"message": "client.messages.SynthesisServiceRequest",
+		"request": map[string]interface{}{
+			"text":     text,
+			"language": "zh-CN",
+			"voice":    voice,
+			"outputFormat": "audio-24khz-48kbitrate-mono-mp3",
+			"ssml":     ssml,
+		},
+	}
+	b, _ := json.Marshal(payload)
+	msg := "X-RequestId:" + connID +
+		"\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.message\r\n\r\n" +
+		string(b)
+	return t.conn.WriteText(msg)
+}
+
+// readUntilDone 读取帧直到收到 turn.end(合成完成)。返回预估时长。
+func (t *EdgeTTS) readUntilDone() int {
+	var durMs int
+	for {
+		opcode, payload, err := t.conn.ReadFrame()
+		if err != nil {
+			return durMs // 连接关闭
+		}
+		if opcode == 0x8 { // close
+			return durMs
+		}
+		if opcode == 0x2 || opcode == 0x1 { // binary / text
+			if opcode == 0x2 && len(payload) > 0 {
+				// 二进制音频帧:前 2 字节头,之后是音频数据
+				// 边缘情况:可能含 header 的 Path 前缀
+				data := t.extractAudio(payload)
+				if len(data) > 0 {
+					t.mu.Lock()
+					t.audio = append(t.audio, data...)
+					t.mu.Unlock()
+				}
+			} else {
+				// 文本帧:可能是 turn.end 或 metadata
+				s := string(payload)
+				if strings.Contains(s, "Path:turn.end") {
+					return durMs
+				}
+			}
+		}
+	}
+}
+
+// extractAudio 从二进制帧提取音频数据(Edge-TTS 二进制帧含 2 字节长度头)。
+func (t *EdgeTTS) extractAudio(payload []byte) []byte {
+	if len(payload) <= 2 {
+		return nil
+	}
+	// 第一个字节通常是 0x02;第 2-3 字节为头部长度
+	headerLen := int(binary.BigEndian.Uint16(payload[0:2]))
+	if headerLen <= 0 || headerLen+2 > len(payload) {
+		// 兜底:直接返回除去 2 字节头
+		return payload[2:]
+	}
+	// 头部可能含 Path 信息,音频在 headerLen+2 之后
+	start := 2 + headerLen
+	if start >= len(payload) {
+		return nil
+	}
+	return payload[start:]
+}
+
+// escapeXML 转义 XML 特殊字符(SSML 内容安全)
+func escapeXML(s string) string {
+	r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
+	return r.Replace(s)
+}
+
+// randomHex 生成 n 字节的随机十六进制字符串
+func randomHex(n int) string {
+	b := make([]byte, n)
+	_, _ = rand.Read(b)
+	return fmt.Sprintf("%x", b)
+}
+
+// ============================================================================
+// 简易 WebSocket 客户端(标准库实现,零外部依赖)
+// ============================================================================
+
+type wsConn struct {
+	conn io.ReadWriteCloser
+	br   *bufio.Reader
+}
+
+// dialWS 发起 WebSocket 握手(HTTP Upgrade,TLS 手写)。
+// 用 net.Dial + tls.Client 拿底层连接,手写 Upgrade 请求与响应解析,
+// 避免 http.Client 隐藏底层连接(无法双向读写)。
+func dialWS(wsURL, origin string) (*wsConn, error) {
+	u, err := url.Parse(wsURL)
+	if err != nil {
+		return nil, err
+	}
+	if u.Scheme != "wss" {
+		return nil, errors.New("仅支持 wss 加密连接")
+	}
+	host := u.Host
+	if !strings.Contains(host, ":") {
+		host += ":443"
+	}
+
+	// TCP 拨号
+	tcpConn, err := net.DialTimeout("tcp", host, 15*time.Second)
+	if err != nil {
+		return nil, err
+	}
+	// TLS 包装
+	tlsConn := tls.Client(tcpConn, &tls.Config{
+		ServerName: u.Hostname(),
+		MinVersion: tls.VersionTLS12,
+	})
+	tlsConn.SetDeadline(time.Now().Add(30 * time.Second))
+	if err := tlsConn.Handshake(); err != nil {
+		tcpConn.Close()
+		return nil, err
+	}
+
+	// 手写 HTTP Upgrade 请求
+	key := base64.StdEncoding.EncodeToString([]byte(randomHex(16)))
+	reqPath := u.RequestURI()
+	if reqPath == "" {
+		reqPath = "/"
+	}
+	reqStr := fmt.Sprintf(
+		"GET %s HTTP/1.1\r\nHost: %s\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n"+
+			"Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n"+
+			"Origin: https://%s\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 Edg/120.0\r\n"+
+			"Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n\r\n",
+		reqPath, u.Host, key, origin)
+	if _, err := tlsConn.Write([]byte(reqStr)); err != nil {
+		tlsConn.Close()
+		return nil, err
+	}
+
+	// 读取响应头(直到空行)
+	br := bufio.NewReader(tlsConn)
+	resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodGet})
+	if err != nil {
+		tlsConn.Close()
+		return nil, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusSwitchingProtocols {
+		tlsConn.Close()
+		return nil, fmt.Errorf("WebSocket 握手失败: %d", resp.StatusCode)
+	}
+	// 校验 Sec-WebSocket-Accept(可选)
+	// 成功:清除 deadline,返回双向连接
+	tlsConn.SetDeadline(time.Time{})
+	return &wsConn{conn: tlsConn, br: br}, nil
+}
+
+// WriteText 发送文本帧(opcode=0x1)
+func (w *wsConn) WriteText(s string) error {
+	return w.writeFrame(0x1, []byte(s))
+}
+
+// WriteBinary 发送二进制帧(opcode=0x2)
+func (w *wsConn) WriteBinary(b []byte) error {
+	return w.writeFrame(0x2, b)
+}
+
+// writeFrame 写入一个 WebSocket 帧(客户端 → 服务端,不掩码)
+func (w *wsConn) writeFrame(opcode byte, payload []byte) error {
+	header := []byte{0x80 | opcode} // FIN=1
+	n := len(payload)
+	switch {
+	case n < 126:
+		header = append(header, byte(n))
+	case n < 65536:
+		header = append(header, 126, byte(n>>8), byte(n))
+	default:
+		header = append(header, 127)
+		var l [8]byte
+		binary.BigEndian.PutUint64(l[:], uint64(n))
+		header = append(header, l[:]...)
+	}
+	// 客户端帧必须掩码(置 0x80),但微软端点接受未掩码;为兼容标准库可读,这里发未掩码。
+	// 多数服务端接受;若拒绝再启用掩码。
+	if _, err := w.conn.Write(header); err != nil {
+		return err
+	}
+	_, err := w.conn.Write(payload)
+	return err
+}
+
+// ReadFrame 读取一个帧,返回 (opcode, payload, error)。
+func (w *wsConn) ReadFrame() (byte, []byte, error) {
+	var h [2]byte
+	if _, err := io.ReadFull(w.br, h[:]); err != nil {
+		return 0, nil, err
+	}
+	opcode := h[0] & 0x0f
+	masked := h[1]&0x80 != 0
+	length := int(h[1] & 0x7f)
+
+	switch length {
+	case 126:
+		var ext [2]byte
+		if _, err := io.ReadFull(w.br, ext[:]); err != nil {
+			return 0, nil, err
+		}
+		length = int(binary.BigEndian.Uint16(ext[:]))
+	case 127:
+		var ext [8]byte
+		if _, err := io.ReadFull(w.br, ext[:]); err != nil {
+			return 0, nil, err
+		}
+		length = int(binary.BigEndian.Uint64(ext[:]))
+	}
+
+	var mask [4]byte
+	if masked {
+		if _, err := io.ReadFull(w.br, mask[:]); err != nil {
+			return 0, nil, err
+		}
+	}
+
+	payload := make([]byte, length)
+	if _, err := io.ReadFull(w.br, payload); err != nil {
+		return 0, nil, err
+	}
+	if masked {
+		for i := range payload {
+			payload[i] ^= mask[i%4]
+		}
+	}
+	return opcode, payload, nil
+}
+
+// Close 关闭连接
+func (w *wsConn) Close() error {
+	// 发送关闭帧
+	w.writeFrame(0x8, nil)
+	return w.conn.Close()
+}

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

@@ -167,6 +167,38 @@ export class AppSettings {
              */
              */
             this["apiAddr"] = "";
             this["apiAddr"] = "";
         }
         }
+        if (!("apiAutoStart" in $$source)) {
+            /**
+             * 应用启动时自动启动内置 API 服务(默认开启)
+             * @member
+             * @type {boolean}
+             */
+            this["apiAutoStart"] = false;
+        }
+        if (!("uploadPassword" in $$source)) {
+            /**
+             * 文件上传密码(空=不启用上传/下载接口)
+             * @member
+             * @type {string}
+             */
+            this["uploadPassword"] = "";
+        }
+        if (!("uploadDir" in $$source)) {
+            /**
+             * 文件上传保存目录(空=用户配置目录/uploads)
+             * @member
+             * @type {string}
+             */
+            this["uploadDir"] = "";
+        }
+        if (!("uploadMaxSize" in $$source)) {
+            /**
+             * 单个上传文件大小上限(MB),<=0 用默认 512
+             * @member
+             * @type {number}
+             */
+            this["uploadMaxSize"] = 0;
+        }
         if (!("todoPath" in $$source)) {
         if (!("todoPath" in $$source)) {
             /**
             /**
              * 待办(TODO)文件路径
              * 待办(TODO)文件路径
@@ -310,10 +342,10 @@ export class AppSettings {
         const $$createField6_0 = $$createType2;
         const $$createField6_0 = $$createType2;
         const $$createField9_0 = $$createType4;
         const $$createField9_0 = $$createType4;
         const $$createField10_0 = $$createType6;
         const $$createField10_0 = $$createType6;
-        const $$createField16_0 = $$createType7;
-        const $$createField17_0 = $$createType7;
-        const $$createField18_0 = $$createType8;
-        const $$createField19_0 = $$createType9;
+        const $$createField20_0 = $$createType7;
+        const $$createField21_0 = $$createType7;
+        const $$createField22_0 = $$createType8;
+        const $$createField23_0 = $$createType9;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("tabOrder" in $$parsedSource) {
         if ("tabOrder" in $$parsedSource) {
             $$parsedSource["tabOrder"] = $$createField4_0($$parsedSource["tabOrder"]);
             $$parsedSource["tabOrder"] = $$createField4_0($$parsedSource["tabOrder"]);
@@ -331,16 +363,16 @@ export class AppSettings {
             $$parsedSource["customTabs"] = $$createField10_0($$parsedSource["customTabs"]);
             $$parsedSource["customTabs"] = $$createField10_0($$parsedSource["customTabs"]);
         }
         }
         if ("stockTicker" in $$parsedSource) {
         if ("stockTicker" in $$parsedSource) {
-            $$parsedSource["stockTicker"] = $$createField16_0($$parsedSource["stockTicker"]);
+            $$parsedSource["stockTicker"] = $$createField20_0($$parsedSource["stockTicker"]);
         }
         }
         if ("newsTicker" in $$parsedSource) {
         if ("newsTicker" in $$parsedSource) {
-            $$parsedSource["newsTicker"] = $$createField17_0($$parsedSource["newsTicker"]);
+            $$parsedSource["newsTicker"] = $$createField21_0($$parsedSource["newsTicker"]);
         }
         }
         if ("poem" in $$parsedSource) {
         if ("poem" in $$parsedSource) {
-            $$parsedSource["poem"] = $$createField18_0($$parsedSource["poem"]);
+            $$parsedSource["poem"] = $$createField22_0($$parsedSource["poem"]);
         }
         }
         if ("mouseAlert" in $$parsedSource) {
         if ("mouseAlert" in $$parsedSource) {
-            $$parsedSource["mouseAlert"] = $$createField19_0($$parsedSource["mouseAlert"]);
+            $$parsedSource["mouseAlert"] = $$createField23_0($$parsedSource["mouseAlert"]);
         }
         }
         return new AppSettings(/** @type {Partial<AppSettings>} */($$parsedSource));
         return new AppSettings(/** @type {Partial<AppSettings>} */($$parsedSource));
     }
     }
@@ -1075,7 +1107,7 @@ export class PoemData {
 }
 }
 
 
 /**
 /**
- * RecordItem 一条记录:姓名 + 名称 + 值 + 录入时间,供记录 Tab 列表与柱状图使用
+ * RecordItem 一条记录:姓名 + 名称 + 值 + 备注 + 录入时间,供记录 Tab 列表与柱状图使用
  */
  */
 export class RecordItem {
 export class RecordItem {
     /**
     /**
@@ -1107,6 +1139,14 @@ export class RecordItem {
              */
              */
             this["value"] = 0;
             this["value"] = 0;
         }
         }
+        if (!("remark" in $$source)) {
+            /**
+             * 备注(可选)
+             * @member
+             * @type {string}
+             */
+            this["remark"] = "";
+        }
         if (!("createdAt" in $$source)) {
         if (!("createdAt" in $$source)) {
             /**
             /**
              * 录入时间 YYYY-MM-DD HH:MM:SS(前端新增时生成;为空时后端用当前时间)
              * 录入时间 YYYY-MM-DD HH:MM:SS(前端新增时生成;为空时后端用当前时间)

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

@@ -255,6 +255,14 @@ export function MarkGithubRepoChecked(url, latest) {
     return $Call.ByID(1378316359, url, latest);
     return $Call.ByID(1378316359, url, latest);
 }
 }
 
 
+/**
+ * PickUploadFile 弹出文件选择对话框,返回用户选中的本地文件路径(取消返回空串)。
+ * @returns {$CancellablePromise<string>}
+ */
+export function PickUploadFile() {
+    return $Call.ByID(2719376250);
+}
+
 /**
 /**
  * PickWebDir 弹出目录选择对话框,返回选中的目录(取消返回空串)。
  * PickWebDir 弹出目录选择对话框,返回选中的目录(取消返回空串)。
  * @returns {$CancellablePromise<string>}
  * @returns {$CancellablePromise<string>}
@@ -431,6 +439,18 @@ export function StopWebService() {
     return $Call.ByID(1864477289);
     return $Call.ByID(1864477289);
 }
 }
 
 
+/**
+ * TTS 用微软 Edge 神经网络语音合成文本,返回 data URI(audio/mp3)。
+ * 前端直接赋给 <audio src> 播放。voice 可选:xiaoxiao/yunxi/yunjian/xiaoyi/yunyang。
+ * 古诗词朗读用云希(yunxi,沉稳男声)效果最佳。
+ * @param {string} text
+ * @param {string} voice
+ * @returns {$CancellablePromise<string>}
+ */
+export function TTS(text, voice) {
+    return $Call.ByID(734529301, text, voice);
+}
+
 /**
 /**
  * Translate 翻译文本:输入中文翻译为英文,输入英文翻译为中文(自动检测方向)。
  * Translate 翻译文本:输入中文翻译为英文,输入英文翻译为中文(自动检测方向)。
  * 使用 MyMemory 免费 API,有每日配额限制。
  * 使用 MyMemory 免费 API,有每日配额限制。
@@ -443,6 +463,19 @@ export function Translate(text) {
     }));
     }));
 }
 }
 
 
+/**
+ * UploadFile 供桌面端「服务」页直接上传本地文件(不走 HTTP、无需密码)。
+ * 把本地文件压缩为 zip 存入上传目录(zip 内保留原文件名),返回下载信息。
+ * 仅本机桌面调用,不暴露给外部网络。
+ * @param {string} diskPath
+ * @returns {$CancellablePromise<{ [_ in string]?: any }>}
+ */
+export function UploadFile(diskPath) {
+    return $Call.ByID(417387035, diskPath).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType22($result);
+    }));
+}
+
 /**
 /**
  * WebServiceStatus 返回静态文件服务当前状态。
  * WebServiceStatus 返回静态文件服务当前状态。
  * @returns {$CancellablePromise<$models.WebServiceInfo>}
  * @returns {$CancellablePromise<$models.WebServiceInfo>}
@@ -506,3 +539,4 @@ const $$createType18 = $models.RecordItem.createFrom;
 const $$createType19 = $Create.Array($$createType18);
 const $$createType19 = $Create.Array($$createType18);
 const $$createType20 = $models.WebServiceInfo.createFrom;
 const $$createType20 = $models.WebServiceInfo.createFrom;
 const $$createType21 = $models.TranslateResult.createFrom;
 const $$createType21 = $models.TranslateResult.createFrom;
+const $$createType22 = $Create.Map($Create.Any, $Create.Any);

+ 81 - 1
frontend/index.html

@@ -130,11 +130,19 @@
                     <label class="record-label">名称
                     <label class="record-label">名称
                         <select class="input record-select" id="record-item-filter"><option value="">全部</option></select>
                         <select class="input record-select" id="record-item-filter"><option value="">全部</option></select>
                     </label>
                     </label>
+                    <label class="record-label">从
+                        <input class="input record-date" id="record-date-from" type="date">
+                    </label>
+                    <label class="record-label">到
+                        <input class="input record-date" id="record-date-to" type="date">
+                    </label>
+                    <button class="btn-ghost" id="record-filter-clear" title="清除时间过滤">清除</button>
                     <span class="record-count" id="record-count"></span>
                     <span class="record-count" id="record-count"></span>
                 </div>
                 </div>
                 <div class="record-chart-wrap">
                 <div class="record-chart-wrap">
                     <canvas class="record-chart" id="record-chart"></canvas>
                     <canvas class="record-chart" id="record-chart"></canvas>
-                    <div class="record-chart-empty" id="record-chart-empty">请先选择姓名与名称,才显示该组合的柱状图</div>
+                    <div class="record-chart-tooltip" id="record-chart-tooltip" hidden></div>
+                    <div class="record-chart-empty" id="record-chart-empty">请先选择姓名,才显示柱状图(可选名称进一步筛选)</div>
                 </div>
                 </div>
                 <!-- 自动补全候选:姓名/名称输入框通过 list 属性引用;随数据库历史值刷新。
                 <!-- 自动补全候选:姓名/名称输入框通过 list 属性引用;随数据库历史值刷新。
                      必须放在 record-list 容器之外,避免 loadRecords() 的 innerHTML='' 把它们清空 -->
                      必须放在 record-list 容器之外,避免 loadRecords() 的 innerHTML='' 把它们清空 -->
@@ -320,6 +328,37 @@
                         </div>
                         </div>
                         <p id="api-status">检查中…</p>
                         <p id="api-status">检查中…</p>
                     </div>
                     </div>
+                    <!-- 文件上传:需密码,上传成功显示二维码 -->
+                    <div class="card" id="upload-card">
+                        <div class="card-title-row">
+                            <span class="card-title"><span class="dot"></span>文件上传</span>
+                        </div>
+                        <div class="upload-box">
+                            <div class="upload-row">
+                                <button class="btn-ghost" id="upload-btn">选择文件并上传</button>
+                                <span class="upload-name" id="upload-name"></span>
+                            </div>
+                            <div class="upload-progress" id="upload-progress" hidden><div class="upload-bar"></div></div>
+                            <div class="upload-result" id="upload-result" hidden>
+                                <div class="upload-qr" id="upload-qr"></div>
+                                <div class="upload-meta">
+                                    <div class="upload-name" id="upload-name2"></div>
+                                    <a class="upload-link" id="upload-link" href="#" target="_blank">下载地址</a>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                    <!-- 客户端上传页:手机/其他设备扫码即可上传文件 -->
+                    <div class="card" id="client-upload-card">
+                        <div class="card-title-row">
+                            <span class="card-title"><span class="dot"></span>客户端上传页</span>
+                        </div>
+                        <div class="upload-box">
+                            <p class="tool-hint">手机等设备扫码打开上传页,输入密码即可上传文件;上传成功自动显示下载二维码。</p>
+                            <div class="upload-qr" id="client-upload-qr"></div>
+                            <div class="client-upload-url" id="client-upload-url"></div>
+                        </div>
+                    </div>
                     <div class="card">
                     <div class="card">
                         <div class="card-title"><span class="dot"></span>电源命令</div>
                         <div class="card-title"><span class="dot"></span>电源命令</div>
                         <p id="pc-status">暂无请求</p>
                         <p id="pc-status">暂无请求</p>
@@ -335,6 +374,8 @@
                     <li><code>POST /api/settings</code>保存配置</li>
                     <li><code>POST /api/settings</code>保存配置</li>
                     <li><code>GET /api/stocks</code>股票行情(?codes= 可临时指定)</li>
                     <li><code>GET /api/stocks</code>股票行情(?codes= 可临时指定)</li>
                     <li><code>GET /api/screenshot</code>屏幕截图(?display= 指定显示器)</li>
                     <li><code>GET /api/screenshot</code>屏幕截图(?display= 指定显示器)</li>
+                    <li><code>POST /api/upload</code>上传文件(字段 password+file,密码正确才保存)</li>
+                    <li><code>GET /api/download/:filename</code>下载已上传文件(?password= 校验密码)</li>
                     <li><code>GET /api/pc/poweroff</code>30 秒后关机</li>
                     <li><code>GET /api/pc/poweroff</code>30 秒后关机</li>
                     <li><code>GET /api/pc/restart</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/sleep</code>30 秒后休眠</li>
@@ -551,6 +592,18 @@
                         </div>
                         </div>
                     </div>
                     </div>
 
 
+                    <!-- 服务自动启动:应用启动时自动开启内置 API 服务(默认勾选) -->
+                    <div class="setting-row" data-tab-group="host">
+                        <div class="setting-info">
+                            <div class="setting-title">服务自动启动</div>
+                            <div class="setting-hint">启动腾图工具箱时自动开启内置 API 服务(默认开启)</div>
+                        </div>
+                        <label class="switch">
+                            <input type="checkbox" id="api-auto-start" checked>
+                            <span class="switch-track"></span>
+                        </label>
+                    </div>
+
                     <!-- API 监听地址 -->
                     <!-- API 监听地址 -->
                     <div class="setting-row" data-tab-group="host">
                     <div class="setting-row" data-tab-group="host">
                         <div class="setting-info">
                         <div class="setting-info">
@@ -560,6 +613,33 @@
                         <input class="input" id="api-addr" type="text" placeholder="127.0.0.1:17890">
                         <input class="input" id="api-addr" type="text" placeholder="127.0.0.1:17890">
                     </div>
                     </div>
 
 
+                    <!-- 上传密码 -->
+                    <div class="setting-row" data-tab-group="host">
+                        <div class="setting-info">
+                            <div class="setting-title">上传密码</div>
+                            <div class="setting-hint">服务页文件上传需输入该密码;留空则不启用上传/下载接口</div>
+                        </div>
+                        <input class="input" id="upload-password" type="password" placeholder="留空=不启用上传">
+                    </div>
+
+                    <!-- 上传保存目录 -->
+                    <div class="setting-row" data-tab-group="host">
+                        <div class="setting-info">
+                            <div class="setting-title">上传保存目录</div>
+                            <div class="setting-hint">上传文件的保存目录,留空则保存到用户配置目录/uploads</div>
+                        </div>
+                        <input class="input" id="upload-dir" type="text" placeholder="D:\My\腾图\上传">
+                    </div>
+
+                    <!-- 上传大小上限 -->
+                    <div class="setting-row" data-tab-group="host">
+                        <div class="setting-info">
+                            <div class="setting-title">上传大小上限</div>
+                            <div class="setting-hint">单个上传文件允许的最大大小(MB),超出会被拒绝</div>
+                        </div>
+                        <input class="input" id="upload-max-size" type="number" min="1" step="1" placeholder="默认 512MB">
+                    </div>
+
                     <!-- 待办文件路径 -->
                     <!-- 待办文件路径 -->
                     <div class="setting-row" data-tab-group="todo">
                     <div class="setting-row" data-tab-group="todo">
                         <div class="setting-info">
                         <div class="setting-info">

+ 174 - 11
frontend/public/style.css

@@ -23,6 +23,7 @@
     --read-bg: rgba(255, 224, 138, 0.12);    /* 朗读中新闻行的背景(暗色主题) */
     --read-bg: rgba(255, 224, 138, 0.12);    /* 朗读中新闻行的背景(暗色主题) */
     --read-border: rgba(255, 224, 138, 0.55);/* 朗读中新闻行的边框(暗色主题) */
     --read-border: rgba(255, 224, 138, 0.55);/* 朗读中新闻行的边框(暗色主题) */
     --read-ink: #ffe08a;                     /* 朗读中新闻标题文字(暗色主题:金色) */
     --read-ink: #ffe08a;                     /* 朗读中新闻标题文字(暗色主题:金色) */
+    --tooltip-bg: rgba(28, 32, 48, 0.97);    /* 悬浮提示背景(暗色主题:近实深色) */
 
 
     /* ---------- 间距(黄金比 φ ≈ 1.618) ---------- */
     /* ---------- 间距(黄金比 φ ≈ 1.618) ---------- */
     --s-1: 0.618rem;
     --s-1: 0.618rem;
@@ -57,6 +58,7 @@
     --read-bg: rgba(217, 138, 30, 0.14);      /* 朗读中新闻行的背景(亮色主题:淡琥珀) */
     --read-bg: rgba(217, 138, 30, 0.14);      /* 朗读中新闻行的背景(亮色主题:淡琥珀) */
     --read-border: rgba(180, 110, 10, 0.55);  /* 朗读中新闻行的边框(亮色主题) */
     --read-border: rgba(180, 110, 10, 0.55);  /* 朗读中新闻行的边框(亮色主题) */
     --read-ink: #8a5a00;                       /* 朗读中新闻标题文字(亮色主题:深琥珀,浅底清晰) */
     --read-ink: #8a5a00;                       /* 朗读中新闻标题文字(亮色主题:深琥珀,浅底清晰) */
+    --tooltip-bg: rgba(255, 255, 255, 0.98);  /* 悬浮提示背景(亮色主题:近实浅色) */
     color-scheme: light;
     color-scheme: light;
 }
 }
 
 
@@ -472,15 +474,26 @@ body::before {
 .setting-list {
 .setting-list {
     display: flex;
     display: flex;
     flex-direction: column;
     flex-direction: column;
-    gap: 10px;
+    gap: 14px;
+}
+
+/* 设置分组卡片:每组设置项一个带边框的容器,明显区分各页设置 */
+.setting-group {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    padding: 12px 12px 14px;
+    border: 1px solid var(--glass-border);
+    border-radius: calc(var(--radius) + 2px);
+    background: var(--glass);
 }
 }
 
 
 /* 设置分组标题:每组设置项前显示所属 Tab 名称 */
 /* 设置分组标题:每组设置项前显示所属 Tab 名称 */
 .setting-group-title {
 .setting-group-title {
-    margin: 14px 2px 2px;
-    color: var(--muted);
+    margin: 2px 6px 6px;
+    color: var(--accent);
     font-size: calc(14.5px + var(--font-delta));
     font-size: calc(14.5px + var(--font-delta));
-    font-weight: 600;
+    font-weight: 700;
     letter-spacing: 0.5px;
     letter-spacing: 0.5px;
 }
 }
 
 
@@ -494,9 +507,15 @@ body::before {
     justify-content: space-between;
     justify-content: space-between;
     gap: 18px;
     gap: 18px;
     padding: 16px 18px;
     padding: 16px 18px;
-    border: 1px solid var(--glass-border);
+    border: 1px solid rgba(255, 255, 255, 0.055);
     border-radius: var(--radius);
     border-radius: var(--radius);
-    background: var(--glass);
+    background: rgba(255, 255, 255, 0.02); /* 组卡片内的行:更浅背景,弱化边框,形成内嵌层级 */
+}
+
+/* 亮色主题下组卡片内的行用淡色底 */
+:root[data-theme="light"] .setting-row {
+    border-color: rgba(20, 26, 40, 0.07);
+    background: rgba(255, 255, 255, 0.55);
 }
 }
 
 
 .setting-title {
 .setting-title {
@@ -1804,6 +1823,24 @@ a.ver-muted:hover {
 .record-select:focus {
 .record-select:focus {
     border-color: var(--accent);
     border-color: var(--accent);
 }
 }
+.record-date {
+    min-width: 140px;
+    padding: 6px 8px;
+    border: 1px solid var(--glass-border);
+    border-radius: calc(var(--radius) - 2px);
+    background: var(--glass-strong);
+    color: var(--ink);
+    font-size: calc(14.5px + var(--font-delta));
+    outline: none;
+    cursor: pointer;
+    transition: border-color 0.15s;
+}
+.record-date:hover {
+    border-color: rgba(255, 77, 77, 0.45);
+}
+.record-date:focus {
+    border-color: var(--accent);
+}
 .record-count {
 .record-count {
     margin-left: auto;
     margin-left: auto;
     color: var(--muted);
     color: var(--muted);
@@ -1833,12 +1870,35 @@ a.ver-muted:hover {
     color: var(--muted);
     color: var(--muted);
     font-size: calc(15px + var(--font-delta));
     font-size: calc(15px + var(--font-delta));
 }
 }
+/* 柱状图鼠标悬停提示 */
+.record-chart-tooltip {
+    position: absolute;
+    z-index: 5;
+    pointer-events: none;                   /* 不阻挡鼠标,避免闪烁 */
+    max-width: 240px;
+    padding: 6px 10px;
+    border-radius: 8px;
+    border: 1px solid var(--glass-border, rgba(127,127,127,0.3));
+    background: var(--tooltip-bg, rgba(28,32,48,0.97));
+    color: var(--ink, #e9ecf5);
+    font-size: calc(12px + var(--font-delta));
+    line-height: 1.5;
+    box-shadow: 0 4px 14px rgba(0,0,0,0.35);
+    white-space: nowrap;
+}
+.record-chart-tooltip .tip-name {
+    font-weight: 600;
+}
+.record-chart-tooltip div + div {
+    margin-top: 2px;
+}
 
 
 /* 记录列表:每行「姓名|名称|值|删除」 */
 /* 记录列表:每行「姓名|名称|值|删除」 */
 .record-list {
 .record-list {
     flex: 1;
     flex: 1;
     min-height: 120px;                      /* 保证列表区有最小可用高度,避免被固定元素挤压成一行 */
     min-height: 120px;                      /* 保证列表区有最小可用高度,避免被固定元素挤压成一行 */
     overflow-y: auto;
     overflow-y: auto;
+    overflow-x: hidden;
     display: flex;
     display: flex;
     flex-direction: column;
     flex-direction: column;
     gap: 6px;
     gap: 6px;
@@ -1848,6 +1908,7 @@ a.ver-muted:hover {
     display: flex;
     display: flex;
     align-items: center;
     align-items: center;
     gap: 10px;
     gap: 10px;
+    min-width: 0;
     padding: 8px 12px;
     padding: 8px 12px;
     border: 1px solid var(--glass-border);
     border: 1px solid var(--glass-border);
     border-radius: var(--radius);
     border-radius: var(--radius);
@@ -1868,19 +1929,30 @@ a.ver-muted:hover {
 }
 }
 .record-name {
 .record-name {
     width: 150px;
     width: 150px;
-    flex-shrink: 0;
+    flex: 0 1 150px;
+    min-width: 60px;
 }
 }
 .record-item {
 .record-item {
     width: 200px;
     width: 200px;
-    flex-shrink: 0;
+    flex: 0 1 200px;
+    min-width: 70px;
 }
 }
 .record-value {
 .record-value {
     width: 110px;
     width: 110px;
-    flex-shrink: 0;
+    flex: 0 1 110px;
+    min-width: 60px;
+}
+.record-remark {
+    width: 180px;
+    flex: 0 1 180px;
+    min-width: 60px;
+    color: var(--muted);
+    font-size: calc(13.5px + var(--font-delta));
 }
 }
 .record-time {
 .record-time {
     width: 170px;
     width: 170px;
-    flex-shrink: 0;
+    flex: 0 1 170px;
+    min-width: 90px;
     color: var(--muted);
     color: var(--muted);
     font-size: calc(13.5px + var(--font-delta));
     font-size: calc(13.5px + var(--font-delta));
 }
 }
@@ -1953,6 +2025,94 @@ a.ver-muted:hover {
     border-color: currentColor;
     border-color: currentColor;
 }
 }
 
 
+/* 文件上传卡片 */
+.upload-box {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+}
+.upload-box input[type="password"] {
+    width: 100%;
+    padding: 8px 10px;
+    border-radius: 8px;
+    border: 1px solid var(--border, rgba(127,127,127,0.25));
+    background: var(--input-bg, rgba(127,127,127,0.08));
+    color: var(--text, #e9ecf5);
+    font-size: calc(13px + var(--font-delta));
+}
+.upload-row {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+}
+.upload-input {
+    flex: 1;
+    min-width: 0;
+    font-size: calc(12px + var(--font-delta));
+    color: var(--muted, #8b93a7);
+}
+.upload-progress {
+    height: 6px;
+    border-radius: 4px;
+    background: rgba(127,127,127,0.15);
+    overflow: hidden;
+}
+.upload-bar {
+    height: 100%;
+    width: 0%;
+    border-radius: 4px;
+    background: var(--brand, #4f8cff);
+    transition: width .15s ease;
+}
+.upload-result {
+    display: flex;
+    gap: 12px;
+    align-items: center;
+    padding-top: 6px;
+}
+.upload-qr {
+    width: 96px;
+    height: 96px;
+    border-radius: 8px;
+    padding: 6px;
+    background: #fff;
+    flex-shrink: 0;
+}
+.upload-qr img {
+    width: 100%;
+    height: 100%;
+    object-fit: contain;
+    display: block;
+}
+.upload-meta {
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+}
+.upload-name {
+    font-size: calc(13px + var(--font-delta));
+    color: var(--text, #e9ecf5);
+    word-break: break-all;
+}
+.upload-link {
+    font-size: calc(12px + var(--font-delta));
+    color: var(--brand-text, #4f8cff);
+    text-decoration: none;
+    word-break: break-all;
+}
+.upload-link:hover {
+    text-decoration: underline;
+}
+/* 客户端上传页地址文字 */
+.client-upload-url {
+    margin-top: 10px;
+    font-size: calc(12px + var(--font-delta));
+    color: var(--muted, #8b93a7);
+    word-break: break-all;
+    text-align: center;
+}
+
 .section-title {
 .section-title {
     margin: 0 0 10px;
     margin: 0 0 10px;
     color: var(--ink);
     color: var(--ink);
@@ -2541,8 +2701,11 @@ a.ver-muted:hover {
         gap: 10px;
         gap: 10px;
     }
     }
     /* 分组标题与设置行:窄屏收紧间距 */
     /* 分组标题与设置行:窄屏收紧间距 */
+    .setting-group {
+        padding: 10px 10px 12px; /* 窄屏收紧分组卡片内边距 */
+    }
     .setting-group-title {
     .setting-group-title {
-        margin-top: 10px;
+        margin: 0 4px 4px;
     }
     }
     /* 新闻 / 股票列表:窄屏让出高度,避免底部被遮挡 */
     /* 新闻 / 股票列表:窄屏让出高度,避免底部被遮挡 */
     .news-list, .repo-list, .stock-list {
     .news-list, .repo-list, .stock-list {

+ 383 - 43
frontend/src/main.js

@@ -18,6 +18,7 @@ const FALLBACK_SETTINGS = {
     githubRepos: [{url: 'https://github.com/wailsapp/wails', desc: 'Wails 框架'}],
     githubRepos: [{url: 'https://github.com/wailsapp/wails', desc: 'Wails 框架'}],
     customTabs: [],                   // 用户自定义 Tab 页面(名字 + 地址;http/https 为网页,其余为本地文件)
     customTabs: [],                   // 用户自定义 Tab 页面(名字 + 地址;http/https 为网页,其余为本地文件)
     apiAddr: '127.0.0.1:17890',
     apiAddr: '127.0.0.1:17890',
+    apiAutoStart: true, // 应用启动时自动启动内置 API 服务(默认开启)
     todoPath: '', // 待办路径:无默认值,空值提示用户到设置页配置
     todoPath: '', // 待办路径:无默认值,空值提示用户到设置页配置
     recordPath: '', // 记录路径:无默认值,空值提示用户到设置页配置(保留兼容,记录 Tab 实际使用数据库)
     recordPath: '', // 记录路径:无默认值,空值提示用户到设置页配置(保留兼容,记录 Tab 实际使用数据库)
     recordDBPath: '', // 记录数据库路径:无默认值,空值提示用户到设置页配置
     recordDBPath: '', // 记录数据库路径:无默认值,空值提示用户到设置页配置
@@ -322,19 +323,21 @@ function applyTabOrder(order) {
             if (!grouped[g]) grouped[g] = [];
             if (!grouped[g]) grouped[g] = [];
             grouped[g].push(row);
             grouped[g].push(row);
         });
         });
-        // 分组标题:系统在前,其后按 Tab 顺序
+        // 分组容器:系统在前,其后按 Tab 顺序;每组一个带边框的卡片,明显区分各页设置
         const groupOrder = ['system', ...ordered];
         const groupOrder = ['system', ...ordered];
-        settingList.querySelectorAll('.setting-group-title').forEach(el => el.remove());
-        settingList.querySelectorAll('.setting-row').forEach(row => row.remove());
+        settingList.querySelectorAll('.setting-group').forEach(el => el.remove());
         const frag = document.createDocumentFragment();
         const frag = document.createDocumentFragment();
         groupOrder.forEach(g => {
         groupOrder.forEach(g => {
             const gRows = grouped[g];
             const gRows = grouped[g];
-            if (!gRows || !gRows.length) return; // 该组无设置项则不显示标题
+            if (!gRows || !gRows.length) return; // 该组无设置项则不显示
+            const group = document.createElement('section');
+            group.className = 'setting-group';
             const title = document.createElement('div');
             const title = document.createElement('div');
             title.className = 'setting-group-title';
             title.className = 'setting-group-title';
             title.textContent = g === 'system' ? '系统' : getTabName(g); // 内置或自定义 Tab 名
             title.textContent = g === 'system' ? '系统' : getTabName(g); // 内置或自定义 Tab 名
-            frag.appendChild(title);
-            gRows.forEach(row => frag.appendChild(row));
+            group.appendChild(title);
+            gRows.forEach(row => group.appendChild(row));
+            frag.appendChild(group);
         });
         });
         settingList.appendChild(frag);
         settingList.appendChild(frag);
     }
     }
@@ -425,6 +428,10 @@ const newsKeywordsInput = document.getElementById('news-keywords'); // 新闻关
 const biliCookieInput = document.getElementById('bili-cookie');      // B站登录 cookie
 const biliCookieInput = document.getElementById('bili-cookie');      // B站登录 cookie
 const githubReposText = document.getElementById('github-repos');    // Github 项目(每行 地址|说明)
 const githubReposText = document.getElementById('github-repos');    // Github 项目(每行 地址|说明)
 const apiAddrInput = document.getElementById('api-addr');           // API 监听地址
 const apiAddrInput = document.getElementById('api-addr');           // API 监听地址
+const apiAutoStartToggle = document.getElementById('api-auto-start'); // 服务自动启动开关
+const uploadPasswordInput = document.getElementById('upload-password'); // 上传密码
+const uploadDirInput = document.getElementById('upload-dir');         // 上传保存目录
+const uploadMaxSizeInput = document.getElementById('upload-max-size'); // 上传大小上限(MB)
 const todoPathInput = document.getElementById('todo-path');         // 待办(TODO)文件路径
 const todoPathInput = document.getElementById('todo-path');         // 待办(TODO)文件路径
 const recordDBPathInput = document.getElementById('record-db-path'); // 记录(Record)数据库路径
 const recordDBPathInput = document.getElementById('record-db-path'); // 记录(Record)数据库路径
 const backupDirInput = document.getElementById('backup-dir');       // 文本备份目录(文件类自定义 Tab「备份」按钮使用)
 const backupDirInput = document.getElementById('backup-dir');       // 文本备份目录(文件类自定义 Tab「备份」按钮使用)
@@ -551,6 +558,10 @@ async function syncForm(settings) {
     githubReposText.value = (settings.githubRepos || []).map(r => `${r.url}|${r.desc}|${r.version || ''}`).join('\n');
     githubReposText.value = (settings.githubRepos || []).map(r => `${r.url}|${r.desc}|${r.version || ''}`).join('\n');
     customTabsText.value = (settings.customTabs || []).map(t => `${t.name}|${t.url}${t.desc ? '|' + t.desc : ''}`).join('\n');
     customTabsText.value = (settings.customTabs || []).map(t => `${t.name}|${t.url}${t.desc ? '|' + t.desc : ''}`).join('\n');
     apiAddrInput.value = settings.apiAddr;
     apiAddrInput.value = settings.apiAddr;
+    apiAutoStartToggle.checked = settings.apiAutoStart !== false; // 默认开启(兼容旧配置无该字段)
+    uploadPasswordInput.value = settings.uploadPassword || '';    // 上传密码
+    uploadDirInput.value = settings.uploadDir || '';              // 上传保存目录
+    uploadMaxSizeInput.value = settings.uploadMaxSize || '';      // 上传大小上限(MB)
     todoPathInput.value = settings.todoPath;
     todoPathInput.value = settings.todoPath;
     recordDBPathInput.value = settings.recordDBPath;
     recordDBPathInput.value = settings.recordDBPath;
     backupDirInput.value = settings.backupDir;
     backupDirInput.value = settings.backupDir;
@@ -747,6 +758,10 @@ function collectForm() {
         githubRepos: parsePipeLines3(fallbackArr(githubReposText, 'githubRepos'), 'url', 'desc', 'version').filter(r => r.url),
         githubRepos: parsePipeLines3(fallbackArr(githubReposText, 'githubRepos'), 'url', 'desc', 'version').filter(r => r.url),
         customTabs: parsePipeLines3(fallbackArr(customTabsText, 'customTabs'), 'name', 'url', 'desc').filter(t => t.name && t.url),
         customTabs: parsePipeLines3(fallbackArr(customTabsText, 'customTabs'), 'name', 'url', 'desc').filter(t => t.name && t.url),
         apiAddr: apiAddrInput.value.trim(),
         apiAddr: apiAddrInput.value.trim(),
+        apiAutoStart: apiAutoStartToggle.checked,
+        uploadPassword: uploadPasswordInput.value.trim(), // 上传密码(空=不启用)
+        uploadDir: uploadDirInput.value.trim(),           // 上传保存目录(空=默认目录)
+        uploadMaxSize: parseInt(uploadMaxSizeInput.value, 10) || 512, // 上传大小上限(MB,默认 512)
         todoPath: todoPathInput.value.trim(),
         todoPath: todoPathInput.value.trim(),
         recordDBPath: recordDBPathInput.value.trim(),
         recordDBPath: recordDBPathInput.value.trim(),
         backupDir: backupDirInput.value.trim(),
         backupDir: backupDirInput.value.trim(),
@@ -881,11 +896,13 @@ function setFooterText(text, opts = {}) {
     updateFooterMarquee();
     updateFooterMarquee();
 }
 }
 
 
-// 古诗朗读(TTS);再点停止
+// 古诗朗读(Edge-TTS);再点停止
+const poemAudio = new Audio(); // 隐藏 audio 播放 Edge-TTS mp3
 function togglePoemRead() {
 function togglePoemRead() {
     if (!poemReadBtn) return;
     if (!poemReadBtn) return;
     if (poemSpeaking) {
     if (poemSpeaking) {
-        speechSynthesis.cancel();
+        poemAudio.pause();
+        poemAudio.src = '';
         poemSpeaking = false;
         poemSpeaking = false;
         poemReadBtn.classList.remove('is-active');
         poemReadBtn.classList.remove('is-active');
         return;
         return;
@@ -894,20 +911,32 @@ function togglePoemRead() {
     const lines = [currentPoem.title, currentPoem.author, ...(currentPoem.content || [])]
     const lines = [currentPoem.title, currentPoem.author, ...(currentPoem.content || [])]
         .filter(s => s && s.trim())
         .filter(s => s && s.trim())
         .join(',');
         .join(',');
-    const utter = new SpeechSynthesisUtterance(lines);
-    utter.lang = 'zh-CN';
-    utter.rate = 0.9;
-    utter.onend = () => {
+    poemReadBtn.classList.add('is-active');
+    // Edge 神经网络语音(云希:沉稳男声,适合古诗吟诵)
+    window.SettingsService.TTS(lines, 'yunxi')
+        .then(src => {
+            if (!src) throw new Error('无音频');
+            poemAudio.src = src;
+            poemAudio.play().then(() => {
+                poemSpeaking = true;
+            }).catch(() => {
+                poemSpeaking = false;
+                poemReadBtn.classList.remove('is-active');
+            });
+        })
+        .catch(err => {
+            console.error('古诗 TTS 失败:', err);
+            poemSpeaking = false;
+            poemReadBtn.classList.remove('is-active');
+        });
+    poemAudio.onended = () => {
         poemSpeaking = false;
         poemSpeaking = false;
         poemReadBtn.classList.remove('is-active');
         poemReadBtn.classList.remove('is-active');
     };
     };
-    utter.onerror = () => {
+    poemAudio.onerror = () => {
         poemSpeaking = false;
         poemSpeaking = false;
         poemReadBtn.classList.remove('is-active');
         poemReadBtn.classList.remove('is-active');
     };
     };
-    speechSynthesis.speak(utter);
-    poemSpeaking = true;
-    poemReadBtn.classList.add('is-active');
 }
 }
 
 
 // 把一首古诗渲染到 footer 说明区(滚动显示,带朗读按钮)
 // 把一首古诗渲染到 footer 说明区(滚动显示,带朗读按钮)
@@ -1686,11 +1715,16 @@ const recordStatus = document.getElementById('record-status');             // 
 const recordPathDesc = document.getElementById('record-path-desc');        // 显示当前记录文件路径
 const recordPathDesc = document.getElementById('record-path-desc');        // 显示当前记录文件路径
 const recordNameFilter = document.getElementById('record-name-filter');    // 姓名筛选下拉
 const recordNameFilter = document.getElementById('record-name-filter');    // 姓名筛选下拉
 const recordItemFilter = document.getElementById('record-item-filter');    // 名称筛选下拉
 const recordItemFilter = document.getElementById('record-item-filter');    // 名称筛选下拉
+const recordDateFrom = document.getElementById('record-date-from');        // 开始日期过滤
+const recordDateTo = document.getElementById('record-date-to');            // 结束日期过滤
+const recordFilterClear = document.getElementById('record-filter-clear');  // 清除时间过滤
 const recordCount = document.getElementById('record-count');               // 匹配记录数提示
 const recordCount = document.getElementById('record-count');               // 匹配记录数提示
 const recordChart = document.getElementById('record-chart');               // 柱状图画布
 const recordChart = document.getElementById('record-chart');               // 柱状图画布
 const recordChartEmpty = document.getElementById('record-chart-empty');    // 柱状图空提示
 const recordChartEmpty = document.getElementById('record-chart-empty');    // 柱状图空提示
+const recordChartTip = document.getElementById('record-chart-tooltip');    // 鼠标悬停提示
 let recordDirty = false; // 是否有未保存修改
 let recordDirty = false; // 是否有未保存修改
 let recordData = [];     // 当前记录数组 [{name, item, value}]
 let recordData = [];     // 当前记录数组 [{name, item, value}]
+let chartHitAreas = [];  // 柱状图命中区域(x/宽/数据),用于鼠标悬停提示
 
 
 // 自动补全历史值(来自数据库,去重):姓名 / 名称
 // 自动补全历史值(来自数据库,去重):姓名 / 名称
 // 用自定义下拉替代 <datalist>:WebView2 的 datalist 在输入框为空、仅聚焦点击时不会弹出候选,
 // 用自定义下拉替代 <datalist>:WebView2 的 datalist 在输入框为空、仅聚焦点击时不会弹出候选,
@@ -1753,8 +1787,8 @@ function attachAutocomplete(input, optionsRef, onChange) {
     input._ac = dl; // 供销毁时清理
     input._ac = dl; // 供销毁时清理
 }
 }
 
 
-// 渲染一行记录:姓名输入(自动补全)+ 名称输入(自动补全)+ 值输入 + 时间 + 删除按钮
-function renderRecordRow(name, item, value, createdAt) {
+// 渲染一行记录:姓名输入(自动补全)+ 名称输入(自动补全)+ 值输入 + 备注输入 + 时间 + 删除按钮
+function renderRecordRow(name, item, value, remark, createdAt) {
     const row = document.createElement('div');
     const row = document.createElement('div');
     row.className = 'record-row';
     row.className = 'record-row';
 
 
@@ -1765,6 +1799,11 @@ function renderRecordRow(name, item, value, createdAt) {
     nameInput.placeholder = '姓名';
     nameInput.placeholder = '姓名';
     nameInput.spellcheck = false;
     nameInput.spellcheck = false;
     nameInput.setAttribute('list', 'record-names-dl'); // 保留 list 属性作为备用(自定义下拉为主)
     nameInput.setAttribute('list', 'record-names-dl'); // 保留 list 属性作为备用(自定义下拉为主)
+    // 编辑期间标记行:筛选逻辑对正在编辑的行保持可见,避免修改后行因不再匹配而消失
+    const markEditing = () => { if (!row.dataset.new) row.dataset.editing = '1'; };
+    const unmarkEditing = () => { if (row.dataset.editing) { delete row.dataset.editing; applyRecordFilter(); } };
+    nameInput.addEventListener('focus', markEditing);
+    nameInput.addEventListener('blur', unmarkEditing);
     nameInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
     nameInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
     nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
     nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
     attachAutocomplete(nameInput, () => mergeRecordCandidates(recordNameOptions, '.record-name'), () => { recordDirty = true; refreshRecordFilterOptions(); });
     attachAutocomplete(nameInput, () => mergeRecordCandidates(recordNameOptions, '.record-name'), () => { recordDirty = true; refreshRecordFilterOptions(); });
@@ -1776,6 +1815,8 @@ function renderRecordRow(name, item, value, createdAt) {
     itemInput.placeholder = '名称';
     itemInput.placeholder = '名称';
     itemInput.spellcheck = false;
     itemInput.spellcheck = false;
     itemInput.setAttribute('list', 'record-items-dl'); // 保留 list 属性作为备用(自定义下拉为主)
     itemInput.setAttribute('list', 'record-items-dl'); // 保留 list 属性作为备用(自定义下拉为主)
+    itemInput.addEventListener('focus', markEditing);
+    itemInput.addEventListener('blur', unmarkEditing);
     itemInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
     itemInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
     itemInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
     itemInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
     attachAutocomplete(itemInput, () => mergeRecordCandidates(recordItemOptions, '.record-item'), () => { recordDirty = true; refreshRecordFilterOptions(); });
     attachAutocomplete(itemInput, () => mergeRecordCandidates(recordItemOptions, '.record-item'), () => { recordDirty = true; refreshRecordFilterOptions(); });
@@ -1785,8 +1826,22 @@ function renderRecordRow(name, item, value, createdAt) {
     valueInput.className = 'record-input record-value';
     valueInput.className = 'record-input record-value';
     valueInput.value = value;
     valueInput.value = value;
     valueInput.placeholder = '值';
     valueInput.placeholder = '值';
+    valueInput.addEventListener('focus', markEditing);
+    valueInput.addEventListener('blur', unmarkEditing);
     valueInput.addEventListener('input', () => { recordDirty = true; redrawRecordChart(); });
     valueInput.addEventListener('input', () => { recordDirty = true; redrawRecordChart(); });
 
 
+    // 备注:可选说明文字,不参与统计与柱状图
+    const remarkInput = document.createElement('input');
+    remarkInput.type = 'text';
+    remarkInput.className = 'record-input record-remark';
+    remarkInput.value = remark || '';
+    remarkInput.placeholder = '备注';
+    remarkInput.spellcheck = false;
+    remarkInput.title = '备注(可选)';
+    remarkInput.addEventListener('focus', markEditing);
+    remarkInput.addEventListener('blur', unmarkEditing);
+    remarkInput.addEventListener('input', () => { recordDirty = true; });
+
     // 录入时间:新增时自动填当前时间(可编辑,便于回溯)
     // 录入时间:新增时自动填当前时间(可编辑,便于回溯)
     const timeInput = document.createElement('input');
     const timeInput = document.createElement('input');
     timeInput.type = 'text';
     timeInput.type = 'text';
@@ -1812,6 +1867,7 @@ function renderRecordRow(name, item, value, createdAt) {
     row.appendChild(nameInput);
     row.appendChild(nameInput);
     row.appendChild(itemInput);
     row.appendChild(itemInput);
     row.appendChild(valueInput);
     row.appendChild(valueInput);
+    row.appendChild(remarkInput);
     row.appendChild(timeInput);
     row.appendChild(timeInput);
     row.appendChild(del);
     row.appendChild(del);
     return row;
     return row;
@@ -1826,10 +1882,12 @@ function formatNow() {
 
 
 // 添加一行空的记录(自动记录当前时间,聚焦姓名输入框)
 // 添加一行空的记录(自动记录当前时间,聚焦姓名输入框)
 function addRecordRow() {
 function addRecordRow() {
-    const row = renderRecordRow('', '', '', '');
+    const row = renderRecordRow('', '', '', '', '');
+    row.dataset.new = '1'; // 标记为新增行:未选姓名+名称时也保持可见,不被筛选隐藏
     recordList.appendChild(row);
     recordList.appendChild(row);
     row.querySelector('.record-name').focus();
     row.querySelector('.record-name').focus();
     recordDirty = true;
     recordDirty = true;
+    applyRecordFilter();
 }
 }
 
 
 // 收集所有记录行的数据(过滤全空行)
 // 收集所有记录行的数据(过滤全空行)
@@ -1839,8 +1897,9 @@ function collectRecords() {
         const name = row.querySelector('.record-name').value.trim();
         const name = row.querySelector('.record-name').value.trim();
         const item = row.querySelector('.record-item').value.trim();
         const item = row.querySelector('.record-item').value.trim();
         const value = row.querySelector('.record-value').value.trim();
         const value = row.querySelector('.record-value').value.trim();
+        const remark = row.querySelector('.record-remark').value.trim();
         if (!name && !item && !value) return; // 全空行跳过
         if (!name && !item && !value) return; // 全空行跳过
-        rows.push({ name, item, value: parseFloat(value) || 0, createdAt: row.querySelector('.record-time').value.trim() });
+        rows.push({ name, item, value: parseFloat(value) || 0, remark, createdAt: row.querySelector('.record-time').value.trim() });
     });
     });
     return rows;
     return rows;
 }
 }
@@ -1848,12 +1907,15 @@ function collectRecords() {
 // 刷新姓名/名称下拉的候选(来自当前所有记录行,去重)
 // 刷新姓名/名称下拉的候选(来自当前所有记录行,去重)
 function refreshRecordFilterOptions() {
 function refreshRecordFilterOptions() {
     const names = new Set();
     const names = new Set();
-    const items = new Set();
+    const nameItems = new Map(); // name -> Set(item):供「选择姓名后名称只显示该姓名的名称」过滤
     recordList.querySelectorAll('.record-row').forEach(row => {
     recordList.querySelectorAll('.record-row').forEach(row => {
         const n = row.querySelector('.record-name').value.trim();
         const n = row.querySelector('.record-name').value.trim();
         const it = row.querySelector('.record-item').value.trim();
         const it = row.querySelector('.record-item').value.trim();
         if (n) names.add(n);
         if (n) names.add(n);
-        if (it) items.add(it);
+        if (n && it) {
+            if (!nameItems.has(n)) nameItems.set(n, new Set());
+            nameItems.get(n).add(it);
+        }
     });
     });
     // 保留当前选中值
     // 保留当前选中值
     const curName = recordNameFilter.value;
     const curName = recordNameFilter.value;
@@ -1865,14 +1927,23 @@ function refreshRecordFilterOptions() {
         recordNameFilter.appendChild(o);
         recordNameFilter.appendChild(o);
     });
     });
     if (curName && names.has(curName)) recordNameFilter.value = curName;
     if (curName && names.has(curName)) recordNameFilter.value = curName;
+    // 名称下拉:仅显示「当前选中姓名」下存在的名称;未选姓名时显示全部
+    const selName = recordNameFilter.value;
+    const itemSet = selName ? (nameItems.get(selName) || new Set()) : new Set([...nameItems.values()].flatMap(s => [...s]));
     recordItemFilter.innerHTML = '<option value="">全部</option>';
     recordItemFilter.innerHTML = '<option value="">全部</option>';
-    [...items].sort().forEach(it => {
+    [...itemSet].sort().forEach(it => {
         const o = document.createElement('option');
         const o = document.createElement('option');
         o.value = it; o.textContent = it;
         o.value = it; o.textContent = it;
         recordItemFilter.appendChild(o);
         recordItemFilter.appendChild(o);
     });
     });
-    if (curItem && items.has(curItem)) recordItemFilter.value = curItem;
-    redrawRecordChart();
+    // 若当前名称不属于所选姓名(或未选姓名时已不在候选集),清空名称选择
+    if (curItem && itemSet.has(curItem)) {
+        recordItemFilter.value = curItem;
+    } else {
+        recordItemFilter.value = '';
+    }
+    // 行内容/选项变化后重新应用筛选显示状态(内部含柱状图重绘)
+    applyRecordFilter();
 }
 }
 
 
 // 刷新姓名/名称自动补全候选:从数据库拉取历史值(去重),供自定义下拉使用
 // 刷新姓名/名称自动补全候选:从数据库拉取历史值(去重),供自定义下拉使用
@@ -1922,12 +1993,13 @@ async function loadRecords() {
         recordStatus.textContent = '';
         recordStatus.textContent = '';
         refreshRecordFilterOptions();
         refreshRecordFilterOptions();
         loadRecordAutocomplete();
         loadRecordAutocomplete();
+        applyRecordFilter(); // 初始隐藏数据列表(未选组合时)
         return;
         return;
     }
     }
     try {
     try {
         const items = await SettingsService.ReadRecords();
         const items = await SettingsService.ReadRecords();
         if (items.length) {
         if (items.length) {
-            items.forEach(it => recordList.appendChild(renderRecordRow(it.name, it.item, String(it.value), it.createdAt)));
+            items.forEach(it => recordList.appendChild(renderRecordRow(it.name, it.item, String(it.value), it.remark || '', it.createdAt)));
         } else {
         } else {
             addRecordRow();
             addRecordRow();
         }
         }
@@ -1935,6 +2007,7 @@ async function loadRecords() {
         recordStatus.textContent = '';
         recordStatus.textContent = '';
         refreshRecordFilterOptions();
         refreshRecordFilterOptions();
         loadRecordAutocomplete();
         loadRecordAutocomplete();
+        applyRecordFilter(); // 初始隐藏数据列表(未选组合时)
     } catch (err) {
     } catch (err) {
         recordStatus.textContent = '读取失败:' + (err?.message || err);
         recordStatus.textContent = '读取失败:' + (err?.message || err);
     }
     }
@@ -1947,14 +2020,20 @@ async function saveRecords() {
         const name = row.querySelector('.record-name').value.trim();
         const name = row.querySelector('.record-name').value.trim();
         const item = row.querySelector('.record-item').value.trim();
         const item = row.querySelector('.record-item').value.trim();
         const value = row.querySelector('.record-value').value.trim();
         const value = row.querySelector('.record-value').value.trim();
+        const remark = row.querySelector('.record-remark').value.trim();
         if (!name && !item && !value) return; // 空行不保存
         if (!name && !item && !value) return; // 空行不保存
-        items.push({ name, item, value: parseFloat(value) || 0, createdAt: row.querySelector('.record-time').value.trim() || formatNow() });
+        items.push({ name, item, value: parseFloat(value) || 0, remark, createdAt: row.querySelector('.record-time').value.trim() || formatNow() });
     });
     });
     recordStatus.textContent = '保存中…';
     recordStatus.textContent = '保存中…';
     try {
     try {
         await SettingsService.WriteRecords(items);
         await SettingsService.WriteRecords(items);
         recordStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
         recordStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
         recordDirty = false;
         recordDirty = false;
+        // 保存后所有行转为历史行:移除新增/编辑标记,按当前筛选重新显示
+        recordList.querySelectorAll('.record-row').forEach(row => {
+            delete row.dataset.new;
+            delete row.dataset.editing;
+        });
         refreshRecordFilterOptions();
         refreshRecordFilterOptions();
         loadRecordAutocomplete(); // 保存后刷新自动补全候选(新姓名/名称加入历史)
         loadRecordAutocomplete(); // 保存后刷新自动补全候选(新姓名/名称加入历史)
     } catch (err) {
     } catch (err) {
@@ -1962,16 +2041,79 @@ async function saveRecords() {
     }
     }
 }
 }
 
 
-// 计算柱状图数据:必须同时选择姓名与名称,才按该组合过滤并绘图。
-// 不同姓名、不同名称绝不混在同一张图上——只有同时选定姓名和名称,
-// 才显示该组合下的全部记录(每行一根柱,展示其随时间的值变化)。
+// 判断记录时间(createdAt)是否在选定的日期区间内(含边界,按日期部分比较)
+function recordInDateRange(createdAt) {
+    const from = recordDateFrom.value;
+    const to = recordDateTo.value;
+    if (!from && !to) return true; // 未选时间:不过滤
+    const t = String(createdAt || '').trim();
+    if (!t) return true; // 无时间的记录不因时间过滤隐藏(避免误删)
+    const d = t.slice(0, 10); // 取 YYYY-MM-DD
+    if (from && d < from) return false;
+    if (to && d > to) return false;
+    return true;
+}
+
+// 计算当前筛选后的统计:{count, sum, avg}
+function recordFilterStats() {
+    let count = 0, sum = 0;
+    recordList.querySelectorAll('.record-row').forEach(row => {
+        if (row.style.display === 'none') return;
+        count++;
+        sum += parseFloat(row.querySelector('.record-value').value) || 0;
+    });
+    const avg = count ? sum / count : 0;
+    return { count, sum, avg };
+}
+
+// 应用筛选:选择姓名+名称后,才显示该组合的数据(上图下数据);否则隐藏数据列表
+function applyRecordFilter() {
+    const selName = recordNameFilter.value;
+    const selItem = recordItemFilter.value;
+    const active = selName; // 选定姓名即可显示该姓名的历史数据(名称可选)
+    recordList.querySelectorAll('.record-row').forEach(row => {
+        // 新增行(尚未保存)始终可见:添加记录/输入姓名时不被筛选隐藏
+        // 正在编辑的历史行也保持可见:修改姓名/名称后行不会因不再匹配当前筛选而消失
+        if (row.dataset.new || row.dataset.editing) {
+            row.style.display = '';
+            return;
+        }
+        const name = row.querySelector('.record-name').value.trim();
+        const item = row.querySelector('.record-item').value.trim();
+        const createdAt = row.querySelector('.record-time').value.trim();
+        // 历史数据行:选定姓名后显示其记录;若再选名称则进一步限定该名称
+        let show = active && name === selName && (!selItem || item === selItem);
+        if (show && !recordInDateRange(createdAt)) show = false;
+        row.style.display = show ? '' : 'none';
+    });
+    // 柱状图(含时间过滤)+ 统计一起刷新
+    redrawRecordChart();
+}
+
+// 更新统计提示:XX条记录,和xxx,平均xxxx
+function updateRecordStats() {
+    const { count, sum, avg } = recordFilterStats();
+    const fmt = (n) => {
+        if (!isFinite(n)) return '0';
+        return Number(n).toLocaleString('zh-CN', { maximumFractionDigits: 2 });
+    };
+    recordCount.textContent = count
+        ? `${count} 条记录,和 ${fmt(sum)},平均 ${fmt(avg)}`
+        : '0 条记录';
+}
+
+// 计算柱状图数据:选定姓名即可出图(名称可选)。
+// 无论是否选定名称,每条记录都对应一根柱(与列表行一一对应),
+// 保证「列表有几行,图就有几根柱」,不按名称合并。
 function recordChartData() {
 function recordChartData() {
     const selName = recordNameFilter.value;
     const selName = recordNameFilter.value;
     const selItem = recordItemFilter.value;
     const selItem = recordItemFilter.value;
-    if (!selName || !selItem) return []; // 未同时选定姓名与名称:不出图
-    return collectRecords()
-        .filter(r => r.name === selName && r.item === selItem)
-        .map(r => ({ label: `${r.name}/${r.item}`, value: r.value }));
+    if (!selName) return []; // 未选定姓名:不出图
+    let rows = collectRecords().filter(r => r.name === selName && recordInDateRange(r.createdAt));
+    if (selItem) {
+        rows = rows.filter(r => r.item === selItem);
+    }
+    return rows.map(r => ({ label: r.item || '(未命名)', value: r.value, createdAt: r.createdAt }));
 }
 }
 
 
 // 绘制柱状图(canvas),无数据时显示空提示
 // 绘制柱状图(canvas),无数据时显示空提示
@@ -1981,12 +2123,14 @@ function redrawRecordChart() {
     if (!data.length) {
     if (!data.length) {
         recordChartEmpty.style.display = 'flex';   // 空提示居中(CSS 默认为 flex)
         recordChartEmpty.style.display = 'flex';   // 空提示居中(CSS 默认为 flex)
         recordChart.style.display = 'none';
         recordChart.style.display = 'none';
-        recordCount.textContent = '0 条记录';
+        chartHitAreas = [];                        // 无数据:清空命中区并隐藏提示
+        if (recordChartTip) recordChartTip.hidden = true;
+        updateRecordStats();
         return;
         return;
     }
     }
     recordChartEmpty.style.display = 'none';
     recordChartEmpty.style.display = 'none';
     recordChart.style.display = 'block';           // canvas 用块级显示,否则 CSS 的 display:none 使其永不出现
     recordChart.style.display = 'block';           // canvas 用块级显示,否则 CSS 的 display:none 使其永不出现
-    recordCount.textContent = `${data.length} 条记录`;
+    updateRecordStats();
 
 
     const dpr = window.devicePixelRatio || 1;
     const dpr = window.devicePixelRatio || 1;
     const rect = recordChart.getBoundingClientRect();
     const rect = recordChart.getBoundingClientRect();
@@ -1997,6 +2141,10 @@ function redrawRecordChart() {
     const ctx = recordChart.getContext('2d');
     const ctx = recordChart.getContext('2d');
     ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
     ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
     ctx.clearRect(0, 0, w, h);
     ctx.clearRect(0, 0, w, h);
+    // 文字颜色跟随主题:取当前 data-theme 下的 --ink / --muted,保证亮色主题下可读
+    const isLight = document.documentElement.dataset.theme === 'light';
+    const cs = getComputedStyle(document.documentElement);
+    const mutedColor = cs.getPropertyValue('--muted').trim(); // 轴标签/刻度文字色
 
 
     const padL = 44, padR = 12, padT = 14, padB = 30;
     const padL = 44, padR = 12, padT = 14, padB = 30;
     const plotW = w - padL - padR, plotH = h - padT - padB;
     const plotW = w - padL - padR, plotH = h - padT - padB;
@@ -2006,7 +2154,7 @@ function redrawRecordChart() {
 
 
     // 背景网格线 + Y 轴刻度
     // 背景网格线 + Y 轴刻度
     ctx.strokeStyle = 'rgba(127,127,127,0.18)';
     ctx.strokeStyle = 'rgba(127,127,127,0.18)';
-    ctx.fillStyle = 'rgba(127,127,127,0.7)';
+    ctx.fillStyle = mutedColor || (isLight ? '#5d6678' : '#8b93a7');
     ctx.lineWidth = 1;
     ctx.lineWidth = 1;
     ctx.font = '10px sans-serif';
     ctx.font = '10px sans-serif';
     ctx.textAlign = 'right';
     ctx.textAlign = 'right';
@@ -2023,25 +2171,34 @@ function redrawRecordChart() {
     // 柱
     // 柱
     const barColor = 'rgba(255,90,90,0.85)';
     const barColor = 'rgba(255,90,90,0.85)';
     ctx.textAlign = 'center';
     ctx.textAlign = 'center';
+    chartHitAreas = []; // 重建命中区域
     data.forEach((d, i) => {
     data.forEach((d, i) => {
         const cx = padL + gap * i + gap / 2;
         const cx = padL + gap * i + gap / 2;
         const bh = Math.abs(d.value) / maxVal * plotH;
         const bh = Math.abs(d.value) / maxVal * plotH;
         const y0 = padT + plotH - bh;
         const y0 = padT + plotH - bh;
         ctx.fillStyle = barColor;
         ctx.fillStyle = barColor;
         ctx.fillRect(cx - barW / 2, y0, barW, bh);
         ctx.fillRect(cx - barW / 2, y0, barW, bh);
-        // 值标注
-        ctx.fillStyle = 'rgba(255,255,255,0.9)';
-        ctx.fillText(d.value.toFixed(1), cx, y0 - 4);
-        // x 轴标签:姓名/名称(长标签截断,避免重叠)
+        // 命中区域:以柱为中心、略加宽,便于鼠标悬停
+        chartHitAreas.push({ x0: cx - barW / 2 - 4, x1: cx + barW / 2 + 4, y0, y1: padT + plotH, d });
+        // x 轴标签:名称(长标签截断,避免重叠)。数值与名称只在鼠标悬停提示中显示
         const lbl = d.label || '·';
         const lbl = d.label || '·';
+        ctx.fillStyle = mutedColor || (isLight ? '#5d6678' : '#8b93a7');
         ctx.fillText(lbl.length > 10 ? lbl.slice(0, 10) + '…' : lbl, cx, h - 10);
         ctx.fillText(lbl.length > 10 ? lbl.slice(0, 10) + '…' : lbl, cx, h - 10);
     });
     });
 }
 }
 
 
 recordSaveBtn.addEventListener('click', saveRecords);
 recordSaveBtn.addEventListener('click', saveRecords);
 recordAddBtn.addEventListener('click', addRecordRow);
 recordAddBtn.addEventListener('click', addRecordRow);
-recordNameFilter.addEventListener('change', redrawRecordChart);
-recordItemFilter.addEventListener('change', redrawRecordChart);
+// 姓名筛选变化:联动刷新名称下拉(只显示该姓名下的名称),并应用筛选
+recordNameFilter.addEventListener('change', () => { refreshRecordFilterOptions(); });
+recordItemFilter.addEventListener('change', applyRecordFilter);
+recordDateFrom.addEventListener('change', applyRecordFilter);
+recordDateTo.addEventListener('change', applyRecordFilter);
+recordFilterClear.addEventListener('click', () => {
+    recordDateFrom.value = '';
+    recordDateTo.value = '';
+    applyRecordFilter();
+});
 
 
 // 记录页 DOM 变化时自动刷新下拉与图表(例如删除行)
 // 记录页 DOM 变化时自动刷新下拉与图表(例如删除行)
 const recordPanel = document.getElementById('tab-record');
 const recordPanel = document.getElementById('tab-record');
@@ -2053,6 +2210,57 @@ if (recordPanel) {
 // 窗口尺寸变化时重绘柱状图
 // 窗口尺寸变化时重绘柱状图
 window.addEventListener('resize', () => { redrawRecordChart(); });
 window.addEventListener('resize', () => { redrawRecordChart(); });
 
 
+// ------------------------------------------------------------------------
+// 柱状图鼠标悬停提示(tooltip):根据命中区域找到对应柱,显示该柱完整信息
+// ------------------------------------------------------------------------
+function showRecordTip(hit, e) {
+    if (!hit || !recordChartTip) return;
+    const d = hit.d;
+    const name = recordNameFilter.value || '';
+    const timeStr = d.createdAt || '';
+    // 提示内容:名称、值、姓名、时间
+    recordChartTip.innerHTML = '';
+    const lines = [
+        `名称:${d.label || '(未命名)'}`,
+        `值:${d.value.toFixed(1)}`,
+    ];
+    if (name) lines.push(`姓名:${name}`);
+    if (timeStr) lines.push(`时间:${timeStr}`);
+    lines.forEach((t, i) => {
+        const div = document.createElement('div');
+        div.className = i === 0 ? 'tip-name' : '';
+        div.textContent = t;
+        recordChartTip.appendChild(div);
+    });
+    recordChartTip.hidden = false;
+    // 定位:跟随鼠标,超出画布右/下边缘时翻转到另一侧
+    const wrapRect = recordChart.getBoundingClientRect();
+    const tipRect = recordChartTip.getBoundingClientRect();
+    let left = e.clientX - wrapRect.left + 14;
+    if (left + tipRect.width > wrapRect.width - 4) left = e.clientX - wrapRect.left - tipRect.width - 14;
+    let top = e.clientY - wrapRect.top - tipRect.height - 10;
+    if (top < 2) top = e.clientY - wrapRect.top + 16;
+    recordChartTip.style.left = Math.max(2, left) + 'px';
+    recordChartTip.style.top = Math.max(2, top) + 'px';
+}
+
+function hideRecordTip() {
+    if (recordChartTip) recordChartTip.hidden = true;
+}
+
+if (recordChart) {
+    recordChart.addEventListener('mousemove', (e) => {
+        const rect = recordChart.getBoundingClientRect();
+        const x = e.clientX - rect.left;
+        const y = e.clientY - rect.top;
+        const hit = chartHitAreas.find(h => x >= h.x0 && x <= h.x1 && y >= h.y0 && y <= h.y1);
+        recordChart.style.cursor = hit ? 'pointer' : 'default';
+        if (hit) showRecordTip(hit, e);
+        else hideRecordTip();
+    });
+    recordChart.addEventListener('mouseleave', hideRecordTip);
+}
+
 // ------------------------------------------------------------------------
 // ------------------------------------------------------------------------
 // 主机:API 服务状态、电源命令状态与 Gin 日志
 // 主机:API 服务状态、电源命令状态与 Gin 日志
 // ------------------------------------------------------------------------
 // ------------------------------------------------------------------------
@@ -2180,12 +2388,42 @@ async function loadHostPanel() {
         const logs = await SettingsService.GetAPILogs();
         const logs = await SettingsService.GetAPILogs();
         logView.textContent = logs && logs.length ? logs.join('\n') : '暂无日志';
         logView.textContent = logs && logs.length ? logs.join('\n') : '暂无日志';
         logView.scrollTop = logView.scrollHeight; // 滚动到最新一行
         logView.scrollTop = logView.scrollHeight; // 滚动到最新一行
+
+        await renderClientUploadQR(status); // 刷新客户端上传页二维码
     } catch (err) {
     } catch (err) {
         apiStatusEl.textContent = '状态获取失败(浏览器预览模式无后端)';
         apiStatusEl.textContent = '状态获取失败(浏览器预览模式无后端)';
         console.warn('主机面板加载失败:', err);
         console.warn('主机面板加载失败:', err);
     }
     }
 }
 }
 
 
+// 渲染「客户端上传页」二维码:扫码打开 http://<addr>/upload,供手机等设备上传文件
+const clientUploadQrBox = document.getElementById('client-upload-qr');
+const clientUploadUrlEl = document.getElementById('client-upload-url');
+async function renderClientUploadQR(status) {
+    if (!clientUploadQrBox) return;
+    clientUploadQrBox.innerHTML = '';
+    if (clientUploadUrlEl) clientUploadUrlEl.textContent = '';
+    if (!status || !status.running) return; // 未运行则不显示二维码
+
+    // 本机展示用 127.0.0.1;通配地址 0.0.0.0/[::] 无法在浏览器中访问
+    let addr = String(status.addr || '').trim();
+    if (addr.startsWith('0.0.0.0') || addr.startsWith('[::]')) {
+        addr = addr.replace(/^0\.0\.0\.0/, '127.0.0.1').replace(/^\[::\]/, '127.0.0.1');
+    }
+    const pageUrl = `http://${addr}/upload`;
+    try {
+        const qrUri = await SettingsService.GenerateQRCode(pageUrl, 0);
+        const img = document.createElement('img');
+        img.src = qrUri;
+        img.alt = '客户端上传页二维码';
+        img.title = '扫码打开客户端上传页';
+        clientUploadQrBox.appendChild(img);
+    } catch (err) {
+        console.warn('客户端上传页二维码生成失败:', err);
+    }
+    if (clientUploadUrlEl) clientUploadUrlEl.textContent = pageUrl;
+}
+
 // 乒乓切换 API 服务:运行中→停止,未运行→启动
 // 乒乓切换 API 服务:运行中→停止,未运行→启动
 async function toggleAPIService() {
 async function toggleAPIService() {
     apiToggleBtn.disabled = true;
     apiToggleBtn.disabled = true;
@@ -2207,6 +2445,108 @@ async function toggleAPIService() {
 
 
 apiToggleBtn.addEventListener('click', toggleAPIService);
 apiToggleBtn.addEventListener('click', toggleAPIService);
 
 
+// ------------------------------------------------------------------------
+// 主机:文件上传——本机选择文件直接上传(无需密码,走 Wails 绑定,不暴露给网络)
+// ------------------------------------------------------------------------
+const uploadBtn = document.getElementById('upload-btn');            // 上传按钮
+const uploadProgressBox = document.getElementById('upload-progress'); // 进度条容器
+const uploadBar = document.querySelector('.upload-bar');            // 进度条填充
+const uploadResultBox = document.getElementById('upload-result');   // 结果容器
+const uploadQrBox = document.getElementById('upload-qr');           // 二维码容器
+const uploadNameEl = document.getElementById('upload-name');        // 文件名(选择提示)
+const uploadName2El = document.getElementById('upload-name2');      // 文件名(结果)
+const uploadLinkEl = document.getElementById('upload-link');        // 下载链接
+
+// 获取当前 API 服务地址(http://host:port),用于拼接上传/下载 URL。
+// 本机访问时把通配地址 0.0.0.0 / [::] 换成 127.0.0.1,否则浏览器无法连接。
+async function apiBaseURL() {
+    const status = await SettingsService.GetAPIStatus();
+    if (!status.running) throw new Error('API 服务未运行,请先启动服务');
+    let addr = String(status.addr || '').trim();
+    if (addr.startsWith('0.0.0.0') || addr.startsWith('[::]')) {
+        addr = addr.replace(/^0\.0\.0\.0/, '127.0.0.1').replace(/^\[::\]/, '127.0.0.1');
+    }
+    return 'http://' + addr;
+}
+
+// 上传文件:本机选择文件,走 Wails 绑定 UploadFile(无需密码),成功后显示下载二维码。
+// 下载地址需要密码(供他人扫码下载),这里把设置的上传密码拼进 URL。
+async function doUploadFile() {
+    uploadBtn.disabled = true;
+    uploadBtn.classList.add('loading');
+    uploadProgressBox.hidden = false;
+    uploadBar.style.width = '8%';
+
+    try {
+        // 1) 弹出文件选择对话框
+        const path = await SettingsService.PickUploadFile();
+        if (!path) {
+            // 用户取消
+            uploadNameEl.textContent = '';
+            return;
+        }
+        uploadNameEl.textContent = path.split(/[\\/]/).pop() || path;
+        uploadBar.style.width = '30%';
+
+        // 2) 直接上传(本机,无密码)
+        const result = await SettingsService.UploadFile(path);
+        uploadBar.style.width = '70%';
+
+        // 3) 拼完整下载地址(带上传密码,供他人扫码下载)
+        let fullUrl = '';
+        try {
+            const base = await apiBaseURL();
+            const settings = await SettingsService.GetSettings();
+            const pwd = (settings && settings.uploadPassword) || '';
+            fullUrl = `${base}${result.downloadUrl}${pwd ? '?password=' + encodeURIComponent(pwd) : ''}`;
+        } catch (e) {
+            // API 服务未运行:仍显示相对下载路径
+            fullUrl = result.downloadUrl || '';
+        }
+
+        uploadName2El.textContent = `${result.filename}(${formatBytes(result.size)})`;
+        uploadLinkEl.href = fullUrl;
+        uploadLinkEl.textContent = fullUrl ? '下载地址' : '下载地址(需启动 API 服务)';
+        uploadBar.style.width = '88%';
+
+        // 4) 生成二维码
+        if (fullUrl) {
+            const qrUri = await SettingsService.GenerateQRCode(fullUrl, 0);
+            uploadQrBox.innerHTML = '';
+            const img = document.createElement('img');
+            img.src = qrUri;
+            img.alt = '下载二维码';
+            uploadQrBox.appendChild(img);
+        } else {
+            uploadQrBox.innerHTML = '';
+        }
+        uploadBar.style.width = '100%';
+        uploadResultBox.hidden = false;
+    } catch (err) {
+        console.warn('上传失败:', err);
+        uploadResultBox.hidden = false;
+        uploadQrBox.innerHTML = '';
+        uploadName2El.textContent = '上传失败:' + (err?.message || String(err));
+        uploadLinkEl.textContent = '';
+        uploadLinkEl.removeAttribute('href');
+    } finally {
+        uploadBtn.disabled = false;
+        uploadBtn.classList.remove('loading');
+        setTimeout(() => { uploadProgressBox.hidden = true; }, 600);
+    }
+}
+
+// 字节数格式化
+function formatBytes(n) {
+    if (n == null || n <= 0) return '0 B';
+    const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+    let i = 0, v = n;
+    while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
+    return v.toFixed(v >= 10 || i === 0 ? 0 : 1) + ' ' + units[i];
+}
+
+if (uploadBtn) uploadBtn.addEventListener('click', doUploadFile);
+
 // 后端实时推送的日志行:追加显示,保持滚动到底部
 // 后端实时推送的日志行:追加显示,保持滚动到底部
 Events.On('apilog', (event) => {
 Events.On('apilog', (event) => {
     if (logView.textContent === '暂无日志') logView.textContent = '';
     if (logView.textContent === '暂无日志') logView.textContent = '';

+ 14 - 7
recordservice.go

@@ -19,11 +19,12 @@ import (
 	_ "modernc.org/sqlite" // 纯 Go SQLite 驱动(无需 CGO)
 	_ "modernc.org/sqlite" // 纯 Go SQLite 驱动(无需 CGO)
 )
 )
 
 
-// RecordItem 一条记录:姓名 + 名称 + 值 + 录入时间,供记录 Tab 列表与柱状图使用
+// RecordItem 一条记录:姓名 + 名称 + 值 + 备注 + 录入时间,供记录 Tab 列表与柱状图使用
 type RecordItem struct {
 type RecordItem struct {
 	Name      string  `json:"name"`      // 姓名
 	Name      string  `json:"name"`      // 姓名
 	Item      string  `json:"item"`      // 名称
 	Item      string  `json:"item"`      // 名称
 	Value     float64 `json:"value"`     // 值
 	Value     float64 `json:"value"`     // 值
+	Remark    string  `json:"remark"`    // 备注(可选)
 	CreatedAt string  `json:"createdAt"` // 录入时间 YYYY-MM-DD HH:MM:SS(前端新增时生成;为空时后端用当前时间)
 	CreatedAt string  `json:"createdAt"` // 录入时间 YYYY-MM-DD HH:MM:SS(前端新增时生成;为空时后端用当前时间)
 }
 }
 
 
@@ -37,8 +38,8 @@ func openRecordDB(dbPath string) (*sql.DB, error) {
 	if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
 	if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-	// DSN 加 _pragma 参数:启用 WAL 提升并发读;foreign_keys 开启
-	dsn := "file:" + filepath.ToSlash(dbPath) + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)"
+	// DSN 加 _pragma 参数:启用 WAL 提升并发读;foreign_keys 开启;busy_timeout 让写锁等待而非立即报「database is locked」
+	dsn := "file:" + filepath.ToSlash(dbPath) + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)"
 	db, err := sql.Open("sqlite", dsn)
 	db, err := sql.Open("sqlite", dsn)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -48,11 +49,17 @@ func openRecordDB(dbPath string) (*sql.DB, error) {
 		name       TEXT NOT NULL DEFAULT '',
 		name       TEXT NOT NULL DEFAULT '',
 		item       TEXT NOT NULL DEFAULT '',
 		item       TEXT NOT NULL DEFAULT '',
 		value      REAL NOT NULL DEFAULT 0,
 		value      REAL NOT NULL DEFAULT 0,
+		remark     TEXT NOT NULL DEFAULT '',
 		created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
 		created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
 	)`); err != nil {
 	)`); err != nil {
 		db.Close()
 		db.Close()
 		return nil, err
 		return nil, err
 	}
 	}
+	// 兼容旧库:若 records 表已存在但缺 remark 列(早期版本无备注),执行迁移补列
+	if _, err := db.Exec(`ALTER TABLE records ADD COLUMN remark TEXT NOT NULL DEFAULT ''`); err != nil {
+		// 列已存在(重复添加)会报错,忽略;其他错误也仅忽略——迁移为尽力而为
+		_ = err
+	}
 	return db, nil
 	return db, nil
 }
 }
 
 
@@ -64,7 +71,7 @@ func (s *SettingsService) ReadRecords() ([]RecordItem, error) {
 		return nil, err
 		return nil, err
 	}
 	}
 	defer db.Close()
 	defer db.Close()
-	rows, err := db.Query(`SELECT name, item, value, created_at FROM records ORDER BY id ASC`)
+	rows, err := db.Query(`SELECT name, item, value, remark, created_at FROM records ORDER BY id ASC`)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -72,7 +79,7 @@ func (s *SettingsService) ReadRecords() ([]RecordItem, error) {
 	items := make([]RecordItem, 0, 64)
 	items := make([]RecordItem, 0, 64)
 	for rows.Next() {
 	for rows.Next() {
 		var it RecordItem
 		var it RecordItem
-		if err := rows.Scan(&it.Name, &it.Item, &it.Value, &it.CreatedAt); err != nil {
+		if err := rows.Scan(&it.Name, &it.Item, &it.Value, &it.Remark, &it.CreatedAt); err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
 		items = append(items, it)
 		items = append(items, it)
@@ -96,7 +103,7 @@ func (s *SettingsService) WriteRecords(items []RecordItem) error {
 	if _, err := tx.Exec(`DELETE FROM records`); err != nil {
 	if _, err := tx.Exec(`DELETE FROM records`); err != nil {
 		return err
 		return err
 	}
 	}
-	stmt, err := tx.Prepare(`INSERT INTO records (name, item, value, created_at) VALUES (?, ?, ?, ?)`)
+	stmt, err := tx.Prepare(`INSERT INTO records (name, item, value, remark, created_at) VALUES (?, ?, ?, ?, ?)`)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -107,7 +114,7 @@ func (s *SettingsService) WriteRecords(items []RecordItem) error {
 		if ts == "" {
 		if ts == "" {
 			ts = now // 前端未传时间时用当前时间(数据库默认值仅在列缺省时生效,显式插入需自行填充)
 			ts = now // 前端未传时间时用当前时间(数据库默认值仅在列缺省时生效,显式插入需自行填充)
 		}
 		}
-		if _, err := stmt.Exec(strings.TrimSpace(it.Name), strings.TrimSpace(it.Item), it.Value, ts); err != nil {
+		if _, err := stmt.Exec(strings.TrimSpace(it.Name), strings.TrimSpace(it.Item), it.Value, strings.TrimSpace(it.Remark), ts); err != nil {
 			return err
 			return err
 		}
 		}
 	}
 	}

+ 20 - 5
settings.go

@@ -96,7 +96,11 @@ type AppSettings struct {
 	BiliCookie   string       `json:"biliCookie"`   // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
 	BiliCookie   string       `json:"biliCookie"`   // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
 	GithubRepos  []RepoItem   `json:"githubRepos"`  // 关注的 GitHub 仓库列表
 	GithubRepos  []RepoItem   `json:"githubRepos"`  // 关注的 GitHub 仓库列表
 	CustomTabs   []CustomTab  `json:"customTabs"`   // 用户自定义 Tab 页面(名字 + 网址)
 	CustomTabs   []CustomTab  `json:"customTabs"`   // 用户自定义 Tab 页面(名字 + 网址)
-	APIAddr      string       `json:"apiAddr"`      // 内置 API 服务监听地址
+	APIAddr        string `json:"apiAddr"`        // 内置 API 服务监听地址
+	APIAutoStart   bool   `json:"apiAutoStart"`   // 应用启动时自动启动内置 API 服务(默认开启)
+	UploadPassword string `json:"uploadPassword"` // 文件上传密码(空=不启用上传/下载接口)
+	UploadDir      string `json:"uploadDir"`      // 文件上传保存目录(空=用户配置目录/uploads)
+	UploadMaxSize  int    `json:"uploadMaxSize"`  // 单个上传文件大小上限(MB),<=0 用默认 512
 	TodoPath     string       `json:"todoPath"`     // 待办(TODO)文件路径
 	TodoPath     string       `json:"todoPath"`     // 待办(TODO)文件路径
 	RecordPath   string       `json:"recordPath"`   // 记录(Record)文件路径:每行「姓名|名称|值」(保留兼容)
 	RecordPath   string       `json:"recordPath"`   // 记录(Record)文件路径:每行「姓名|名称|值」(保留兼容)
 	RecordDBPath string       `json:"recordDBPath"` // 记录数据库路径:SQLite3 数据库文件(记录 Tab 实际存储)
 	RecordDBPath string       `json:"recordDBPath"` // 记录数据库路径:SQLite3 数据库文件(记录 Tab 实际存储)
@@ -140,8 +144,12 @@ func defaultSettings() AppSettings {
 		GithubRepos: []RepoItem{
 		GithubRepos: []RepoItem{
 			{URL: "https://github.com/wailsapp/wails", Desc: "Wails:Go + Web 桌面应用框架"},
 			{URL: "https://github.com/wailsapp/wails", Desc: "Wails:Go + Web 桌面应用框架"},
 		},
 		},
-		APIAddr:     DefaultAPIAddr,
-		TodoPath:    "", // 待办路径:无默认值,用户须在设置中配置(空值时强制进入设置)
+		APIAddr:        DefaultAPIAddr,
+		APIAutoStart:   true, // 内置 API 服务随应用启动(默认开启)
+		UploadPassword: "",  // 上传密码:默认空(不启用上传/下载)
+		UploadDir:      "",  // 上传保存目录:默认空(回退到用户配置目录/uploads)
+		UploadMaxSize:  512, // 上传大小上限:默认 512MB
+		TodoPath:       "",  // 待办路径:无默认值,用户须在设置中配置(空值时强制进入设置)
 		RecordPath:  "", // 记录路径:无默认值,用户须在设置中配置(空值时强制进入设置)
 		RecordPath:  "", // 记录路径:无默认值,用户须在设置中配置(空值时强制进入设置)
 		RecordDBPath: "", // 记录数据库路径:无默认值,用户须在设置中配置(空值时强制进入设置)
 		RecordDBPath: "", // 记录数据库路径:无默认值,用户须在设置中配置(空值时强制进入设置)
 		BackupDir:   "", // 备份目录:无默认值,用户须在设置中配置(空值时强制进入设置)
 		BackupDir:   "", // 备份目录:无默认值,用户须在设置中配置(空值时强制进入设置)
@@ -257,6 +265,8 @@ func normalizeSettings(settings AppSettings) AppSettings {
 	if strings.TrimSpace(settings.APIAddr) == "" {
 	if strings.TrimSpace(settings.APIAddr) == "" {
 		settings.APIAddr = DefaultAPIAddr
 		settings.APIAddr = DefaultAPIAddr
 	}
 	}
+	settings.UploadPassword = strings.TrimSpace(settings.UploadPassword) // 上传密码:空表示不启用
+	settings.UploadDir = strings.TrimSpace(settings.UploadDir)           // 上传目录:空回退到默认目录
 	// TodoPath / RecordPath / RecordDBPath / BackupDir 无默认值:空值保留,前端据此强制用户进入设置配置(不做硬编码回填)
 	// TodoPath / RecordPath / RecordDBPath / BackupDir 无默认值:空值保留,前端据此强制用户进入设置配置(不做硬编码回填)
 	settings.StockTicker = normalizeTicker(settings.StockTicker, "#ff4d4d", 80) // 股票条默认红字
 	settings.StockTicker = normalizeTicker(settings.StockTicker, "#ff4d4d", 80) // 股票条默认红字
 	settings.NewsTicker = normalizeTicker(settings.NewsTicker, "#3ddc84", 60)   // 新闻条默认绿字
 	settings.NewsTicker = normalizeTicker(settings.NewsTicker, "#3ddc84", 60)   // 新闻条默认绿字
@@ -419,6 +429,11 @@ func applySettings(settings AppSettings) {
 		_ = app.Autostart.Disable() // 移除开机启动项
 		_ = app.Autostart.Disable() // 移除开机启动项
 	}
 	}
 
 
-	ensureAPIServer(settings.APIAddr) // API 地址变化时重启内置服务
-	syncTickerWindows(settings)       // 按配置创建 / 关闭 / 刷新滚动条窗口
+	// 内置 API 服务:按「服务自动启动」开关决定启动或停止(地址变化时重启)
+	if settings.APIAutoStart {
+		ensureAPIServer(settings.APIAddr)
+	} else {
+		StopAPIServer()
+	}
+	syncTickerWindows(settings) // 按配置创建 / 关闭 / 刷新滚动条窗口
 }
 }

+ 74 - 0
settingsservice.go

@@ -7,7 +7,9 @@
 package main
 package main
 
 
 import (
 import (
+	"encoding/base64"
 	"fmt"
 	"fmt"
+	"net/url"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
 	"strings"
 	"strings"
@@ -28,6 +30,17 @@ func (s *SettingsService) AppVersion() string {
 	return AppVersion
 	return AppVersion
 }
 }
 
 
+// TTS 用微软 Edge 神经网络语音合成文本,返回 data URI(audio/mp3)。
+// 前端直接赋给 <audio src> 播放。voice 可选:xiaoxiao/yunxi/yunjian/xiaoyi/yunyang。
+// 古诗词朗读用云希(yunxi,沉稳男声)效果最佳。
+func (s *SettingsService) TTS(text, voice string) (string, error) {
+	res, err := SpeakEdge(text, voice)
+	if err != nil {
+		return "", fmt.Errorf("语音合成失败: %w", err)
+	}
+	return "data:audio/mp3;base64," + base64.StdEncoding.EncodeToString(res.Audio), nil
+}
+
 // GetSettings 返回当前配置(读取配置文件,不存在则返回默认值)
 // GetSettings 返回当前配置(读取配置文件,不存在则返回默认值)
 func (s *SettingsService) GetSettings() AppSettings {
 func (s *SettingsService) GetSettings() AppSettings {
 	return loadSettings()
 	return loadSettings()
@@ -229,6 +242,67 @@ func (s *SettingsService) WriteTodo(content string) error {
 	return os.WriteFile(path, []byte(content), 0o644)
 	return os.WriteFile(path, []byte(content), 0o644)
 }
 }
 
 
+// PickUploadFile 弹出文件选择对话框,返回用户选中的本地文件路径(取消返回空串)。
+func (s *SettingsService) PickUploadFile() (string, error) {
+	if app == nil {
+		return "", fmt.Errorf("应用未初始化")
+	}
+	return app.Dialog.OpenFile().
+		CanChooseDirectories(false).
+		CanChooseFiles(true).
+		SetTitle("选择要上传的文件").
+		PromptForSingleSelection()
+}
+
+// UploadFile 供桌面端「服务」页直接上传本地文件(不走 HTTP、无需密码)。
+// 把本地文件压缩为 zip 存入上传目录(zip 内保留原文件名),返回下载信息。
+// 仅本机桌面调用,不暴露给外部网络。
+func (s *SettingsService) UploadFile(diskPath string) (map[string]interface{}, error) {
+	diskPath = strings.TrimSpace(diskPath)
+	if diskPath == "" {
+		return nil, fmt.Errorf("未指定文件路径")
+	}
+	st, err := os.Stat(diskPath)
+	if err != nil {
+		return nil, fmt.Errorf("文件不存在: %w", err)
+	}
+	if st.IsDir() {
+		return nil, fmt.Errorf("不能上传文件夹")
+	}
+	// 大小上限
+	maxMB := loadSettings().UploadMaxSize
+	if maxMB <= 0 {
+		maxMB = 512
+	}
+	if st.Size() > int64(maxMB)<<20 {
+		return nil, fmt.Errorf("文件过大,上限 %dMB", maxMB)
+	}
+
+	origBase := filepath.Base(strings.ReplaceAll(diskPath, "\\", "/"))
+	if origBase == "." || origBase == "" {
+		origBase = "upload.bin"
+	}
+	dir := uploadDirPath()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		return nil, fmt.Errorf("无法创建上传目录: %w", err)
+	}
+	zipName := newStoredName(origBase + ".zip")
+	zipPath := filepath.Join(dir, zipName)
+	if err := zipFile(diskPath, zipPath, origBase); err != nil {
+		os.Remove(zipPath)
+		return nil, fmt.Errorf("压缩文件失败: %w", err)
+	}
+	savedName := filepath.Base(zipPath)
+	downloadURL := "/api/download/" + url.PathEscape(savedName)
+	fmt.Fprintf(apiLogRing, "[SYS] 桌面端上传并压缩: %s -> %s (%d 字节)\n", origBase, savedName, st.Size())
+	return map[string]interface{}{
+		"ok":          true,
+		"filename":    origBase,
+		"size":        st.Size(),
+		"downloadUrl": downloadURL,
+	}, nil
+}
+
 // GetAPILogs 返回 Gin 日志环形缓冲的全部内容
 // GetAPILogs 返回 Gin 日志环形缓冲的全部内容
 func (s *SettingsService) GetAPILogs() []string {
 func (s *SettingsService) GetAPILogs() []string {
 	return apiLogRing.Snapshot()
 	return apiLogRing.Snapshot()