修复: 粘贴图失效与SFTP串台回退等一批问题

粘贴图根修(Ctrl+V直读系统剪贴板,不再依赖不可靠的paste事件)/SFTP串台双刀(拨号期禁回退本机+盘符路径自动回根)/PDF导出目录设置与文案带落盘路径/性能批(连接保存下载防重/md渲染防抖+预热/hljs按需加载/审计锁外置/快捷方式解析超时/webview-watch可停)/前端62类型错清零(含keyPath蛇形字段炸弹)/目录图标字段错位修复
This commit is contained in:
lxy
2026-10-24 13:48:54 +08:00
parent 4675ae381d
commit 75a42c0b6a
48 changed files with 1072 additions and 220 deletions
+4
View File
@@ -9,3 +9,7 @@ build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
u-desk.exe
u-fs-agent-linux
docs/08-用户指南/u-desk-site/
temp/
frontend/temp/
build/publish/*.exe
+7 -6
View File
@@ -13,6 +13,7 @@ import (
"golang.org/x/sync/errgroup"
"u-desk/internal/api"
"u-desk/internal/common"
"u-desk/internal/filewatch"
"u-desk/internal/filesystem"
"u-desk/internal/hotkey"
@@ -134,7 +135,7 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
slog.Info("可用的模块", "modules", visibleTabs)
// 非关键路径并行初始化(PdfAPI/Filesystem/Cleanup 不阻塞首屏渲染)
go func() {
common.Go("init-modules", func() {
// 2.6. 初始化PDF导出API
slog.Info("初始化PDF导出模块")
if pdfAPI, err := api.NewPdfAPI(); err != nil {
@@ -154,10 +155,10 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
// 清理过期的下载缓存
storage.CleanupExpiredCache()
storage.CleanupSiteCacheDirs()
}()
})
// 6. 异步初始化:UpdateAPI(涉及网络请求,完全异步)
go func() {
common.Go("init-update-api", func() {
checkURL := "https://c.1216.top/last-version.json"
if cfg, err := service.LoadUpdateConfig(); err == nil && cfg.CheckURL != "" {
checkURL = cfg.CheckURL
@@ -176,12 +177,12 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
a.updateAPI.SetConfigChangedCallback(a.resetAutoUpdateCheck)
a.startAutoUpdateCheck()
}
}()
})
// 延迟注册全局热键(轮询等待原生窗口创建完成)
// RegisterHotKey 必须在创建窗口的同一线程调用,
// 通过 PostMessage 将注册请求投递到主线程消息循环
go func() {
common.Go("register-hotkey", func() {
delays := []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond}
for i := 0; i < 15; i++ {
time.Sleep(delays[min(i, len(delays)-1)])
@@ -195,7 +196,7 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions)
}
}
slog.Warn("等待窗口超时")
}()
})
return nil
}
+164
View File
@@ -0,0 +1,164 @@
//go:build windows
package main
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"image/png"
"runtime"
"syscall"
"time"
"unsafe"
"golang.org/x/image/bmp"
"golang.org/x/sys/windows"
)
// binaryLE DIB/BMP 头字段均为小端
var binaryLE = binary.LittleEndian
var (
procRegisterClipboardFormatW = windows.NewLazySystemDLL("user32.dll").NewProc("RegisterClipboardFormatW")
procGlobalLockImg = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalLock")
procGlobalUnlockImg = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalUnlock")
procGlobalSizeImg = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalSize")
)
// cfDIB 剪贴板设备无关位图格式,Win32 稳定 ABI 值
const cfDIB = 8
// pngFormatAtom "PNG" 注册格式的剪贴板原子号(系统级稳定,进程内缓存一次)
var pngFormatAtom uintptr
// ReadClipboardImage 读取系统剪贴板中的图片,返回 PNG 的 base64(无图返回空串+nil)。
// 优先取 "PNG" 注册格式(截图工具/浏览器复制时放的就是完整 PNG 文件字节),
// 否则回落 CF_DIB:补 14 字节 BMP 文件头后交 x/image/bmp 解码再重编为 PNG。
// 供前端 Ctrl+V keydown 直读(WebView2 无编辑焦点时 DOM paste 事件不可靠派发)。
func (a *App) ReadClipboardImage() (string, error) {
// OpenClipboard 与 CloseClipboard 必须同线程,否则剪贴板死锁
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if pngFormatAtom == 0 {
name, err := syscall.UTF16PtrFromString("PNG")
if err != nil {
return "", err
}
if atom, _, _ := procRegisterClipboardFormatW.Call(uintptr(unsafe.Pointer(name))); atom != 0 {
pngFormatAtom = atom
}
}
// 剪贴板是全局互斥资源,短重试避开其他进程的瞬时占用
opened := false
for i := 0; i < 20; i++ {
if r, _, _ := procOpenClipboard.Call(0); r != 0 {
opened = true
break
}
time.Sleep(10 * time.Millisecond)
}
if !opened {
return "", syscall.EBUSY
}
defer procCloseClipboard.Call()
if pngFormatAtom != 0 {
if r, _, _ := procIsClipboardFormatAvailable.Call(pngFormatAtom); r != 0 {
data, ok, err := clipboardFormatData(pngFormatAtom)
if err != nil {
return "", err
}
if ok && len(data) > 0 {
return base64.StdEncoding.EncodeToString(data), nil
}
}
}
if r, _, _ := procIsClipboardFormatAvailable.Call(cfDIB); r == 0 {
return "", nil
}
data, ok, err := clipboardFormatData(cfDIB)
if err != nil {
return "", err
}
if !ok || len(data) == 0 {
return "", nil
}
pngBytes, err := dibToPNG(data)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(pngBytes), nil
}
// clipboardFormatData 取指定格式的剪贴板字节(调用方须已 OpenClipboard)
func clipboardFormatData(format uintptr) ([]byte, bool, error) {
h, _, _ := procGetClipboardData.Call(format)
if h == 0 {
return nil, false, nil
}
p, _, _ := procGlobalLockImg.Call(h)
if p == 0 {
return nil, false, syscall.EBUSY
}
defer procGlobalUnlockImg.Call(h)
size, _, _ := procGlobalSizeImg.Call(h)
if size == 0 {
return nil, false, nil
}
data := make([]byte, size)
copy(data, unsafe.Slice((*byte)(unsafe.Pointer(p)), size))
return data, true, nil
}
// dibToPNG 把 CF_DIB(BITMAPINFO 起、无 14 字节文件头)转为 PNG 字节。
// 覆盖 24/32bpp 的 BI_RGB(0) 与 BI_BITFIELDS(3) 常见情形:DIB 原样透传,
// 仅补 BMP 文件头后交给 x/image/bmp 解码(行字节对齐、bottom-up、负高度=top-down 均由解码器处理)
func dibToPNG(dib []byte) ([]byte, error) {
if len(dib) < 40 {
return nil, fmt.Errorf("DIB 数据过短: %d 字节", len(dib))
}
headerSize := binaryLE.Uint32(dib[0:4])
bpp := binaryLE.Uint16(dib[14:16])
compression := binaryLE.Uint32(dib[16:20])
if headerSize < 40 || int(headerSize) > len(dib) {
return nil, fmt.Errorf("不支持的 DIB 头大小: %d", headerSize)
}
if bpp != 24 && bpp != 32 {
return nil, fmt.Errorf("不支持的位深: %d", bpp)
}
if compression != 0 && compression != 3 { // BI_RGB=0, BI_BITFIELDS=3
return nil, fmt.Errorf("不支持的压缩格式: %d", compression)
}
// 像素数据偏移 = 14 字节文件头 + 信息头;BI_BITFIELDS 且 40 字节头时
// 3 个掩码 DWORD 紧随其后(V4/V5 头 ≥108 字节的掩码内嵌在头里,无需外加)
pixelOffset := 14 + int(headerSize)
if compression == 3 && headerSize == 40 {
pixelOffset += 12
}
bmpBuf := make([]byte, 14+len(dib))
bmpBuf[0], bmpBuf[1] = 'B', 'M'
binaryLE.PutUint32(bmpBuf[2:6], uint32(len(bmpBuf)))
// 6..9 保留位保持 0
binaryLE.PutUint32(bmpBuf[10:14], uint32(pixelOffset))
copy(bmpBuf[14:], dib)
img, err := bmp.Decode(bytes.NewReader(bmpBuf))
if err != nil {
return nil, fmt.Errorf("DIB 解码失败: %w", err)
}
var out bytes.Buffer
if err := png.Encode(&out, img); err != nil {
return nil, fmt.Errorf("PNG 编码失败: %w", err)
}
return out.Bytes(), nil
}
+8 -2
View File
@@ -41,12 +41,18 @@ func (a *App) SftpConnect(req SftpConnectRequest) (string, error) {
}
svc := a.ensureSftpService()
_, err := svc.GetManager().Connect(config)
// 冷连接 SSH 链路串行且最长 10s,各阶段通过事件推给前端做分步反馈,替代盲等
connID := sftp.ConnID(config.Host, config.Port)
_, err := svc.GetManager().Connect(config, func(stage string) {
a.emitEvent("sftp:connect-stage", map[string]interface{}{
"conn_id": connID,
"stage": stage,
})
})
if err != nil {
return "", sftp.ToUserMessage(err)
}
connID := sftp.ConnID(config.Host, config.Port)
return connID, nil
}
+4 -3
View File
@@ -6,6 +6,7 @@ import (
"time"
"u-desk/internal/api"
"u-desk/internal/common"
)
// ========== 版本更新管理接口 ==========
@@ -139,9 +140,9 @@ func (a *App) startAutoUpdateCheck() {
ticker := a.updateTicker
a.mu.Unlock()
go a.checkUpdate()
common.Go("check-update", a.checkUpdate)
go func() {
common.Go("update-ticker", func() {
for {
select {
case <-ticker.C:
@@ -150,7 +151,7 @@ func (a *App) startAutoUpdateCheck() {
return
}
}
}()
})
}
// stopUpdateTicker 停止自动更新检查定时器及其 goroutine
+60 -22
View File
@@ -10,6 +10,7 @@ import (
"unsafe"
"golang.org/x/sys/windows"
"u-desk/internal/common"
)
// Chrome_WidgetWin 系列类名是 WebView2 的宿主窗口层级
@@ -28,6 +29,12 @@ var (
// 修复去重:同一时刻只允许一个校验在跑(回调写共享切片要求互斥)
var webviewFixRunning atomic.Bool
// WM_SIZE 去重:脏标记 + 单 worker(每条 WM_SIZE 只置位,不再批量 spawn goroutine)
var (
webviewSizeDirty atomic.Bool
webviewSizeWorkerActive atomic.Bool
)
// 当前校验的目标主窗口(枚举回调过滤直属子窗口用)
var webviewTargetHwnd uintptr
@@ -77,32 +84,60 @@ func checkAndFixFill(hwnd uintptr) {
}
}
// runGuarded 统一 panic 兜底:windowsgui 下 goroutine panic 会静默杀进程
// runGuarded 同步调用点的 panic 兜底:panic 取证落盘后不外抛,
// 让调用方(如周期循环)能继续跑下一轮
func runGuarded(fn func()) {
defer func() { recover() }()
defer func() {
if r := recover(); r != nil {
common.AppendPanicLog("webview-fix", r)
}
}()
fn()
}
// fixWebviewFill WM_SIZE 路径:等 resize 序列结束、wails 内部去抖落定后校验一次
// fixWebviewFill WM_SIZE 路径:等 resize 序列结束、wails 内部去抖落定后校验一次。
// WM_SIZE 只置脏标记;活跃期间的后续 WM_SIZE 合并进去(worker 循环消费),
// 拖拽缩放的风暴从"每条消息一个 goroutine"降为全程单 worker
func fixWebviewFill(hwnd uintptr) {
if !webviewFixRunning.CompareAndSwap(false, true) {
webviewTargetHwnd = hwnd
webviewSizeDirty.Store(true)
if !webviewSizeWorkerActive.CompareAndSwap(false, true) {
return
}
go func() {
defer webviewFixRunning.Store(false)
runGuarded(func() {
common.Go("webview-fix", func() {
defer webviewSizeWorkerActive.Store(false)
for webviewSizeDirty.CompareAndSwap(true, false) {
time.Sleep(150 * time.Millisecond)
checkAndFixFill(hwnd)
if webviewFixRunning.CompareAndSwap(false, true) {
runGuarded(func() { checkAndFixFill(webviewTargetHwnd) })
webviewFixRunning.Store(false)
}
}
})
}()
}
// stopWebviewWatch 周期兜底循环的停止信号;StartWebviewFillWatch 重入时先关旧的(重启语义)
var stopWebviewWatch chan struct{}
// StartWebviewFillWatch 周期兜底:覆盖"无新 WM_SIZE 的迟到重放"场景
// (主线程忙时排队的过期重绘在窗口静止时才执行,此时不会产生任何 WM_SIZE)
func StartWebviewFillWatch() {
go runGuarded(func() {
if stopWebviewWatch != nil {
select {
case <-stopWebviewWatch: // 旧循环已自行退出
default:
close(stopWebviewWatch)
}
}
stop := make(chan struct{})
stopWebviewWatch = stop
common.Go("webview-watch", func() {
for {
time.Sleep(3 * time.Second)
select {
case <-stop:
return
case <-time.After(3 * time.Second):
}
if webviewFixRunning.CompareAndSwap(false, true) {
runGuarded(func() {
checkAndFixFill(findMainWindowHwnd())
@@ -114,20 +149,23 @@ func StartWebviewFillWatch() {
}
// findMainWindowHwnd 找本进程的可见主窗口
func findMainWindowHwnd() uintptr {
var found uintptr
pid := windows.GetCurrentProcessId()
cb := syscall.NewCallback(func(h uintptr, _ uintptr) uintptr {
// 回调必须进程级复用(同 enumChildWebviewHosts):runtime 的 Windows callback
// 硬上限 2000 个且按闭包指针去重,每 3 秒新建一个约 100 分钟即耗尽,
// 触发 runtime throw("too many callback functions") 直接静默退出(无法 recover)
var mainWindowHwndResult uintptr
var enumMainWindowHwnd = syscall.NewCallback(func(h uintptr, _ uintptr) uintptr {
var winPid uint32
windows.GetWindowThreadProcessId(windows.HWND(h), &winPid)
if winPid == pid && windows.IsWindowVisible(windows.HWND(h)) {
found = h
if winPid == windows.GetCurrentProcessId() && windows.IsWindowVisible(windows.HWND(h)) {
mainWindowHwndResult = h
return 0
}
return 1
})
runGuarded(func() {
windows.EnumWindows(cb, nil)
})
return found
})
func findMainWindowHwnd() uintptr {
mainWindowHwndResult = 0
windows.EnumWindows(enumMainWindowHwnd, nil)
return mainWindowHwndResult
}
+9
View File
@@ -0,0 +1,9 @@
{
"version": "0.5.0",
"download_url": "https://c.1216.top/download/u-desk-0.5.0.exe",
"changelog": "### 新增 ✨\n- **预览导出 PDF**: HTML/Markdown 预览一键导出为 PDF,成功后自动打开所在文件夹并选中文件\n- **剪贴板粘贴文件**: 资源管理器复制的文件/文件夹可直接粘贴进文件列表(本机=复制,SFTP=上传)\n- **收藏展示名自定义**: 收藏项可自定义显示名称,收藏列表默认展开\n- **服务器右键菜单**: 服务器列表支持右键菜单,连接/管理操作直达\n- **一键全连**: 服务器管理菜单新增\"一键全连\",并发连接全部服务器\n\n### 优化 🚀\n- **收藏置顶项**: 置顶背景加深为清晰浅蓝,一眼区分置顶与普通收藏\n- **收藏栏宽度可调**: 拖拽调整侧边栏收藏区宽度并记忆\n\n### 修复 🐛\n- **窗口内容收窄**: 切换服务器/目录时偶发的内容区收窄卡住不再出现,自动恢复满宽\n- **Markdown 导出**: file URL 转义修正,含空格/特殊字符路径导出正常\n- **PDF 导出稳定性**: 浏览器探测补全,导出失败立即提示不再无响应",
"force_update": false,
"release_date": "2026-10-01",
"file_size": 8324096,
"sha256": "26422786300ea1d0b7100295893b11aaf9bfb61078180745f12b5d09e019878d"
}
+85
View File
@@ -0,0 +1,85 @@
{
"updated_at": "2026-10-01T20:30:00+08:00",
"versions": [
{
"version": "0.5.0",
"release_date": "2026-10-01",
"changelog": "### 新增 ✨\n- **预览导出 PDF**: HTML/Markdown 预览一键导出为 PDF,成功后自动打开所在文件夹并选中文件\n- **剪贴板粘贴文件**: 资源管理器复制的文件/文件夹可直接粘贴进文件列表(本机=复制,SFTP=上传)\n- **收藏展示名自定义**: 收藏项可自定义显示名称,收藏列表默认展开\n- **服务器右键菜单**: 服务器列表支持右键菜单,连接/管理操作直达\n- **一键全连**: 服务器管理菜单新增\"一键全连\",并发连接全部服务器\n\n### 优化 🚀\n- **收藏置顶项**: 置顶背景加深为清晰浅蓝,一眼区分置顶与普通收藏\n- **收藏栏宽度可调**: 拖拽调整侧边栏收藏区宽度并记忆\n\n### 修复 🐛\n- **窗口内容收窄**: 切换服务器/目录时偶发的内容区收窄卡住不再出现,自动恢复满宽\n- **Markdown 导出**: file URL 转义修正,含空格/特殊字符路径导出正常\n- **PDF 导出稳定性**: 浏览器探测补全,导出失败立即提示不再无响应",
"download_url": "https://c.1216.top/download/u-desk-0.5.0.exe",
"file_size": 8324096,
"sha256": "26422786300ea1d0b7100295893b11aaf9bfb61078180745f12b5d09e019878d"
},
{
"version": "0.4.2",
"release_date": "2026-09-15",
"changelog": "### 修复 🐛\n- 启动初始化: 启动瞬间操作文件不再无响应\n- 跨盘剪切: 大目录移动显示进度,失败自动清理残留\n- SFTP 空目录下载/上传正确创建\n- drawio 首次下载失败立即提示\n- 网络重试后进度不再虚跳,失败条目正确显示错误\n- 连接失败后不再错误回落本地磁盘列表\n- OSS 签名兼容性,batch 脚本 %1/%%i 高亮",
"download_url": "https://c.1216.top/download/u-desk-0.4.2.exe",
"file_size": 8315904,
"sha256": "d753648db78f68d7ad5646e60ea063ac23afaa9d10da4017d2b0657101836c53"
},
{
"version": "0.4.1",
"release_date": "2026-05-16",
"changelog": "### 新增 ✨\n- 云 OSS 存储: 七牛云/阿里云双厂商\n- HTML 网站预览,BGM 播放器+LRC 歌词\n- 全局热键,远程服务器监控,侧边栏自定义\n\n### 优化 🚀\n- Wails v3 迁移,GBK 编码自动转换,SFTP 功能对齐\n\n### 修复 🐛\n- 阿里云 OSS 签名/OSS 目录重命名等",
"download_url": "https://c.1216.top/download/u-desk-0.4.1.exe",
"file_size": 8225280,
"sha256": "60f0ffbee46f35358f33bafa66c6d178c0b919b389e64ba5915d015b587185db"
},
{
"version": "0.4.0",
"release_date": "2026-04-25",
"changelog": "### 重构 🔧\n- **移除数据库客户端模块**: 删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理\n- **清理依赖**: 移除 go-sql-driver/mysql、go-redis/v9、mongo-driver/v2 等驱动依赖\n- **构建体积优化**: 原始 exe 从 36MB 降至 26MB,UPX 压缩后仅 7.5MB(压缩率 28.8%)",
"download_url": "https://c.1216.top/download/u-desk-0.4.0.exe",
"file_size": 7766016,
"sha256": "532c30bdc57ea0ff5bc71756714b7ca18388ad3e09b2c4eefcdb6816349c7dda"
},
{
"version": "0.3.3",
"release_date": "2026-04-13",
"changelog": "### 新增 ✨\n- Markdown 编辑器、PDF 导出、窗口置顶、收藏夹置顶\n- Excel/Word 文件预览、数据库 UI 大幅改进\n\n### 优化 🚀\n- MySQL 动态连接池、SQL 查询优化器、Redis Pipeline\n- FileListPanel 重写、CSV 编辑优化\n\n### 安全修复 🔒\n- XSS 防护、PDF 导出路径穿越防护\n\n### 重构 🔧\n- CodeMirror 架构优化、大规模死代码清理 (-1306 行)",
"download_url": "https://c.1216.top/download/u-desk-0.3.3.exe",
"file_size": 9801728,
"sha256": "829c79a91c10277011159749110f4ebee5e3638a078e86850c03b1c9f09e184c"
},
{
"version": "0.3.2",
"release_date": "2026-02-05",
"changelog": "### 重构 🔧\n- CodeMirror 统一导出避免多实例问题\n- 动态主题切换(Compartment 无损切换)\n\n### 优化 🚀\n- 编辑器防抖、亮色主题改进",
"download_url": "",
"file_size": 0,
"sha256": ""
},
{
"version": "0.3.0",
"release_date": "2026-02-04",
"changelog": "### 新增 ✨\n- Markdown 图表支持 (Mermaid)\n- 代码语法高亮 (20+ 语言)\n- 文件列表排序优化",
"download_url": "",
"file_size": 0,
"sha256": ""
},
{
"version": "0.2.0",
"release_date": "2026-01-28",
"changelog": "### 新增 ✨\n- 应用配置管理、智能更新提醒\n- 应用更名为 u-desk",
"download_url": "",
"file_size": 0,
"sha256": ""
},
{
"version": "0.1.5",
"release_date": "2026-01-22",
"changelog": "### 新增 ✨\n- 文件管理模块、版本更新管理、系统信息查询",
"download_url": "",
"file_size": 0,
"sha256": ""
},
{
"version": "0.1.0",
"release_date": "2026-01-18",
"changelog": "### 新增 ✨\n- 数据库管理 - 支持多种数据库连接和查询功能",
"download_url": "",
"file_size": 0,
"sha256": ""
}
]
}
@@ -1,6 +1,6 @@
# U-Desk 功能清单
> 应用版本:v0.5.0
> 应用版本:v0.5.1
> 更新时间:2026-10-01 10:00
> 用途:测试、开发、迭代过程中验证核对
> 核对状态:✅ 已通过Git版本对比 + 源码验证
@@ -489,7 +489,7 @@
**文档维护**: 开发团队
**最后更新**: 2026-10-01 10:00
**应用版本**: v0.5.0
**应用版本**: v0.5.1
**核对状态**: ✅ 已通过Git版本对比 + 源码验证 + 需求对照
**核对报告**: [功能清单核对报告.md](./功能清单核对报告.md)
**下次审查**: 每次发版前
+5 -3
View File
@@ -56,8 +56,8 @@
</a-layout-header>
<a-layout-content class="content">
<!-- 动态渲染 Tab 内容 -->
<!-- 使用 KeepAlive 缓存组件状态,避免切换时重新加载 -->
<KeepAlive include="FileSystem">
<!-- 缓存全部顶层 tab:include 白名单会让 Markdown 编辑器切走时丢状态,故不再过滤 -->
<KeepAlive>
<component :is="getComponent(activeTab)"/>
</KeepAlive>
</a-layout-content>
@@ -104,7 +104,9 @@ import {
WindowMinimize, WindowToggleAlwaysOnTop,
WindowMaximize, WindowIsMaximized, WindowClose
} from './wailsjs/v3-bindings/u-desk/app'
import { OffAll } from '@wailsio/events'
import { Events } from '@wailsio/runtime'
const { OffAll } = Events
// 存储键
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
+52 -2
View File
@@ -10,6 +10,8 @@ import { SftpTransport } from './sftp-transport'
import { OssTransport } from './oss-transport'
import { getFileServerBaseURL } from './file-server'
import { errMessage } from '@/utils/errors'
import { parseWailsEventData } from '@/utils/wailsEvent'
import { Events } from '@wailsio/runtime'
import {
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
SftpGetSystemInfo, GetLocalSystemInfo,
@@ -62,12 +64,32 @@ class ConnectionManagerImpl {
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
/** 每个 profile 的独立连接状态(连接菜单圆点用) */
private _profileStates = new Map<string, { state: ConnectionState; error?: string }>()
/** 每个 profile 的连接阶段(dial/auth/channel,冷连接分步反馈用) */
private _profileStages = new Map<string, string>()
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
private _sysInfoCache = new Map<string, SystemInfo>()
constructor() {
this.initDefaultLocal()
this.loadFromDB()
// 单例常驻,事件监听注册一次即可,无需退订
Events.On('sftp:connect-stage', this.onConnectStage)
}
/**
* 后端连接阶段事件:conn_id 为 host:port,映射到同地址的 sftp profile
*/
private onConnectStage = (event: unknown) => {
const data = parseWailsEventData(event) as { conn_id?: string; stage?: string }
if (!data?.conn_id || !data?.stage) return
let matched = false
for (const p of this._profiles) {
if (p.type === 'sftp' && `${p.host}:${p.port || 22}` === data.conn_id) {
matched = true
this._profileStages.set(p.id, data.stage)
}
}
if (matched) this.notifyChange()
}
private initDefaultLocal() {
@@ -139,7 +161,7 @@ class ConnectionManagerImpl {
port: profile.port,
username: profile.username || 'root',
password: profile.password || '',
keyPath: profile.keyPath || '',
key_path: profile.keyPath || '',
type: profile.type,
provider: profile.provider || '',
token: profile.token || '',
@@ -148,7 +170,7 @@ class ConnectionManagerImpl {
bucket: profile.bucket || '',
region: profile.region || '',
endpoint: profile.endpoint || '',
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
last_connected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
})
const realId = Number((result as any)?.id ?? 0)
return realId > 0 ? realId : undefined
@@ -168,6 +190,10 @@ class ConnectionManagerImpl {
/** 更新单个 profile 的连接状态(对齐 Rust 版,供连接菜单圆点着色) */
private setProfileState(profileId: string, state: ConnectionState, error?: string) {
this._profileStates.set(profileId, { state, error })
// 离开 connecting 即连接成功或失败,阶段文案随之失效
if (state !== 'connecting') {
this._profileStages.delete(profileId)
}
this.notifyChange()
}
@@ -176,6 +202,11 @@ class ConnectionManagerImpl {
return this._profileStates.get(profileId) ?? { state: 'disconnected' }
}
/** 获取单个 profile 的连接阶段(未在连接中时为空串) */
getProfileStage(profileId: string): string {
return this._profileStages.get(profileId) ?? ''
}
private notifyChange() {
this._stateChangeCallbacks.forEach(cb => cb(this._state))
}
@@ -201,6 +232,10 @@ class ConnectionManagerImpl {
if (this._activeId && this._pool.has(this._activeId)) {
return this._pool.get(this._activeId)!
}
// 拨号窗口期禁止静默回退本机 transport:否则远端面板标签+本机数据错位串台
if (this._activeId && this._activeId !== 'local-default') {
throw new Error('连接建立中,请稍候重试')
}
return this._pool.get('local-default')!
}
@@ -276,12 +311,27 @@ class ConnectionManagerImpl {
return info
}
/** per-profileId 的 in-flight connect 去重:同配置连点共享同一次连接(成功/失败都清理) */
private _connectInflight = new Map<string, Promise<void>>()
/**
* 切换到指定 profile(连接池模式)
* 池中有 → 直接复用,瞬间切换
* 池中无 → 新建连接并存入池
*/
async connect(profileId: string): Promise<void> {
// 同 profile 已在连接中:复用 in-flight Promise,避免并发拨号重复建连/状态互踩
const inflight = this._connectInflight.get(profileId)
if (inflight) return inflight
const p = this.doConnect(profileId).finally(() => {
this._connectInflight.delete(profileId)
})
this._connectInflight.set(profileId, p)
return p
}
private async doConnect(profileId: string): Promise<void> {
const profile = this._profiles.find(p => p.id === profileId)
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
+36 -2
View File
@@ -24,16 +24,29 @@ export class SftpTransport implements FsTransport {
}
async connect(): Promise<string> {
const result = await SftpConnect({
// 前端反馈层 9s 超时:后端连接继续由既有逻辑管理(不主动断),
// 仅避免 UI 在网络不可达时无限挂起,用户能立刻拿到失败反馈
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('连接超时,请检查网络')), 9000)
})
try {
const result = await Promise.race([
SftpConnect({
host: this.profile.host,
port: this.profile.port || 22,
username: this.profile.username || 'root',
password: this.profile.password || '',
key_path: this.profile.keyPath || '',
key_passphrase: '',
})
}),
timeout,
])
this.connID = result
return result
} finally {
if (timer) clearTimeout(timer)
}
}
async isAlive(): Promise<boolean> {
@@ -56,23 +69,34 @@ export class SftpTransport implements FsTransport {
return this.connID
}
/** 远程路径守卫:本机盘符路径串入 SFTP(tab 恢复/面板状态错位)会在服务器端报 No such file */
private assertRemotePath(path: string): void {
if (/^[A-Za-z]:[\\/]/.test(path)) {
throw new Error(`本机路径不可用于远程操作: ${path}`)
}
}
// ====== 文件列表与信息 ======
async listDir(path: string): Promise<FileItem[]> {
this.assertRemotePath(path)
return transformFileList(await SftpListDir(this.requireConn(), path))
}
async getFileInfo(path: string): Promise<Record<string, any>> {
this.assertRemotePath(path)
return SftpGetFileInfo(this.requireConn(), path)
}
// ====== 文件读写 ======
async readFile(path: string): Promise<string> {
this.assertRemotePath(path)
return SftpReadFile(this.requireConn(), path)
}
async writeFile(path: string, content: string): Promise<void> {
this.assertRemotePath(path)
await SftpWriteFile({
session_id: this.requireConn(),
path,
@@ -82,24 +106,30 @@ export class SftpTransport implements FsTransport {
async saveBase64File(path: string, content: string): Promise<void> {
if (!content) throw new Error('无效的 base64 内容')
this.assertRemotePath(path)
await SftpWriteBase64File(this.requireConn(), path, content)
}
// ====== 文件操作 ======
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
this.assertRemotePath(dirPath)
return SftpCreateFile(this.requireConn(), joinPath(dirPath, filename))
}
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
this.assertRemotePath(parentPath)
return SftpCreateDir(this.requireConn(), joinPath(parentPath, dirname))
}
async deletePath(path: string): Promise<FileOperationResult> {
this.assertRemotePath(path)
return SftpDeletePath(this.requireConn(), path)
}
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
this.assertRemotePath(oldPath)
this.assertRemotePath(newPath)
return SftpRenamePath({
session_id: this.requireConn(),
old_path: oldPath,
@@ -121,11 +151,13 @@ export class SftpTransport implements FsTransport {
/** 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径 */
async downloadToFile(remotePath: string, localDstDir: string): Promise<string> {
this.assertRemotePath(remotePath)
return SftpDownloadToFile(this.requireConn(), remotePath, localDstDir)
}
/** 上传本地文件/目录到远程目录(流式,目录递归) */
async uploadFromFile(localPath: string, remoteDir: string): Promise<void> {
this.assertRemotePath(remoteDir)
await SftpUploadFromFile(this.requireConn(), localPath, remoteDir)
}
@@ -190,11 +222,13 @@ export class SftpTransport implements FsTransport {
/** 下载远程文件到本地临时目录用于预览(缓存由 Go 后端管理) */
async downloadForPreview(remotePath: string): Promise<string> {
this.assertRemotePath(remotePath)
return SftpDownloadToTemp(this.requireConn(), remotePath)
}
/** 下载 HTML 及其网站资源到临时目录用于预览 */
async downloadSiteForPreview(remotePath: string): Promise<string> {
this.assertRemotePath(remotePath)
return SftpDownloadSiteForPreview(this.requireConn(), remotePath)
}
+5 -5
View File
@@ -5,7 +5,7 @@
import type { FileItem } from '@/types/file-system'
import { connectionManager } from './connection-manager'
import { PickDirectory } from '../wailsjs/v3-bindings/u-desk/app'
import { PickDirectory, GetCPUInfo, GetMemoryInfo, GetDiskInfo, GetEnvVars } from '../wailsjs/v3-bindings/u-desk/app'
/** 转换后端蛇形字段为前端驼峰格式 */
function toFileItem(file: any): FileItem {
@@ -22,17 +22,17 @@ export async function getSystemInfo() { return t().getFileInfo('/') }
export async function getCPUInfo() {
if (connectionManager.isRemote()) return {}
try { return (await window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
try { return (await GetCPUInfo()) ?? {} } catch { return {} }
}
export async function getMemoryInfo() {
if (connectionManager.isRemote()) return {}
try { return (await window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
try { return (await GetMemoryInfo()) ?? {} } catch { return {} }
}
export async function getDiskInfo() {
if (connectionManager.isRemote()) return {}
try { return (await window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
try { return (await GetDiskInfo()) ?? {} } catch { return {} }
}
export async function listDir(path: string): Promise<FileItem[]> {
@@ -88,7 +88,7 @@ export async function uploadFromFile(localPath: string, remoteDir: string): Prom
}
export async function getEnvVars(): Promise<Record<string, string>> {
try { return (await window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
try { return (await GetEnvVars()) ?? {} } catch { return {} }
}
export async function listZipContents(zipPath: string): Promise<FileItem[]> {
+9 -6
View File
@@ -167,16 +167,19 @@ const createEditor = (docContent = '') => {
view = new EditorView({ state, parent: editorContainer.value })
// 滚动时防抖保存位置
view.scrollDOM.addEventListener('scroll', () => {
if (saveScrollTimer) clearTimeout(saveScrollTimer)
saveScrollTimer = setTimeout(saveScrollPosition, 200)
}, { passive: true })
// 滚动时防抖保存位置(具名引用:removeEventListener 必须传同一函数引用才生效)
view.scrollDOM.addEventListener('scroll', onEditorScroll, { passive: true })
// 初始化语言
initLanguage()
}
// 滚动防抖保存位置(注册/移除共用同一具名引用)
const onEditorScroll = () => {
if (saveScrollTimer) clearTimeout(saveScrollTimer)
saveScrollTimer = setTimeout(saveScrollPosition, 200)
}
// ==================== 生命周期 ====================
onMounted(() => {
@@ -196,7 +199,7 @@ onBeforeUnmount(() => {
if (emitTimeout) clearTimeout(emitTimeout)
if (saveScrollTimer) clearTimeout(saveScrollTimer)
if (view?.scrollDOM) {
view.scrollDOM.removeEventListener('scroll', saveScrollPosition)
view.scrollDOM.removeEventListener('scroll', onEditorScroll)
}
view?.destroy()
view = null
@@ -9,7 +9,8 @@
@click.stop="toggleMenu"
@mouseenter="onDirHover"
@mouseleave="onDirLeave">
<span class="label">{{ label }}</span>
<a-spin v-if="activeStage" :size="12" />
<span class="label">{{ activeStage || label }}</span>
<!-- 悬停弹出:根目录子目录列表 -->
<Transition name="dropdown-fade">
@@ -54,6 +55,7 @@
>
<span :class="['dot', dotClass(p)]"></span>
<span class="menu-name">{{ p.name }}</span>
<span v-if="stageLabel(p)" class="menu-stage">{{ stageLabel(p) }}</span>
<span
v-if="p.type !== 'local'"
class="more-btn"
@@ -120,6 +122,27 @@ const label = computed(() => {
return p.name
})
// 连接阶段的中文文案,未知阶段回落到连接中
const STAGE_TEXTS: Record<string, string> = { dial: '连接中', auth: '认证中', channel: '建立通道' }
function stageText(stage: string): string {
return STAGE_TEXTS[stage] ?? '连接中'
}
// 活跃连接进行中的阶段文案(空串表示非连接中)
const activeStage = computed(() => {
const p = profiles.value.find(p => p.id === activeId.value)
if (!p || p.type === 'local') return ''
if (connectionManager.getProfileState(p.id).state !== 'connecting') return ''
return stageText(connectionManager.getProfileStage(p.id))
})
// 菜单条目进行中的阶段文案(空串表示非连接中)
function stageLabel(p: { id: string; type: string }): string {
if (p.type === 'local') return ''
if (connectionManager.getProfileState(p.id).state !== 'connecting') return ''
return stageText(connectionManager.getProfileStage(p.id))
}
connectionManager.onStateChange(() => {
profiles.value = connectionManager.profiles
activeId.value = connectionManager.activeProfile?.id ?? ''
@@ -148,8 +171,8 @@ const dirError = ref('')
const dirChildren = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
const dirLastLoadedPath = ref('')
const dirHoverTimer = ref<NodeJS.Timeout | null>(null)
const dirCloseTimer = ref<NodeJS.Timeout | null>(null)
const dirHoverTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const dirCloseTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const rootPath = computed(() => {
const active = connectionManager.activeProfile
@@ -261,8 +284,10 @@ onUnmounted(() => {
})
// === 原有方法 ===
// 连接动作由父级统一入口处理,这里只上抛所选服务器
function handleSelect(p: { id: string }) {
// 连接动作由父级统一入口处理,这里只上抛所选服务器;
// connecting 中忽略(防 UI 层重复触发,connect 侧另有 in-flight 去重兜底)
function handleSelect(p: { id: string; type?: string }) {
if (p.type && p.type !== 'local' && connectionManager.getProfileState(p.id).state === 'connecting') return
showMenu.value = false
emit('select', p.id)
}
@@ -407,6 +432,12 @@ watch(() => props.filePath, () => {
flex: 1;
}
.menu-stage {
flex-shrink: 0;
font-size: 12px;
color: var(--color-text-3);
}
.more-btn {
margin-left: auto;
opacity: 0;
@@ -7,7 +7,7 @@
@click.stop
>
<!-- 空白区域菜单 -->
<template v-if="config.context === 'blank'">
<template v-if="config.context === 'empty'">
<div class="context-menu-item" @click="handleCreateFile">
<span class="context-menu-icon">📄</span>
<span>新建文件</span>
@@ -31,7 +31,7 @@
</template>
<!-- 文件菜单 -->
<template v-else-if="config.context === 'file' && config.selectedFile">
<template v-else-if="config.context === 'file-list' && config.selectedFile">
<div class="context-menu-item" @click="handleCreateFile">
<span class="context-menu-icon">📄</span>
<span>新建文件</span>
@@ -27,7 +27,9 @@
// 关键:viewer base 必须用本地文件服务器(getFileServerBaseURL),与 /drawio-viewer/ 路由及 .drawio 原文同源。
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { On, Off } from '@wailsio/events'
import { Events } from '@wailsio/runtime'
const { On, Off } = Events
import { parseWailsEventData } from '@/utils/wailsEvent'
import { resolveFileUrl } from '../composables/useFilePreview'
import { getFileServerBaseURL } from '@/api/file-server'
@@ -84,8 +84,8 @@ const error = ref('')
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
const style = ref<{ top: string; left: string }>({ top: '0px', left: '0px' })
const hoverTimer = ref<NodeJS.Timeout | null>(null)
const leaveTimer = ref<NodeJS.Timeout | null>(null)
const hoverTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const leaveTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const hoveringMenu = ref(false)
const menuKey = `menu-${props.item.path}-${props.level}`
@@ -168,8 +168,8 @@ const onClick = (event: MouseEvent) => {
// 阻止事件冒泡,避免触发父级 breadcrumb-segment 的点击
event.stopPropagation()
const eventType = props.item.isDir ? 'navigate' : 'openFile'
emit(eventType, props.item.path)
if (props.item.isDir) emit('navigate', props.item.path)
else emit('openFile', props.item.path)
}
const emitNavigate = (path: string) => emit('navigate', path)
@@ -143,7 +143,7 @@
</a-button>
</a-tooltip>
<a-tooltip v-if="config.canSaveFile" position="left" content="保存 (Ctrl+S)">
<a-button type="primary" size="small" @click="handleSave">
<a-button type="primary" size="small" :loading="config.isSaving" @click="handleSave">
<template #icon><icon-save /></template>
</a-button>
</a-tooltip>
@@ -450,7 +450,9 @@ import { useThemeStore } from '@/stores/theme'
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
import { isDirty } from '../composables/useMultiPreview'
import { On, Off } from '@wailsio/events'
import { Events } from '@wailsio/runtime'
const { On, Off } = Events
import { ExportHtmlFileToPDF } from '@bindings/u-desk/app'
import { connectionManager } from '@/api/connection-manager'
import { listDir, readFile } from '@/api/system'
@@ -545,7 +547,7 @@ const htmlPreviewLocalPath = ref('')
watch(
() => [props.config.currentFileFullPath, props.config.isHtmlFile],
async ([path, isHtml]) => {
async ([path, isHtml]: [string, boolean]) => {
htmlPreviewUrl.value = ''
htmlPreviewLocalPath.value = ''
if (!path || !isHtml) return
@@ -929,8 +931,8 @@ const handleExportHtmlPDF = async () => {
try {
const res = await ExportHtmlFileToPDF(srcPath, baseName)
if (res?.success) {
// 后端导出成功已自动打开资源管理器选中文件,弹窗只做明确指向,不依赖用户记住路径
Message.success('PDF 已导出,已在桌面打开所在位置')
// 导出成功只落盘不打开(用户要求),提示带落盘路径让产物位置明确
Message.success({ content: `PDF 已导出到: ${res?.path || '桌面'}`, duration: 6000 })
} else {
Message.error(res?.message || 'PDF 导出失败')
}
@@ -11,7 +11,7 @@
<div class="col-setting-title">列设置</div>
<div class="col-setting-item" style="cursor: default;">
<span class="drag-handle"></span>
<a-checkbox :model-value="showHeader" @change="(val: boolean) => { showHeader = val; localStorage.setItem(SHOW_HEADER_KEY, String(val)) }">
<a-checkbox :model-value="showHeader" @change="(val: boolean) => toggleShowHeader(val)">
显示表头
</a-checkbox>
</div>
@@ -175,6 +175,12 @@ const colSettings = ref<ColumnConfig[]>(loadColSettings())
// 默认隐藏表头(localStorage 无值时默认不显示)
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) === 'true')
/** 切换表头显示并持久化(模板表达式取不到 localStorage,经此方法写入) */
const toggleShowHeader = (val: boolean) => {
showHeader.value = val
localStorage.setItem(SHOW_HEADER_KEY, String(val))
}
// 手动持久化(避免 deep watch 频繁写入)
function saveColSettings() {
localStorage.setItem(COL_SETTINGS_KEY, JSON.stringify(colSettings.value))
@@ -235,12 +241,12 @@ function buildColumn(key: string, editPath: string | undefined) {
]),
width: 32,
bodyCellClass: 'col-icon',
render: ({ record }: { record: FileItem }) => {
render: ({ record }: { record: any }) => {
const ext = getExt(record.name)
return h('span', {
class: 'file-item-icon',
title: ext ? `.${ext.toUpperCase()} : ${record.name}` : record.name
}, getFileIcon(record))
}, getFileIcon({ is_dir: record.isDir, name: record.name }))
}
}
@@ -255,7 +261,7 @@ function buildColumn(key: string, editPath: string | undefined) {
]),
dataIndex: 'name',
ellipsis: true,
render: ({ record }: { record: FileItem }) => {
render: ({ record }: { record: any }) => {
const isEditing = editPath === record.path
if (isEditing) {
return h(Input, {
@@ -287,7 +293,7 @@ function buildColumn(key: string, editPath: string | undefined) {
dataIndex: 'modified_time',
width: 125,
align: 'right' as const,
render: ({ record }: { record: FileItem }) => {
render: ({ record }: { record: any }) => {
if (editPath === record.path || !record.modified_time) return null
return h('span', { class: 'file-item-time' }, formatFileTime(record.modified_time))
}
@@ -305,7 +311,7 @@ function buildColumn(key: string, editPath: string | undefined) {
dataIndex: 'size',
width: 70,
align: 'right' as const,
render: ({ record }: { record: FileItem }) => {
render: ({ record }: { record: any }) => {
if (record.isDir || editPath === record.path) return null
return h('span', { class: 'file-item-size' }, formatBytes(record.size))
}
@@ -315,7 +321,7 @@ function buildColumn(key: string, editPath: string | undefined) {
return {
title: '',
width: 28,
render: ({ record }: { record: FileItem }) => {
render: ({ record }: { record: any }) => {
if (editPath === record.path) return null
const favorited = props.favorites.includes(record.path)
return h(Button, {
@@ -7,7 +7,7 @@
class="breadcrumb-segment"
:class="{ 'is-hoverable': index < segments.length - 1 || segments.length === 1 }"
@mouseenter="onHover(segment, index)"
@mouseleave="onLeave"
@mouseleave="onMenuLeave"
@click="onClick(segment)"
>
<span class="segment-text">{{ segment.name }}</span>
@@ -125,8 +125,8 @@ const segments = computed<PathSegment[]>(() => {
})
const activeIndex = ref<number | null>(null)
const hoverTimer = ref<NodeJS.Timeout | null>(null)
const closeTimer = ref<NodeJS.Timeout | null>(null)
const hoverTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const closeTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
const loading = ref(false)
const error = ref('')
@@ -639,10 +639,12 @@ function stateText(profileId: string): string {
return '未连接'
}
// 同一服务器重复点击也上抛切换事件,由父级强制重载列表
// 同一服务器重复点击也上抛切换事件,由父级强制重载列表;
// 但 connecting 中直接忽略(connect 已有 in-flight 去重,这里防 UI 层重复触发)
function handleSelect(p: { id: string; type: string }) {
moreOpenId.value = null
moreProfile.value = null
if (p.type !== 'local' && connectionManager.getProfileState(p.id).state === 'connecting') return
emit('serverSelect', p.id)
}
@@ -152,7 +152,8 @@ provide('dropdownOwner', dropdownOwner)
// Props
interface Props {
config: ToolbarConfig
openConnectionDialog?: boolean
/** 连接对话框触发计数器:父组件自增触发打开(>0 即开) */
openConnectionDialog?: number
editProfileId?: string | null
}
@@ -220,6 +221,10 @@ const handleNavigateToZipRoot = () => {
emit('navigateToZipDirectory', '')
}
const handleNavigateToZipDirectory = (path: string) => {
emit('navigateToZipDirectory', path)
}
const handleToggleSidebar = () => {
emit('update:showSidebar', !props.config.showSidebar)
}
@@ -317,6 +317,9 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
* 保存文件内容
*/
const saveFile = async (path?: string, isShortcut: boolean = false) => {
// 防重入:上次保存尚未落盘时忽略本次触发(Ctrl+S 连按/按钮连点会并发写同一文件)
if (isSaving.value) return
// 获取目标路径(优先使用传入的 path,否则从 currentFilePath 中提取)
let targetPath = path
if (!targetPath && currentFilePath.value) {
+99 -22
View File
@@ -125,11 +125,13 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, watchEffect, nextTick } from 'vue'
import { getPathSeparator } from '@/utils/fileUtils'
import { Message, Modal } from '@arco-design/web-vue'
import { On, Off } from '@wailsio/events'
import { ReadClipboardFiles } from '@bindings/u-desk/app'
import { Events } from '@wailsio/runtime'
const { On, Off } = Events
import { ReadClipboardFiles, ReadClipboardImage } from '@bindings/u-desk/app'
import { renderMarkdown, parseMarkdown, isMarkedReady, preloadMarked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme'
import { useTransferStore } from '@/stores/transfer'
@@ -337,7 +339,7 @@ const contextMenuConfig = ref<ContextMenuConfig>({
visible: false,
x: 0,
y: 0,
context: 'blank',
context: 'empty',
selectedFile: undefined
})
@@ -379,7 +381,7 @@ const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, dete
useFilePreview()
// 文件编辑
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, isSaving, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
useFileEdit({
currentFilePath: selectedFileItem,
currentDirectory: filePath
@@ -506,6 +508,7 @@ const fileEditorPanelConfig = computed(() => {
officeLoading: false,
officeError: null,
canSaveFile: canSaveFile.value,
isSaving: isSaving.value,
canResetContent: canResetContent.value,
canPreviewFile: isEditableWithPreview(currentFileName),
imageLoading: imageLoading.value,
@@ -954,7 +957,7 @@ const handleContextMenu = (event: MouseEvent, file: FileItem | null = null) => {
visible: true,
x: event.clientX,
y: event.clientY,
context: file ? 'file' : 'blank',
context: file ? 'file-list' : 'empty',
selectedFile: file || undefined,
isSftp: connectionManager.isSftp(),
clipboardEmpty: clipboardEmpty.value
@@ -1094,6 +1097,11 @@ const handlePasteClipboard = async () => {
/** SFTP 文件/目录下载到本机(目录选择器 → 流式下载) */
const handleDownloadToLocal = async (file: FileItem) => {
// 防状态错位串台:本机路径混入 SFTP 下载会让远程端 stat 盘符路径报"No such file"
if (!connectionManager.isSftp()) {
Message.warning('本机文件无需下载,请使用复制/剪切')
return
}
try {
const dstDir = await pickDirectory()
if (!dstDir) return
@@ -1613,6 +1621,11 @@ const loadFileContent = async (path: string) => {
}
const loadDirectory = async (path: string) => {
// SFTP 面板收到本机盘符路径=连接切换窗口期串台,重置回远程根目录
if (connectionManager.isSftp() && /^[A-Za-z]:[\\/]/.test(path)) {
Message.warning('远程面板不能浏览本机路径,已回到根目录')
return loadDirectory('/')
}
fileLoading.value = true
try {
fileList.value = await fileOps.listDirectory(path)
@@ -1943,11 +1956,16 @@ const handleKeyDown = async (event: KeyboardEvent) => {
const key = event.key.toLowerCase()
if (key === 'v') {
// 剪贴板有文件项时优先生效(远程面板由粘贴逻辑 toast 提示),否则放行走「粘贴剪贴板图片」路径
// 剪贴板有文件项时优先生效(远程面板由粘贴逻辑 toast 提示)
if (clipboard.value && clipboard.value.items.length > 0) {
event.preventDefault()
await handlePasteClipboard()
return
}
// 应用内剪贴板空:keydown 直读系统剪贴板图片。
// WebView2 无编辑焦点时 window paste 事件不可靠派发,只依赖它会全程静默(bug 根修)
event.preventDefault()
await pasteClipboardImage()
return
}
// 复制/剪切仅本机面板生效;SFTP 面板静默(菜单本就不提供),
@@ -2065,11 +2083,48 @@ const handlePaste = async (event: ClipboardEvent) => {
const imageItem = Array.from(items).find(item => item.type.startsWith('image/'))
if (imageItem) {
// 同一次 Ctrl+V 的 keydown 路径刚保存过时跳过(双保险链路防双写)
if (Date.now() - lastClipboardImageSaveAt < 1500) return
event.preventDefault()
await pasteImageToFile(imageItem)
}
}
// keydown 路径最近一次保存剪贴板图片的时间戳(与 DOM paste 事件链去重用)
let lastClipboardImageSaveAt = 0
// Ctrl+V keydown 直读系统剪贴板图片(后端 ReadClipboardImage,PNG base64)。
// 无图静默维持原行为;有图落盘当前目录,文件名 clipboard_YYYYMMDD_HHmmss.png 同款
const pasteClipboardImage = async (): Promise<void> => {
if (!filePath.value) return
try {
const base64 = await ReadClipboardImage()
if (!base64) return
lastClipboardImageSaveAt = Date.now()
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
const fileName = `clipboard_${ts}.png`
const fullPath = filePath.value + getPathSeparator(filePath.value) + fileName
await saveClipboardImage(fullPath, base64)
} catch { /* 剪贴板占用/格式不支持等,维持原静默 */ }
}
// 剪贴板图片落盘公共实现:保存 base64 到 fullPath,成功后提示并刷新列表
const saveClipboardImage = async (fullPath: string, base64Data: string) => {
const fileName = fullPath.replace(/^.*[\\/]/, '')
Message.loading({ content: `正在保存 ${fileName}...`, duration: 0 })
try {
await saveBase64File(fullPath, base64Data)
Message.clear()
Message.success(`已保存: ${fileName}`)
loadDirectory(filePath.value)
} catch (err: any) {
Message.clear()
Message.error('保存失败: ' + (err?.message || err))
}
}
// 将剪贴板图片保存为文件
const pasteImageToFile = async (item: DataTransferItem) => {
if (!filePath.value) {
@@ -2104,17 +2159,8 @@ const pasteImageToFile = async (item: DataTransferItem) => {
Message.error('图片数据格式无效')
return
}
Message.loading({ content: `正在保存 ${fileName}...`, duration: 0 })
try {
await saveBase64File(fullPath, base64Data)
Message.clear()
Message.success(`已保存: ${fileName}`)
loadDirectory(filePath.value)
} catch (err: any) {
Message.clear()
Message.error('保存失败: ' + (err?.message || err))
}
lastClipboardImageSaveAt = Date.now()
await saveClipboardImage(fullPath, base64Data)
}
reader.readAsDataURL(file)
} catch (err: any) {
@@ -2122,16 +2168,47 @@ const pasteImageToFile = async (item: DataTransferItem) => {
}
}
// 渲染 Mermaid 图表
watch(async () => {
// 渲染 Mermaid 图表:300ms 防抖 + in-flight 标志。
// mermaid.run 并发重入会互相打断产生半渲染/残留节点,连续触发热源(内容流式更新等)在此收敛
let mermaidRenderTimer: ReturnType<typeof setTimeout> | null = null
let mermaidRendering = false
let mermaidRenderPending = false
const runMermaidRender = async () => {
mermaidRenderTimer = null
if (mermaidRendering) {
mermaidRenderPending = true
return
}
mermaidRendering = true
try {
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
await nextTick()
await nextTick()
await renderMermaidDiagrams()
// 强制容器重新计算高度
await nextTick()
}
}, { immediate: true })
} finally {
mermaidRendering = false
if (mermaidRenderPending) {
mermaidRenderPending = false
scheduleMermaidRender()
}
}
}
const scheduleMermaidRender = () => {
if (mermaidRenderTimer) clearTimeout(mermaidRenderTimer)
mermaidRenderTimer = setTimeout(runMermaidRender, 300)
}
// 同步读取依赖保持原有触发条件,执行体走防抖+防并发
// (watchEffect 即立即执行+依赖重跑,等价原 immediate 语义且类型正确)
watchEffect(() => {
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
scheduleMermaidRender()
}
})
// 主题变化时重新渲染 mermaid 图表(跟随暗色/亮色)
const themeStore = useThemeStore()
+61 -1
View File
@@ -140,6 +140,30 @@
<a-tab-pane key="update" title="版本更新">
<UpdatePanel @open-version-history="handleOpenVersionHistory" />
</a-tab-pane>
<!-- 导出设置 -->
<a-tab-pane key="export" title="导出设置">
<a-space direction="vertical" fill style="width: 100%">
<a-alert type="info" :show-icon="true">
设置 PDF 导出的保存位置;不设置时默认保存到桌面
</a-alert>
<a-space>
<a-input
readonly
:model-value="pdfExportDir || '默认(桌面)'"
style="width: 340px"
/>
<a-button type="primary" @click="handlePickExportDir">
<template #icon><icon-folder /></template>
选择目录
</a-button>
<a-button :disabled="!pdfExportDir" @click="handleResetExportDir">
<template #icon><icon-refresh /></template>
恢复默认
</a-button>
</a-space>
</a-space>
</a-tab-pane>
</a-tabs>
</a-drawer>
</template>
@@ -147,7 +171,9 @@
<script setup>
import { ref, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconDragArrow, IconCheck, IconRefresh } from '@arco-design/web-vue/es/icon'
import { IconDragArrow, IconCheck, IconRefresh, IconFolder } from '@arco-design/web-vue/es/icon'
import { GetPDFExportDir, SavePDFExportDir } from '@bindings/u-desk/app'
import { pickDirectory } from '@/api/system'
import UpdatePanel from './UpdatePanel.vue'
// Props
@@ -179,6 +205,40 @@ const localConfig = ref({
})
const saving = ref(false)
// PDF 导出目录设置(独立于 tab 配置保存链,选完即存)
const pdfExportDir = ref('')
const loadExportDir = async () => {
try {
pdfExportDir.value = (await GetPDFExportDir()) || ''
} catch {
// 读取失败按未设置处理,走默认桌面
pdfExportDir.value = ''
}
}
const handlePickExportDir = async () => {
const dir = await pickDirectory()
if (!dir) return
try {
await SavePDFExportDir(dir)
pdfExportDir.value = dir
Message.success('导出目录已保存')
} catch (e) {
Message.error(`保存导出目录失败: ${e instanceof Error ? e.message : e}`)
}
}
const handleResetExportDir = async () => {
try {
await SavePDFExportDir('')
pdfExportDir.value = ''
Message.success('已恢复默认导出目录')
} catch (e) {
Message.error(`恢复默认失败: ${e instanceof Error ? e.message : e}`)
}
}
watch(visible, (val) => {
if (val) loadExportDir()
})
const draggedIndex = ref(null)
// 隐藏的 Tabs(不在 visibleTabs 中)
+1 -1
View File
@@ -13,7 +13,7 @@
</div>
<a-progress
:percent="percent(item)"
:status="item.status === 'error' ? 'exception' : item.status === 'done' ? 'success' : 'normal'"
:status="item.status === 'error' ? 'danger' : item.status === 'done' ? 'success' : 'normal'"
:show-text="false"
size="small"
/>
+3 -3
View File
@@ -42,7 +42,7 @@ export function useTimeout(options: TimeoutOptions = {}) {
const { autoCleanup = true } = options
// 使用 Set 存储所有定时器 ID
const timers = ref<Set<NodeJS.Timeout>>(new Set())
const timers = ref<Set<ReturnType<typeof setTimeout>>>(new Set())
/**
* 设置定时器(自动管理生命周期)
@@ -53,7 +53,7 @@ export function useTimeout(options: TimeoutOptions = {}) {
const setTimeout = <T = void>(
callback: () => T,
delay: number
): NodeJS.Timeout => {
): ReturnType<typeof setTimeout> => {
const timer = window.setTimeout(() => {
try {
callback()
@@ -73,7 +73,7 @@ export function useTimeout(options: TimeoutOptions = {}) {
* 清除特定定时器
* @param timer - 要清除的定时器 ID
*/
const clearTimeout = (timer: NodeJS.Timeout) => {
const clearTimeout = (timer: ReturnType<typeof setTimeout>) => {
window.clearTimeout(timer)
timers.value.delete(timer)
}
+12
View File
@@ -5,6 +5,7 @@ import './style.css'
import App from './App.vue'
import { useThemeStore } from './stores/theme'
import { startFreezeWatchdog } from './utils/freezeWatchdog'
import { preloadMarked } from './utils/markedExtensions'
import '@/plugin/built-in' // 注册内置预览插件(drawio 等)
const app = createApp(App)
@@ -21,3 +22,14 @@ startFreezeWatchdog()
app.mount('#app')
// 空闲预热 markdown 渲染链(marked + highlight.js 各语言模块)与 mermaid 模块:
// 只拉取模块不执行渲染,首屏不受影响;requestIdleCallback 缺失时 setTimeout 3s 兜底。
// 首次打开 md 文件时 ensureMarked/loadMermaid 的动态 import 直接命中缓存,实现秒开
const idlePreload = typeof window.requestIdleCallback === 'function'
? (cb) => window.requestIdleCallback(() => cb())
: (cb) => setTimeout(cb, 3000)
idlePreload(() => {
preloadMarked()
import('mermaid').catch(() => {})
})
+3 -1
View File
@@ -1,6 +1,8 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { On, Off } from '@wailsio/events'
import { Events } from '@wailsio/runtime'
const { On, Off } = Events
import { parseWailsEventData } from '@/utils/wailsEvent'
/** 传输条目(与后端 transfer-progress 事件 payload 对应) */
+3 -17
View File
@@ -6,7 +6,9 @@ import { parseWailsEventData } from '@/utils/wailsEvent'
import {
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate, InstallUpdateWithHash
} from '../wailsjs/v3-bindings/u-desk/app'
import { On, Off } from '@wailsio/events'
import { Events } from '@wailsio/runtime'
const { On, Off } = Events
// 本次应用运行内已自动弹过的版本(升级后自动恢复提醒)
let sessionNotifiedVersion = ''
@@ -63,15 +65,6 @@ export const useUpdateStore = defineStore('update', () => {
if (version) {
sessionNotifiedVersion = version
}
if (!force) {
// 自动发现时的系统通知
try {
window.runtime?.SendNotification?.({
title: 'U-Desk',
body: `发现新版本 ${version},点击查看`
})
} catch { /* 通知不可用时忽略 */ }
}
}
/**
@@ -246,13 +239,6 @@ export const useUpdateStore = defineStore('update', () => {
const fileSize = (data.file_size as number) || 0
// 系统通知:下载完成
try {
window.runtime?.SendNotification?.({
title: 'U-Desk',
body: `更新包下载完成 (${formatFileSize(fileSize)}),正在安装...`
})
} catch { /* 通知不可用时忽略 */ }
progressInfo.value = {
speed: 0,
downloaded: fileSize,
+2
View File
@@ -195,6 +195,8 @@ export interface FileEditorPanelConfig {
officeError: string | null
/** 是否可以保存 */
canSaveFile: boolean
/** 保存中(保存按钮 loading,防重复点击) */
isSaving: boolean
/** 是否可以重置 */
canResetContent: boolean
/** 是否可以预览 */
+64 -14
View File
@@ -3,6 +3,62 @@ import { getHljsLanguage } from './languageMap'
let mermaidInstance: typeof import('mermaid').default | null = null
let mermaidTheme: string | null = null
// languageMap.ts(commonLangNames 直出 + 扩展名映射值)能产出的 hljs 语言全集。
// 用 lib/core + 逐语言异步 import 替代全量包(全量 ~190 种语言严重拖大首包);
// shell/cmd/html 是 bash/dos/xml 模块自带的别名,plaintext 为 hljs 内建,无需单独注册
const HLJS_LANGUAGES: Record<string, () => Promise<{ default: unknown }>> = {
javascript: () => import('highlight.js/lib/languages/javascript'),
typescript: () => import('highlight.js/lib/languages/typescript'),
python: () => import('highlight.js/lib/languages/python'),
java: () => import('highlight.js/lib/languages/java'),
go: () => import('highlight.js/lib/languages/go'),
rust: () => import('highlight.js/lib/languages/rust'),
c: () => import('highlight.js/lib/languages/c'),
cpp: () => import('highlight.js/lib/languages/cpp'),
csharp: () => import('highlight.js/lib/languages/csharp'),
php: () => import('highlight.js/lib/languages/php'),
ruby: () => import('highlight.js/lib/languages/ruby'),
swift: () => import('highlight.js/lib/languages/swift'),
kotlin: () => import('highlight.js/lib/languages/kotlin'),
scala: () => import('highlight.js/lib/languages/scala'),
dart: () => import('highlight.js/lib/languages/dart'),
lua: () => import('highlight.js/lib/languages/lua'),
r: () => import('highlight.js/lib/languages/r'),
bash: () => import('highlight.js/lib/languages/bash'),
powershell: () => import('highlight.js/lib/languages/powershell'),
dos: () => import('highlight.js/lib/languages/dos'),
sql: () => import('highlight.js/lib/languages/sql'),
yaml: () => import('highlight.js/lib/languages/yaml'),
json: () => import('highlight.js/lib/languages/json'),
xml: () => import('highlight.js/lib/languages/xml'),
markdown: () => import('highlight.js/lib/languages/markdown'),
css: () => import('highlight.js/lib/languages/css'),
scss: () => import('highlight.js/lib/languages/scss'),
less: () => import('highlight.js/lib/languages/less'),
ini: () => import('highlight.js/lib/languages/ini'),
makefile: () => import('highlight.js/lib/languages/makefile'),
dockerfile: () => import('highlight.js/lib/languages/dockerfile'),
cmake: () => import('highlight.js/lib/languages/cmake'),
latex: () => import('highlight.js/lib/languages/latex'),
objectivec: () => import('highlight.js/lib/languages/objectivec'),
autohotkey: () => import('highlight.js/lib/languages/autohotkey'),
diff: () => import('highlight.js/lib/languages/diff'),
graphql: () => import('highlight.js/lib/languages/graphql'),
nginx: () => import('highlight.js/lib/languages/nginx'),
perl: () => import('highlight.js/lib/languages/perl'),
haskell: () => import('highlight.js/lib/languages/haskell'),
elixir: () => import('highlight.js/lib/languages/elixir'),
erlang: () => import('highlight.js/lib/languages/erlang'),
clojure: () => import('highlight.js/lib/languages/clojure'),
ocaml: () => import('highlight.js/lib/languages/ocaml'),
vbnet: () => import('highlight.js/lib/languages/vbnet'),
wasm: () => import('highlight.js/lib/languages/wasm'),
fsharp: () => import('highlight.js/lib/languages/fsharp'),
groovy: () => import('highlight.js/lib/languages/groovy'),
julia: () => import('highlight.js/lib/languages/julia'),
matlab: () => import('highlight.js/lib/languages/matlab'),
}
// 懒加载 marked + hljs,首次调用时才初始化
let _marked: typeof import('marked').marked | null = null
let _initPromise: Promise<typeof import('marked').marked> | null = null
@@ -12,22 +68,16 @@ async function ensureMarked(): Promise<typeof import('marked').marked> {
if (_initPromise) return _initPromise
_initPromise = (async () => {
const [markedModule, hljs, ...extras] = await Promise.all([
const [markedModule, hljs, ...langModules] = await Promise.all([
import('marked'),
import('highlight.js'),
import('highlight.js/lib/languages/powershell'),
import('highlight.js/lib/languages/dos'),
import('highlight.js/lib/languages/autohotkey'),
import('highlight.js/lib/languages/latex'),
import('highlight.js/lib/languages/dockerfile'),
import('highlight.js/lib/languages/cmake'),
import('highlight.js/lib/languages/scala'),
import('highlight.js/lib/languages/dart'),
import('highlight.js/lib/core'),
...Object.values(HLJS_LANGUAGES).map(load => load()),
])
// 注册额外语言到 hljs
const langNames = ['powershell', 'dos', 'autohotkey', 'latex', 'dockerfile', 'cmake', 'scala', 'dart']
extras.forEach((mod, i) => {
hljs.default.registerLanguage(langNames[i], (mod as any).default)
// 注册语言到 hljs core(Promise.all 保序,键序与模块序一一对应)
const hljsCore = hljs.default
const langNames = Object.keys(HLJS_LANGUAGES)
langNames.forEach((name, i) => {
hljsCore.registerLanguage(name, (langModules[i] as any).default)
})
const marked = markedModule.marked
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"sync/atomic"
"time"
"u-desk/internal/common"
"u-desk/internal/service"
)
@@ -133,7 +134,7 @@ func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}
return errorResponse("已有下载任务进行中"), nil
}
go func() {
common.Go("download-update", func() {
defer api.downloading.Store(false)
progressCallback := func(progress float64, speed float64, downloaded int64, total int64) {
@@ -162,7 +163,7 @@ func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}
}
api.emit("download-complete", resultInfo)
}
}()
})
return successResponse(map[string]interface{}{
"message": "下载已开始",
+89
View File
@@ -0,0 +1,89 @@
package common
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
)
// GetLogsDir 日志目录:~/.u-desk/logs(audit 日志同目录)
func GetLogsDir() string {
return filepath.Join(GetUserDataDir(), "logs")
}
// dailyFile 按天滚动的追加写文件:每次写时发现日期变化即切换新文件。
// 只做按天滚动,不搞大小上限/压缩/清理,崩溃取证够用。
type dailyFile struct {
mu sync.Mutex
dir string
naming func(t time.Time) string
f *os.File
day string
}
func newDailyFile(dir string, naming func(time.Time) string) *dailyFile {
return &dailyFile{dir: dir, naming: naming}
}
func (w *dailyFile) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
now := time.Now()
day := now.Format("2006-01-02")
if w.f != nil && day != w.day {
w.f.Close()
w.f = nil
}
if w.f == nil {
if err := os.MkdirAll(w.dir, 0755); err != nil {
return 0, err
}
f, err := os.OpenFile(filepath.Join(w.dir, w.naming(now)), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return 0, err
}
w.f, w.day = f, day
}
return w.f.Write(p)
}
func (w *dailyFile) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.f == nil {
return nil
}
err := w.f.Close()
w.f = nil
return err
}
// FileLogOut 应用文件日志的底层写出器(stderr + 按天文件 MultiWriter)。
// SetupFileLogging 之后有效;供需要独立过滤级别的 logger 复用同一落盘目的地
// (如 wails 系统日志降到 Warn,业务 slog.Default() 保持 INFO 全量)。
var FileLogOut io.Writer = os.Stderr
// SetupFileLogging slog 双路落盘:stderr + ~/.u-desk/logs/app-YYYY-MM-DD.log。
// windowsgui 下 stderr 被丢弃,文件路是日志唯一可见载体;
// 文件侧打不开时静默降级为仅 stderr,不因日志失败阻断启动。
func SetupFileLogging() {
out := io.Writer(os.Stderr)
w := newDailyFile(GetLogsDir(), func(t time.Time) string {
return fmt.Sprintf("app-%s.log", t.Format("2006-01-02"))
})
// 预开当天文件探测文件侧可用性(Write 内部含懒开/换天逻辑)
if _, err := w.Write(nil); err == nil {
// 文件必须放 MultiWriter 首位:windowsgui 下 stderr 句柄无效,
// MultiWriter 按序短路,stderr 在前会吞掉后续文件路(真机零日志的根因)
out = io.MultiWriter(w, os.Stderr)
} else {
w.Close()
}
FileLogOut = out
slog.SetDefault(slog.New(slog.NewTextHandler(out, nil)))
}
+46
View File
@@ -0,0 +1,46 @@
package common
import (
"fmt"
"os"
"path/filepath"
"runtime/debug"
"sync"
"time"
)
var panicLogMu sync.Mutex
// AppendPanicLog 把一次 panic 的来源名、值与完整栈追加写入
// ~/.u-desk/logs/panic-YYYYMMDD.log。写失败静默放弃:取证失败不能引发二次故障。
func AppendPanicLog(name string, r any) {
dir := GetLogsDir()
panicLogMu.Lock()
defer panicLogMu.Unlock()
if err := os.MkdirAll(dir, 0755); err != nil {
return
}
path := filepath.Join(dir, fmt.Sprintf("panic-%s.log", time.Now().Format("20060102")))
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return
}
defer f.Close()
// debug.Stack 在 defer 内 panic 未展开时调用,包含 panic 原始调用栈
fmt.Fprintf(f, "[%s] goroutine=%s panic=%v\n%s\n",
time.Now().Format("2006-01-02 15:04:05.000"), name, r, debug.Stack())
}
// Go 带 panic 兜底的 goroutine:panic 时写完整取证后仅结束该 goroutine,
// 不让单点 panic 杀掉整个进程(windowsgui 下默认静默退出零痕迹)。
func Go(name string, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
AppendPanicLog(name, r)
}
}()
fn()
}()
}
+3 -2
View File
@@ -199,10 +199,11 @@ func RunConcurrent[T any](tasks []T, limit int, fn func(t T)) {
for _, task := range tasks {
sem <- struct{}{}
wg.Add(1)
go func(t T) {
t := task
Go("bounded-parallel", func() {
defer func() { <-sem; wg.Done() }()
fn(t)
}(task)
})
}
wg.Wait()
}
+6 -4
View File
@@ -14,6 +14,8 @@ import (
"strings"
"sync"
"time"
"u-desk/internal/common"
)
const DefaultFileServerPort = 2652
@@ -142,11 +144,11 @@ func listenWithFallback(basePort int, handler http.Handler) (addr string, srv *h
l, e := net.Listen("tcp", addr)
if e == nil {
srv = &http.Server{Handler: handler}
go func() {
common.Go("local-file-server-serve", func() {
if se := srv.Serve(l); se != http.ErrServerClosed {
log.Printf("[LocalFileServer] 异常退出: %v", se)
}
}()
})
return addr, srv, nil
}
log.Printf("[LocalFileServer] 端口 %d 被占用,尝试 %d...", port, port+1)
@@ -346,11 +348,11 @@ type LocalFileHandler struct {
// NewLocalFileHandler 创建本地文件处理器
func NewLocalFileHandler() *LocalFileHandler {
// 启动本地文件服务器
go func() {
common.Go("local-file-handler-start", func() {
if _, err := StartLocalFileServer(); err != nil {
log.Printf("[LocalFileHandler] 启动本地文件服务器失败: %v", err)
}
}()
})
return &LocalFileHandler{}
}
+59 -26
View File
@@ -3,11 +3,14 @@ package filesystem
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"u-desk/internal/common"
)
// AuditOperation 审计操作类型
@@ -78,7 +81,7 @@ func (a *AuditLogger) ensureReady() error {
}
a.logFile = f
a.initialized = true
go a.backgroundFlush()
common.Go("audit-flush", a.backgroundFlush)
return nil
}
@@ -102,10 +105,14 @@ func (a *AuditLogger) Log(entry AuditLogEntry) error {
// 添加到缓冲区
a.buffer = append(a.buffer, entry)
// 如果缓冲区满了,立即写入
// 如果缓冲区满了,立即写入(缓冲满即刷的热路径语义不变:写+Sync 在已持有的锁内完成)
if len(a.buffer) >= a.bufferSize {
if err := a.flush(); err != nil {
if f, err := a.flush(); err != nil {
return err
} else if f != nil {
if err := f.Sync(); err != nil {
return err
}
}
}
@@ -166,10 +173,11 @@ func (a *AuditLogger) LogRead(path string, size int64, err error) {
_ = a.Log(entry)
}
// flush 将缓冲区写入文件
func (a *AuditLogger) flush() error {
// flush 将缓冲区写入文件(调用方须持有 a.mu),返回刚写入的文件供调用方在锁外 Sync。
// Sync 是慢速磁盘操作,不放在锁内执行,避免阻塞 Log 热路径。
func (a *AuditLogger) flush() (*os.File, error) {
if len(a.buffer) == 0 {
return nil
return nil, nil
}
// 序列化所有条目为JSON(每行一个)
@@ -179,19 +187,25 @@ func (a *AuditLogger) flush() error {
continue // 序列化失败,跳过该条目
}
if _, err := a.logFile.Write(append(data, '\n')); err != nil {
return err
return nil, err
}
}
// 刷新到磁盘
if err := a.logFile.Sync(); err != nil {
return err
}
// 清空缓冲区
a.buffer = a.buffer[:0]
return nil
return a.logFile, nil
}
// flushSynced 锁内写缓冲、锁外 Sync:fsync 不占 a.mu,后台定时刷盘不再阻塞 Log
func (a *AuditLogger) flushSynced() error {
a.mu.Lock()
f, err := a.flush()
a.mu.Unlock()
if err != nil || f == nil {
return err
}
return f.Sync()
}
// backgroundFlush 后台协程,定期刷新缓冲区
@@ -202,14 +216,10 @@ func (a *AuditLogger) backgroundFlush() {
for {
select {
case <-ticker.C:
a.mu.Lock()
_ = a.flush()
a.mu.Unlock()
_ = a.flushSynced()
case <-a.stopChan:
// 停止前刷新一次
a.mu.Lock()
_ = a.flush()
a.mu.Unlock()
_ = a.flushSynced()
return
}
}
@@ -227,31 +237,54 @@ func (a *AuditLogger) Close() error {
return nil
}
// 刷新剩余缓冲区
if err := a.flush(); err != nil {
// 刷新剩余缓冲区(进程退出路径,Sync 在锁内不影响运行期热路径)
if f, err := a.flush(); err != nil {
return err
} else if f != nil {
if err := f.Sync(); err != nil {
return err
}
}
// 关闭文件
return a.logFile.Close()
}
// GetRecentLogs 获取最近的审计日志
// GetRecentLogs 获取最近的审计日志(尾部倒读:文件大于 64KB 时只 Seek 读末尾 64KB,
// 日志单行远小于 64KB,足以覆盖 limit 条最新记录,避免随日志增长全量读盘)
func GetRecentLogs(logDir string, limit int) ([]AuditLogEntry, error) {
// 读取今天的日志文件
timestamp := time.Now().Format("2006-01-02")
logPath := filepath.Join(logDir, fmt.Sprintf("audit_%s.log", timestamp))
data, err := os.ReadFile(logPath)
f, err := os.Open(logPath)
if err != nil {
return nil, err
}
defer f.Close()
// 解析JSON(每行一个条目)
var entries []AuditLogEntry
const tailSize = 64 * 1024
info, err := f.Stat()
if err != nil {
return nil, err
}
offset := info.Size() - tailSize
if offset < 0 {
offset = 0
}
data := make([]byte, info.Size()-offset)
if _, err := f.ReadAt(data, offset); err != nil && err != io.EOF {
return nil, err
}
// 从文件中间开始读时首个字节位置未必在行边界,丢弃首条残行
lines := parseLines(string(data))
if offset > 0 && len(lines) > 0 {
lines = lines[1:]
}
// 从后往前读取(最新的在前)
// 解析JSON(每行一个条目),从后往前读取(最新的在前)
var entries []AuditLogEntry
start := len(lines) - limit
if start < 0 {
start = 0
+4 -2
View File
@@ -6,6 +6,8 @@ import (
"path/filepath"
"runtime"
"time"
"u-desk/internal/common"
)
// ========== 辅助函数 ==========
@@ -43,10 +45,10 @@ func OpenPath(path string) error {
}
// 给进程一点时间启动
go func() {
common.Go("fs-process-release", func() {
time.Sleep(100 * time.Millisecond)
cmd.Process.Release()
}()
})
return nil
}
+1 -1
View File
@@ -52,7 +52,7 @@ func NewRecycleBin(binPath string) (*RecycleBin, error) {
}
// 启动自动清理协程
go bin.autoCleanup()
common.Go("recycle-autoclean", bin.autoCleanup)
return bin, nil
}
+7 -2
View File
@@ -878,10 +878,15 @@ func (s *FileSystemService) ResolveShortcut(lnkPath string) (targetPath string,
lnkPath,
)
// 执行 PowerShell 命令
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
// 执行 PowerShell 命令(10s 超时:PowerShell 冷启动/被安全软件拖住时不无限挂起)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
output, err := cmd.Output()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("解析快捷方式超时(10s)")
}
return "", fmt.Errorf("解析快捷方式失败: %w", err)
}
+3 -1
View File
@@ -9,6 +9,8 @@ import (
"path/filepath"
"strings"
"time"
"u-desk/internal/common"
)
// ZipFileEntry 表示 zip 文件中的一个文件条目
@@ -231,7 +233,7 @@ func ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
debugLog("[ExtractFileFromZipToTemp] 开始提取: %s from %s", filePath, zipPath)
// 启动临时文件清理协程
go CleanOldTempFiles()
common.Go("zip-clean-temp", CleanOldTempFiles)
// 创建临时目录
tempDir := filepath.Join(os.TempDir(), TempFileDir)
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/fsnotify/fsnotify"
"u-desk/internal/common"
)
type Watcher struct {
@@ -62,7 +63,7 @@ func (w *Watcher) WatchFile(path string) error {
w.watched = absPath
// 后台消费事件
go w.consumeEvents()
common.Go("filewatch-consume", w.consumeEvents)
return nil
}
+4 -4
View File
@@ -210,10 +210,10 @@ func InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash
// 自动重启
if autoRestart {
go func() {
common.Go("update-restart", func() {
time.Sleep(2 * time.Second)
restartApplication()
}()
})
}
return &InstallResult{Success: true, Message: "安装成功"}, nil
@@ -275,10 +275,10 @@ func replaceExecutableFileWindows(newFilePath, execPath string) error {
}
// 延迟删除旧文件
go func() {
common.Go("update-remove-old-exe", func() {
time.Sleep(10 * time.Second)
os.Remove(oldExecPath)
}()
})
return nil
}
+30 -12
View File
@@ -10,11 +10,16 @@ import (
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
"u-desk/internal/common"
)
// 连接建立全流程(拨号+认证+SFTP 子系统)的总超时预算
const connectTimeout = 10 * time.Second
// 拨号独立预算,小于总预算:冷连接卡死大多发生在 TCP 拨号,
// 独立预算保证握手认证阶段始终有剩余 deadline 可用
const dialTimeout = 6 * time.Second
// 保活参数:周期发送 keepalive 并等待回复,超过 keepaliveReplyWait 未回复判定连接已死
const (
keepaliveInterval = 30 * time.Second
@@ -45,7 +50,8 @@ func GetManager() *Manager {
}
// Connect 创建或复用 SFTP 连接
func (m *Manager) Connect(config *Config) (*Client, error) {
// onStage 在连接各阶段推进时回调(dial/auth/channel),可为 nil
func (m *Manager) Connect(config *Config, onStage func(stage string)) (*Client, error) {
key := fmt.Sprintf("%s:%d", config.Host, config.Port)
m.mu.Lock()
@@ -60,7 +66,7 @@ func (m *Manager) Connect(config *Config) (*Client, error) {
m.clients.Delete(key)
}
c, err := newClient(config)
c, err := newClient(config, onStage)
if err != nil {
return nil, err
}
@@ -102,8 +108,8 @@ func (m *Manager) Shutdown() {
// --- 内部 ---
// newClient 建立连接并启动保活循环
func newClient(config *Config) (*Client, error) {
c, err := buildClient(config)
func newClient(config *Config, onStage func(stage string)) (*Client, error) {
c, err := buildClient(config, onStage)
if err != nil {
return nil, err
}
@@ -112,7 +118,14 @@ func newClient(config *Config) (*Client, error) {
}
// buildClient 拨号+认证+SFTP 子系统建立(全程受 connectTimeout 约束),不启动保活
func buildClient(config *Config) (*Client, error) {
// onStage 标记各阶段开始:dial=拨号、auth=认证、channel=建通道;SSH 协议链路串行,
// 无法并行加速,分步回调让前端可见当前卡点
func buildClient(config *Config, onStage func(stage string)) (*Client, error) {
notify := func(stage string) {
if onStage != nil {
onStage(stage)
}
}
sshConfig := &ssh.ClientConfig{
Config: ssh.Config{
KeyExchanges: []string{
@@ -160,14 +173,17 @@ func buildClient(config *Config) (*Client, error) {
// JoinHostPort 保证 IPv6 主机地址被方括号包裹,fmt 拼接对 IPv6 产生非法地址
addr := net.JoinHostPort(config.Host, strconv.Itoa(config.Port))
// 拨号与认证、SFTP 子系统握手共享同一总超时预算,认证阶段无界挂起由 deadline 掐断
// 总 deadline 从拨号前起算:拨号占独立 6s 预算,认证与 SFTP 子系统握手受剩余总预算约束,
// 认证阶段无界挂起由 deadline 掐断
deadline := time.Now().Add(connectTimeout)
sshConn, err := net.DialTimeout("tcp", addr, connectTimeout)
notify("dial")
sshConn, err := net.DialTimeout("tcp", addr, dialTimeout)
if err != nil {
return nil, &ConnectionError{Op: "dial", Err: err}
}
sshConn.SetDeadline(deadline)
notify("auth")
sshConnConn, chans, reqs, err := ssh.NewClientConn(sshConn, addr, sshConfig)
if err != nil {
sshConn.Close()
@@ -176,6 +192,7 @@ func buildClient(config *Config) (*Client, error) {
sshClient := ssh.NewClient(sshConnConn, chans, reqs)
notify("channel")
sftpClient, err := sftp.NewClient(sshClient)
sshConn.SetDeadline(time.Time{})
if err != nil {
@@ -196,7 +213,7 @@ func (c *Client) startKeepalive() {
stop := make(chan struct{})
sshClient := c.sshClient
c.stopKeep = stop
go func() {
common.Go("sftp-keepalive", func() {
t := time.NewTicker(keepaliveInterval)
defer t.Stop()
for {
@@ -210,16 +227,16 @@ func (c *Client) startKeepalive() {
return
}
}
}()
})
}
// sshKeepalive 发送一次 keepalive 请求并限时等待回复,超时或出错视作连接已死
func sshKeepalive(sshClient *ssh.Client, wait time.Duration) bool {
done := make(chan error, 1)
go func() {
common.Go("sftp-keepalive-probe", func() {
_, _, err := sshClient.SendRequest("keepalive@openssh.com", true, nil)
done <- err
}()
})
select {
case err := <-done:
return err == nil
@@ -295,7 +312,8 @@ func (c *Client) WithRetry(fn func(*sftp.Client) error) error {
func (c *Client) reconnect() error {
// 用 buildClient 而非 newClient,避免保活循环绑定到即将丢弃的临时容器
nc, err := buildClient(c.config)
// 后台重连无阶段展示需求,回调传 nil
nc, err := buildClient(c.config, nil)
if err != nil {
return err
}
+3 -3
View File
@@ -844,9 +844,9 @@ func (s *Service) GetSystemInfo(connID string) (map[string]interface{}, error) {
out, e := c.RunCommand(cmd)
results <- cmdResult{key, out, e}
}
go runCmd("df", "df -B1 / 2>/dev/null | tail -1")
go runCmd("mem", "free -b 2>/dev/null | grep -E '^Mem:' || cat /proc/meminfo 2>/dev/null | head -2")
go runCmd("cpu", "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
common.Go("sftp-cmd-df", func() { runCmd("df", "df -B1 / 2>/dev/null | tail -1") })
common.Go("sftp-cmd-mem", func() { runCmd("mem", "free -b 2>/dev/null | grep -E '^Mem:' || cat /proc/meminfo 2>/dev/null | head -2") })
common.Go("sftp-cmd-cpu", func() { runCmd("cpu", "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1") })
var dfOut, memOut, cpuOut string
hasErr := false
+17 -2
View File
@@ -3,11 +3,13 @@ package main
import (
"embed"
"fmt"
"log/slog"
"net/http"
"os"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"u-desk/internal/common"
"u-desk/internal/hotkey"
"u-desk/internal/service"
)
@@ -23,6 +25,15 @@ var (
)
func main() {
// 崩溃取证基建先于一切:slog 落盘 + 主 goroutine panic 落盘
common.SetupFileLogging()
defer func() {
if r := recover(); r != nil {
common.AppendPanicLog("main", r)
os.Exit(2)
}
}()
// 启动时完成上次更新遗留的 exe 替换
service.ReplacePendingFile()
@@ -31,6 +42,9 @@ func main() {
wailsApp := application.New(application.Options{
Name: "U-Desk",
Description: "桌面文件管理器",
// wails 系统日志(webview 生命周期、AssetFileServerFS 的 INFO 刷屏等)降到 Warn 落盘,
// 业务日志仍走 slog.Default() 的 INFO 全量——两路共用同一文件 writer,只是过滤级别不同
Logger: slog.New(slog.NewTextHandler(common.FileLogOut, &slog.HandlerOptions{Level: slog.LevelWarn})),
SingleInstance: &application.SingleInstanceOptions{
UniqueID: "top.1216.udesk",
},
@@ -65,8 +79,9 @@ func main() {
return 0, true
}
case wmSizeMsg:
// webview 尺寸失同步兜底(app_window_fix.go)
go fixWebviewFill(hwnd)
// webview 尺寸失同步兜底(app_window_fix.go):
// 只置脏标记,单 worker goroutine 统一消费,WM_SIZE 风暴不再批量 spawn goroutine
fixWebviewFill(hwnd)
}
return 0, false
},