Browse Source

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

soease 1 tuần trước cách đây
mục cha
commit
eeab343ede

+ 3 - 0
.gitignore

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

+ 343 - 0
apiserver.go

@@ -7,12 +7,19 @@
 package main
 
 import (
+	"archive/zip"
 	"context"
+	"crypto/rand"
+	"crypto/subtle"
+	"encoding/base64"
+	"encoding/binary"
 	"fmt"
 	"io"
 	"log"
 	"net/http"
+	"net/url"
 	"os"
+	"path/filepath"
 	"strconv"
 	"strings"
 	"sync"
@@ -171,6 +178,8 @@ func runAPIServer(addr string) {
 	router.GET("/health", func(c *gin.Context) {
 		c.JSON(http.StatusOK, gin.H{"status": "ok"}) // 健康检查
 	})
+	router.GET("/upload", handleUploadPage)       // 客户端上传页(手机等设备浏览器访问)
+	router.GET("/api/qr", handleQRImage)          // 生成二维码图片(供客户端上传页/桌面端展示)
 
 	api := router.Group("/api")
 	api.POST("/notify", handleNotify)          // 弹出系统托盘通知
@@ -180,6 +189,8 @@ func runAPIServer(addr string) {
 	api.POST("/settings", handleSaveSettings)  // 保存配置
 	api.GET("/stocks", handleGetStocks)        // 获取股票行情
 	api.GET("/screenshot", handleScreenshot)   // 截取屏幕,返回 PNG 数据 URI
+	api.POST("/upload", handleUpload)          // 上传文件(需密码)
+	api.GET("/download/:filename", handleDownload) // 下载已上传文件(需密码)
 
 	pc := api.Group("/pc")              // 主机控制接口(移植自主机服务)
 	pc.GET("/poweroff", handlePoweroff) // 30 秒后关机
@@ -334,3 +345,335 @@ func handleScreenshot(c *gin.Context) {
 	}
 	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:
       - "**/*.[jt]s"
       - exclude: frontend/**/*
+      - exclude: voicewake/**/*   # 独立语音唤醒模块,不参与 Wails 构建/监听
       - frontend/bindings/**/*  # Rerun when switching between dev/production mode causes changes in output
       - "**/*.go"
+      - exclude: voicewake/**/*   # 独立语音唤醒模块,不参与 Wails 构建/监听
       - go.mod
       - go.sum
     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"] = "";
         }
+        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)) {
             /**
              * 待办(TODO)文件路径
@@ -310,10 +342,10 @@ export class AppSettings {
         const $$createField6_0 = $$createType2;
         const $$createField9_0 = $$createType4;
         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;
         if ("tabOrder" in $$parsedSource) {
             $$parsedSource["tabOrder"] = $$createField4_0($$parsedSource["tabOrder"]);
@@ -331,16 +363,16 @@ export class AppSettings {
             $$parsedSource["customTabs"] = $$createField10_0($$parsedSource["customTabs"]);
         }
         if ("stockTicker" in $$parsedSource) {
-            $$parsedSource["stockTicker"] = $$createField16_0($$parsedSource["stockTicker"]);
+            $$parsedSource["stockTicker"] = $$createField20_0($$parsedSource["stockTicker"]);
         }
         if ("newsTicker" in $$parsedSource) {
-            $$parsedSource["newsTicker"] = $$createField17_0($$parsedSource["newsTicker"]);
+            $$parsedSource["newsTicker"] = $$createField21_0($$parsedSource["newsTicker"]);
         }
         if ("poem" in $$parsedSource) {
-            $$parsedSource["poem"] = $$createField18_0($$parsedSource["poem"]);
+            $$parsedSource["poem"] = $$createField22_0($$parsedSource["poem"]);
         }
         if ("mouseAlert" in $$parsedSource) {
-            $$parsedSource["mouseAlert"] = $$createField19_0($$parsedSource["mouseAlert"]);
+            $$parsedSource["mouseAlert"] = $$createField23_0($$parsedSource["mouseAlert"]);
         }
         return new AppSettings(/** @type {Partial<AppSettings>} */($$parsedSource));
     }
@@ -1075,7 +1107,7 @@ export class PoemData {
 }
 
 /**
- * RecordItem 一条记录:姓名 + 名称 + 值 + 录入时间,供记录 Tab 列表与柱状图使用
+ * RecordItem 一条记录:姓名 + 名称 + 值 + 备注 + 录入时间,供记录 Tab 列表与柱状图使用
  */
 export class RecordItem {
     /**
@@ -1107,6 +1139,14 @@ export class RecordItem {
              */
             this["value"] = 0;
         }
+        if (!("remark" in $$source)) {
+            /**
+             * 备注(可选)
+             * @member
+             * @type {string}
+             */
+            this["remark"] = "";
+        }
         if (!("createdAt" in $$source)) {
             /**
              * 录入时间 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);
 }
 
+/**
+ * PickUploadFile 弹出文件选择对话框,返回用户选中的本地文件路径(取消返回空串)。
+ * @returns {$CancellablePromise<string>}
+ */
+export function PickUploadFile() {
+    return $Call.ByID(2719376250);
+}
+
 /**
  * PickWebDir 弹出目录选择对话框,返回选中的目录(取消返回空串)。
  * @returns {$CancellablePromise<string>}
@@ -431,6 +439,18 @@ export function StopWebService() {
     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 翻译文本:输入中文翻译为英文,输入英文翻译为中文(自动检测方向)。
  * 使用 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 返回静态文件服务当前状态。
  * @returns {$CancellablePromise<$models.WebServiceInfo>}
@@ -506,3 +539,4 @@ const $$createType18 = $models.RecordItem.createFrom;
 const $$createType19 = $Create.Array($$createType18);
 const $$createType20 = $models.WebServiceInfo.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">名称
                         <select class="input record-select" id="record-item-filter"><option value="">全部</option></select>
                     </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>
                 </div>
                 <div class="record-chart-wrap">
                     <canvas class="record-chart" id="record-chart"></canvas>
-                    <div class="record-chart-empty" id="record-chart-empty">请先选择姓名与名称,才显示该组合的柱状图</div>
+                    <div class="record-chart-tooltip" id="record-chart-tooltip" hidden></div>
+                    <div class="record-chart-empty" id="record-chart-empty">请先选择姓名,才显示柱状图(可选名称进一步筛选)</div>
                 </div>
                 <!-- 自动补全候选:姓名/名称输入框通过 list 属性引用;随数据库历史值刷新。
                      必须放在 record-list 容器之外,避免 loadRecords() 的 innerHTML='' 把它们清空 -->
@@ -320,6 +328,37 @@
                         </div>
                         <p id="api-status">检查中…</p>
                     </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-title"><span class="dot"></span>电源命令</div>
                         <p id="pc-status">暂无请求</p>
@@ -335,6 +374,8 @@
                     <li><code>POST /api/settings</code>保存配置</li>
                     <li><code>GET /api/stocks</code>股票行情(?codes= 可临时指定)</li>
                     <li><code>GET /api/screenshot</code>屏幕截图(?display= 指定显示器)</li>
+                    <li><code>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/restart</code>30 秒后重启</li>
                     <li><code>GET /api/pc/sleep</code>30 秒后休眠</li>
@@ -551,6 +592,18 @@
                         </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 监听地址 -->
                     <div class="setting-row" data-tab-group="host">
                         <div class="setting-info">
@@ -560,6 +613,33 @@
                         <input class="input" id="api-addr" type="text" placeholder="127.0.0.1:17890">
                     </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-info">

+ 174 - 11
frontend/public/style.css

@@ -23,6 +23,7 @@
     --read-bg: rgba(255, 224, 138, 0.12);    /* 朗读中新闻行的背景(暗色主题) */
     --read-border: rgba(255, 224, 138, 0.55);/* 朗读中新闻行的边框(暗色主题) */
     --read-ink: #ffe08a;                     /* 朗读中新闻标题文字(暗色主题:金色) */
+    --tooltip-bg: rgba(28, 32, 48, 0.97);    /* 悬浮提示背景(暗色主题:近实深色) */
 
     /* ---------- 间距(黄金比 φ ≈ 1.618) ---------- */
     --s-1: 0.618rem;
@@ -57,6 +58,7 @@
     --read-bg: rgba(217, 138, 30, 0.14);      /* 朗读中新闻行的背景(亮色主题:淡琥珀) */
     --read-border: rgba(180, 110, 10, 0.55);  /* 朗读中新闻行的边框(亮色主题) */
     --read-ink: #8a5a00;                       /* 朗读中新闻标题文字(亮色主题:深琥珀,浅底清晰) */
+    --tooltip-bg: rgba(255, 255, 255, 0.98);  /* 悬浮提示背景(亮色主题:近实浅色) */
     color-scheme: light;
 }
 
@@ -472,15 +474,26 @@ body::before {
 .setting-list {
     display: flex;
     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 名称 */
 .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-weight: 600;
+    font-weight: 700;
     letter-spacing: 0.5px;
 }
 
@@ -494,9 +507,15 @@ body::before {
     justify-content: space-between;
     gap: 18px;
     padding: 16px 18px;
-    border: 1px solid var(--glass-border);
+    border: 1px solid rgba(255, 255, 255, 0.055);
     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 {
@@ -1804,6 +1823,24 @@ a.ver-muted:hover {
 .record-select:focus {
     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 {
     margin-left: auto;
     color: var(--muted);
@@ -1833,12 +1870,35 @@ a.ver-muted:hover {
     color: var(--muted);
     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 {
     flex: 1;
     min-height: 120px;                      /* 保证列表区有最小可用高度,避免被固定元素挤压成一行 */
     overflow-y: auto;
+    overflow-x: hidden;
     display: flex;
     flex-direction: column;
     gap: 6px;
@@ -1848,6 +1908,7 @@ a.ver-muted:hover {
     display: flex;
     align-items: center;
     gap: 10px;
+    min-width: 0;
     padding: 8px 12px;
     border: 1px solid var(--glass-border);
     border-radius: var(--radius);
@@ -1868,19 +1929,30 @@ a.ver-muted:hover {
 }
 .record-name {
     width: 150px;
-    flex-shrink: 0;
+    flex: 0 1 150px;
+    min-width: 60px;
 }
 .record-item {
     width: 200px;
-    flex-shrink: 0;
+    flex: 0 1 200px;
+    min-width: 70px;
 }
 .record-value {
     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 {
     width: 170px;
-    flex-shrink: 0;
+    flex: 0 1 170px;
+    min-width: 90px;
     color: var(--muted);
     font-size: calc(13.5px + var(--font-delta));
 }
@@ -1953,6 +2025,94 @@ a.ver-muted:hover {
     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 {
     margin: 0 0 10px;
     color: var(--ink);
@@ -2541,8 +2701,11 @@ a.ver-muted:hover {
         gap: 10px;
     }
     /* 分组标题与设置行:窄屏收紧间距 */
+    .setting-group {
+        padding: 10px 10px 12px; /* 窄屏收紧分组卡片内边距 */
+    }
     .setting-group-title {
-        margin-top: 10px;
+        margin: 0 4px 4px;
     }
     /* 新闻 / 股票列表:窄屏让出高度,避免底部被遮挡 */
     .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 框架'}],
     customTabs: [],                   // 用户自定义 Tab 页面(名字 + 地址;http/https 为网页,其余为本地文件)
     apiAddr: '127.0.0.1:17890',
+    apiAutoStart: true, // 应用启动时自动启动内置 API 服务(默认开启)
     todoPath: '', // 待办路径:无默认值,空值提示用户到设置页配置
     recordPath: '', // 记录路径:无默认值,空值提示用户到设置页配置(保留兼容,记录 Tab 实际使用数据库)
     recordDBPath: '', // 记录数据库路径:无默认值,空值提示用户到设置页配置
@@ -322,19 +323,21 @@ function applyTabOrder(order) {
             if (!grouped[g]) grouped[g] = [];
             grouped[g].push(row);
         });
-        // 分组标题:系统在前,其后按 Tab 顺序
+        // 分组容器:系统在前,其后按 Tab 顺序;每组一个带边框的卡片,明显区分各页设置
         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();
         groupOrder.forEach(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');
             title.className = 'setting-group-title';
             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);
     }
@@ -425,6 +428,10 @@ const newsKeywordsInput = document.getElementById('news-keywords'); // 新闻关
 const biliCookieInput = document.getElementById('bili-cookie');      // B站登录 cookie
 const githubReposText = document.getElementById('github-repos');    // Github 项目(每行 地址|说明)
 const apiAddrInput = document.getElementById('api-addr');           // API 监听地址
+const 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 recordDBPathInput = document.getElementById('record-db-path'); // 记录(Record)数据库路径
 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');
     customTabsText.value = (settings.customTabs || []).map(t => `${t.name}|${t.url}${t.desc ? '|' + t.desc : ''}`).join('\n');
     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;
     recordDBPathInput.value = settings.recordDBPath;
     backupDirInput.value = settings.backupDir;
@@ -747,6 +758,10 @@ function collectForm() {
         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),
         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(),
         recordDBPath: recordDBPathInput.value.trim(),
         backupDir: backupDirInput.value.trim(),
@@ -881,11 +896,13 @@ function setFooterText(text, opts = {}) {
     updateFooterMarquee();
 }
 
-// 古诗朗读(TTS);再点停止
+// 古诗朗读(Edge-TTS);再点停止
+const poemAudio = new Audio(); // 隐藏 audio 播放 Edge-TTS mp3
 function togglePoemRead() {
     if (!poemReadBtn) return;
     if (poemSpeaking) {
-        speechSynthesis.cancel();
+        poemAudio.pause();
+        poemAudio.src = '';
         poemSpeaking = false;
         poemReadBtn.classList.remove('is-active');
         return;
@@ -894,20 +911,32 @@ function togglePoemRead() {
     const lines = [currentPoem.title, currentPoem.author, ...(currentPoem.content || [])]
         .filter(s => s && s.trim())
         .join(',');
-    const utter = new SpeechSynthesisUtterance(lines);
-    utter.lang = 'zh-CN';
-    utter.rate = 0.9;
-    utter.onend = () => {
+    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;
         poemReadBtn.classList.remove('is-active');
     };
-    utter.onerror = () => {
+    poemAudio.onerror = () => {
         poemSpeaking = false;
         poemReadBtn.classList.remove('is-active');
     };
-    speechSynthesis.speak(utter);
-    poemSpeaking = true;
-    poemReadBtn.classList.add('is-active');
 }
 
 // 把一首古诗渲染到 footer 说明区(滚动显示,带朗读按钮)
@@ -1686,11 +1715,16 @@ const recordStatus = document.getElementById('record-status');             // 
 const recordPathDesc = document.getElementById('record-path-desc');        // 显示当前记录文件路径
 const recordNameFilter = document.getElementById('record-name-filter');    // 姓名筛选下拉
 const recordItemFilter = document.getElementById('record-item-filter');    // 名称筛选下拉
+const 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 recordChart = document.getElementById('record-chart');               // 柱状图画布
 const recordChartEmpty = document.getElementById('record-chart-empty');    // 柱状图空提示
+const recordChartTip = document.getElementById('record-chart-tooltip');    // 鼠标悬停提示
 let recordDirty = false; // 是否有未保存修改
 let recordData = [];     // 当前记录数组 [{name, item, value}]
+let chartHitAreas = [];  // 柱状图命中区域(x/宽/数据),用于鼠标悬停提示
 
 // 自动补全历史值(来自数据库,去重):姓名 / 名称
 // 用自定义下拉替代 <datalist>:WebView2 的 datalist 在输入框为空、仅聚焦点击时不会弹出候选,
@@ -1753,8 +1787,8 @@ function attachAutocomplete(input, optionsRef, onChange) {
     input._ac = dl; // 供销毁时清理
 }
 
-// 渲染一行记录:姓名输入(自动补全)+ 名称输入(自动补全)+ 值输入 + 时间 + 删除按钮
-function renderRecordRow(name, item, value, createdAt) {
+// 渲染一行记录:姓名输入(自动补全)+ 名称输入(自动补全)+ 值输入 + 备注输入 + 时间 + 删除按钮
+function renderRecordRow(name, item, value, remark, createdAt) {
     const row = document.createElement('div');
     row.className = 'record-row';
 
@@ -1765,6 +1799,11 @@ function renderRecordRow(name, item, value, createdAt) {
     nameInput.placeholder = '姓名';
     nameInput.spellcheck = false;
     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('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
     attachAutocomplete(nameInput, () => mergeRecordCandidates(recordNameOptions, '.record-name'), () => { recordDirty = true; refreshRecordFilterOptions(); });
@@ -1776,6 +1815,8 @@ function renderRecordRow(name, item, value, createdAt) {
     itemInput.placeholder = '名称';
     itemInput.spellcheck = false;
     itemInput.setAttribute('list', 'record-items-dl'); // 保留 list 属性作为备用(自定义下拉为主)
+    itemInput.addEventListener('focus', markEditing);
+    itemInput.addEventListener('blur', unmarkEditing);
     itemInput.addEventListener('input', () => { recordDirty = true; refreshRecordFilterOptions(); });
     itemInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addRecordRow(); });
     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.value = value;
     valueInput.placeholder = '值';
+    valueInput.addEventListener('focus', markEditing);
+    valueInput.addEventListener('blur', unmarkEditing);
     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');
     timeInput.type = 'text';
@@ -1812,6 +1867,7 @@ function renderRecordRow(name, item, value, createdAt) {
     row.appendChild(nameInput);
     row.appendChild(itemInput);
     row.appendChild(valueInput);
+    row.appendChild(remarkInput);
     row.appendChild(timeInput);
     row.appendChild(del);
     return row;
@@ -1826,10 +1882,12 @@ function formatNow() {
 
 // 添加一行空的记录(自动记录当前时间,聚焦姓名输入框)
 function addRecordRow() {
-    const row = renderRecordRow('', '', '', '');
+    const row = renderRecordRow('', '', '', '', '');
+    row.dataset.new = '1'; // 标记为新增行:未选姓名+名称时也保持可见,不被筛选隐藏
     recordList.appendChild(row);
     row.querySelector('.record-name').focus();
     recordDirty = true;
+    applyRecordFilter();
 }
 
 // 收集所有记录行的数据(过滤全空行)
@@ -1839,8 +1897,9 @@ function collectRecords() {
         const name = row.querySelector('.record-name').value.trim();
         const item = row.querySelector('.record-item').value.trim();
         const value = row.querySelector('.record-value').value.trim();
+        const remark = row.querySelector('.record-remark').value.trim();
         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;
 }
@@ -1848,12 +1907,15 @@ function collectRecords() {
 // 刷新姓名/名称下拉的候选(来自当前所有记录行,去重)
 function refreshRecordFilterOptions() {
     const names = new Set();
-    const items = new Set();
+    const nameItems = new Map(); // name -> Set(item):供「选择姓名后名称只显示该姓名的名称」过滤
     recordList.querySelectorAll('.record-row').forEach(row => {
         const n = row.querySelector('.record-name').value.trim();
         const it = row.querySelector('.record-item').value.trim();
         if (n) names.add(n);
-        if (it) items.add(it);
+        if (n && it) {
+            if (!nameItems.has(n)) nameItems.set(n, new Set());
+            nameItems.get(n).add(it);
+        }
     });
     // 保留当前选中值
     const curName = recordNameFilter.value;
@@ -1865,14 +1927,23 @@ function refreshRecordFilterOptions() {
         recordNameFilter.appendChild(o);
     });
     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>';
-    [...items].sort().forEach(it => {
+    [...itemSet].sort().forEach(it => {
         const o = document.createElement('option');
         o.value = it; o.textContent = it;
         recordItemFilter.appendChild(o);
     });
-    if (curItem && items.has(curItem)) recordItemFilter.value = curItem;
-    redrawRecordChart();
+    // 若当前名称不属于所选姓名(或未选姓名时已不在候选集),清空名称选择
+    if (curItem && itemSet.has(curItem)) {
+        recordItemFilter.value = curItem;
+    } else {
+        recordItemFilter.value = '';
+    }
+    // 行内容/选项变化后重新应用筛选显示状态(内部含柱状图重绘)
+    applyRecordFilter();
 }
 
 // 刷新姓名/名称自动补全候选:从数据库拉取历史值(去重),供自定义下拉使用
@@ -1922,12 +1993,13 @@ async function loadRecords() {
         recordStatus.textContent = '';
         refreshRecordFilterOptions();
         loadRecordAutocomplete();
+        applyRecordFilter(); // 初始隐藏数据列表(未选组合时)
         return;
     }
     try {
         const items = await SettingsService.ReadRecords();
         if (items.length) {
-            items.forEach(it => recordList.appendChild(renderRecordRow(it.name, it.item, String(it.value), it.createdAt)));
+            items.forEach(it => recordList.appendChild(renderRecordRow(it.name, it.item, String(it.value), it.remark || '', it.createdAt)));
         } else {
             addRecordRow();
         }
@@ -1935,6 +2007,7 @@ async function loadRecords() {
         recordStatus.textContent = '';
         refreshRecordFilterOptions();
         loadRecordAutocomplete();
+        applyRecordFilter(); // 初始隐藏数据列表(未选组合时)
     } catch (err) {
         recordStatus.textContent = '读取失败:' + (err?.message || err);
     }
@@ -1947,14 +2020,20 @@ async function saveRecords() {
         const name = row.querySelector('.record-name').value.trim();
         const item = row.querySelector('.record-item').value.trim();
         const value = row.querySelector('.record-value').value.trim();
+        const remark = row.querySelector('.record-remark').value.trim();
         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 = '保存中…';
     try {
         await SettingsService.WriteRecords(items);
         recordStatus.textContent = '已保存 ' + new Date().toLocaleTimeString('zh-CN');
         recordDirty = false;
+        // 保存后所有行转为历史行:移除新增/编辑标记,按当前筛选重新显示
+        recordList.querySelectorAll('.record-row').forEach(row => {
+            delete row.dataset.new;
+            delete row.dataset.editing;
+        });
         refreshRecordFilterOptions();
         loadRecordAutocomplete(); // 保存后刷新自动补全候选(新姓名/名称加入历史)
     } 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() {
     const selName = recordNameFilter.value;
     const selItem = recordItemFilter.value;
-    if (!selName || !selItem) return []; // 未同时选定姓名与名称:不出图
-    return collectRecords()
-        .filter(r => r.name === selName && r.item === selItem)
-        .map(r => ({ label: `${r.name}/${r.item}`, value: r.value }));
+    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),无数据时显示空提示
@@ -1981,12 +2123,14 @@ function redrawRecordChart() {
     if (!data.length) {
         recordChartEmpty.style.display = 'flex';   // 空提示居中(CSS 默认为 flex)
         recordChart.style.display = 'none';
-        recordCount.textContent = '0 条记录';
+        chartHitAreas = [];                        // 无数据:清空命中区并隐藏提示
+        if (recordChartTip) recordChartTip.hidden = true;
+        updateRecordStats();
         return;
     }
     recordChartEmpty.style.display = 'none';
     recordChart.style.display = 'block';           // canvas 用块级显示,否则 CSS 的 display:none 使其永不出现
-    recordCount.textContent = `${data.length} 条记录`;
+    updateRecordStats();
 
     const dpr = window.devicePixelRatio || 1;
     const rect = recordChart.getBoundingClientRect();
@@ -1997,6 +2141,10 @@ function redrawRecordChart() {
     const ctx = recordChart.getContext('2d');
     ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
     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 plotW = w - padL - padR, plotH = h - padT - padB;
@@ -2006,7 +2154,7 @@ function redrawRecordChart() {
 
     // 背景网格线 + Y 轴刻度
     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.font = '10px sans-serif';
     ctx.textAlign = 'right';
@@ -2023,25 +2171,34 @@ function redrawRecordChart() {
     // 柱
     const barColor = 'rgba(255,90,90,0.85)';
     ctx.textAlign = 'center';
+    chartHitAreas = []; // 重建命中区域
     data.forEach((d, i) => {
         const cx = padL + gap * i + gap / 2;
         const bh = Math.abs(d.value) / maxVal * plotH;
         const y0 = padT + plotH - bh;
         ctx.fillStyle = barColor;
         ctx.fillRect(cx - barW / 2, y0, barW, bh);
-        // 值标注
-        ctx.fillStyle = 'rgba(255,255,255,0.9)';
-        ctx.fillText(d.value.toFixed(1), cx, y0 - 4);
-        // x 轴标签:姓名/名称(长标签截断,避免重叠)
+        // 命中区域:以柱为中心、略加宽,便于鼠标悬停
+        chartHitAreas.push({ x0: cx - barW / 2 - 4, x1: cx + barW / 2 + 4, y0, y1: padT + plotH, d });
+        // x 轴标签:名称(长标签截断,避免重叠)。数值与名称只在鼠标悬停提示中显示
         const lbl = d.label || '·';
+        ctx.fillStyle = mutedColor || (isLight ? '#5d6678' : '#8b93a7');
         ctx.fillText(lbl.length > 10 ? lbl.slice(0, 10) + '…' : lbl, cx, h - 10);
     });
 }
 
 recordSaveBtn.addEventListener('click', saveRecords);
 recordAddBtn.addEventListener('click', addRecordRow);
-recordNameFilter.addEventListener('change', redrawRecordChart);
-recordItemFilter.addEventListener('change', redrawRecordChart);
+// 姓名筛选变化:联动刷新名称下拉(只显示该姓名下的名称),并应用筛选
+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 变化时自动刷新下拉与图表(例如删除行)
 const recordPanel = document.getElementById('tab-record');
@@ -2053,6 +2210,57 @@ if (recordPanel) {
 // 窗口尺寸变化时重绘柱状图
 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 日志
 // ------------------------------------------------------------------------
@@ -2180,12 +2388,42 @@ async function loadHostPanel() {
         const logs = await SettingsService.GetAPILogs();
         logView.textContent = logs && logs.length ? logs.join('\n') : '暂无日志';
         logView.scrollTop = logView.scrollHeight; // 滚动到最新一行
+
+        await renderClientUploadQR(status); // 刷新客户端上传页二维码
     } catch (err) {
         apiStatusEl.textContent = '状态获取失败(浏览器预览模式无后端)';
         console.warn('主机面板加载失败:', err);
     }
 }
 
+// 渲染「客户端上传页」二维码:扫码打开 http://<addr>/upload,供手机等设备上传文件
+const clientUploadQrBox = document.getElementById('client-upload-qr');
+const clientUploadUrlEl = document.getElementById('client-upload-url');
+async function renderClientUploadQR(status) {
+    if (!clientUploadQrBox) return;
+    clientUploadQrBox.innerHTML = '';
+    if (clientUploadUrlEl) clientUploadUrlEl.textContent = '';
+    if (!status || !status.running) return; // 未运行则不显示二维码
+
+    // 本机展示用 127.0.0.1;通配地址 0.0.0.0/[::] 无法在浏览器中访问
+    let addr = String(status.addr || '').trim();
+    if (addr.startsWith('0.0.0.0') || addr.startsWith('[::]')) {
+        addr = addr.replace(/^0\.0\.0\.0/, '127.0.0.1').replace(/^\[::\]/, '127.0.0.1');
+    }
+    const pageUrl = `http://${addr}/upload`;
+    try {
+        const qrUri = await SettingsService.GenerateQRCode(pageUrl, 0);
+        const img = document.createElement('img');
+        img.src = qrUri;
+        img.alt = '客户端上传页二维码';
+        img.title = '扫码打开客户端上传页';
+        clientUploadQrBox.appendChild(img);
+    } catch (err) {
+        console.warn('客户端上传页二维码生成失败:', err);
+    }
+    if (clientUploadUrlEl) clientUploadUrlEl.textContent = pageUrl;
+}
+
 // 乒乓切换 API 服务:运行中→停止,未运行→启动
 async function toggleAPIService() {
     apiToggleBtn.disabled = true;
@@ -2207,6 +2445,108 @@ async function 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) => {
     if (logView.textContent === '暂无日志') logView.textContent = '';

+ 14 - 7
recordservice.go

@@ -19,11 +19,12 @@ import (
 	_ "modernc.org/sqlite" // 纯 Go SQLite 驱动(无需 CGO)
 )
 
-// RecordItem 一条记录:姓名 + 名称 + 值 + 录入时间,供记录 Tab 列表与柱状图使用
+// RecordItem 一条记录:姓名 + 名称 + 值 + 备注 + 录入时间,供记录 Tab 列表与柱状图使用
 type RecordItem struct {
 	Name      string  `json:"name"`      // 姓名
 	Item      string  `json:"item"`      // 名称
 	Value     float64 `json:"value"`     // 值
+	Remark    string  `json:"remark"`    // 备注(可选)
 	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 {
 		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)
 	if err != nil {
 		return nil, err
@@ -48,11 +49,17 @@ func openRecordDB(dbPath string) (*sql.DB, error) {
 		name       TEXT NOT NULL DEFAULT '',
 		item       TEXT NOT NULL DEFAULT '',
 		value      REAL NOT NULL DEFAULT 0,
+		remark     TEXT NOT NULL DEFAULT '',
 		created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
 	)`); err != nil {
 		db.Close()
 		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
 }
 
@@ -64,7 +71,7 @@ func (s *SettingsService) ReadRecords() ([]RecordItem, error) {
 		return nil, err
 	}
 	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 {
 		return nil, err
 	}
@@ -72,7 +79,7 @@ func (s *SettingsService) ReadRecords() ([]RecordItem, error) {
 	items := make([]RecordItem, 0, 64)
 	for rows.Next() {
 		var it RecordItem
-		if err := rows.Scan(&it.Name, &it.Item, &it.Value, &it.CreatedAt); err != nil {
+		if err := rows.Scan(&it.Name, &it.Item, &it.Value, &it.Remark, &it.CreatedAt); err != nil {
 			return nil, err
 		}
 		items = append(items, it)
@@ -96,7 +103,7 @@ func (s *SettingsService) WriteRecords(items []RecordItem) error {
 	if _, err := tx.Exec(`DELETE FROM records`); err != nil {
 		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 {
 		return err
 	}
@@ -107,7 +114,7 @@ func (s *SettingsService) WriteRecords(items []RecordItem) error {
 		if ts == "" {
 			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
 		}
 	}

+ 20 - 5
settings.go

@@ -96,7 +96,11 @@ type AppSettings struct {
 	BiliCookie   string       `json:"biliCookie"`   // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
 	GithubRepos  []RepoItem   `json:"githubRepos"`  // 关注的 GitHub 仓库列表
 	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)文件路径
 	RecordPath   string       `json:"recordPath"`   // 记录(Record)文件路径:每行「姓名|名称|值」(保留兼容)
 	RecordDBPath string       `json:"recordDBPath"` // 记录数据库路径:SQLite3 数据库文件(记录 Tab 实际存储)
@@ -140,8 +144,12 @@ func defaultSettings() AppSettings {
 		GithubRepos: []RepoItem{
 			{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:  "", // 记录路径:无默认值,用户须在设置中配置(空值时强制进入设置)
 		RecordDBPath: "", // 记录数据库路径:无默认值,用户须在设置中配置(空值时强制进入设置)
 		BackupDir:   "", // 备份目录:无默认值,用户须在设置中配置(空值时强制进入设置)
@@ -257,6 +265,8 @@ func normalizeSettings(settings AppSettings) AppSettings {
 	if strings.TrimSpace(settings.APIAddr) == "" {
 		settings.APIAddr = DefaultAPIAddr
 	}
+	settings.UploadPassword = strings.TrimSpace(settings.UploadPassword) // 上传密码:空表示不启用
+	settings.UploadDir = strings.TrimSpace(settings.UploadDir)           // 上传目录:空回退到默认目录
 	// TodoPath / RecordPath / RecordDBPath / BackupDir 无默认值:空值保留,前端据此强制用户进入设置配置(不做硬编码回填)
 	settings.StockTicker = normalizeTicker(settings.StockTicker, "#ff4d4d", 80) // 股票条默认红字
 	settings.NewsTicker = normalizeTicker(settings.NewsTicker, "#3ddc84", 60)   // 新闻条默认绿字
@@ -419,6 +429,11 @@ func applySettings(settings AppSettings) {
 		_ = 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
 
 import (
+	"encoding/base64"
 	"fmt"
+	"net/url"
 	"os"
 	"path/filepath"
 	"strings"
@@ -28,6 +30,17 @@ func (s *SettingsService) AppVersion() string {
 	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 返回当前配置(读取配置文件,不存在则返回默认值)
 func (s *SettingsService) GetSettings() AppSettings {
 	return loadSettings()
@@ -229,6 +242,67 @@ func (s *SettingsService) WriteTodo(content string) error {
 	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 日志环形缓冲的全部内容
 func (s *SettingsService) GetAPILogs() []string {
 	return apiLogRing.Snapshot()