Browse Source

添加工具:Croc、Memos、yt-dl;允许双击图标缩小窗口

soease 2 days ago
parent
commit
0b9daa1007

+ 35 - 12
README.md

@@ -1,6 +1,6 @@
 # 腾图工具箱(TengTu Toolbox)
 
-基于 Wails v3 的桌面工具箱应用。前端使用原生 HTML/CSS/JS + Vite 构建,后端使用 Go;主窗口内嵌多个网页 Tab,并提供新闻聚合、Github 仓库监听、股票行情、记录/待办编辑、硬件监控、主机管理、工具(Web 服务 / 翻译 / 二维码)、聊天与设置等面板。
+基于 Wails v3 的桌面工具箱应用。前端使用原生 HTML/CSS/JS + Vite 构建,后端使用 Go;主窗口内嵌多个网页 Tab,并提供新闻聚合、Github 仓库监听、股票行情、记录/待办编辑、硬件监控、主机管理、工具(Web 服务 / 翻译 / 二维码 / ntfy / 截图 / 延时关机 / 哈希 / LED 滚动文字 / 定时提醒 / LocalSend 互传 / Croc 传送 / 视频下载 / Memos 速记)、聊天与设置等面板。
 
 ## 快速开始
 
@@ -26,7 +26,7 @@ wails3 build
 | 记录 | 文本记录文件编辑,支持 Markdown 实时预览(编辑 / 预览切换,预览用后端 gomarkdown 渲染),路径可在设置中修改 |
 | 待办 | 每行一个待办任务,可勾选完成、编辑、删除;每条可设置提醒时间,到点自动弹出置顶消息窗提醒(即使窗口隐藏也会触发) |
 | 硬件 | 内嵌硬件监控页,地址可在设置中修改 |
-| 工具 | 开发/运维辅助工具:Web 服务(选目录开启浏览服务)、翻译(中英互译)、二维码生成、发送 ntfy 消息、ntfy 聊天室、古诗词、屏幕截图;各功能区默认折叠,点击标题展开 |
+| 工具 | 开发/运维辅助工具:Web 服务(选目录开启浏览服务)、翻译(中英互译)、二维码生成、发送 ntfy 消息、ntfy 聊天室、古诗词、屏幕截图、延时关机、MD5 加密、SHA256 编码、LED 滚动文字、定时提醒、LocalSend 互传、Croc 传送(自动检测系统 PATH 中的 `croc`,存在才显示)、视频下载(自动检测 `yt-dlp`,监视剪贴板中的 B 站链接)、Memos 速记(调用 Memos REST API 新建备忘);各功能区默认折叠,点击标题展开 |
 | 服务 | 内置 API 服务状态、电源命令状态与 Gin 日志(「清空日志」在标题右侧);原截屏功能已迁移至工具页 |
 | 聊天 | 内嵌网页,地址可在设置中修改 |
 | 设置 | 按「系统 + 各 Tab」分组展示配置项,保存后立即生效 |
@@ -37,8 +37,12 @@ wails3 build
 
 ### 设置项
 
-- 系统:界面样式(暗色/亮色)、系统启动时运行、窗口置顶、呼出窗口热键
+- 系统:界面样式(暗色/亮色)、系统启动时运行、窗口置顶、启动时缩小(直接收拢为圆点)、呼出窗口热键
 - 各 Tab 对应设置:页面地址、Tab 显示顺序(逗号分隔)、股票代码与涨跌颜色、股市刷新间隔与休市刷新间隔、新闻关键词、新闻刷新间隔与显示条数、Github 项目与刷新间隔、API 监听地址、记录文件路径、待办文件路径、股票/新闻滚动条等
+- LocalSend:本机名称(广播给其他设备)、接收目录、接收模式(自动接收 / 手动确认)、监听端口
+- Croc 传送:发送/接收共用的密码(至少 6 个字符;设置后作为固定传输码 `croc send --code`,双方一致即可传输;留空则使用 croc 随机传输码)、接收保存目录(留空=程序当前工作目录);未检测到 croc 时这些设置项自动隐藏
+- 视频下载:yt-dlp 下载保存目录(留空=程序当前工作目录)、mp3 转换质量(VBR 0/2/5 或 128K/192K/320K,默认最佳 VBR 0);未检测到 yt-dlp 时这些设置项自动隐藏
+- Memos 速记:Memos 实例地址(默认 `https://m.scwy.net`)、个人访问令牌(在 Memos 网页端「设置 → 访问令牌」创建,仅存本机);两项未配置时工具页输入框不可编辑
 
 ### 滚动文字
 
@@ -53,11 +57,24 @@ wails3 build
 - **ntfy 聊天室**:输入聊天室名称(ntfy 主题)并「开始监听」,通过 SSE 长连接实时接收该主题消息并显示在列表中;主题留空则不监听。可与手机 / 其他设备用同一主题互发消息,实现跨设备聊天 / 通知。监听时收到消息自动滚动到底部(上滚可暂停跟随)。
 - **古诗词**:显示主窗口 footer 最近获取到的古诗词内容(题目 / 作者 / 诗句),可点击「刷新获取」拉取新的一首;footer 加载到的古诗也会同步显示在此。数据来自古诗词网随机接口。
 - **屏幕截图**:点击「截屏」后自动隐藏本程序窗口,截取整个屏幕(全部显示器拼接),完成后重新显示本程序并预览截图;截到的画面不含本程序自身。原位于主机页的截屏功能已迁移至此。
+- **延时关机**:输入多少分钟后关机(1 ~ 1440 分钟),确认后复用 `pccontrol.go` 的系统命令构造(`shutdown /s /t N /f`)交给 Windows 系统计时,即使本程序退出仍会执行;也可随时取消。
+- **MD5 加密**:输入任意字符串,计算其 32 位小写十六进制 MD5 摘要。常用于文件/字符串校验,注意 MD5 已不适合密码存储。
+- **SHA256 编码**:输入任意字符串,计算其 64 位小写十六进制 SHA256 摘要。适用于校验、签名与安全存储。
+- **LED 滚动文字**:输入文字(支持中文),转换为 LED 点阵,在独立的无边框、透明、置顶、可拖动窗口中横向循环滚动显示;可配置点阵颜色与滚动速度(像素/秒),窗口可关闭。由 Win32 API 原生实现(`ledwindow_windows.go`),不经过 Wails WebView2。
+- **定时提醒**:输入提醒信息与间隔分钟数,每 xx 分钟弹出一次提醒(广播 `periodicReminder` 事件并唤出主窗口),直到手动取消。适合定时喝水、休息、吃药等。
+- **LocalSend 互传**:与局域网内的 LocalSend 客户端(手机 / 电脑)互相发现并互传文件。启动后其他设备可发送文件到本机(按设置中的「接收模式」自动或手动接收),也可从本机选择文件发送给发现的设备。纯 Go 实现,与官方 LocalSend v2.2 完全互通。
+- **Croc 传送**:自动通过系统 PATH 查找 `croc`(`exec.LookPath`),未安装时整张卡片与「设置 → 工具」中的 Croc 配置项一并隐藏。卡片上只有「启动/停止」乒乓按钮与「发送」按钮:点「启动」以设置中的密码作为传输码开始接收(再点停止),点「发送」选择文件后发送(发送中按钮变为停止发送);密码与接收保存目录都在「设置 → 工具」中配置。发送/接收日志(含进度)实时输出到卡片内。注意 `--pass` 在 croc 中是中继服务器密码(公共中继固定 `pass123`),因此密码采用 `--code` 方式实现双方共用。
+- **视频下载**:自动通过系统 PATH 查找 `yt-dlp`,未安装时卡片与「设置 → 工具」中的下载目录配置一并隐藏。运行后每 1.5 秒读取一次系统剪贴板(Wails 原生剪贴板 API),一旦发现 B 站链接(`bilibili.com` / `b23.tv` 短链,支持从分享文案中提取)就加入卡片列表(去重、最多保留 20 条),并用 `yt-dlp --get-title` 异步解析视频标题(标题 + 原链接两行显示,解析失败回退链接);每条后面带「下载」按钮,若 PATH 中还有 `ffmpeg`,再追加「下载转 mp3」(`-x --audio-format mp3`)。每条前面有勾选框,配合顶部「全选」「批量下载(N)」与(有 ffmpeg 时的)「批量转 mp3(N)」可串行批量执行;下载完成的条目会移到列表末尾、背景变暗,按钮变为「重下载」(mp3 为「重下载 mp3」)。下载中按钮变为「停止」,日志与进度实时输出。下载目录在设置中配置,留空则保存到程序当前工作目录。
+- **Memos 速记**:把输入内容通过 Memos REST API(`POST /api/memos`,`Authorization: Bearer <访问令牌>`)发送到设置中配置的实例,固定创建**私有**备忘,支持 Markdown 与 `#标签`,可用 `Ctrl+Enter` 快捷发送;说明写在卡片标题的悬浮提示中。未填写地址或访问令牌时,输入框只读并显示提示。配置在「设置 → 工具」中完成。
 
 ### 鼠标提醒
 
 通过内置 API 触发系统级鼠标提醒:平时鼠标为正常样式;触发后鼠标图标闪烁(白↔红,约 250ms 周期),光标下方浮出半透明提示文字,可覆盖任意窗口(系统级),默认 8 秒自动消退。文字默认带黑色描边(可在设置中调整描边颜色与宽度,0 关闭),避免在亮/暗不同背景下看不清。详见 [mousealert_windows.go](mousealert_windows.go)。
 
+### 拖动手柄
+
+Tab 栏最左侧的圆点手柄区域用于拖动窗口。双击手柄可在「全尺寸」与「仅手柄大小」(约 32×38,收拢后窗口只剩一个可拖动、可双击的正圆点)之间切换,再双击还原。实现要点:Wails 无边框窗口仍带 `WS_THICKFRAME|WS_SYSMENU|WS_MIN/MAXIMIZEBOX` 等样式位,DWM 对其强制最小宽度(约 136px),`WM_GETMINMAXINFO` 无法覆盖;收拢时临时剥离这些样式位([windowcompact_windows.go](windowcompact_windows.go)),还原时恢复,并配合 `SetMinSize`/`SetSize` 完成缩放。圆点可通过 API 服务触发闪烁(颜色红↔黄交替 + 大小缩放脉冲,默认 3 次),用于提醒:`POST /api/handle/flash`(body 可选 `{"count":3}` 指定次数)。也可用 `POST /api/window/compact` 控制收拢/还原(返回 `{"compact":bool}`)。详见 [windowcompact.go](windowcompact.go)。
+
 ## 项目结构
 
 - `main.go`:Go 后端入口,创建应用、主窗口、系统托盘,注册服务与窗口事件,启动时间广播与待办提醒调度协程
@@ -71,14 +88,26 @@ wails3 build
 - `stockquote.go`:股票行情与分时 / 日K
 - `ticker.go`:滚动条窗口(位置保存)
 - `poem.go`:主窗口首 Tab footer 随机古诗(水平滚动)
-- `apiserver.go`:内置 API 服务(健康检查、通知、窗口控制、电源命令、鼠标提醒、屏幕截图等)
+- `apiserver.go`:内置 API 服务(健康检查、通知、窗口控制、电源命令、鼠标提醒、手柄闪烁、屏幕截图等)
+- `windowcompact.go`:拖动手柄(双击窗口缩小/还原、圆点闪烁 `handleFlash` 事件);`windowcompact_windows.go`/`windowcompact_other.go` 为平台尺寸与样式切换实现
 - `pccontrol.go`:电源控制(关机 / 重启 / 休眠 / 锁屏)
+- `power_shutdown.go`:工具页延时关机(`shutdown /s /t N /f`,系统级倒计时与取消)
 - `timesync.go`(及 `_windows.go` / `_other.go`):系统时间同步
 - `screenshot.go`:屏幕截图(多显示器拼接,返回 PNG base64 data URI)
 - `webservice.go`:工具页 Web 服务(目录选择、静态文件服务、README 渲染)
 - `translate.go`:工具页翻译(MyMemory 免费接口 + 中英文自动检测 + 限流提示)
 - `qrcode.go`:工具页二维码生成(go-qrcode,返回 PNG data URI)
 - `ntfy.go`:工具页 ntfy 消息推送与聊天室(POST ntfy.sh 指定主题、SSE 长连接订阅实时接收消息,主题历史持久化记忆)
+- `hashservice.go`:工具页哈希编码(MD5 / SHA256 字符串摘要)
+- `ledwindow_windows.go`:LED 点阵滚动文字(Windows 原生 Win32 分层窗口实现,见文件头注释)
+- `ledwindow_other.go`:LED 滚动文字非 Windows 空实现
+- `reminderservice.go`:工具页定时提醒(周期广播 `periodicReminder` 事件并唤出主窗口)
+- `localsend_service.go`:LocalSend 集成(启动/停止、本机名、接收模式/目录,发现/接收/发送)
+- `localsend/`:LocalSend 子包(`crypto`、`discovery`、`logging`、`model`、`send`),与官方 LocalSend v2.2 互通
+- `croc_service.go`:工具页 Croc 传送(PATH 检测 `croc`、发送/接收、传输码解析、日志与退出事件)
+- `ytdlp_service.go`:工具页视频下载(PATH 检测 `yt-dlp`、启动/停止下载、日志与退出事件)
+- `memos_service.go`:工具页 Memos 速记(读取配置、调用 REST API 新建备忘)
+- `croc_process_windows.go` / `croc_process_other.go`:croc 子进程窗口隐藏(Windows 下 `HideWindow`)与空实现
 - `todoremind.go`:待办提醒调度(解析待办文件中的 `@ 时间` 标记,到点广播事件)
 - `mousealert_windows.go`:系统级鼠标提醒(Windows,WH_MOUSE_LL 全局钩子 + 置顶透明覆盖层 + GDI 绘制)
 - `mousealert_other.go`:鼠标提醒非 Windows 空实现
@@ -86,6 +115,7 @@ wails3 build
 - `frontend/src/main.js`:前端逻辑,负责调用后端服务、监听后端事件(含 Tab 管理、工具页折叠、待办提醒弹窗、记录 Markdown 预览等)
 - `frontend/src/ticker.js`:滚动条逻辑
 - `frontend/src/lunar.js`:农历与节日计算
+- `frontend/src/led.js`:LED 滚动文字前端逻辑
 - `wails.json`:Wails 项目配置
 
 ## 构建说明
@@ -101,11 +131,4 @@ wails3 build
 2. `wails3 dev` 会根据 `bin/config.yaml` 的配置决定输出目录
 3. 先 `npm run build` 完成后再单独 `go build`(后端需要读取构建后的前端 dist 清单,并行会导致 embed 失败)
 4. 修改 Go 结构体/事件后需运行 `wails3 generate bindings` 重新生成前端绑定
-5. 修改/新增前端页面后需 `cd frontend && npm run build`,再 `wails3 build` 才能打入产物
-
-## 需求
-
-1. 始终没有保存文字滚动条的位置(新闻/股票):移动滚动条后 10 秒保存一次位置,重启程序时调用最后一次保存的位置
-2. 系统级鼠标动态提醒:平时正常样式,API 触发时鼠标闪烁 + 光标下方半透明提示文字(已实现,见 mousealert_windows.go)
-3. 工具页各功能区加区隔,默认折叠(已实现,见 frontend/src/main.js 的 initToolCollapse)
-4. 记录界面支持 Markdown 格式(已实现,编辑/预览切换,见 settingsservice.go 的 RenderMarkdown)
+5. 修改/新增前端页面后需 `cd frontend && npm run build`,再 `wails3 build` 才能打入产物

+ 21 - 0
apiserver.go

@@ -184,7 +184,9 @@ func runAPIServer(addr string) {
 	api := router.Group("/api")
 	api.POST("/notify", handleNotify)          // 弹出系统托盘通知
 	api.POST("/window/show", handleShowWindow) // 显示主窗口
+	api.POST("/window/compact", handleWindowCompact) // 窗口收拢/还原(与双击手柄等效)
 	api.POST("/mouse/alert", handleMouseAlert) // 触发系统级鼠标提醒(闪烁光标+提示文字)
+	api.POST("/handle/flash", handleHandleFlash) // 触发拖动手柄圆点闪烁(颜色+大小变化)
 	api.GET("/settings", handleGetSettings)    // 读取配置
 	api.POST("/settings", handleSaveSettings)  // 保存配置
 	api.GET("/stocks", handleGetStocks)        // 获取股票行情
@@ -279,12 +281,31 @@ func handleMouseAlert(c *gin.Context) {
 	c.JSON(http.StatusOK, gin.H{"ok": true})
 }
 
+// handleHandleFlash 触发拖动手柄圆点闪烁,例如:POST /api/handle/flash {"count":3}
+// count 为闪烁次数(可选,默认 3);前端收到 handleFlash 事件后播放颜色+大小变化动画。
+func handleHandleFlash(c *gin.Context) {
+	var req struct {
+		Count int `json:"count"`
+	}
+	// 允许空 body(默认 3 次闪烁)
+	_ = c.ShouldBindJSON(&req)
+	(&SettingsService{}).HandleFlash(req.Count)
+	c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
 // handleShowWindow 显示并聚焦主窗口(从托盘恢复)
 func handleShowWindow(c *gin.Context) {
 	showMainWindow()
 	c.JSON(http.StatusOK, gin.H{"ok": true})
 }
 
+// handleWindowCompact 窗口收拢/还原(与双击手柄等效)。
+// 返回当前是否处于收拢(仅手柄)状态。
+func handleWindowCompact(c *gin.Context) {
+	isCompact := (&SettingsService{}).ToggleWindowCompact()
+	c.JSON(http.StatusOK, gin.H{"ok": true, "compact": isCompact})
+}
+
 // handleGetSettings 返回当前配置
 func handleGetSettings(c *gin.Context) {
 	c.JSON(http.StatusOK, loadSettings())

+ 8 - 0
croc_process_other.go

@@ -0,0 +1,8 @@
+//go:build !windows
+
+// croc_process_other.go — 非 Windows 平台无需隐藏控制台窗口。
+package main
+
+import "os/exec"
+
+func hideProcessWindow(cmd *exec.Cmd) {}

+ 17 - 0
croc_process_windows.go

@@ -0,0 +1,17 @@
+//go:build windows
+
+// croc_process_windows.go — Windows 下隐藏 croc 子进程的控制台窗口。
+// 主程序以 -H windowsgui 构建,若 croc 子进程弹出黑色控制台窗口会很突兀。
+package main
+
+import (
+	"os/exec"
+	"syscall"
+)
+
+func hideProcessWindow(cmd *exec.Cmd) {
+	if cmd.SysProcAttr == nil {
+		cmd.SysProcAttr = &syscall.SysProcAttr{}
+	}
+	cmd.SysProcAttr.HideWindow = true // 不显示子进程控制台窗口
+}

+ 261 - 0
croc_service.go

@@ -0,0 +1,261 @@
+// croc_service.go — Croc 命令行传输工具的 Wails 服务。
+//
+// croc 是一款点对点加密文件传输 CLI:发送端执行 `croc send <文件>` 会生成
+// 一个传输码(如 mug-aids-amend),接收端执行 `croc <传输码>` 即可接收。
+//
+// 本服务自动在系统 PATH 中查找 croc(exec.LookPath),找到才允许前端展示
+// 「Croc 传送」工具卡片;发送/接收共用的密码保存在设置中(AppSettings.Croc)。
+// 设置了密码时,发送改用 `croc send --code <密码>`,接收端用同一密码作为
+// 传输码,双方只要密码一致即可完成传输。
+// 注意:croc 的 `--pass` 是中继服务器密码(公共中继固定为 pass123),不能用它
+// 承载用户密码,否则会连不上公共中继。
+package main
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"os/exec"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+)
+
+// CrocConfig 是 Croc 传送的配置(发送/接收共用密码)。
+type CrocConfig struct {
+	Password string `json:"password"` // 发送/接收共用密码:作为 croc 传输码(--code),空=使用随机传输码
+	Dir      string `json:"dir"`      // 接收保存目录,空=当前工作目录
+}
+
+// CrocInfo 描述本机 croc 的可用性,用于前端决定是否展示工具卡片。
+type CrocInfo struct {
+	Available bool   `json:"available"` // PATH 中是否找到 croc
+	Path      string `json:"path"`      // croc 可执行文件路径
+	Version   string `json:"version"`   // 版本信息(croc --version 输出)
+}
+
+// CrocService 是 Croc 传送功能的 Wails 服务。
+type CrocService struct {
+	mu         sync.Mutex
+	sendCancel context.CancelFunc
+	recvCancel context.CancelFunc
+}
+
+// crocAnsiRe 匹配终端颜色控制符,输出到界面前先剥离。
+var crocAnsiRe = regexp.MustCompile("\x1b\\[[0-9;]*[a-zA-Z]")
+
+// NewCrocService 创建 Croc 服务实例。
+func NewCrocService() *CrocService {
+	return &CrocService{}
+}
+
+// Available 检测 PATH 中是否存在 croc,并返回其路径与版本。
+func (s *CrocService) Available() CrocInfo {
+	path, err := exec.LookPath("croc")
+	if err != nil || path == "" {
+		return CrocInfo{}
+	}
+	info := CrocInfo{Available: true, Path: path}
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, path, "--version")
+	hideProcessWindow(cmd)
+	if out, err := cmd.Output(); err == nil {
+		info.Version = strings.TrimSpace(string(out))
+	}
+	return info
+}
+
+// GetConfig 读取当前 Croc 配置(每次从设置文件读取,保证与设置页保存结果一致)。
+func (s *CrocService) GetConfig() CrocConfig {
+	return loadSettings().Croc
+}
+
+// StartSend 开始发送文件:croc --yes [--code 密码] send <文件...>。
+// 设置了密码时用密码作为传输码(双方一致即可),否则由 croc 生成随机传输码。
+// 日志通过 croc:log 事件推送。
+func (s *CrocService) StartSend(files []string) error {
+	if len(files) == 0 {
+		return errors.New("请先选择要发送的文件")
+	}
+	path, err := exec.LookPath("croc")
+	if err != nil {
+		return errors.New("未找到 croc,请先安装并加入系统 PATH")
+	}
+
+	s.mu.Lock()
+	if s.sendCancel != nil {
+		s.mu.Unlock()
+		return errors.New("已有发送任务正在进行")
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	s.sendCancel = cancel
+
+	args := append(s.baseArgs(), "send")
+	if pw := s.password(); pw != "" {
+		if len(pw) < 6 {
+			s.sendCancel = nil
+			cancel()
+			s.mu.Unlock()
+			return errors.New("Croc 密码至少 6 个字符(croc 对传输码长度的限制)")
+		}
+		args = append(args, "--code", pw) // 密码即传输码:发送/接收共用
+	}
+	args = append(args, files...)
+	cmd := exec.CommandContext(ctx, path, args...)
+	hideProcessWindow(cmd)
+	if err := s.streamProcess(cmd, "send"); err != nil {
+		s.sendCancel = nil
+		cancel()
+		s.mu.Unlock()
+		return fmt.Errorf("启动发送失败:%w", err)
+	}
+	s.mu.Unlock()
+	return nil
+}
+
+// StartReceive 开始接收:croc --yes [--out 目录] <传输码>。
+// 传输码为空但设置中配置了密码时,自动把密码当作传输码,便于双方只用同一密码。
+func (s *CrocService) StartReceive(code, dir string) error {
+	code = strings.TrimSpace(code)
+	if code == "" {
+		code = s.password() // 密码即传输码:接收端可只配置密码
+	}
+	if code == "" {
+		return errors.New("请输入对方发来的传输码(或在设置中配置 Croc 传送密码)")
+	}
+	if code == s.password() && len(code) < 6 {
+		return errors.New("Croc 密码至少 6 个字符(croc 对传输码长度的限制)")
+	}
+	path, err := exec.LookPath("croc")
+	if err != nil {
+		return errors.New("未找到 croc,请先安装并加入系统 PATH")
+	}
+
+	s.mu.Lock()
+	if s.recvCancel != nil {
+		s.mu.Unlock()
+		return errors.New("已有接收任务正在进行")
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	s.recvCancel = cancel
+
+	args := s.baseArgs()
+	if dir = strings.TrimSpace(dir); dir != "" {
+		args = append(args, "--out", dir)
+	}
+	args = append(args, code)
+	cmd := exec.CommandContext(ctx, path, args...)
+	hideProcessWindow(cmd)
+	if err := s.streamProcess(cmd, "recv"); err != nil {
+		s.recvCancel = nil
+		cancel()
+		s.mu.Unlock()
+		return fmt.Errorf("启动接收失败:%w", err)
+	}
+	s.mu.Unlock()
+	return nil
+}
+
+// StopSend 终止发送任务(若在运行)。
+func (s *CrocService) StopSend() {
+	s.mu.Lock()
+	cancel := s.sendCancel
+	s.mu.Unlock()
+	if cancel != nil {
+		cancel()
+	}
+}
+
+// StopReceive 终止接收任务(若在运行)。
+func (s *CrocService) StopReceive() {
+	s.mu.Lock()
+	cancel := s.recvCancel
+	s.mu.Unlock()
+	if cancel != nil {
+		cancel()
+	}
+}
+
+// baseArgs 返回发送/接收共用的命令行参数:自动确认所有提示。
+func (s *CrocService) baseArgs() []string {
+	return []string{"--yes"}
+}
+
+// password 返回设置中保存的发送/接收共用密码(已去空格)。
+func (s *CrocService) password() string {
+	return strings.TrimSpace(s.GetConfig().Password)
+}
+
+// streamProcess 启动进程并把 stdout/stderr 逐行推送给前端(croc:log)。
+// 调用方需持有 s.mu。
+func (s *CrocService) streamProcess(cmd *exec.Cmd, kind string) error {
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return err
+	}
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		return err
+	}
+	if err := cmd.Start(); err != nil {
+		return err
+	}
+
+	emit := func(raw string) {
+		line := strings.TrimSpace(crocAnsiRe.ReplaceAllString(raw, ""))
+		if line == "" {
+			return
+		}
+		app.Event.Emit("croc:log", map[string]string{"kind": kind, "line": line})
+	}
+	go scanCrocStream(stdout, emit)
+	go scanCrocStream(stderr, emit)
+
+	go func() {
+		waitErr := cmd.Wait()
+		exitCode := 0
+		if waitErr != nil {
+			var ee *exec.ExitError
+			if errors.As(waitErr, &ee) {
+				exitCode = ee.ExitCode()
+			} else {
+				exitCode = -1
+			}
+		}
+		s.mu.Lock()
+		if kind == "send" {
+			s.sendCancel = nil
+		} else {
+			s.recvCancel = nil
+		}
+		s.mu.Unlock()
+		app.Event.Emit("croc:exit", map[string]any{"kind": kind, "code": exitCode})
+	}()
+	return nil
+}
+
+// scanCrocStream 按 \r 或 \n 切分输出(croc 的进度用 \r 刷新),逐段回调。
+func scanCrocStream(r io.Reader, emit func(string)) {
+	scanner := bufio.NewScanner(r)
+	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+	scanner.Split(func(data []byte, atEOF bool) (int, []byte, error) {
+		if atEOF && len(data) == 0 {
+			return 0, nil, nil
+		}
+		if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
+			return i + 1, data[:i], nil
+		}
+		if atEOF {
+			return len(data), data, nil
+		}
+		return 0, nil, nil
+	})
+	for scanner.Scan() {
+		emit(scanner.Text())
+	}
+}

+ 78 - 0
frontend/bindings/TengTuToolbox/crocservice.js

@@ -0,0 +1,78 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * CrocService 是 Croc 传送功能的 Wails 服务。
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * Available 检测 PATH 中是否存在 croc,并返回其路径与版本。
+ * @returns {$CancellablePromise<$models.CrocInfo>}
+ */
+export function Available() {
+    return $Call.ByID(2415933301).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType0($result);
+    }));
+}
+
+/**
+ * GetConfig 读取当前 Croc 配置(每次从设置文件读取,保证与设置页保存结果一致)。
+ * @returns {$CancellablePromise<$models.CrocConfig>}
+ */
+export function GetConfig() {
+    return $Call.ByID(1190897156).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType1($result);
+    }));
+}
+
+/**
+ * StartReceive 开始接收:croc --yes [--out 目录] <传输码>。
+ * 传输码为空但设置中配置了密码时,自动把密码当作传输码,便于双方只用同一密码。
+ * @param {string} code
+ * @param {string} dir
+ * @returns {$CancellablePromise<void>}
+ */
+export function StartReceive(code, dir) {
+    return $Call.ByID(2611871899, code, dir);
+}
+
+/**
+ * StartSend 开始发送文件:croc --yes [--code 密码] send <文件...>。
+ * 设置了密码时用密码作为传输码(双方一致即可),否则由 croc 生成随机传输码。
+ * 日志通过 croc:log 事件推送。
+ * @param {string[]} files
+ * @returns {$CancellablePromise<void>}
+ */
+export function StartSend(files) {
+    return $Call.ByID(3871780532, files);
+}
+
+/**
+ * StopReceive 终止接收任务(若在运行)。
+ * @returns {$CancellablePromise<void>}
+ */
+export function StopReceive() {
+    return $Call.ByID(2629947971);
+}
+
+/**
+ * StopSend 终止发送任务(若在运行)。
+ * @returns {$CancellablePromise<void>}
+ */
+export function StopSend() {
+    return $Call.ByID(1820503948);
+}
+
+// Private type creation functions
+const $$createType0 = $models.CrocInfo.createFrom;
+const $$createType1 = $models.CrocConfig.createFrom;

+ 13 - 2
frontend/bindings/TengTuToolbox/index.js

@@ -2,13 +2,19 @@
 // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
 // This file is automatically generated. DO NOT EDIT
 
+import * as CrocService from "./crocservice.js";
 import * as GreetService from "./greetservice.js";
 import * as LocalSendService from "./localsendservice.js";
+import * as MemosService from "./memosservice.js";
 import * as SettingsService from "./settingsservice.js";
+import * as YTDLPService from "./ytdlpservice.js";
 export {
+    CrocService,
     GreetService,
     LocalSendService,
-    SettingsService
+    MemosService,
+    SettingsService,
+    YTDLPService
 };
 
 export {
@@ -16,10 +22,13 @@ export {
     AppSettings,
     BiliDynamicItem,
     CommitItem,
+    CrocConfig,
+    CrocInfo,
     CustomTab,
     KLineBar,
     LinkItem,
     LocalSendConfig,
+    MemosConfig,
     MinuteChart,
     MinutePoint,
     MouseAlertConfig,
@@ -35,5 +44,7 @@ export {
     TickerConfig,
     TranslateResult,
     TrendingRepo,
-    WebServiceInfo
+    WebServiceInfo,
+    YTDLPConfig,
+    YTDLPInfo
 } from "./models.js";

+ 38 - 0
frontend/bindings/TengTuToolbox/memosservice.js

@@ -0,0 +1,38 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * MemosService 提供 Memos 速记的配置读取与新建备忘接口。
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * CreateMemo 调用 Memos REST API 新建一条私有备忘,返回备忘名称(形如 memos/xxxx)。
+ * @param {string} content
+ * @returns {$CancellablePromise<string>}
+ */
+export function CreateMemo(content) {
+    return $Call.ByID(52460276, content);
+}
+
+/**
+ * GetConfig 返回当前 Memos 配置(供前端回填设置表单)。
+ * @returns {$CancellablePromise<$models.MemosConfig>}
+ */
+export function GetConfig() {
+    return $Call.ByID(3106109976).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType0($result);
+    }));
+}
+
+// Private type creation functions
+const $$createType0 = $models.MemosConfig.createFrom;

+ 300 - 29
frontend/bindings/TengTuToolbox/models.js

@@ -95,6 +95,14 @@ export class AppSettings {
              */
             this["alwaysOnTop"] = false;
         }
+        if (!("startCompact" in $$source)) {
+            /**
+             * 启动时自动收拢为圆点(窗口缩小)
+             * @member
+             * @type {boolean}
+             */
+            this["startCompact"] = false;
+        }
         if (!("hotkey" in $$source)) {
             /**
              * 呼出(显示)窗口的全局热键
@@ -351,6 +359,30 @@ export class AppSettings {
              */
             this["localSend"] = (new LocalSendConfig());
         }
+        if (!("croc" in $$source)) {
+            /**
+             * Croc 传送配置(发送/接收共用密码)
+             * @member
+             * @type {CrocConfig}
+             */
+            this["croc"] = (new CrocConfig());
+        }
+        if (!("ytdlp" in $$source)) {
+            /**
+             * 视频下载配置(yt-dlp 保存目录)
+             * @member
+             * @type {YTDLPConfig}
+             */
+            this["ytdlp"] = (new YTDLPConfig());
+        }
+        if (!("memos" in $$source)) {
+            /**
+             * Memos 速记配置(地址/访问令牌/默认可见性)
+             * @member
+             * @type {MemosConfig}
+             */
+            this["memos"] = (new MemosConfig());
+        }
         if (!("toolOrder" in $$source)) {
             /**
              * 工具页卡片显示顺序(工具 id 列表,未列出的追加到末尾)
@@ -369,50 +401,62 @@ export class AppSettings {
      * @returns {AppSettings}
      */
     static createFrom($$source = {}) {
-        const $$createField4_0 = $$createType0;
         const $$createField5_0 = $$createType0;
-        const $$createField6_0 = $$createType2;
-        const $$createField9_0 = $$createType4;
-        const $$createField10_0 = $$createType6;
-        const $$createField20_0 = $$createType7;
+        const $$createField6_0 = $$createType0;
+        const $$createField7_0 = $$createType2;
+        const $$createField10_0 = $$createType4;
+        const $$createField11_0 = $$createType6;
         const $$createField21_0 = $$createType7;
-        const $$createField22_0 = $$createType8;
-        const $$createField23_0 = $$createType9;
-        const $$createField34_0 = $$createType10;
-        const $$createField35_0 = $$createType0;
+        const $$createField22_0 = $$createType7;
+        const $$createField23_0 = $$createType8;
+        const $$createField24_0 = $$createType9;
+        const $$createField35_0 = $$createType10;
+        const $$createField36_0 = $$createType11;
+        const $$createField37_0 = $$createType12;
+        const $$createField38_0 = $$createType13;
+        const $$createField39_0 = $$createType0;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("tabOrder" in $$parsedSource) {
-            $$parsedSource["tabOrder"] = $$createField4_0($$parsedSource["tabOrder"]);
+            $$parsedSource["tabOrder"] = $$createField5_0($$parsedSource["tabOrder"]);
         }
         if ("stockCodes" in $$parsedSource) {
-            $$parsedSource["stockCodes"] = $$createField5_0($$parsedSource["stockCodes"]);
+            $$parsedSource["stockCodes"] = $$createField6_0($$parsedSource["stockCodes"]);
         }
         if ("links" in $$parsedSource) {
-            $$parsedSource["links"] = $$createField6_0($$parsedSource["links"]);
+            $$parsedSource["links"] = $$createField7_0($$parsedSource["links"]);
         }
         if ("githubRepos" in $$parsedSource) {
-            $$parsedSource["githubRepos"] = $$createField9_0($$parsedSource["githubRepos"]);
+            $$parsedSource["githubRepos"] = $$createField10_0($$parsedSource["githubRepos"]);
         }
         if ("customTabs" in $$parsedSource) {
-            $$parsedSource["customTabs"] = $$createField10_0($$parsedSource["customTabs"]);
+            $$parsedSource["customTabs"] = $$createField11_0($$parsedSource["customTabs"]);
         }
         if ("stockTicker" in $$parsedSource) {
-            $$parsedSource["stockTicker"] = $$createField20_0($$parsedSource["stockTicker"]);
+            $$parsedSource["stockTicker"] = $$createField21_0($$parsedSource["stockTicker"]);
         }
         if ("newsTicker" in $$parsedSource) {
-            $$parsedSource["newsTicker"] = $$createField21_0($$parsedSource["newsTicker"]);
+            $$parsedSource["newsTicker"] = $$createField22_0($$parsedSource["newsTicker"]);
         }
         if ("poem" in $$parsedSource) {
-            $$parsedSource["poem"] = $$createField22_0($$parsedSource["poem"]);
+            $$parsedSource["poem"] = $$createField23_0($$parsedSource["poem"]);
         }
         if ("mouseAlert" in $$parsedSource) {
-            $$parsedSource["mouseAlert"] = $$createField23_0($$parsedSource["mouseAlert"]);
+            $$parsedSource["mouseAlert"] = $$createField24_0($$parsedSource["mouseAlert"]);
         }
         if ("localSend" in $$parsedSource) {
-            $$parsedSource["localSend"] = $$createField34_0($$parsedSource["localSend"]);
+            $$parsedSource["localSend"] = $$createField35_0($$parsedSource["localSend"]);
+        }
+        if ("croc" in $$parsedSource) {
+            $$parsedSource["croc"] = $$createField36_0($$parsedSource["croc"]);
+        }
+        if ("ytdlp" in $$parsedSource) {
+            $$parsedSource["ytdlp"] = $$createField37_0($$parsedSource["ytdlp"]);
+        }
+        if ("memos" in $$parsedSource) {
+            $$parsedSource["memos"] = $$createField38_0($$parsedSource["memos"]);
         }
         if ("toolOrder" in $$parsedSource) {
-            $$parsedSource["toolOrder"] = $$createField35_0($$parsedSource["toolOrder"]);
+            $$parsedSource["toolOrder"] = $$createField39_0($$parsedSource["toolOrder"]);
         }
         return new AppSettings(/** @type {Partial<AppSettings>} */($$parsedSource));
     }
@@ -522,6 +566,94 @@ export class CommitItem {
     }
 }
 
+/**
+ * CrocConfig 是 Croc 传送的配置(发送/接收共用密码)。
+ */
+export class CrocConfig {
+    /**
+     * Creates a new CrocConfig instance.
+     * @param {Partial<CrocConfig>} [$$source = {}] - The source object to create the CrocConfig.
+     */
+    constructor($$source = {}) {
+        if (!("password" in $$source)) {
+            /**
+             * 发送/接收共用密码:作为 croc 传输码(--code),空=使用随机传输码
+             * @member
+             * @type {string}
+             */
+            this["password"] = "";
+        }
+        if (!("dir" in $$source)) {
+            /**
+             * 接收保存目录,空=当前工作目录
+             * @member
+             * @type {string}
+             */
+            this["dir"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new CrocConfig instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {CrocConfig}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new CrocConfig(/** @type {Partial<CrocConfig>} */($$parsedSource));
+    }
+}
+
+/**
+ * CrocInfo 描述本机 croc 的可用性,用于前端决定是否展示工具卡片。
+ */
+export class CrocInfo {
+    /**
+     * Creates a new CrocInfo instance.
+     * @param {Partial<CrocInfo>} [$$source = {}] - The source object to create the CrocInfo.
+     */
+    constructor($$source = {}) {
+        if (!("available" in $$source)) {
+            /**
+             * PATH 中是否找到 croc
+             * @member
+             * @type {boolean}
+             */
+            this["available"] = false;
+        }
+        if (!("path" in $$source)) {
+            /**
+             * croc 可执行文件路径
+             * @member
+             * @type {string}
+             */
+            this["path"] = "";
+        }
+        if (!("version" in $$source)) {
+            /**
+             * 版本信息(croc --version 输出)
+             * @member
+             * @type {string}
+             */
+            this["version"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new CrocInfo instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {CrocInfo}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new CrocInfo(/** @type {Partial<CrocInfo>} */($$parsedSource));
+    }
+}
+
 /**
  * CustomTab 用户自定义 Tab 页面
  */
@@ -754,6 +886,46 @@ export class LocalSendConfig {
     }
 }
 
+/**
+ * MemosConfig 保存 Memos 速记所需的配置。
+ */
+export class MemosConfig {
+    /**
+     * Creates a new MemosConfig instance.
+     * @param {Partial<MemosConfig>} [$$source = {}] - The source object to create the MemosConfig.
+     */
+    constructor($$source = {}) {
+        if (!("url" in $$source)) {
+            /**
+             * 实例地址,如 https://m.scwy.net
+             * @member
+             * @type {string}
+             */
+            this["url"] = "";
+        }
+        if (!("token" in $$source)) {
+            /**
+             * 个人访问令牌(PAT),只存本地
+             * @member
+             * @type {string}
+             */
+            this["token"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new MemosConfig instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {MemosConfig}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new MemosConfig(/** @type {Partial<MemosConfig>} */($$parsedSource));
+    }
+}
+
 /**
  * MinuteChart 分时图数据
  */
@@ -797,7 +969,7 @@ export class MinuteChart {
      * @returns {MinuteChart}
      */
     static createFrom($$source = {}) {
-        const $$createField2_0 = $$createType12;
+        const $$createField2_0 = $$createType15;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("points" in $$parsedSource) {
             $$parsedSource["points"] = $$createField2_0($$parsedSource["points"]);
@@ -1469,8 +1641,8 @@ export class RepoFeed {
      * @returns {RepoFeed}
      */
     static createFrom($$source = {}) {
-        const $$createField9_0 = $$createType14;
-        const $$createField10_0 = $$createType16;
+        const $$createField9_0 = $$createType17;
+        const $$createField10_0 = $$createType19;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("newer" in $$parsedSource) {
             $$parsedSource["newer"] = $$createField9_0($$parsedSource["newer"]);
@@ -1922,6 +2094,102 @@ export class WebServiceInfo {
     }
 }
 
+/**
+ * YTDLPConfig 是「视频下载」的配置。
+ */
+export class YTDLPConfig {
+    /**
+     * Creates a new YTDLPConfig instance.
+     * @param {Partial<YTDLPConfig>} [$$source = {}] - The source object to create the YTDLPConfig.
+     */
+    constructor($$source = {}) {
+        if (!("dir" in $$source)) {
+            /**
+             * 下载保存目录,空=程序当前工作目录
+             * @member
+             * @type {string}
+             */
+            this["dir"] = "";
+        }
+        if (!("mp3Quality" in $$source)) {
+            /**
+             * mp3 转换质量:0-9(VBR,0 最好)或 128K/192K/320K,默认 0
+             * @member
+             * @type {string}
+             */
+            this["mp3Quality"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new YTDLPConfig instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {YTDLPConfig}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new YTDLPConfig(/** @type {Partial<YTDLPConfig>} */($$parsedSource));
+    }
+}
+
+/**
+ * YTDLPInfo 描述本机 yt-dlp 的可用性,用于前端决定是否展示工具卡片。
+ */
+export class YTDLPInfo {
+    /**
+     * Creates a new YTDLPInfo instance.
+     * @param {Partial<YTDLPInfo>} [$$source = {}] - The source object to create the YTDLPInfo.
+     */
+    constructor($$source = {}) {
+        if (!("available" in $$source)) {
+            /**
+             * PATH 中是否找到 yt-dlp
+             * @member
+             * @type {boolean}
+             */
+            this["available"] = false;
+        }
+        if (!("path" in $$source)) {
+            /**
+             * yt-dlp 可执行文件路径
+             * @member
+             * @type {string}
+             */
+            this["path"] = "";
+        }
+        if (!("version" in $$source)) {
+            /**
+             * 版本信息(yt-dlp --version 输出)
+             * @member
+             * @type {string}
+             */
+            this["version"] = "";
+        }
+        if (!("ffmpeg" in $$source)) {
+            /**
+             * PATH 中是否找到 ffmpeg(用于「下载转 mp3」)
+             * @member
+             * @type {boolean}
+             */
+            this["ffmpeg"] = false;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new YTDLPInfo instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {YTDLPInfo}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new YTDLPInfo(/** @type {Partial<YTDLPInfo>} */($$parsedSource));
+    }
+}
+
 // Private type creation functions
 const $$createType0 = $Create.Array($Create.Any);
 const $$createType1 = LinkItem.createFrom;
@@ -1934,9 +2202,12 @@ const $$createType7 = TickerConfig.createFrom;
 const $$createType8 = PoemConfig.createFrom;
 const $$createType9 = MouseAlertConfig.createFrom;
 const $$createType10 = LocalSendConfig.createFrom;
-const $$createType11 = MinutePoint.createFrom;
-const $$createType12 = $Create.Array($$createType11);
-const $$createType13 = ReleaseItem.createFrom;
-const $$createType14 = $Create.Array($$createType13);
-const $$createType15 = CommitItem.createFrom;
-const $$createType16 = $Create.Array($$createType15);
+const $$createType11 = CrocConfig.createFrom;
+const $$createType12 = YTDLPConfig.createFrom;
+const $$createType13 = MemosConfig.createFrom;
+const $$createType14 = MinutePoint.createFrom;
+const $$createType15 = $Create.Array($$createType14);
+const $$createType16 = ReleaseItem.createFrom;
+const $$createType17 = $Create.Array($$createType16);
+const $$createType18 = CommitItem.createFrom;
+const $$createType19 = $Create.Array($$createType18);

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

@@ -277,6 +277,16 @@ export function GetTrendingRepos() {
     }));
 }
 
+/**
+ * HandleFlash 触发拖动手柄圆点闪烁:向所有前端窗口广播 handleFlash 事件。
+ * count 为闪烁次数(<=0 默认 3 次)。前端订阅后播放颜色 + 大小变化动画。
+ * @param {number} count
+ * @returns {$CancellablePromise<void>}
+ */
+export function HandleFlash(count) {
+    return $Call.ByID(863239368, count);
+}
+
 /**
  * MD5Hash 计算输入字符串的 MD5 摘要(32 位小写十六进制)。
  * 空输入返回空串(前端用于校验)。
@@ -586,6 +596,15 @@ export function TTS(text, voice) {
     return $Call.ByID(734529301, text, voice);
 }
 
+/**
+ * ToggleWindowCompact 双击拖动手柄:在「全尺寸」与「仅手柄大小」之间切换。
+ * 返回切换后是否处于紧凑(仅手柄)状态;前端据此切换 UI(隐藏内容、显示居中还原圆点)。
+ * @returns {$CancellablePromise<boolean>}
+ */
+export function ToggleWindowCompact() {
+    return $Call.ByID(1749284713);
+}
+
 /**
  * Translate 翻译文本:输入中文翻译为英文,输入英文翻译为中文(自动检测方向)。
  * 使用 MyMemory 免费 API,有每日配额限制。

+ 70 - 0
frontend/bindings/TengTuToolbox/ytdlpservice.js

@@ -0,0 +1,70 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * YTDLPService 是「视频下载」功能的 Wails 服务。
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * Available 检测 PATH 中是否存在 yt-dlp,并返回其路径与版本。
+ * @returns {$CancellablePromise<$models.YTDLPInfo>}
+ */
+export function Available() {
+    return $Call.ByID(1705936597).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType0($result);
+    }));
+}
+
+/**
+ * FetchTitle 用 yt-dlp 解析视频标题(不下载),供工具列表显示。
+ * 解析失败(网络/链接失效等)返回空字符串,由前端回退显示链接。
+ * @param {string} url
+ * @returns {$CancellablePromise<string>}
+ */
+export function FetchTitle(url) {
+    return $Call.ByID(1795836596, url);
+}
+
+/**
+ * GetConfig 读取当前下载配置(每次从设置文件读取,保证与设置页保存结果一致)。
+ * @returns {$CancellablePromise<$models.YTDLPConfig>}
+ */
+export function GetConfig() {
+    return $Call.ByID(1661667492).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType1($result);
+    }));
+}
+
+/**
+ * StartDownload 开始下载视频:yt-dlp --newline [-P <目录>] [-x --audio-format mp3] -o "%(title)s.%(ext)s" <url>。
+ * audioOnly 为 true 时下载并转换为 mp3(需要 ffmpeg)。
+ * 日志与退出状态分别通过 ytdlp:log / ytdlp:exit 事件推送。
+ * @param {string} url
+ * @param {boolean} audioOnly
+ * @returns {$CancellablePromise<void>}
+ */
+export function StartDownload(url, audioOnly) {
+    return $Call.ByID(1529748726, url, audioOnly);
+}
+
+/**
+ * StopDownload 终止下载任务(若在运行)。
+ * @returns {$CancellablePromise<void>}
+ */
+export function StopDownload() {
+    return $Call.ByID(2755176414);
+}
+
+// Private type creation functions
+const $$createType0 = $models.YTDLPInfo.createFrom;
+const $$createType1 = $models.YTDLPConfig.createFrom;

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

@@ -11,6 +11,11 @@ import "../github.com/wailsapp/wails/v3/internal/eventcreate.js";
  */
 export const Apilog = "apilog";
 
+/**
+ * Name of the custom event "handleFlash".
+ */
+export const HandleFlash = "handleFlash";
+
 /**
  * Name of the custom event "ntfyChat".
  */

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

@@ -9,6 +9,7 @@ declare module "@wailsio/runtime" {
     namespace Events {
         interface CustomEvents {
             "apilog": string;
+            "handleFlash": number;
             "ntfyChat": string;
             "time": string;
             "todoReminder": string;

+ 136 - 1
frontend/index.html

@@ -11,9 +11,14 @@
 </head>
 <body data-file-drop-target>
 
+    <!-- 窗口收拢时居中显示的还原圆点:双击还原窗口大小 -->
+    <div id="compact-restore" title="双击还原窗口"><span></span></div>
+
     <div class="tab-container">
         <!-- 标签栏(兼作标题栏):左侧 tab,右侧窗口关闭按钮 -->
         <div class="tab-buttons">
+            <!-- 左侧拖动手柄:圆点提示,留出距离,便于拖动窗口 -->
+            <div class="tab-drag-handle" title="拖动窗口"><span class="tab-drag-dot"></span></div>
             <button class="tab-btn active" data-tab="links">网址</button>
             <button class="tab-btn" data-tab="news">新闻</button>
             <button class="tab-btn" data-tab="github">Github</button>
@@ -426,6 +431,57 @@
                         </div>
                     </div>
                 </div>
+
+                <!-- 工具 14:Croc 传送——自动检测 PATH 中的 croc CLI,点对点加密传输文件 -->
+                <div class="tool-card is-collapsed" data-tool="croc" id="croc-card" style="display:none">
+                    <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
+                        <span class="tool-card-title" title="通过 croc 命令行工具在任意两台电脑间点对点加密传输文件(自动检测系统 PATH 中的 croc)。点击「启动」用设置中的密码作为传输码开始接收,再点一次停止;点击「发送」选择文件后发送,密码同样作为传输码。密码与接收目录在「设置 → 工具」中配置。"><span class="tool-arrow">▸</span><span class="dot"></span>Croc 传送</span>
+                        <span class="tool-status" id="croc-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <div class="tool-row tool-actions">
+                            <button class="btn-ghost" id="croc-toggle" type="button">启动</button>
+                            <button class="btn-primary" id="croc-send" type="button">发送</button>
+                            <span class="tool-hint" id="croc-hint"></span>
+                        </div>
+                        <pre class="croc-log" id="croc-log" hidden></pre>
+                    </div>
+                </div>
+
+                <!-- 工具 15:视频下载——自动检测 PATH 中的 yt-dlp,监视剪贴板中的 B 站链接 -->
+                <div class="tool-card is-collapsed" data-tool="ytdlp" id="ytdlp-card" style="display:none">
+                    <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
+                        <span class="tool-card-title" title="自动检测系统 PATH 中的 yt-dlp(未安装时隐藏本工具)。运行时自动读取剪贴板,发现 B 站链接(bilibili.com / b23.tv)就列在下方,点「下载」即可用 yt-dlp 下载到「设置 → 工具」中配置的目录。"><span class="tool-arrow">▸</span><span class="dot"></span>视频下载</span>
+                        <span class="tool-status" id="ytdlp-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <div class="tool-row tool-actions">
+                            <label class="ytdlp-select-all"><input type="checkbox" id="ytdlp-select-all">全选</label>
+                            <button class="btn-primary" id="ytdlp-batch-download" type="button" disabled>批量下载</button>
+                            <button class="btn-ghost" id="ytdlp-batch-mp3" type="button" style="display:none" disabled>批量转 mp3</button>
+                        </div>
+                        <div class="ytdlp-list" id="ytdlp-list"></div>
+                        <pre class="croc-log" id="ytdlp-log" hidden></pre>
+                    </div>
+                </div>
+
+                <!-- 工具 16:Memos 速记——把内容一键发送到 Memos 实例 -->
+                <div class="tool-card is-collapsed" data-tool="memos">
+                    <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
+                        <span class="tool-card-title" title="写内容后点「发送」即创建一条私有 Memos 备忘(支持 Markdown 与 #标签,Ctrl+Enter 快捷发送)。实例地址与访问令牌在「设置 → 工具」中配置;未配置前输入框不可编辑。"><span class="tool-arrow">▸</span><span class="dot"></span>Memos 速记</span>
+                        <span class="tool-status" id="memos-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <textarea class="input tr-input" id="memos-input" rows="5" placeholder="请先到「设置 → 工具」填写 Memos 地址与访问令牌" spellcheck="false" readonly></textarea>
+                        <div class="tool-row tool-actions">
+                            <button class="btn-primary" id="memos-send" type="button" disabled>发送</button>
+                            <span class="tool-hint" id="memos-hint"></span>
+                        </div>
+                    </div>
+                </div>
             </div>
             <div class="tab-panel" id="tab-host">
                 <div class="host-cards">
@@ -470,6 +526,8 @@
                     <li><code>GET /health</code>健康检查</li>
                     <li><code>POST /api/notify</code>弹出系统通知</li>
                     <li><code>POST /api/window/show</code>显示主窗口</li>
+                    <li><code>POST /api/mouse/alert</code>系统级鼠标提醒(闪烁光标+提示文字)</li>
+                    <li><code>POST /api/handle/flash</code>拖动手柄圆点闪烁(颜色+大小变化,?count= 次数)</li>
                     <li><code>GET /api/settings</code>读取配置</li>
                     <li><code>POST /api/settings</code>保存配置</li>
                     <li><code>GET /api/stocks</code>股票行情(?codes= 可临时指定)</li>
@@ -546,6 +604,18 @@
                         </label>
                     </div>
 
+                    <!-- 启动时缩小 -->
+                    <div class="setting-row">
+                        <div class="setting-info">
+                            <div class="setting-title">启动时缩小</div>
+                            <div class="setting-hint">启动后直接收拢为圆点,双击圆点或拖动手柄即可还原窗口</div>
+                        </div>
+                        <label class="switch">
+                            <input type="checkbox" id="start-compact">
+                            <span class="switch-track"></span>
+                        </label>
+                    </div>
+
                     <!-- 呼出窗口热键 -->
                     <div class="setting-row">
                         <div class="setting-info">
@@ -784,7 +854,72 @@
                         </div>
                     </div>
 
-                    <!-- 待办文件路径 -->
+                    <!-- Croc 传送密码(发送/接收共用) -->
+                    <div class="setting-row croc-setting-row" data-tab-group="tools">
+                        <div class="setting-info">
+                            <div class="setting-title">Croc 传送密码</div>
+                            <div class="setting-hint">发送与接收共用同一密码(至少 6 个字符):设置后作为固定传输码(croc send --code),双方密码一致即可传输;留空则由 croc 生成随机传输码。需本机 PATH 中存在 croc 才会显示「Croc 传送」工具</div>
+                        </div>
+                        <input class="input" id="croc-password" type="password" placeholder="发送/接收共用密码" autocomplete="new-password" spellcheck="false">
+                    </div>
+
+                    <!-- Croc 接收保存目录 -->
+                    <div class="setting-row croc-setting-row" data-tab-group="tools">
+                        <div class="setting-info">
+                            <div class="setting-title">Croc 接收保存目录</div>
+                            <div class="setting-hint">接收到的文件保存位置;留空则保存到程序当前工作目录</div>
+                        </div>
+                        <div class="browser-path-group">
+                            <input class="input" id="croc-recvdir" type="text" placeholder="留空=当前目录" spellcheck="false">
+                            <button class="btn-ghost" id="croc-pickdir" type="button" title="选择接收目录">选择目录</button>
+                        </div>
+                    </div>
+
+                    <!-- 视频下载保存目录 -->
+                    <div class="setting-row ytdlp-setting-row" data-tab-group="tools">
+                        <div class="setting-info">
+                            <div class="setting-title">视频下载保存目录</div>
+                            <div class="setting-hint">「视频下载」工具用 yt-dlp 下载视频的保存位置;留空则保存到程序当前工作目录。需本机 PATH 中存在 yt-dlp 才会显示该工具</div>
+                        </div>
+                        <div class="browser-path-group">
+                            <input class="input" id="ytdlp-dir" type="text" placeholder="留空=当前目录" spellcheck="false">
+                            <button class="btn-ghost" id="ytdlp-pickdir" type="button" title="选择下载目录">选择目录</button>
+                        </div>
+                    </div>
+
+                    <!-- mp3 转换质量 -->
+                    <div class="setting-row ytdlp-setting-row" data-tab-group="tools">
+                        <div class="setting-info">
+                            <div class="setting-title">mp3 转换质量</div>
+                            <div class="setting-hint">「下载转 mp3」「批量转 mp3」使用的音频质量;VBR 数字越小质量越高,也可指定固定码率</div>
+                        </div>
+                        <select class="input" id="ytdlp-mp3-quality">
+                            <option value="0">最佳(VBR 0)</option>
+                            <option value="2">较好(VBR 2)</option>
+                            <option value="5">一般(VBR 5)</option>
+                            <option value="128K">128 kbps</option>
+                            <option value="192K">192 kbps</option>
+                            <option value="320K">320 kbps</option>
+                        </select>
+                    </div>
+
+                    <!-- Memos 地址 -->
+                    <div class="setting-row" data-tab-group="tools">
+                        <div class="setting-info">
+                            <div class="setting-title">Memos 实例地址</div>
+                            <div class="setting-hint">「Memos 速记」发送的目标实例,例如 https://m.scwy.net</div>
+                        </div>
+                        <input class="input" id="memos-url" type="text" placeholder="https://m.scwy.net" spellcheck="false">
+                    </div>
+
+                    <!-- Memos 访问令牌 -->
+                    <div class="setting-row" data-tab-group="tools">
+                        <div class="setting-info">
+                            <div class="setting-title">Memos 访问令牌</div>
+                            <div class="setting-hint">在 Memos 网页端「设置 → 访问令牌」中创建后填入,仅保存在本机</div>
+                        </div>
+                        <input class="input" id="memos-token" type="password" placeholder="粘贴个人访问令牌" spellcheck="false">
+                    </div>
                     <div class="setting-row" data-tab-group="todo">
                         <div class="setting-info">
                             <div class="setting-title">待办文件路径</div>

+ 264 - 0
frontend/public/style.css

@@ -109,6 +109,98 @@ body::before {
         linear-gradient(180deg, #ffffff 0%, var(--bg) 68%);
 }
 
+/* ============================== 窗口收拢(仅手柄)状态 ============================== */
+/* 收拢时隐藏正文全部内容,仅显示居中的还原圆点,确保窗口缩小后仍能双击还原 */
+body.window-compact .tab-container,
+body.window-compact .footer {
+    display: none;
+}
+
+/* 收拢时窗口背景全透明:只保留中间的圆点,四边不显示底色与环境光 */
+html.window-compact,
+body.window-compact {
+    background: transparent;
+}
+body.window-compact::before {
+    display: none;                          /* 移除左上/右上环境光渐变 */
+}
+
+/* 居中还原圆点:仅在 window-compact 时可见,填满整个收拢窗口(32px),
+   既是拖动手柄也是双击还原按钮 */
+#compact-restore {
+    position: fixed;
+    inset: 0;
+    z-index: 9999;
+    display: none;                          /* 默认隐藏,收拢时显示 */
+    align-items: center;
+    justify-content: center;
+    cursor: grab;
+    background: transparent;
+    --wails-draggable: drag;                /* 收拢态整个窗口可拖动 */
+}
+body.window-compact #compact-restore {
+    display: flex;
+}
+#compact-restore span {
+    position: relative;
+    width: 100%;
+    aspect-ratio: 1 / 1;                      /* 正圆:窗口高度可能被系统钳得略高,用宽度决定直径 */
+    max-height: 100%;
+    border-radius: 50%;                     /* 圆点:铺满窗口宽度 */
+    /* 立体球体:左上光源高光 + 右下暗部渐深 */
+    background:
+        radial-gradient(circle at 32% 26%, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0) 30%),
+        radial-gradient(circle at 38% 34%, #ff9a8a 0%, #ff5b63 32%, #e81640 62%, #900c26 88%, #4d0512 100%);
+    box-shadow:
+        inset -3px -5px 9px rgba(70, 0, 14, 0.60),   /* 下缘内阴影:球体暗面 */
+        inset 4px 5px 7px rgba(255, 255, 255, 0.24), /* 上缘内高光:受光面 */
+        0 3px 8px rgba(0, 0, 0, 0.45),               /* 落地投影 */
+        0 0 10px rgba(255, 61, 90, 0.5);             /* 环境光晕 */
+    transition: transform 0.15s ease;
+    --wails-draggable: drag;                /* 圆点自身也可拖动窗口 */
+}
+/* 镜面高光:一个柔和的小亮点,增强球体质感 */
+#compact-restore span::after {
+    content: "";
+    position: absolute;
+    left: 24%;
+    top: 15%;
+    width: 32%;
+    height: 24%;
+    border-radius: 50%;
+    background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0) 72%);
+    filter: blur(1px);
+    pointer-events: none;
+}
+#compact-restore:hover span {
+    transform: scale(1.05);                 /* 悬停轻微放大,提示可交互 */
+}
+
+/* 收拢时圆点缓慢呼吸:缩放 + 透明度 + 光晕同步变化 */
+body.window-compact #compact-restore span {
+    animation: compactBreath 6s ease-in-out infinite;
+}
+@keyframes compactBreath {
+    0%, 100% {
+        transform: scale(0.82);
+        opacity: 0.5;
+        box-shadow:
+            inset -3px -5px 9px rgba(70, 0, 14, 0.60),
+            inset 4px 5px 7px rgba(255, 255, 255, 0.24),
+            0 2px 6px rgba(0, 0, 0, 0.38),
+            0 0 5px rgba(255, 61, 90, 0.30);
+    }
+    50% {
+        transform: scale(1);
+        opacity: 1;
+        box-shadow:
+            inset -3px -5px 9px rgba(70, 0, 14, 0.60),
+            inset 4px 5px 7px rgba(255, 255, 255, 0.24),
+            0 3px 8px rgba(0, 0, 0, 0.45),
+            0 0 16px rgba(255, 61, 90, 0.85);
+    }
+}
+
 /* ============================== 标签页容器 ============================== */
 .tab-container {
     display: flex;
@@ -137,6 +229,60 @@ body::before {
 .tab-buttons::-webkit-scrollbar {
     display: none;                          /* 隐藏滚动条(WebKit) */
 }
+/* 左侧拖动手柄:圆点 + 留白,提示可拖动窗口,同时给首个 Tab 留出距离 */
+.tab-drag-handle {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 30px;
+    height: 30px;
+    margin-right: 6px;                          /* 与首个 Tab 留出距离 */
+    flex-shrink: 0;
+    border-radius: 8px;
+    cursor: grab;
+    --wails-draggable: drag;                    /* 手柄区域可拖动窗口 */
+    transition: background 0.18s ease;
+}
+.tab-drag-handle:hover {
+    background: var(--glass-strong);            /* 悬停提示可拖 */
+}
+.tab-drag-dot {
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+    background: var(--muted);                   /* 中性灰点,跟随主题 */
+    opacity: 0.8;
+    transition: opacity 0.18s ease, background 0.18s ease;
+}
+.tab-drag-handle:hover .tab-drag-dot {
+    opacity: 1;
+    background: var(--grad);                    /* 悬停渐变高亮 */
+}
+
+/* 圆点闪烁动画:API 触发时颜色交替(红↔黄)+ 大小脉冲;
+   --flash-count 控制闪烁次数(每次循环含一次缩放+颜色跳变) */
+.tab-drag-dot.handle-flashing {
+    animation: handleFlash var(--flash-duration, 0.9s) ease-in-out;
+    animation-iteration-count: var(--flash-count, 3);
+}
+@keyframes handleFlash {
+    0% {
+        transform: scale(1);
+        background: var(--muted);
+        box-shadow: 0 0 0 rgba(255, 61, 90, 0);
+    }
+    50% {
+        transform: scale(1.8);                  /* 放大:大小变化 */
+        background: #ff3d3d;                    /* 红色:颜色变化 */
+        box-shadow: 0 0 12px rgba(255, 61, 90, 0.9);
+    }
+    100% {
+        transform: scale(1);
+        background: #ffd23d;                    /* 黄色:颜色变化 */
+        box-shadow: 0 0 0 rgba(255, 210, 61, 0);
+    }
+}
+
 .tab-btn {
     display: inline-flex;
     align-items: center;
@@ -2554,6 +2700,118 @@ a.ver-muted:hover {
     padding: 6px 2px;
 }
 
+/* Croc 传送:命令行日志输出 */
+.croc-log {
+    margin: 10px 0 0;
+    padding: 10px 12px;
+    max-height: 200px;
+    overflow: auto;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass-bg);
+    font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+    font-size: calc(12px + var(--font-delta));
+    line-height: 1.5;
+    white-space: pre-wrap;
+    word-break: break-all;
+    color: var(--muted);
+}
+.croc-log[hidden] {
+    display: none;
+}
+
+/* 视频下载:剪贴板中收集到的 B 站链接列表 */
+.ytdlp-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    margin-top: 10px;
+}
+.ytdlp-select-all {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    color: var(--muted);
+    font-size: calc(13px + var(--font-delta));
+    cursor: pointer;
+    user-select: none;
+}
+.ytdlp-select-all input,
+.ytdlp-check {
+    width: 15px;
+    height: 15px;
+    accent-color: var(--accent);
+    cursor: pointer;
+    flex-shrink: 0;
+}
+.ytdlp-list:empty {
+    display: none;                      /* 无链接时不占空间 */
+}
+.ytdlp-item {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 8px 10px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--glass);
+}
+.ytdlp-meta {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+}
+.ytdlp-title {
+    color: var(--ink);
+    font-size: calc(14px + var(--font-delta));
+    font-weight: 600;
+    text-decoration: none;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.ytdlp-title:hover {
+    color: var(--accent);               /* 悬停高亮,点击可在浏览器查看 */
+    text-decoration: underline;
+}
+.ytdlp-url {
+    color: var(--muted);
+    font-size: calc(12px + var(--font-delta));
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    -webkit-user-select: text;
+    user-select: text;
+}
+.ytdlp-item .tool-hint {
+    flex-shrink: 0;
+}
+.ytdlp-item button {
+    flex-shrink: 0;                     /* 按钮不被标题挤压 */
+}
+/* 已下载完成:移到列表末尾,用虚线、暗背景与降透明度区分 */
+.ytdlp-item.is-done {
+    border-style: dashed;
+    background: rgba(0, 0, 0, 0.28);    /* 背景压暗 */
+    opacity: 0.62;
+}
+.ytdlp-item.is-done .ytdlp-title {
+    color: var(--muted);
+    font-weight: 500;
+}
+.ytdlp-item.is-done .ytdlp-url {
+    opacity: 0.7;
+}
+.ytdlp-item.is-done .tool-hint {
+    color: var(--down);                 /* 「已下载」用绿色提示 */
+    opacity: 1;                         /* 抵消父级透明度,保持可读 */
+}
+.ytdlp-item.is-done .tool-hint:empty {
+    display: none;
+}
+
 /* LocalSend 文件接收确认弹窗 */
 .ls-offer-mask {
     position: fixed;
@@ -3085,3 +3343,9 @@ a.ver-muted:hover {
         flex: 1;
     }
 }
+
+/* Memos 速记:未配置地址/令牌时输入框只读,视觉上弱化 */
+#memos-input[readonly] {
+    opacity: 0.55;
+    cursor: not-allowed;
+}

+ 726 - 4
frontend/src/main.js

@@ -1,6 +1,9 @@
-import {Events, WML, Window} from "@wailsio/runtime"; // 导入 Wails 运行时的事件系统、WML 工具与窗口控制 API
+import {Events, WML, Window, Clipboard} from "@wailsio/runtime"; // 导入 Wails 运行时的事件系统、WML 工具、窗口控制与剪贴板 API
 import {SettingsService, AppSettings} from "../bindings/TengTuToolbox/index.js"; // 设置服务与配置模型
 import {LocalSendService} from "../bindings/TengTuToolbox/index.js"; // LocalSend 服务(发现设备/接收文件)
+import {CrocService} from "../bindings/TengTuToolbox/index.js"; // Croc 服务(命令行加密传输文件)
+import {MemosService} from "../bindings/TengTuToolbox/index.js"; // Memos 服务(REST API 新建备忘)
+import {YTDLPService} from "../bindings/TengTuToolbox/index.js"; // yt-dlp 服务(视频下载)
 import {getCalendarInfo} from "./lunar.js"; // 农历与节日计算
 
 WML.Enable(); // 为带 data-wml-openURL 属性的链接绑定打开行为(动态内容渲染后需调 WML.Reload())
@@ -29,6 +32,7 @@ const FALLBACK_SETTINGS = {
     theme: localStorage.getItem('tengtu-theme') || 'dark',
     autostart: true,
     alwaysOnTop: false,
+    startCompact: false, // 启动时自动收拢为圆点
     hotkey: 'CmdOrCtrl+Shift+G',
     tabOrder: ['links', 'news', 'github', 'stock', 'todo', 'record', 'tools', 'host', 'settings'],
     stockCodes: ['sh000001', 'sh600519', 'sz000001'],
@@ -51,7 +55,10 @@ const FALLBACK_SETTINGS = {
     newsCount: 30,                // 新闻页显示条数
     githubRefreshMinutes: 120,    // Github 自动刷新间隔(分钟)
     fontDelta: 0,                 // 全局字号偏移(px),+-3,默认 0(基准字号已含 +2)
-    toolOrder: ['websvc', 'translate', 'qrcode', 'ntfy', 'chat', 'poem', 'screenshot', 'shutdown', 'md5', 'sha256', 'led', 'reminder', 'localsend'], // 工具页卡片默认顺序
+    toolOrder: ['websvc', 'translate', 'qrcode', 'ntfy', 'chat', 'poem', 'screenshot', 'shutdown', 'md5', 'sha256', 'led', 'reminder', 'localsend', 'croc', 'ytdlp', 'memos'], // 工具页卡片默认顺序
+    croc: {password: '', dir: ''}, // Croc 传送:发送/接收共用密码、接收保存目录
+    ytdlp: {dir: '', mp3Quality: '0'}, // 视频下载:yt-dlp 下载保存目录 + mp3 转换质量
+    memos: {url: 'https://m.scwy.net', token: ''}, // Memos 速记:实例地址/访问令牌
     stockTicker: {enabled: false, width: 1400, fontSize: 14, color: '#ff4d4d', speed: 80, transparent: true, x: -1, y: -1},
     newsTicker: {enabled: false, width: 1400, fontSize: 14, color: '#3ddc84', speed: 60, transparent: true, x: -1, y: -1},
     poem: {enabled: false, color: '#c98a3a', fontSize: 16, tts: false, speed: 18},
@@ -374,7 +381,7 @@ function applyTabOrder(order) {
             label.textContent = named.get(g) || getTabName(g); // 内置/自定义 Tab 名,无 Tab 的组用映射名
             const count = document.createElement('span');
             count.className = 'setting-group-count';
-            count.textContent = `${gRows.length} 项`;
+            count.textContent = `${gRows.filter(r => r.style.display !== 'none').length} 项`; // 只统计可见设置项(如未安装 croc 时隐藏的项)
             title.appendChild(arrow);
             title.appendChild(label);
             title.appendChild(count);
@@ -399,7 +406,7 @@ function applyTabOrder(order) {
 // 未在 toolOrder 中的工具(如新增工具)追加到末尾,保证全部工具都能显示。
 const DEFAULT_TOOL_ORDER = [
     'websvc', 'translate', 'qrcode', 'ntfy', 'chat', 'poem',
-    'screenshot', 'shutdown', 'md5', 'sha256', 'led', 'reminder', 'localsend',
+    'screenshot', 'shutdown', 'md5', 'sha256', 'led', 'reminder', 'localsend', 'croc', 'ytdlp',
 ];
 
 function applyToolOrder(order) {
@@ -557,6 +564,7 @@ document.querySelectorAll('#theme-seg .seg-btn').forEach(btn => {
 // ------------------------------------------------------------------------
 const autostartToggle = document.getElementById('autostart');       // 开机启动开关
 const topToggle = document.getElementById('always-on-top');         // 窗口置顶开关
+const startCompactToggle = document.getElementById('start-compact'); // 启动时缩小开关
 const hotkeyInput = document.getElementById('hotkey');              // 热键输入框
 const tabOrderText = document.getElementById('tab-order');          // Tab 显示顺序(每行一个 id)
 const stockCodesInput = document.getElementById('stock-codes');     // 股票代码
@@ -678,6 +686,7 @@ function markDirty() {
 async function syncForm(settings) {
     autostartToggle.checked = settings.autostart;
     topToggle.checked = settings.alwaysOnTop;
+    if (startCompactToggle) startCompactToggle.checked = !!settings.startCompact;
     hotkeyInput.value = settings.hotkey;
     // 先重建自定义 Tab(供顺序文本框解析名称与 applyTabOrder 使用)
     await applyCustomTabs(settings.customTabs);
@@ -718,6 +727,9 @@ async function syncForm(settings) {
     syncPoemForm(settings.poem);
     syncMouseAlertForm(settings.mouseAlert);
     syncLocalSendForm(settings.localSend);
+    syncCrocForm(settings.croc);
+    syncYTDLPForm(settings.ytdlp);
+    syncMemosForm(settings.memos);
     applyTheme(settings.theme, false);
     applyFontDelta(settings.fontDelta ?? FALLBACK_SETTINGS.fontDelta); // 应用全局字号偏移(默认 +2)
     applyTabOrder(settings.tabOrder); // 按设置重排 Tab 顺序
@@ -837,6 +849,10 @@ async function loadSettings() {
         currentSettings = {...FALLBACK_SETTINGS};
     }
     await syncForm(currentSettings);
+    // 「启动时缩小」:开启则启动后立即收拢为圆点
+    if (currentSettings.startCompact && typeof SettingsService.ToggleWindowCompact === 'function') {
+        setCompactUI(!!SettingsService.ToggleWindowCompact());
+    }
     renderLinks(currentSettings.links || []);
     initFooterPoem(currentSettings.poem);  // footer 古诗按配置显示并加载
     restartAutoTimers(); // 按真实设置的间隔重建自动刷新定时器
@@ -908,6 +924,7 @@ function collectForm() {
         theme: document.documentElement.dataset.theme || 'dark',
         autostart: autostartToggle.checked,
         alwaysOnTop: topToggle.checked,
+        startCompact: !!startCompactToggle?.checked,
         hotkey: hotkeyInput.value.trim() || FALLBACK_SETTINGS.hotkey,
         tabOrder: collectTabOrder(),
         stockCodes,
@@ -938,6 +955,9 @@ function collectForm() {
         poem: collectPoem(),
         mouseAlert: collectMouseAlert(),
         localSend: collectLocalSend(),
+        croc: collectCroc(),
+        ytdlp: collectYTDLP(),
+        memos: collectMemos(),
         toolOrder: collectToolOrder(), // 工具页卡片顺序(含用户刚调整过的)
     };
 }
@@ -972,6 +992,117 @@ function syncLocalSendForm(localSend) {
     }
 }
 
+// 从设置页收集 Croc 配置(发送/接收共用密码、接收保存目录)
+function collectCroc() {
+    return {
+        password: (document.getElementById('croc-password')?.value || '').trim(),
+        dir: (document.getElementById('croc-recvdir')?.value || '').trim(),
+    };
+}
+
+// 回填 Croc 配置到设置页表单,并在卡片未提示时给出接收前提说明
+function syncCrocForm(croc) {
+    const pwdInput = document.getElementById('croc-password');
+    if (pwdInput) pwdInput.value = croc?.password || '';
+    const dirInput = document.getElementById('croc-recvdir');
+    if (dirInput) dirInput.value = croc?.dir || '';
+    const hint = document.getElementById('croc-hint');
+    if (hint && !hint.textContent) {
+        hint.textContent = croc?.password
+            ? '接收时将使用设置中的密码作为传输码'
+            : '接收前请在「设置 → 工具」配置 Croc 传送密码';
+    }
+}
+
+// 选择 Croc 接收保存目录
+const crocPickDirBtn = document.getElementById('croc-pickdir');
+if (crocPickDirBtn) crocPickDirBtn.addEventListener('click', async () => {
+    try {
+        const dir = await SettingsService.PickWebDir(); // 复用目录选择对话框
+        if (!dir) return;
+        const input = document.getElementById('croc-recvdir');
+        if (!input) return;
+        input.value = dir;
+        markDirty();
+        input.dispatchEvent(new Event('input'));
+        await autoSaveSettings(); // 选完立即保存,工具卡片提示同步更新
+    } catch (err) {
+        console.warn('选择 Croc 接收目录失败:', err);
+    }
+});
+
+// 从设置页收集视频下载配置(yt-dlp 保存目录 + mp3 质量)
+function collectYTDLP() {
+    return {
+        dir: (document.getElementById('ytdlp-dir')?.value || '').trim(),
+        mp3Quality: document.getElementById('ytdlp-mp3-quality')?.value || '0',
+    };
+}
+
+// 回填视频下载配置到设置页表单
+function syncYTDLPForm(ytdlp) {
+    const dirInput = document.getElementById('ytdlp-dir');
+    if (dirInput) dirInput.value = ytdlp?.dir || '';
+    const qualitySelect = document.getElementById('ytdlp-mp3-quality');
+    if (qualitySelect) qualitySelect.value = ytdlp?.mp3Quality || '0';
+}
+
+// 从设置页收集 Memos 配置(实例地址/访问令牌)
+function collectMemos() {
+    return {
+        url: (document.getElementById('memos-url')?.value || '').trim(),
+        token: (document.getElementById('memos-token')?.value || '').trim(),
+    };
+}
+
+// 回填 Memos 配置到设置页表单,并刷新工具页的可用状态
+function syncMemosForm(memos) {
+    const urlInput = document.getElementById('memos-url');
+    if (urlInput) urlInput.value = memos?.url || 'https://m.scwy.net';
+    const tokenInput = document.getElementById('memos-token');
+    if (tokenInput) tokenInput.value = memos?.token || '';
+    memosApplyReadiness(memos?.url, memos?.token);
+}
+
+// Memos 是否已配置(地址 + 令牌);未配置时禁止在工具页输入并给出提示
+const memosInput = document.getElementById('memos-input');
+const memosSendBtn = document.getElementById('memos-send');
+const memosHint = document.getElementById('memos-hint');
+let memosReady = false;
+function memosApplyReadiness(url, token) {
+    memosReady = !!((url || '').trim() && (token || '').trim());
+    if (memosInput) {
+        memosInput.readOnly = !memosReady;
+        memosInput.placeholder = memosReady
+            ? '写点什么……支持 Markdown 与 #标签,Ctrl+Enter 发送'
+            : '请先到「设置 → 工具」填写 Memos 地址与访问令牌';
+        if (!memosReady) memosInput.value = '';
+    }
+    if (memosSendBtn) memosSendBtn.disabled = !memosReady;
+    if (memosHint && !memosReady) {
+        memosHint.textContent = '未配置 Memos 地址或访问令牌,暂时无法输入';
+    } else if (memosHint) {
+        memosHint.textContent = '';
+    }
+}
+
+// 选择视频下载保存目录
+const ytdlpPickDirBtn = document.getElementById('ytdlp-pickdir');
+if (ytdlpPickDirBtn) ytdlpPickDirBtn.addEventListener('click', async () => {
+    try {
+        const dir = await SettingsService.PickWebDir();
+        if (!dir) return;
+        const input = document.getElementById('ytdlp-dir');
+        if (!input) return;
+        input.value = dir;
+        markDirty();
+        input.dispatchEvent(new Event('input'));
+        await autoSaveSettings(); // 选完立即保存,下载时读取最新目录
+    } catch (err) {
+        console.warn('选择视频下载目录失败:', err);
+    }
+});
+
 // 从控件收集滚动条配置;位置(x/y)由拖动自动保存,表单不修改
 function collectTicker(kind) {
     const inputs = tickerInputs[kind];
@@ -1058,6 +1189,11 @@ function updateFooterMarquee() {
         const singleW = inner.scrollWidth;
         if (singleW > footerMarqueeEl.clientWidth + 4) {
             inner.textContent = single + single; // 双份文本:-50% 位移正好滚完一份并接上下一份
+            // 动画时长与文本宽度成正比,保证滚动速度(像素/秒)恒定,避免长诗「滚得飞快」
+            // --poem-speed 为基准秒数(文本宽 = 容器宽时滚一轮的时长,默认 18s)
+            const base = parseFloat(getComputedStyle(footerMarqueeEl).getPropertyValue('--poem-speed')) || 18;
+            const dur = singleW / Math.max(footerMarqueeEl.clientWidth, 1) * base;
+            inner.style.animationDuration = dur.toFixed(2) + 's';
             footerMarqueeEl.classList.add('scrolling');
         }
     });
@@ -2979,6 +3115,512 @@ lsOfferReject?.addEventListener('click', async () => {
     }
 })();
 
+// ===== 工具页:Croc 传送——自动检测 PATH 中的 croc CLI,点对点加密传输文件 =====
+const crocCard = document.getElementById('croc-card');              // 工具卡片(croc 不存在时保持隐藏)
+const crocStatusEl = document.getElementById('croc-status');        // 状态徽标
+const crocToggleBtn = document.getElementById('croc-toggle');       // 接收开关(启动/停止乒乓按钮)
+const crocSendBtn = document.getElementById('croc-send');           // 发送(发送中变为停止发送)
+const crocHintEl = document.getElementById('croc-hint');            // 统一提示
+const crocLogEl = document.getElementById('croc-log');              // 日志输出
+
+let crocSending = false;    // 是否正在发送(发送按钮在「发送/停止发送」间切换)
+let crocReceiving = false;  // 是否正在接收(启动/停止乒乓按钮)
+
+// 更新 Croc 状态徽标(busy=运行中,高亮显示)
+function crocSetStatus(text, busy) {
+    if (!crocStatusEl) return;
+    crocStatusEl.textContent = text || '';
+    crocStatusEl.classList.toggle('is-on', !!busy);
+}
+
+// 追加一行日志到输出区域
+function crocAppendLog(kind, line) {
+    if (!crocLogEl) return;
+    crocLogEl.hidden = false;
+    const prefix = kind === 'send' ? '[发送] ' : '[接收] ';
+    crocLogEl.textContent += prefix + line + '\n';
+    crocLogEl.scrollTop = crocLogEl.scrollHeight; // 滚动到最新一行
+}
+
+// 按当前发送/接收状态刷新徽标(都空闲时显示已就绪)
+function crocRefreshStatus() {
+    if (crocSending) crocSetStatus('发送中', true);
+    else if (crocReceiving) crocSetStatus('接收中', true);
+    else crocSetStatus('已就绪', false);
+}
+
+// 发送:空闲时选择文件发送;发送中再点一次则停止发送
+if (crocSendBtn) crocSendBtn.addEventListener('click', async () => {
+    if (crocSending) {
+        try {
+            await CrocService.StopSend();
+            crocHintEl.textContent = '已停止发送';
+        } catch (err) {
+            crocHintEl.textContent = '停止失败:' + (err?.message || err);
+        }
+        return;
+    }
+
+    let files = [];
+    try {
+        files = await SettingsService.PickSendFiles();
+    } catch (err) {
+        crocHintEl.textContent = '选择文件失败:' + (err?.message || err);
+        return;
+    }
+    if (!files || !files.length) return; // 用户取消
+
+    if (crocLogEl) { crocLogEl.textContent = ''; crocLogEl.hidden = false; }
+    crocHintEl.textContent = `正在发送 ${files.length} 个文件…`;
+    crocSending = true;
+    crocSendBtn.textContent = '停止发送';
+    try {
+        await CrocService.StartSend(files);
+        crocRefreshStatus();
+    } catch (err) {
+        crocSending = false;
+        crocSendBtn.textContent = '发送';
+        crocHintEl.textContent = '发送失败:' + (err?.message || err);
+    }
+});
+
+// 启动/停止乒乓按钮:接收端用设置中的密码作为传输码
+if (crocToggleBtn) crocToggleBtn.addEventListener('click', async () => {
+    if (crocReceiving) {
+        try {
+            await CrocService.StopReceive();
+            crocHintEl.textContent = '已停止接收';
+        } catch (err) {
+            crocHintEl.textContent = '停止失败:' + (err?.message || err);
+        }
+        return;
+    }
+
+    const pwd = (currentSettings?.croc?.password || '').trim();
+    if (!pwd) {
+        crocHintEl.textContent = '请先在「设置 → 工具」配置 Croc 传送密码,接收时以该密码作为传输码';
+        return;
+    }
+    if (crocLogEl) { crocLogEl.textContent = ''; crocLogEl.hidden = false; }
+    crocHintEl.textContent = '正在接收…(用设置中的密码作为传输码)';
+    try {
+        await CrocService.StartReceive('', (currentSettings?.croc?.dir || '').trim());
+        crocReceiving = true;
+        crocToggleBtn.textContent = '停止';
+        crocRefreshStatus();
+    } catch (err) {
+        crocHintEl.textContent = '接收失败:' + (err?.message || err);
+    }
+});
+
+// 后端日志事件
+Events.On('croc:log', (e) => {
+    const d = e?.data || {};
+    crocAppendLog(d.kind || 'send', d.line || '');
+});
+Events.On('croc:exit', (e) => {
+    const d = e?.data || {};
+    const ok = d.code === 0;
+    if (d.kind === 'send') {
+        crocSending = false;
+        if (crocSendBtn) crocSendBtn.textContent = '发送';
+        crocHintEl.textContent = ok ? '发送已结束' : `发送已停止(退出码 ${d.code})`;
+    } else {
+        crocReceiving = false;
+        if (crocToggleBtn) crocToggleBtn.textContent = '启动';
+        crocHintEl.textContent = ok ? '接收已完成' : `接收已停止(退出码 ${d.code})`;
+    }
+    crocRefreshStatus();
+});
+
+// 隐藏指定的设置行,并同步刷新各设置分组标题上的「N 项」计数
+function hideSettingRows(selector) {
+    document.querySelectorAll(selector).forEach(row => { row.style.display = 'none'; });
+    document.querySelectorAll('.setting-group').forEach(group => {
+        const visible = Array.from(group.querySelectorAll('.setting-row'))
+            .filter(row => row.style.display !== 'none').length;
+        const count = group.querySelector('.setting-group-count');
+        if (count) count.textContent = `${visible} 项`;
+    });
+}
+
+// 未检测到 croc 时隐藏设置页中的 Croc 配置项(密码 / 接收保存目录)
+function hideCrocSettings() {
+    hideSettingRows('.croc-setting-row');
+}
+
+// 页面加载时检测 croc 是否可用:不可用则隐藏整张卡片与相关设置项
+(async function initCroc() {
+    if (!crocCard) return;
+    try {
+        const info = await CrocService.Available();
+        if (!info?.available) {
+            crocCard.style.display = 'none'; // PATH 中没有 croc:不展示工具
+            hideCrocSettings();              // 设置页中的 Croc 配置一并隐藏
+            return;
+        }
+        crocCard.style.display = ''; // 可用:正常展示(默认折叠)
+        crocCard.querySelector('.tool-card-title')?.setAttribute(
+            'title',
+            `通过 croc 命令行工具在任意两台电脑间点对点加密传输文件。已检测到:${info.path || 'croc'}${info.version ? '(' + info.version + ')' : ''}。点击「启动」用设置中的密码作为传输码开始接收,再点一次停止;点击「发送」选择文件后发送。密码与接收目录在「设置 → 工具」中配置。`
+        );
+        crocSetStatus('已就绪', false);
+    } catch (err) {
+        crocCard.style.display = 'none';
+        hideCrocSettings();
+        console.warn('检测 croc 失败:', err);
+    }
+})();
+
+
+// ===== 工具页:视频下载——自动检测 PATH 中的 yt-dlp,监视剪贴板中的 B 站链接 =====
+const ytdlpCard = document.getElementById('ytdlp-card');       // 工具卡片(yt-dlp 不存在时保持隐藏)
+const ytdlpStatusEl = document.getElementById('ytdlp-status'); // 状态徽标
+const ytdlpListEl = document.getElementById('ytdlp-list');     // B 站链接列表
+const ytdlpLogEl = document.getElementById('ytdlp-log');       // 下载日志
+const ytdlpSelectAll = document.getElementById('ytdlp-select-all');       // 全选
+const ytdlpBatchBtn = document.getElementById('ytdlp-batch-download');    // 批量下载
+const ytdlpBatchMp3Btn = document.getElementById('ytdlp-batch-mp3');      // 批量转 mp3(ffmpeg 可用时显示)
+
+let ytdlpAvailable = false;   // PATH 中是否存在 yt-dlp
+let ytdlpFFmpeg = false;      // PATH 中是否存在 ffmpeg(决定是否显示「下载转 mp3」)
+let ytdlpDownloading = false; // 是否正在下载(同时只允许一个任务)
+let ytdlpActiveBtn = null;    // 当前下载行的按钮(用于恢复文字/禁用状态)
+let ytdlpActiveHint = null;   // 当前下载行的状态提示
+let ytdlpActiveLabel = '';    // 当前下载按钮的原始文字(下载 / 下载转 mp3)
+let ytdlpBatching = false;    // 是否处于批量下载流程
+let ytdlpBatchAudio = false;  // 批量流程是否为转 mp3 模式
+let ytdlpBatchQueue = [];     // 批量下载待处理链接队列
+let ytdlpBatchTotal = 0;      // 批量下载总数
+let ytdlpBatchDone = 0;       // 批量下载已完成数
+const ytdlpSeen = new Set();  // 已加入列表的链接,避免剪贴板反复读取导致重复
+const YTDLP_MAX_ITEMS = 20;   // 列表最多保留的链接条数
+
+// 从任意文本中提取第一个 B 站链接(支持 bilibili.com 与 b23.tv 短链)
+function extractBilibiliUrl(text) {
+    const m = String(text || '').match(/https?:\/\/(?:[A-Za-z0-9-]+\.)*(?:bilibili\.com|b23\.tv)\/[^\s"'<>]*/i);
+    if (!m) return '';
+    return m[0].replace(/[),,。;;!!??]+$/, ''); // 去掉尾随的中文标点/括号
+}
+
+// 更新视频下载状态徽标
+function ytdlpSetStatus(text, busy) {
+    if (!ytdlpStatusEl) return;
+    ytdlpStatusEl.textContent = text;
+    ytdlpStatusEl.classList.toggle('is-busy', !!busy);
+}
+
+// 取已勾选的链接(按列表顺序)
+function ytdlpSelectedUrls() {
+    if (!ytdlpListEl) return [];
+    return Array.from(ytdlpListEl.querySelectorAll('.ytdlp-item'))
+        .filter(item => item.querySelector('.ytdlp-check')?.checked)
+        .map(item => item.dataset.url)
+        .filter(Boolean);
+}
+
+// 刷新「全选」与「批量下载」按钮状态
+function ytdlpUpdateBatchCount() {
+    if (!ytdlpListEl) return;
+    const total = ytdlpListEl.querySelectorAll('.ytdlp-item').length;
+    const selected = ytdlpSelectedUrls().length;
+    if (ytdlpSelectAll) {
+        ytdlpSelectAll.checked = total > 0 && selected === total;
+        ytdlpSelectAll.indeterminate = selected > 0 && selected < total;
+        ytdlpSelectAll.disabled = total === 0;
+    }
+    if (ytdlpBatchBtn) {
+        ytdlpBatchBtn.disabled = selected === 0 || ytdlpDownloading;
+        ytdlpBatchBtn.textContent = selected > 0 ? `批量下载(${selected})` : '批量下载';
+    }
+    if (ytdlpBatchMp3Btn) {
+        ytdlpBatchMp3Btn.disabled = selected === 0 || ytdlpDownloading || !ytdlpFFmpeg;
+        ytdlpBatchMp3Btn.textContent = selected > 0 ? `批量转 mp3(${selected})` : '批量转 mp3';
+    }
+}
+
+// 按链接查找列表行
+function ytdlpItemByUrl(url) {
+    return Array.from(ytdlpListEl?.children || []).find(el => el.dataset.url === url) || null;
+}
+
+// 开始批量下载:把勾选的链接放入队列,逐个串行下载(后端同一时间只允许一个任务)
+// audioOnly 为 true 时批量下载并转 mp3。
+function ytdlpStartBatch(audioOnly) {
+    if (ytdlpDownloading) return; // 当前有任务在跑:等它结束再批量
+    const urls = ytdlpSelectedUrls();
+    if (!urls.length) return;
+    ytdlpBatching = true;
+    ytdlpBatchAudio = !!audioOnly;
+    ytdlpBatchQueue = urls.slice();
+    // 点击后自动取消选中
+    ytdlpListEl?.querySelectorAll('.ytdlp-check').forEach(c => { c.checked = false; });
+    ytdlpUpdateBatchCount();
+    ytdlpBatchTotal = urls.length;
+    ytdlpBatchDone = 0;
+    if (ytdlpLogEl) { ytdlpLogEl.textContent = ''; ytdlpLogEl.hidden = false; }
+    ytdlpProcessBatch();
+}
+
+// 处理批量队列中的下一个链接
+function ytdlpProcessBatch() {
+    if (!ytdlpBatching) return;
+    if (!ytdlpBatchQueue.length) {
+        ytdlpBatching = false;
+        ytdlpBatchTotal = 0;
+        ytdlpSetStatus('已就绪', false);
+        ytdlpUpdateBatchCount();
+        return;
+    }
+
+    const url = ytdlpBatchQueue.shift();
+    const item = ytdlpItemByUrl(url);
+    const buttons = Array.from(item?.querySelectorAll('button') || []);
+    // 转 mp3 模式选「下载转 mp3」按钮,否则选第一个「下载」按钮
+    const btn = ytdlpBatchAudio ? (buttons.find(b => b.textContent.includes('mp3')) || buttons[0]) : buttons[0];
+    const hint = item?.querySelector('.tool-hint');
+    if (!item || !btn || !hint) { ytdlpProcessBatch(); return; } // 行已被移除则跳过
+
+    ytdlpDownloading = true;
+    ytdlpBatchDone += 1;
+    ytdlpActiveBtn = btn;
+    ytdlpActiveHint = hint;
+    ytdlpActiveLabel = btn.textContent;
+    btn.textContent = '停止';
+    item.classList.remove('is-done'); // 重新下载时恢复普通样式
+    hint.textContent = ytdlpBatchAudio ? '批量转 mp3 中…' : '批量下载中…';
+    document.querySelectorAll('#ytdlp-list button').forEach(b => { b.disabled = (b !== btn); });
+    const batchLabel = ytdlpBatchAudio ? '批量转 mp3' : '批量下载';
+    ytdlpSetStatus(`${batchLabel} ${ytdlpBatchDone}/${ytdlpBatchTotal}`, true);
+    ytdlpUpdateBatchCount();
+
+    YTDLPService.StartDownload(url, ytdlpBatchAudio).catch(() => {
+        hint.textContent = '下载失败';
+        ytdlpFinishDownload(false);
+    });
+}
+
+// 向列表追加一条 B 站链接(已存在则忽略),返回是否新增
+function addYTDLPItem(url) {
+    if (!ytdlpListEl || ytdlpSeen.has(url)) return false;
+    ytdlpSeen.add(url);
+
+    const item = document.createElement('div');
+    item.className = 'ytdlp-item';
+    item.dataset.url = url;
+
+    // 多选框:支持勾选后批量下载
+    const check = document.createElement('input');
+    check.type = 'checkbox';
+    check.className = 'ytdlp-check';
+    check.title = '勾选后可批量下载';
+    check.addEventListener('change', ytdlpUpdateBatchCount);
+
+    // 标题 + 链接:标题异步用 yt-dlp 解析,解析失败时回退显示链接
+    const meta = document.createElement('div');
+    meta.className = 'ytdlp-meta';
+
+    const title = document.createElement('a');
+    title.className = 'ytdlp-title';
+    title.href = url;
+    title.textContent = '获取标题中…';
+    title.title = '点击在浏览器打开';
+    title.setAttribute('data-wml-openURL', url); // 点击用默认/设置中的浏览器打开
+
+    const link = document.createElement('span');
+    link.className = 'ytdlp-url';
+    link.textContent = url;
+    link.title = url;
+
+    meta.appendChild(title);
+    meta.appendChild(link);
+
+    const hint = document.createElement('span');
+    hint.className = 'tool-hint';
+
+    const btn = document.createElement('button');
+    btn.className = 'btn-primary';
+    btn.type = 'button';
+    btn.textContent = '下载';
+    btn.addEventListener('click', () => ytdlpToggleDownload(url, btn, false, hint));
+
+    item.appendChild(check);
+    item.appendChild(meta);
+    item.appendChild(hint);
+    item.appendChild(btn);
+
+    // PATH 中存在 ffmpeg 时,追加「下载转 mp3」按钮
+    if (ytdlpFFmpeg) {
+        const mp3Btn = document.createElement('button');
+        mp3Btn.className = 'btn-ghost';
+        mp3Btn.type = 'button';
+        mp3Btn.textContent = '下载转 mp3';
+        mp3Btn.title = '下载音频并转换为 mp3(使用 ffmpeg)';
+        mp3Btn.addEventListener('click', () => ytdlpToggleDownload(url, mp3Btn, true, hint));
+        item.appendChild(mp3Btn);
+    }
+
+    ytdlpListEl.insertBefore(item, ytdlpListEl.firstChild); // 最新链接放最上面
+
+    // 异步解析视频标题(不下载),避免用户分不清多个链接
+    YTDLPService.FetchTitle(url).then(t => {
+        title.textContent = t || url;
+    }).catch(() => {
+        title.textContent = url;
+    });
+
+    // 超出上限时移除最旧的(跳过正在下载的行)
+    while (ytdlpListEl.children.length > YTDLP_MAX_ITEMS) {
+        const last = ytdlpListEl.lastElementChild;
+        if (last === ytdlpActiveBtn?.closest('.ytdlp-item')) break;
+        ytdlpSeen.delete(last.dataset.url || '');
+        last.remove();
+    }
+    ytdlpUpdateBatchCount(); // 新链接默认不勾选,刷新全选/批量按钮状态
+    return true;
+}
+
+// 恢复下载按钮与状态到空闲态
+function ytdlpFinishDownload(ok) {
+    ytdlpDownloading = false;
+    const item = ytdlpActiveBtn?.closest('.ytdlp-item');
+    if (ytdlpActiveBtn) {
+        // 已完成的按钮改为「重下载」(mp3 按钮对应「重下载 mp3」),否则恢复原文字
+        if (ok) ytdlpActiveBtn.textContent = (ytdlpActiveLabel || '').includes('mp3') ? '重下载 mp3' : '重下载';
+        else ytdlpActiveBtn.textContent = ytdlpActiveLabel || '下载';
+    }
+    if (ytdlpActiveHint) ytdlpActiveHint.textContent = '';
+    if (ok && item) {
+        // 下载完成:标记为已完成并移到列表末尾,与未下载项区分
+        item.classList.add('is-done');
+        if (ytdlpActiveHint) ytdlpActiveHint.textContent = '已下载';
+        if (ytdlpListEl) ytdlpListEl.appendChild(item);
+    }
+    ytdlpActiveBtn = null;
+    ytdlpActiveHint = null;
+    ytdlpActiveLabel = '';
+    document.querySelectorAll('#ytdlp-list button').forEach(b => { b.disabled = false; });
+    ytdlpSetStatus(ok ? '已就绪' : '已停止', false);
+    ytdlpUpdateBatchCount();
+    if (ytdlpBatching) setTimeout(ytdlpProcessBatch, 300); // 批量流程:继续下一条
+}
+
+// 点击某行的「下载 / 下载转 mp3」:空闲时启动任务,进行中再点同一按钮则停止
+async function ytdlpToggleDownload(url, btn, audioOnly, hintEl) {
+    if (ytdlpDownloading) {
+        if (btn !== ytdlpActiveBtn) return; // 其它按钮已禁用
+        try {
+            await YTDLPService.StopDownload();
+        } catch (err) {
+            hintEl.textContent = '停止失败';
+        }
+        return;
+    }
+
+    ytdlpDownloading = true;
+    ytdlpActiveBtn = btn;
+    ytdlpActiveHint = hintEl;
+    ytdlpActiveLabel = btn.textContent;
+    btn.textContent = '停止';
+    btn.closest('.ytdlp-item')?.classList.remove('is-done'); // 重新下载时恢复为普通样式
+    hintEl.textContent = audioOnly ? '转 mp3 中…' : '下载中…';
+    document.querySelectorAll('#ytdlp-list button').forEach(b => { b.disabled = (b !== btn); });
+    if (ytdlpLogEl) { ytdlpLogEl.textContent = ''; ytdlpLogEl.hidden = false; }
+    ytdlpSetStatus(audioOnly ? '转 mp3 中' : '下载中', true);
+    ytdlpUpdateBatchCount(); // 单条下载期间禁用批量下载
+
+    try {
+        await YTDLPService.StartDownload(url, audioOnly);
+    } catch (err) {
+        hintEl.textContent = '下载失败';
+        ytdlpFinishDownload(false);
+    }
+}
+
+// 读取剪贴板文本:优先用 Wails 原生剪贴板,浏览器预览时回退 Web Clipboard API
+async function readClipboardText() {
+    try {
+        if (typeof Clipboard?.Text === 'function') return await Clipboard.Text();
+    } catch (err) {
+        // 忽略,走下面的浏览器回退
+    }
+    try {
+        return await navigator.clipboard.readText();
+    } catch (err) {
+        return '';
+    }
+}
+
+let ytdlpClipBusy = false; // 防止上一次读取未完成时重入
+// 轮询剪贴板:发现 B 站链接就加入列表
+async function pollYTDLPClipboard() {
+    if (!ytdlpAvailable || document.hidden || ytdlpClipBusy) return;
+    ytdlpClipBusy = true;
+    try {
+        const url = extractBilibiliUrl(await readClipboardText());
+        if (url) addYTDLPItem(url);
+    } finally {
+        ytdlpClipBusy = false;
+    }
+}
+
+// 未检测到 yt-dlp 时隐藏设置页中的下载目录配置
+function hideYTDLPSettings() {
+    hideSettingRows('.ytdlp-setting-row');
+}
+
+// 页面加载时检测 yt-dlp 是否可用:可用则展示卡片并开始监视剪贴板
+(async function initYTDLP() {
+    if (!ytdlpCard) return;
+    try {
+        const info = await YTDLPService.Available();
+        if (!info?.available) {
+            ytdlpCard.style.display = 'none'; // PATH 中没有 yt-dlp:不展示工具
+            hideYTDLPSettings();              // 设置页中的下载目录一并隐藏
+            return;
+        }
+        ytdlpAvailable = true;
+        ytdlpFFmpeg = !!info.ffmpeg; // 有 ffmpeg 才显示「下载转 mp3」与「批量转 mp3」
+        if (ytdlpFFmpeg && ytdlpBatchMp3Btn) ytdlpBatchMp3Btn.style.display = '';
+        ytdlpUpdateBatchCount();
+        ytdlpCard.style.display = ''; // 可用:正常展示(默认折叠)
+        ytdlpCard.querySelector('.tool-card-title')?.setAttribute(
+            'title',
+            `自动检测系统 PATH 中的 yt-dlp(${info.path || 'yt-dlp'}${info.version ? ',版本 ' + info.version : ''})。运行时自动读取剪贴板,发现 B 站链接(bilibili.com / b23.tv)就列在下方,点「下载」即可下载到「设置 → 工具」中配置的目录${info.ffmpeg ? ';检测到 ffmpeg,可点「下载转 mp3」直接提取音频' : ''}。`
+        );
+        ytdlpSetStatus('已就绪', false);
+        pollYTDLPClipboard();                    // 启动后先读一次剪贴板
+        setInterval(pollYTDLPClipboard, 1500);   // 之后每 1.5 秒自动检查
+    } catch (err) {
+        ytdlpCard.style.display = 'none';
+        hideYTDLPSettings();
+        console.warn('检测 yt-dlp 失败:', err);
+    }
+})();
+
+// 全选 / 批量下载按钮
+if (ytdlpSelectAll) {
+    ytdlpSelectAll.addEventListener('change', () => {
+        const on = ytdlpSelectAll.checked;
+        ytdlpListEl?.querySelectorAll('.ytdlp-check').forEach(c => { c.checked = on; });
+        ytdlpUpdateBatchCount();
+    });
+}
+if (ytdlpBatchBtn) ytdlpBatchBtn.addEventListener('click', () => ytdlpStartBatch(false));
+if (ytdlpBatchMp3Btn) ytdlpBatchMp3Btn.addEventListener('click', () => ytdlpStartBatch(true));
+
+// 下载日志与退出事件
+Events.On('ytdlp:log', (e) => {
+    const line = String(e?.data?.line || '').trim();
+    if (!line || !ytdlpLogEl) return;
+    ytdlpLogEl.textContent += (ytdlpLogEl.textContent ? '\n' : '') + line;
+    ytdlpLogEl.scrollTop = ytdlpLogEl.scrollHeight; // 自动滚到底部
+});
+Events.On('ytdlp:exit', (e) => {
+    ytdlpFinishDownload(e?.data?.code === 0);
+});
+
+
 
 async function loadHostPanel() {
     try {
@@ -3919,6 +4561,49 @@ document.getElementById('win-close')?.addEventListener('click', () => {
     Window.Close(); // 触发关闭流程,实际效果为最小化到托盘
 });
 
+// ------------------------------------------------------------------------
+// 左侧拖动手柄:双击在「全尺寸」与「仅手柄大小」之间切换窗口
+// ------------------------------------------------------------------------
+const tabDragHandle = document.querySelector('.tab-drag-handle');
+
+// 同步「收拢」界面状态:隐藏正文、只保留居中还原圆点,并让窗口背景透明
+function setCompactUI(isCompact) {
+    document.body.classList.toggle('window-compact', !!isCompact);
+    document.documentElement.classList.toggle('window-compact', !!isCompact);
+    document.title = isCompact ? '腾图工具箱(收拢)' : '腾图工具箱';
+}
+
+tabDragHandle?.addEventListener('dblclick', () => {
+    if (typeof SettingsService.ToggleWindowCompact === 'function') {
+        const isCompact = SettingsService.ToggleWindowCompact(); // 后端切换窗口尺寸,返回是否紧凑
+        setCompactUI(!!isCompact);
+    }
+});
+
+// 居中还原圆点:双击还原窗口大小(紧凑状态下手柄不可见,用此兜底)
+const compactRestore = document.getElementById('compact-restore');
+compactRestore?.addEventListener('dblclick', () => {
+    if (typeof SettingsService.ToggleWindowCompact === 'function') {
+        SettingsService.ToggleWindowCompact();
+        setCompactUI(false);
+    }
+});
+
+// ------------------------------------------------------------------------
+// 拖动手柄圆点闪烁:API POST /api/handle/flash 触发,颜色+大小变化动画
+// 后端广播 handleFlash 事件(payload 为闪烁次数),前端播放 keyframes 动画
+// ------------------------------------------------------------------------
+const tabDragDot = document.querySelector('.tab-drag-dot');
+Events.On('handleFlash', (event) => {
+    if (!tabDragDot) return;
+    const count = (event && event.data) || 3;
+    // 移除旧动画后强制重排,允许连续触发
+    tabDragDot.classList.remove('handle-flashing');
+    void tabDragDot.offsetWidth; // 强制 reflow 以重启动画
+    tabDragDot.style.setProperty('--flash-count', String(count));
+    tabDragDot.classList.add('handle-flashing');
+});
+
 // ------------------------------------------------------------------------
 // 监听后端 "time" 事件(仅在分钟变化时广播),更新底部时间:
 // 农历 + 公历年月日时分 + 节日
@@ -4308,3 +4993,40 @@ window.addEventListener('resize', () => {
     clearTimeout(resizeTimer);
     resizeTimer = setTimeout(redrawChart, 150);
 });
+
+// ------------------------------------------------------------------------
+// 工具页:Memos 速记
+// 内容发送到设置中配置的 Memos 实例,固定创建私有备忘;未配置地址/令牌时输入框只读
+// ------------------------------------------------------------------------
+async function memosSend() {
+    if (!memosReady) {
+        if (memosHint) memosHint.textContent = '未配置 Memos 地址或访问令牌,暂时无法发送';
+        return;
+    }
+    const content = (memosInput?.value || '').trim();
+    if (!content) {
+        if (memosHint) memosHint.textContent = '内容不能为空';
+        memosInput?.focus();
+        return;
+    }
+    if (memosSendBtn) memosSendBtn.disabled = true;
+    if (memosHint) memosHint.textContent = '发送中…';
+    try {
+        const name = await MemosService.CreateMemo(content);
+        if (memosHint) memosHint.textContent = `已发送(${name})`;
+        if (memosInput) memosInput.value = '';
+    } catch (err) {
+        if (memosHint) memosHint.textContent = `发送失败:${err?.message || err}`;
+    } finally {
+        if (memosSendBtn) memosSendBtn.disabled = !memosReady;
+    }
+}
+
+memosSendBtn?.addEventListener('click', memosSend);
+// Ctrl+Enter 快捷发送
+memosInput?.addEventListener('keydown', (e) => {
+    if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+        e.preventDefault();
+        memosSend();
+    }
+});

+ 4 - 0
main.go

@@ -60,6 +60,7 @@ func init() {
 	application.RegisterEvent[string]("apilog") // Gin 日志行实时推送(「主机」页订阅)
 	application.RegisterEvent[string]("todoReminder") // 待办提醒:到点时向前端广播提醒内容
 	application.RegisterEvent[string]("ntfyChat")     // ntfy 聊天室:订阅主题后收到消息广播给前端
+	application.RegisterEvent[int]("handleFlash")     // 拖动手柄圆点闪烁:API 触发,前端播放颜色+大小动画
 }
 
 // main 是应用程序的入口,整体流程如下:
@@ -90,6 +91,9 @@ func main() {
 			application.NewService(&GreetService{}),    // 注册 GreetService,前端可通过 bindings 直接调用其 Greet 方法
 			application.NewService(&SettingsService{}), // 注册 SettingsService,供设置页读写配置、拉取行情
 			application.NewService(&LocalSendService{}), // 注册 LocalSendService,供 LocalSend 页控制发现/接收
+			application.NewService(NewCrocService()),    // 注册 CrocService,供 Croc 传送工具发送/接收文件
+			application.NewService(NewYTDLPService()),   // 注册 YTDLPService,供视频下载工具调用 yt-dlp
+			application.NewService(NewMemosService()),   // 注册 MemosService,供 Memos 速记工具调用 REST API
 			application.NewService(dockService),        // 注册 dockService,前端可通过 bindings 直接调用其 SetBadge 方法
 		},
 		Assets: application.AssetOptions{

+ 90 - 0
memos_service.go

@@ -0,0 +1,90 @@
+// ============================================================================
+// memos_service.go — Memos 速记
+//
+// 通过 Memos 的 REST API 新建备忘:POST {地址}/api/memos,请求头带
+// Authorization: Bearer <访问令牌>,body 为 {"content": "...", "visibility": "..."}。
+// 地址、访问令牌与默认可见性保存在「设置 → 工具」中。
+// ============================================================================
+package main
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+)
+
+// MemosConfig 保存 Memos 速记所需的配置。
+type MemosConfig struct {
+	URL   string `json:"url"`   // 实例地址,如 https://m.scwy.net
+	Token string `json:"token"` // 个人访问令牌(PAT),只存本地
+}
+
+// MemosService 提供 Memos 速记的配置读取与新建备忘接口。
+type MemosService struct{}
+
+// NewMemosService 创建 Memos 服务实例。
+func NewMemosService() *MemosService { return &MemosService{} }
+
+// GetConfig 返回当前 Memos 配置(供前端回填设置表单)。
+func (s *MemosService) GetConfig() MemosConfig {
+	return loadSettings().Memos
+}
+
+// CreateMemo 调用 Memos REST API 新建一条私有备忘,返回备忘名称(形如 memos/xxxx)。
+func (s *MemosService) CreateMemo(content string) (string, error) {
+	content = strings.TrimSpace(content)
+	if content == "" {
+		return "", errors.New("内容不能为空")
+	}
+
+	cfg := loadSettings().Memos
+	base := strings.TrimRight(strings.TrimSpace(cfg.URL), "/")
+	if base == "" {
+		return "", errors.New("未配置 Memos 地址,请到「设置 → 工具」中填写")
+	}
+	token := strings.TrimSpace(cfg.Token)
+	if token == "" {
+		return "", errors.New("未配置访问令牌,请到「设置 → 工具」中填写")
+	}
+
+	// 固定创建私有备忘
+	payload, err := json.Marshal(map[string]string{
+		"content":    content,
+		"visibility": "PRIVATE",
+	})
+	if err != nil {
+		return "", err
+	}
+
+	req, err := http.NewRequest(http.MethodPost, base+"/api/memos", bytes.NewReader(payload))
+	if err != nil {
+		return "", fmt.Errorf("地址无效:%w", err)
+	}
+	req.Header.Set("Authorization", "Bearer "+token)
+	req.Header.Set("Content-Type", "application/json")
+
+	client := &http.Client{Timeout: 20 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("请求失败:%w", err)
+	}
+	defer resp.Body.Close()
+
+	body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return "", fmt.Errorf("创建失败(HTTP %d):%s", resp.StatusCode, strings.TrimSpace(string(body)))
+	}
+
+	var out struct {
+		Name string `json:"name"`
+	}
+	if json.Unmarshal(body, &out) == nil && out.Name != "" {
+		return out.Name, nil
+	}
+	return "已创建", nil
+}

+ 27 - 2
settings.go

@@ -29,7 +29,7 @@ const (
 	DefaultNewsRefreshMinutes     = 120                 // 新闻自动刷新间隔(分钟)
 	DefaultNewsCount              = 30                  // 新闻页显示条数
 	DefaultGithubRefreshMinutes   = 120                 // Github 自动刷新间隔(分钟)
-	AppVersion                    = "0.1.3"             // 应用自身版本(与 build/windows/info.json 的 ProductVersion 一致),用于仓库更新提示
+	AppVersion                    = "0.1.4"             // 应用自身版本(与 build/windows/info.json 的 ProductVersion 一致),用于仓库更新提示
 
 	// 鼠标提醒默认配置
 	DefaultMouseAlertFontSize     = 20        // 字号(px)
@@ -90,6 +90,7 @@ type AppSettings struct {
 	Theme          string           `json:"theme"`          // 界面主题:dark / light
 	Autostart      bool             `json:"autostart"`      // 系统启动时运行
 	AlwaysOnTop    bool             `json:"alwaysOnTop"`    // 窗口置顶
+	StartCompact   bool             `json:"startCompact"`   // 启动时自动收拢为圆点(窗口缩小)
 	Hotkey         string           `json:"hotkey"`         // 呼出(显示)窗口的全局热键
 	TabOrder       []string         `json:"tabOrder"`       // Tab 显示顺序(tabId 列表,未列出的追加到末尾)
 	StockCodes     []string         `json:"stockCodes"`     // 股市页展示的股票代码列表
@@ -124,6 +125,9 @@ type AppSettings struct {
 	FontDelta              int    `json:"fontDelta"`              // 全局字号偏移(px),+-3 范围内调整
 
 	LocalSend LocalSendConfig `json:"localSend"` // LocalSend 文件互传配置(本机名/接收目录/接收模式等)
+	Croc      CrocConfig      `json:"croc"`      // Croc 传送配置(发送/接收共用密码)
+	YTDLP     YTDLPConfig     `json:"ytdlp"`     // 视频下载配置(yt-dlp 保存目录)
+	Memos     MemosConfig     `json:"memos"`     // Memos 速记配置(地址/访问令牌/默认可见性)
 	ToolOrder []string        `json:"toolOrder"` // 工具页卡片显示顺序(工具 id 列表,未列出的追加到末尾)
 }
 
@@ -141,7 +145,7 @@ func defaultSettings() AppSettings {
 		AlwaysOnTop: false,
 		Hotkey:      DefaultHotkey,
 		TabOrder:    []string{"links", "news", "github", "stock", "todo", "record", "tools", "host", "settings"}, // 默认 Tab 顺序(控制/硬件等固定网页已改为自定义 Tab)
-		ToolOrder:   DefaultToolOrder(), // 工具页卡片默认显示顺序
+		ToolOrder:   DefaultToolOrder(),                                                                          // 工具页卡片默认显示顺序
 		StockCodes:  []string{"sh000001", "sh600519", "sz000001"},
 		Links: []LinkItem{
 			{Name: "煎蛋", URL: "https://jandan.net/"},
@@ -176,6 +180,9 @@ func defaultSettings() AppSettings {
 		NewsCount:              DefaultNewsCount,
 		GithubRefreshMinutes:   DefaultGithubRefreshMinutes,
 		FontDelta:              0, // 全局字号偏移默认 0
+		Memos: MemosConfig{
+			URL: "https://m.scwy.net", // 默认 Memos 实例
+		},
 	}
 }
 
@@ -196,6 +203,9 @@ func DefaultToolOrder() []string {
 		"led",        // LED 滚动文字
 		"reminder",   // 定时提醒
 		"localsend",  // LocalSend 互传
+		"croc",       // Croc 传送(PATH 中存在 croc 时才展示)
+		"ytdlp",      // 视频下载(PATH 中存在 yt-dlp 时才展示)
+		"memos",      // Memos 速记
 	}
 }
 
@@ -352,6 +362,21 @@ func normalizeSettings(settings AppSettings) AppSettings {
 	// LocalSend 配置校正:本机名未设置时随机生成、端口兜底、接收目录默认
 	settings.LocalSend = normalizeLocalSend(settings.LocalSend)
 
+	// Croc 配置校正:密码与接收目录均去首尾空格
+	settings.Croc.Password = strings.TrimSpace(settings.Croc.Password)
+	settings.Croc.Dir = strings.TrimSpace(settings.Croc.Dir)
+	// 视频下载目录去首尾空格
+	settings.YTDLP.Dir = strings.TrimSpace(settings.YTDLP.Dir)
+	// mp3 转换质量:仅允许 0-9(VBR)或常见码率,非法值回退到最佳
+	settings.YTDLP.Mp3Quality = strings.TrimSpace(settings.YTDLP.Mp3Quality)
+	if !validMp3Quality(settings.YTDLP.Mp3Quality) {
+		settings.YTDLP.Mp3Quality = "0"
+	}
+
+	// Memos 配置校正:地址去尾斜杠、令牌去空格
+	settings.Memos.URL = strings.TrimRight(strings.TrimSpace(settings.Memos.URL), "/")
+	settings.Memos.Token = strings.TrimSpace(settings.Memos.Token)
+
 	if settings.StockCodes == nil {
 		settings.StockCodes = defaultSettings().StockCodes // 未设置时用默认自选股
 	}

+ 67 - 0
windowcompact.go

@@ -0,0 +1,67 @@
+// ============================================================================
+// windowcompact.go — 拖动手柄:窗口缩小/还原 + 圆点闪烁
+//
+// 1. 双击 Tab 栏左侧拖动手柄:在「全尺寸」与「仅手柄大小」之间切换窗口,
+//    供快速收拢 / 展开窗口。
+// 2. API 服务可通过 POST /api/handle/flash 触发圆点闪烁(颜色 + 大小变化),
+//    向所有前端窗口广播 handleFlash 事件,前端据此运行动画。
+//
+// 关键实现细节:Wails 无边框窗口仍带 WS_THICKFRAME|WS_SYSMENU|
+// WS_MIN/MAXIMIZEBOX 等样式位,DWM 对它们强制最小宽度(约 136px),
+// WM_GETMINMAXINFO 的 PtMinTrackSize 无法覆盖。因此收拢/还原的具体尺寸与
+// 样式切换由平台实现 compactEnterCompact / compactExitCompact 完成
+// (Windows 下临时剥离这些样式位,还原时恢复)。
+// ============================================================================
+package main
+
+import "sync"
+
+// 拖动手柄窗口压缩状态(用锁保护跨协程访问)
+var (
+	compactMu          sync.Mutex
+	compactActive      bool // 是否处于「仅手柄」压缩状态
+	compactW, compactH int  // 压缩前保存的窗口尺寸(还原用)
+)
+
+const (
+	// 手柄目标尺寸:与收拢圆点同大小(32px),收拢后窗口恰好是一个圆点,
+	// 圆点填满窗口可继续拖动、双击还原。
+	compactWinWidth  = 32
+	compactWinHeight = 32
+)
+
+// ToggleWindowCompact 双击拖动手柄:在「全尺寸」与「仅手柄大小」之间切换。
+// 返回切换后是否处于紧凑(仅手柄)状态;前端据此切换 UI(隐藏内容、显示居中还原圆点)。
+func (s *SettingsService) ToggleWindowCompact() bool {
+	if mainWindow == nil {
+		return false
+	}
+
+	compactMu.Lock()
+	defer compactMu.Unlock()
+
+	if !compactActive {
+		// 首次收拢:记录当前尺寸,缩到仅手柄大小
+		compactW, compactH = mainWindow.Size()
+		compactActive = true
+		// 收拢期间禁止拖拽边缘改变窗口大小(Wails 的 JS 与 Win32 命中测试都受此开关控制)
+		mainWindow.SetResizable(false)
+		compactEnterCompact(mainWindow, compactWinWidth, compactWinHeight)
+		return true
+	}
+
+	// 还原到压缩前尺寸
+	compactActive = false
+	compactExitCompact(mainWindow, compactW, compactH)
+	mainWindow.SetResizable(true) // 还原后恢复可调整大小
+	return false
+}
+
+// HandleFlash 触发拖动手柄圆点闪烁:向所有前端窗口广播 handleFlash 事件。
+// count 为闪烁次数(<=0 默认 3 次)。前端订阅后播放颜色 + 大小变化动画。
+func (s *SettingsService) HandleFlash(count int) {
+	if count <= 0 {
+		count = 3
+	}
+	app.Event.Emit("handleFlash", count)
+}

+ 23 - 0
windowcompact_other.go

@@ -0,0 +1,23 @@
+//go:build !windows
+
+// ============================================================================
+// windowcompact_other.go — 拖动手柄窗口尺寸切换(非 Windows 实现)
+//
+// 非 Windows 平台无 DWM 最小宽度强制问题,直接走 Wails SetSize/SetMinSize。
+// ============================================================================
+package main
+
+import "github.com/wailsapp/wails/v3/pkg/application"
+
+func compactEnterCompact(win *application.WebviewWindow, width, height int) {
+	if win.IsMaximised() {
+		win.UnMaximise()
+	}
+	win.SetMinSize(width, height)
+	win.SetSize(width, height)
+}
+
+func compactExitCompact(win *application.WebviewWindow, width, height int) {
+	win.SetSize(width, height)
+	win.SetMinSize(0, 0)
+}

+ 85 - 0
windowcompact_windows.go

@@ -0,0 +1,85 @@
+//go:build windows
+
+// ============================================================================
+// windowcompact_windows.go — 拖动手柄窗口尺寸切换(Windows 实现)
+//
+// Wails 无边框窗口仍带 WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX |
+// WS_MAXIMIZEBOX | WS_BORDER | WS_DLGFRAME 样式位,DWM 对它们强制最小宽度
+// (实测约 136px,剥离边框后仍有约 46px),WM_GETMINMAXINFO 的
+// PtMinTrackSize 无法覆盖该下限。因此进入收拢状态时临时剥离这些样式位
+// (SWP_FRAMECHANGED 使其生效),还原时恢复原样式。
+// ============================================================================
+package main
+
+import (
+	"unsafe"
+
+	"github.com/wailsapp/wails/v3/pkg/application"
+)
+
+const (
+	gwlStyle = -16 // GWL_STYLE
+
+	// 触发 DWM 最小宽度强制的样式位
+	compactStyleMask = 0x00040000 | // WS_THICKFRAME
+		0x00080000 | // WS_SYSMENU
+		0x00020000 | // WS_MINIMIZEBOX
+		0x00010000 | // WS_MAXIMIZEBOX
+		0x00800000 | // WS_BORDER
+		0x00400000 // WS_DLGFRAME
+
+	swpNoMove        = 0x0002
+	swpNoSize        = 0x0001
+	swpNoZOrder2     = 0x0004
+	swpNoActivate2   = 0x0010
+	swpFrameChanged  = 0x0020
+)
+
+var (
+	procGetWindowLongW = user32.NewProc("GetWindowLongW")
+	procSetWindowLongW = user32.NewProc("SetWindowLongW")
+)
+
+// 收拢时保存的原始窗口样式(还原用)
+var compactSavedStyle int32
+
+func compactWindowHwnd(win *application.WebviewWindow) uintptr {
+	if win == nil {
+		return 0
+	}
+	return uintptr(unsafe.Pointer(win.NativeWindow()))
+}
+
+// compactEnterCompact 进入收拢状态:剥离 DWM 强制最小宽度的样式位后缩小窗口。
+func compactEnterCompact(win *application.WebviewWindow, width, height int) {
+	if win.IsMaximised() {
+		win.UnMaximise() // 最大化状态下 SetSize 不生效,先还原
+	}
+	hwnd := compactWindowHwnd(win)
+	if hwnd != 0 {
+		// 保存并剥离样式位,SWP_FRAMECHANGED 使样式变更立即生效
+		styleIdx := int32(gwlStyle)
+		style, _, _ := procGetWindowLongW.Call(hwnd, uintptr(styleIdx))
+		compactSavedStyle = int32(style)
+		newStyle := int32(style) &^ compactStyleMask
+		procSetWindowLongW.Call(hwnd, uintptr(styleIdx), uintptr(newStyle))
+		procSetWindowPos.Call(hwnd, 0, 0, 0, 0, 0,
+			swpNoMove|swpNoSize|swpNoZOrder2|swpNoActivate2|swpFrameChanged)
+	}
+	win.SetMinSize(width, height) // 解锁 Wails 侧最小尺寸约束
+	win.SetSize(width, height)
+}
+
+// compactExitCompact 退出收拢状态:恢复原样式位并还原窗口尺寸。
+func compactExitCompact(win *application.WebviewWindow, width, height int) {
+	hwnd := compactWindowHwnd(win)
+	if hwnd != 0 && compactSavedStyle != 0 {
+		styleIdx := int32(gwlStyle)
+		procSetWindowLongW.Call(hwnd, uintptr(styleIdx), uintptr(compactSavedStyle))
+		procSetWindowPos.Call(hwnd, 0, 0, 0, 0, 0,
+			swpNoMove|swpNoSize|swpNoZOrder2|swpNoActivate2|swpFrameChanged)
+		compactSavedStyle = 0
+	}
+	win.SetSize(width, height)
+	win.SetMinSize(0, 0)
+}

+ 206 - 0
ytdlp_service.go

@@ -0,0 +1,206 @@
+// ytdlp_service.go — yt-dlp 视频下载的 Wails 服务。
+//
+// 本服务自动在系统 PATH 中查找 yt-dlp(exec.LookPath),找到才允许前端展示
+// 「视频下载」工具卡片;下载保存目录保存在设置中(AppSettings.YTDLP)。
+// 下载过程通过 ytdlp:log / ytdlp:exit 事件把日志与退出状态推送给前端。
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os/exec"
+	"strings"
+	"sync"
+	"time"
+)
+
+// YTDLPConfig 是「视频下载」的配置。
+type YTDLPConfig struct {
+	Dir        string `json:"dir"`        // 下载保存目录,空=程序当前工作目录
+	Mp3Quality string `json:"mp3Quality"` // mp3 转换质量:0-9(VBR,0 最好)或 128K/192K/320K,默认 0
+}
+
+// validMp3Quality 校验 mp3 转换质量参数:0-9(VBR,0 最好)或常见码率。
+func validMp3Quality(q string) bool {
+	switch strings.ToUpper(q) {
+	case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "128K", "192K", "320K":
+		return true
+	}
+	return false
+}
+
+// YTDLPInfo 描述本机 yt-dlp 的可用性,用于前端决定是否展示工具卡片。
+type YTDLPInfo struct {
+	Available bool   `json:"available"` // PATH 中是否找到 yt-dlp
+	Path      string `json:"path"`      // yt-dlp 可执行文件路径
+	Version   string `json:"version"`   // 版本信息(yt-dlp --version 输出)
+	FFmpeg    bool   `json:"ffmpeg"`    // PATH 中是否找到 ffmpeg(用于「下载转 mp3」)
+}
+
+// YTDLPService 是「视频下载」功能的 Wails 服务。
+type YTDLPService struct {
+	mu     sync.Mutex
+	cancel context.CancelFunc
+}
+
+// NewYTDLPService 创建 yt-dlp 服务实例。
+func NewYTDLPService() *YTDLPService {
+	return &YTDLPService{}
+}
+
+// Available 检测 PATH 中是否存在 yt-dlp,并返回其路径与版本。
+func (s *YTDLPService) Available() YTDLPInfo {
+	path, err := exec.LookPath("yt-dlp")
+	if err != nil || path == "" {
+		return YTDLPInfo{}
+	}
+	info := YTDLPInfo{Available: true, Path: path}
+	if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil && ffmpegPath != "" {
+		info.FFmpeg = true // 有 ffmpeg 才能转 mp3
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, path, "--version")
+	hideProcessWindow(cmd)
+	if out, err := cmd.Output(); err == nil {
+		info.Version = strings.TrimSpace(string(out))
+	}
+	return info
+}
+
+// GetConfig 读取当前下载配置(每次从设置文件读取,保证与设置页保存结果一致)。
+func (s *YTDLPService) GetConfig() YTDLPConfig {
+	return loadSettings().YTDLP
+}
+
+// StartDownload 开始下载视频:yt-dlp --newline [-P <目录>] [-x --audio-format mp3] -o "%(title)s.%(ext)s" <url>。
+// audioOnly 为 true 时下载并转换为 mp3(需要 ffmpeg)。
+// 日志与退出状态分别通过 ytdlp:log / ytdlp:exit 事件推送。
+func (s *YTDLPService) StartDownload(url string, audioOnly bool) error {
+	url = strings.TrimSpace(url)
+	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
+		return errors.New("下载地址无效")
+	}
+	path, err := exec.LookPath("yt-dlp")
+	if err != nil {
+		return errors.New("未找到 yt-dlp,请先安装并加入系统 PATH")
+	}
+	if audioOnly {
+		if _, err := exec.LookPath("ffmpeg"); err != nil {
+			return errors.New("未找到 ffmpeg,无法转换为 mp3")
+		}
+	}
+
+	s.mu.Lock()
+	if s.cancel != nil {
+		s.mu.Unlock()
+		return errors.New("已有下载任务正在进行")
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	s.cancel = cancel
+
+	args := []string{"--newline", "--encoding", "utf-8"} // 强制 UTF-8 输出,避免 Windows 下标题/进度按 GBK 输出导致乱码
+	if audioOnly {
+		// 提取音频并转 mp3,质量取设置中的值(0-9 VBR 或 128K/192K/320K)
+		quality := strings.TrimSpace(s.GetConfig().Mp3Quality)
+		if quality == "" {
+			quality = "0"
+		}
+		args = append(args, "-x", "--audio-format", "mp3", "--audio-quality", quality)
+	}
+	if dir := strings.TrimSpace(s.GetConfig().Dir); dir != "" {
+		args = append(args, "-P", dir) // 下载保存目录(设置中配置)
+	}
+	args = append(args, "-o", "%(title)s.%(ext)s", url)
+
+	cmd := exec.CommandContext(ctx, path, args...)
+	hideProcessWindow(cmd)
+	if err := s.streamProcess(cmd); err != nil {
+		s.cancel = nil
+		cancel()
+		s.mu.Unlock()
+		return fmt.Errorf("启动下载失败:%w", err)
+	}
+	s.mu.Unlock()
+	return nil
+}
+
+// StopDownload 终止下载任务(若在运行)。
+func (s *YTDLPService) StopDownload() {
+	s.mu.Lock()
+	cancel := s.cancel
+	s.mu.Unlock()
+	if cancel != nil {
+		cancel()
+	}
+}
+
+// FetchTitle 用 yt-dlp 解析视频标题(不下载),供工具列表显示。
+// 解析失败(网络/链接失效等)返回空字符串,由前端回退显示链接。
+func (s *YTDLPService) FetchTitle(url string) string {
+	url = strings.TrimSpace(url)
+	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
+		return ""
+	}
+	path, err := exec.LookPath("yt-dlp")
+	if err != nil {
+		return ""
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, path, "--encoding", "utf-8", "--no-warnings", "--skip-download", "--get-title", url)
+	hideProcessWindow(cmd)
+	out, err := cmd.Output()
+	if err != nil {
+		return ""
+	}
+	title := strings.TrimSpace(string(out))
+	if i := strings.IndexAny(title, "\r\n"); i >= 0 {
+		title = strings.TrimSpace(title[:i]) // 只取第一行
+	}
+	return title
+}
+
+// streamProcess 启动进程并把输出逐行推送给前端。调用方需持有 s.mu。
+func (s *YTDLPService) streamProcess(cmd *exec.Cmd) error {
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return err
+	}
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		return err
+	}
+	if err := cmd.Start(); err != nil {
+		return err
+	}
+
+	emit := func(raw string) {
+		line := strings.TrimSpace(crocAnsiRe.ReplaceAllString(raw, ""))
+		if line == "" {
+			return
+		}
+		app.Event.Emit("ytdlp:log", map[string]string{"line": line})
+	}
+	go scanCrocStream(stdout, emit)
+	go scanCrocStream(stderr, emit)
+
+	go func() {
+		waitErr := cmd.Wait()
+		exitCode := 0
+		if waitErr != nil {
+			var ee *exec.ExitError
+			if errors.As(waitErr, &ee) {
+				exitCode = ee.ExitCode()
+			} else {
+				exitCode = -1
+			}
+		}
+		s.mu.Lock()
+		s.cancel = nil
+		s.mu.Unlock()
+		app.Event.Emit("ytdlp:exit", map[string]any{"code": exitCode})
+	}()
+	return nil
+}