From 75a42c0b6aca19d2704a18f692aaf663cbda45e1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com>
Date: Sat, 24 Oct 2026 13:48:35 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E7=B2=98=E8=B4=B4?=
=?UTF-8?q?=E5=9B=BE=E5=A4=B1=E6=95=88=E4=B8=8ESFTP=E4=B8=B2=E5=8F=B0?=
=?UTF-8?q?=E5=9B=9E=E9=80=80=E7=AD=89=E4=B8=80=E6=89=B9=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
粘贴图根修(Ctrl+V直读系统剪贴板,不再依赖不可靠的paste事件)/SFTP串台双刀(拨号期禁回退本机+盘符路径自动回根)/PDF导出目录设置与文案带落盘路径/性能批(连接保存下载防重/md渲染防抖+预热/hljs按需加载/审计锁外置/快捷方式解析超时/webview-watch可停)/前端62类型错清零(含keyPath蛇形字段炸弹)/目录图标字段错位修复
---
.gitignore | 4 +
app.go | 13 +-
app_clipboard_image.go | 164 ++++++++++++++++++
app_sftp.go | 10 +-
app_update.go | 7 +-
app_window_fix.go | 92 +++++++---
build/publish/last-version.json | 9 +
build/publish/versions.json | 85 +++++++++
docs/07-项目管理/功能清单/功能清单.md | 4 +-
frontend/src/App.vue | 8 +-
frontend/src/api/connection-manager.ts | 54 +++++-
frontend/src/api/sftp-transport.ts | 52 +++++-
frontend/src/api/system.ts | 10 +-
frontend/src/components/CodeEditor.vue | 15 +-
.../components/ConnectionIndicator.vue | 41 ++++-
.../FileSystem/components/ContextMenu.vue | 4 +-
.../FileSystem/components/DrawioPreview.vue | 4 +-
.../FileSystem/components/DropdownItem.vue | 8 +-
.../FileSystem/components/FileEditorPanel.vue | 12 +-
.../FileSystem/components/FileListPanel.vue | 20 ++-
.../FileSystem/components/PathBreadcrumb.vue | 6 +-
.../FileSystem/components/Sidebar.vue | 4 +-
.../FileSystem/components/Toolbar.vue | 7 +-
.../FileSystem/composables/useFileEdit.ts | 3 +
frontend/src/components/FileSystem/index.vue | 131 +++++++++++---
frontend/src/components/SettingsPanel.vue | 62 ++++++-
frontend/src/components/TransferPanel.vue | 2 +-
frontend/src/composables/useTimeout.ts | 6 +-
frontend/src/main.js | 12 ++
frontend/src/stores/transfer.ts | 4 +-
frontend/src/stores/update.ts | 20 +--
frontend/src/types/file-system.ts | 2 +
frontend/src/utils/markedExtensions.ts | 78 +++++++--
internal/api/update_api.go | 5 +-
internal/common/filelog.go | 89 ++++++++++
internal/common/safego.go | 46 +++++
internal/common/utils.go | 5 +-
internal/filesystem/asset_handler.go | 10 +-
internal/filesystem/audit_log.go | 85 ++++++---
internal/filesystem/fs.go | 6 +-
internal/filesystem/recycle_bin.go | 2 +-
internal/filesystem/service.go | 9 +-
internal/filesystem/zip.go | 4 +-
internal/filewatch/watcher.go | 3 +-
internal/service/update.go | 8 +-
internal/sftp/client.go | 42 +++--
internal/sftp/service.go | 6 +-
main.go | 19 +-
48 files changed, 1072 insertions(+), 220 deletions(-)
create mode 100644 app_clipboard_image.go
create mode 100644 build/publish/last-version.json
create mode 100644 build/publish/versions.json
create mode 100644 internal/common/filelog.go
create mode 100644 internal/common/safego.go
diff --git a/.gitignore b/.gitignore
index 543e46c..aa2632d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/app.go b/app.go
index 9590f38..94111e9 100644
--- a/app.go
+++ b/app.go
@@ -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
}
diff --git a/app_clipboard_image.go b/app_clipboard_image.go
new file mode 100644
index 0000000..16b6990
--- /dev/null
+++ b/app_clipboard_image.go
@@ -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
+}
diff --git a/app_sftp.go b/app_sftp.go
index d8db904..aada060 100644
--- a/app_sftp.go
+++ b/app_sftp.go
@@ -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
}
diff --git a/app_update.go b/app_update.go
index 00f6ee7..fee1b06 100644
--- a/app_update.go
+++ b/app_update.go
@@ -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
diff --git a/app_window_fix.go b/app_window_fix.go
index 56cfa92..94d24cf 100644
--- a/app_window_fix.go
+++ b/app_window_fix.go
@@ -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 找本进程的可见主窗口
+// 回调必须进程级复用(同 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 == windows.GetCurrentProcessId() && windows.IsWindowVisible(windows.HWND(h)) {
+ mainWindowHwndResult = h
+ return 0
+ }
+ return 1
+})
+
func findMainWindowHwnd() uintptr {
- var found uintptr
- pid := windows.GetCurrentProcessId()
- cb := 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
- return 0
- }
- return 1
- })
- runGuarded(func() {
- windows.EnumWindows(cb, nil)
- })
- return found
+ mainWindowHwndResult = 0
+ windows.EnumWindows(enumMainWindowHwnd, nil)
+ return mainWindowHwndResult
}
diff --git a/build/publish/last-version.json b/build/publish/last-version.json
new file mode 100644
index 0000000..917205d
--- /dev/null
+++ b/build/publish/last-version.json
@@ -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"
+}
\ No newline at end of file
diff --git a/build/publish/versions.json b/build/publish/versions.json
new file mode 100644
index 0000000..9b1b3d5
--- /dev/null
+++ b/build/publish/versions.json
@@ -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": ""
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/07-项目管理/功能清单/功能清单.md b/docs/07-项目管理/功能清单/功能清单.md
index 83a4dd0..0792455 100644
--- a/docs/07-项目管理/功能清单/功能清单.md
+++ b/docs/07-项目管理/功能清单/功能清单.md
@@ -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)
**下次审查**: 每次发版前
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 9eb9863..1d0ce1d 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -56,8 +56,8 @@
-
-
+
+
@@ -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'
diff --git a/frontend/src/api/connection-manager.ts b/frontend/src/api/connection-manager.ts
index 3ce55db..49c55ac 100644
--- a/frontend/src/api/connection-manager.ts
+++ b/frontend/src/api/connection-manager.ts
@@ -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()
+ /** 每个 profile 的连接阶段(dial/auth/channel,冷连接分步反馈用) */
+ private _profileStages = new Map()
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
private _sysInfoCache = new Map()
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>()
+
/**
* 切换到指定 profile(连接池模式)
* 池中有 → 直接复用,瞬间切换
* 池中无 → 新建连接并存入池
*/
async connect(profileId: string): Promise {
+ // 同 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 {
const profile = this._profiles.find(p => p.id === profileId)
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
diff --git a/frontend/src/api/sftp-transport.ts b/frontend/src/api/sftp-transport.ts
index 1fead36..201a0c4 100644
--- a/frontend/src/api/sftp-transport.ts
+++ b/frontend/src/api/sftp-transport.ts
@@ -24,16 +24,29 @@ export class SftpTransport implements FsTransport {
}
async connect(): Promise {
- const result = await 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: '',
+ // 前端反馈层 9s 超时:后端连接继续由既有逻辑管理(不主动断),
+ // 仅避免 UI 在网络不可达时无限挂起,用户能立刻拿到失败反馈
+ let timer: ReturnType | undefined
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error('连接超时,请检查网络')), 9000)
})
- this.connID = result
- return result
+ 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 {
@@ -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 {
+ this.assertRemotePath(path)
return transformFileList(await SftpListDir(this.requireConn(), path))
}
async getFileInfo(path: string): Promise> {
+ this.assertRemotePath(path)
return SftpGetFileInfo(this.requireConn(), path)
}
// ====== 文件读写 ======
async readFile(path: string): Promise {
+ this.assertRemotePath(path)
return SftpReadFile(this.requireConn(), path)
}
async writeFile(path: string, content: string): Promise {
+ 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 {
if (!content) throw new Error('无效的 base64 内容')
+ this.assertRemotePath(path)
await SftpWriteBase64File(this.requireConn(), path, content)
}
// ====== 文件操作 ======
async createFile(dirPath: string, filename: string): Promise {
+ this.assertRemotePath(dirPath)
return SftpCreateFile(this.requireConn(), joinPath(dirPath, filename))
}
async createDir(parentPath: string, dirname: string): Promise {
+ this.assertRemotePath(parentPath)
return SftpCreateDir(this.requireConn(), joinPath(parentPath, dirname))
}
async deletePath(path: string): Promise {
+ this.assertRemotePath(path)
return SftpDeletePath(this.requireConn(), path)
}
async renamePath(oldPath: string, newPath: string): Promise {
+ 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 {
+ this.assertRemotePath(remotePath)
return SftpDownloadToFile(this.requireConn(), remotePath, localDstDir)
}
/** 上传本地文件/目录到远程目录(流式,目录递归) */
async uploadFromFile(localPath: string, remoteDir: string): Promise {
+ this.assertRemotePath(remoteDir)
await SftpUploadFromFile(this.requireConn(), localPath, remoteDir)
}
@@ -190,11 +222,13 @@ export class SftpTransport implements FsTransport {
/** 下载远程文件到本地临时目录用于预览(缓存由 Go 后端管理) */
async downloadForPreview(remotePath: string): Promise {
+ this.assertRemotePath(remotePath)
return SftpDownloadToTemp(this.requireConn(), remotePath)
}
/** 下载 HTML 及其网站资源到临时目录用于预览 */
async downloadSiteForPreview(remotePath: string): Promise {
+ this.assertRemotePath(remotePath)
return SftpDownloadSiteForPreview(this.requireConn(), remotePath)
}
diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts
index 19925b4..8af2e2c 100644
--- a/frontend/src/api/system.ts
+++ b/frontend/src/api/system.ts
@@ -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 {
@@ -88,7 +88,7 @@ export async function uploadFromFile(localPath: string, remoteDir: string): Prom
}
export async function getEnvVars(): Promise> {
- try { return (await window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
+ try { return (await GetEnvVars()) ?? {} } catch { return {} }
}
export async function listZipContents(zipPath: string): Promise {
diff --git a/frontend/src/components/CodeEditor.vue b/frontend/src/components/CodeEditor.vue
index 4658cdd..3e98a77 100644
--- a/frontend/src/components/CodeEditor.vue
+++ b/frontend/src/components/CodeEditor.vue
@@ -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
diff --git a/frontend/src/components/FileSystem/components/ConnectionIndicator.vue b/frontend/src/components/FileSystem/components/ConnectionIndicator.vue
index b39415d..406a7d5 100644
--- a/frontend/src/components/FileSystem/components/ConnectionIndicator.vue
+++ b/frontend/src/components/FileSystem/components/ConnectionIndicator.vue
@@ -9,7 +9,8 @@
@click.stop="toggleMenu"
@mouseenter="onDirHover"
@mouseleave="onDirLeave">
- {{ label }}
+
+ {{ activeStage || label }}
@@ -54,6 +55,7 @@
>
+
{
return p.name
})
+// 连接阶段的中文文案,未知阶段回落到连接中
+const STAGE_TEXTS: Record = { 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>([])
const dirLastLoadedPath = ref('')
-const dirHoverTimer = ref(null)
-const dirCloseTimer = ref(null)
+const dirHoverTimer = ref | null>(null)
+const dirCloseTimer = ref | 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;
diff --git a/frontend/src/components/FileSystem/components/ContextMenu.vue b/frontend/src/components/FileSystem/components/ContextMenu.vue
index 80ca4a3..5c2f306 100644
--- a/frontend/src/components/FileSystem/components/ContextMenu.vue
+++ b/frontend/src/components/FileSystem/components/ContextMenu.vue
@@ -7,7 +7,7 @@
@click.stop
>
-
+