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 @@ > {{ p.name }} + {{ stageLabel(p) }} { 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 > -