diff --git a/app.go b/app.go index 667937d..122d63e 100644 --- a/app.go +++ b/app.go @@ -7,10 +7,11 @@ import ( "context" "fmt" "log/slog" - "sync" "time" + "golang.org/x/sync/errgroup" + "u-desk/internal/api" "u-desk/internal/filewatch" "u-desk/internal/filesystem" @@ -123,16 +124,6 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) slog.Warn("配置迁移失败", "error", err) } - // 2.6. 初始化PDF导出API - slog.Info("初始化PDF导出模块") - pdfAPI, err := api.NewPdfAPI() - if err != nil { - slog.Error("PDF导出API初始化失败", "error", err) - } else { - a.pdfAPI = pdfAPI - slog.Info("PDF导出模块初始化完成") - } - // 3. 初始化版本号(提前触发缓存,避免后续重复计算) version := service.GetCurrentVersion() slog.Info("当前版本", "version", version) @@ -141,13 +132,28 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) visibleTabs := a.getVisibleTabs() slog.Info("可用的模块", "modules", visibleTabs) - // 4. 根据配置初始化模块(条件初始化) - if err := a.initModulesByConfig(visibleTabs); err != nil { - return fmt.Errorf("模块初始化失败: %w", err) - } + // 非关键路径并行初始化(PdfAPI/Filesystem/Cleanup 不阻塞首屏渲染) + go func() { + // 2.6. 初始化PDF导出API + slog.Info("初始化PDF导出模块") + if pdfAPI, err := api.NewPdfAPI(); err != nil { + slog.Error("PDF导出API初始化失败", "error", err) + } else { + a.mu.Lock() + a.pdfAPI = pdfAPI + a.mu.Unlock() + slog.Info("PDF导出模块初始化完成") + } - // 5. 清理过期的下载缓存 - storage.CleanupExpiredCache() + // 根据配置初始化模块(条件初始化) + if err := a.initModulesByConfig(visibleTabs); err != nil { + slog.Error("模块初始化失败", "error", err) + } + + // 清理过期的下载缓存 + storage.CleanupExpiredCache() + storage.CleanupSiteCacheDirs() + }() // 6. 异步初始化:UpdateAPI(涉及网络请求,完全异步) go func() { @@ -174,8 +180,9 @@ func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) // RegisterHotKey 必须在创建窗口的同一线程调用, // 通过 PostMessage 将注册请求投递到主线程消息循环 go func() { - for i := 0; i < 20; i++ { - time.Sleep(200 * time.Millisecond) + 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)]) if a.mainWindow == nil { return } @@ -203,31 +210,48 @@ func (a *App) ServiceShutdown() error { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if a.filesystem != nil { - slog.Info("文件系统服务正在关闭") - if err := a.filesystem.Close(shutdownCtx); err != nil { - slog.Error("文件系统服务关闭失败", "error", err) - } else { + g, ctx := errgroup.WithContext(shutdownCtx) + + // 并行关闭所有子系统(互不依赖,共享 5s 总超时) + g.Go(func() error { + if a.filesystem != nil { + slog.Info("文件系统服务正在关闭") + if err := a.filesystem.Close(ctx); err != nil { + slog.Error("文件系统服务关闭失败", "error", err) + return err + } slog.Info("文件系统服务已关闭") } - } + return nil + }) - slog.Info("文件服务器正在关闭") - if err := filesystem.ShutdownLocalFileServer(); err != nil { - slog.Error("文件服务器关闭失败", "error", err) - } else { + g.Go(func() error { + slog.Info("文件服务器正在关闭") + if err := filesystem.ShutdownLocalFileServer(); err != nil { + slog.Error("文件服务器关闭失败", "error", err) + return err + } slog.Info("文件服务器已关闭") + return nil + }) + + g.Go(func() error { + if a.sftpService != nil { + sftp.GetManager().Shutdown() + } + storage.CleanupExpiredCache() + storage.CleanupSiteCacheDirs() + return nil + }) + + g.Go(func() error { + osssvc.GetManager().Shutdown() + return nil + }) + + if err := g.Wait(); err != nil { + slog.Error("部分子系统关闭失败", "error", err) } - - // 关闭所有 SFTP 连接 + 清理临时文件 - if a.sftpService != nil { - sftp.GetManager().Shutdown() - } - storage.CleanupExpiredCache() - - // 关闭所有 OSS 连接 - osssvc.GetManager().Shutdown() - return nil } diff --git a/docs/04-功能迭代/GO-DESK-8.Wails-v3迁移.md b/docs/04-功能迭代/GO-DESK-8.Wails-v3迁移.md deleted file mode 100644 index 28cb4a0..0000000 --- a/docs/04-功能迭代/GO-DESK-8.Wails-v3迁移.md +++ /dev/null @@ -1,126 +0,0 @@ -# GO-DESK-8: Wails v3 迁移变更分析 - -> 范围: `44847e0`(v0.4.0) → `f54bf1c`(fs-only-v3) | 提交数: 1 | 日期: 2026-05-01 - -## 变更总览 - -| 类别 | 数量 | 说明 | -|------|------|------| -| 重命名 | 77 | web/ → frontend/(git rename,历史保留) | -| 新增 | 94 | v3 构建模板 + bindings + 新文件 | -| 删除 | 4 | 旧文件(clipboard png、md5、v2 transport/types) | -| 修改 | 10 | 核心代码适配 v3 API | -| **合计** | **185** | **+7,772 / -918 行** | - ---- - -## 一、框架升级(核心) - -### Wails v2 → v3 API 映射 - -| v2 (旧) | v3 (新) | 文件 | -|---------|---------|------| -| `wails.Run(&options.App{...})` | `application.New()` + `Window.NewWithOptions()` | main.go | -| `options.App.Bind: []interface{}{app}` | `Services: []application.Service{application.NewService(app)}` | main.go | -| `AssetServer: &assetserver.Options{}` | `Assets: application.AssetOptions{Handler, Middleware}` | main.go | -| `app.Startup(ctx)` / `app.Shutdown(ctx)` | `app.ServiceStartup(ctx, opts)` / `app.ServiceShutdown()` | app.go | -| `runtime.Window(ctx)` | `a.mainWindow`(手动注入) | app.go | -| `runtime.*` 函数调用 | `window.*` 方法 + v3-bindings 自动生成 | 前端 | -| `//go:embed all:web/dist` | `//go:embed all:frontend/dist` | main.go | - -### main.go 实质性变更 -- **Middleware 中间件**: 拦截 `/wails/custom.js` 返回空 200,消除控制台 404 -- **DevTools**: 延迟 2s 调用 `window.OpenDevTools()`(production + devtools build tag) -- **窗口主题**: Windows CustomTheme 配置亮/暗模式标题栏颜色 - -### app.go 实质性变更 -- **新增** `SetMainWindow()` — v3 需要手动注入窗口引用 -- **新增** `SetWindowTitleBarColor()` — v3 窗口主题色动态切换 -- **新增** `sync.Mutex` — 并发安全保护 mainWindow -- **生命周期**: `Startup/Shutdown` → `ServiceStartup/ServiceShutdown`(返回 error) -- **错误处理**: panic → return error(符合 Go 惯例) - ---- - -## 二、前端代码修改(有业务逻辑变化的) - -### App.vue (+375/-60 行) -- Tabs padding-top 覆盖(Arco Design 默认 16px → 0) -- import 路径更新:`@/wailsjs/v3-bindings/u-desk/app` -- 窗口控制方法改用 v3 binding 导入 - -### Sidebar.vue (+406 行) -- **新架构**: 双区块折叠(收藏夹 + 帮助文档),各自独立 header + content -- **滚动优化**: `.sidebar overflow:hidden` + 收藏内容区 `overflow-y:auto` 内部滚动 -- **帮助区块**: 固定底部 `flex-shrink:0`,默认展开 -- 折叠动画: max-height + opacity CSS transition - -### useFavorites.ts (+259 行) -- **修复**: `longPressTimer` const → let(解决 Assignment to constant variable TypeError) - -### stores/ (config/theme/update) -- **config.ts**: Wails v3 绑定加载方式调整 -- **theme.ts**: 窗口主题色通过 v3 API 设置 -- **update.ts**: 更新检查逻辑适配 v3 事件系统 - -### wails-transport.ts (+121 行) -- **全新**: v3 transport 层实现(替代 v2 的 runtime 调用) - -### UpdateNotification.vue / UpdatePanel.vue -- 事件监听从 v2 runtime 改为 v3 OffAll/events 模式 - ---- - -## 三、依赖变更 - -```diff -- github.com/wailsapp/wails/v2 v2.12.0 -+ github.com/wailsapp/wails/v3 v3.0.0-alpha.80 - -- go 1.25.6 -+ go 1.26 - -+ github.com/wailsapp/wails/v3/pkg/w32 # Win32 直接调用 -+ dario.cat/mergo v1.0.2 # 结构体合并 -``` - -移除: `go-toast/v2`(v3 自带通知)、`gosod`/`slicer`(v2 工具库) - ---- - -## 四、新增构建基础设施(94 个文件,均为 Wails v3 标准模板) - -以下由 `wails3 task generate` 自动生成,无自定义逻辑: - -| 目录 | 用途 | -|------|------| -| `build/config.yml` | 项目配置(dev mode executes 流水线) | -| `Taskfile.yml` | 根级任务定义(dev/build/run) | -| `build/android/` | Android 构建模板(Gradle + Java Bridge) | -| `build/darwin/` | macOS 构建(Info.plist + Icons) | -| `build/ios/` | iOS 构建(Xcode project) | -| `build/linux/` | Linux 构建(AppImage + nfpm) | -| `build/docker/` | Docker 交叉编译 | -| `build/windows/nsis/` | NSIS 安装包脚本 | -| `build/windows/msix/` | MSIX 打包配置 | -| `frontend/src/wailsjs/v3-bindings/` | v3 TypeScript 绑定(自动生成) | -| `frontend/bindings/` | v3 绑定副本(备用路径) | - ---- - -## 五、删除项(4 个文件) - -| 文件 | 原因 | -|------|------| -| `cmd/agent/clipboard_*.png` | 截图残留,已归档到 `.archive/` | -| `web/package.json.md5` | 旧完整性校验文件 | -| `web/src/api/wails-transport.ts` | v2 版本,已被 frontend 下新版替代 | -| `web/src/types/window.d.ts` | v2 类型声明,已被 frontend 下新版替代 | - ---- - -## 六、风险点 - -1. **alpha.80 稳定性**: Wails v3 仍为 alpha,部分 API 可能后续 breaking change -2. **OpenDevTools sleep hack**: 2s 硬编码延迟不够可靠,待 OnDomReady 稳定后替换 -3. **v2 bindings 残留**: `frontend/src/wailsjs/wailsjs/`(v2)仍随重命名保留在 frontend/ 下,如不再使用应清理 diff --git a/docs/08-用户指南/clipboard_20260413_234653.png b/docs/08-用户指南/clipboard_20260413_234653.png deleted file mode 100644 index e2d96c9..0000000 Binary files a/docs/08-用户指南/clipboard_20260413_234653.png and /dev/null differ diff --git a/docs/08-用户指南/clipboard_20260413_235902.png b/docs/08-用户指南/clipboard_20260413_235902.png deleted file mode 100644 index 7281e03..0000000 Binary files a/docs/08-用户指南/clipboard_20260413_235902.png and /dev/null differ diff --git a/docs/08-用户指南/clipboard_20260414_000648.png b/docs/08-用户指南/clipboard_20260414_000648.png deleted file mode 100644 index dcaec0f..0000000 Binary files a/docs/08-用户指南/clipboard_20260414_000648.png and /dev/null differ diff --git a/docs/08-用户指南/clipboard_20260414_004920.png b/docs/08-用户指南/clipboard_20260414_004920.png deleted file mode 100644 index 3d4e38e..0000000 Binary files a/docs/08-用户指南/clipboard_20260414_004920.png and /dev/null differ diff --git a/docs/08-用户指南/clipboard_20260414_010212.png b/docs/08-用户指南/clipboard_20260414_010212.png deleted file mode 100644 index 58a6e39..0000000 Binary files a/docs/08-用户指南/clipboard_20260414_010212.png and /dev/null differ diff --git a/docs/clipboard_20260413_231715.png b/docs/clipboard_20260413_231715.png deleted file mode 100644 index c0224d0..0000000 Binary files a/docs/clipboard_20260413_231715.png and /dev/null differ diff --git a/docs/clipboard_20260413_232441.png b/docs/clipboard_20260413_232441.png deleted file mode 100644 index 02f6966..0000000 Binary files a/docs/clipboard_20260413_232441.png and /dev/null differ diff --git a/docs/clipboard_20260414_001830.png b/docs/clipboard_20260414_001830.png deleted file mode 100644 index 105375b..0000000 Binary files a/docs/clipboard_20260414_001830.png and /dev/null differ diff --git a/docs/clipboard_20260414_002525.png b/docs/clipboard_20260414_002525.png deleted file mode 100644 index 4079487..0000000 Binary files a/docs/clipboard_20260414_002525.png and /dev/null differ diff --git a/docs/clipboard_20260430_165143.png b/docs/clipboard_20260430_165143.png deleted file mode 100644 index 3c97965..0000000 Binary files a/docs/clipboard_20260430_165143.png and /dev/null differ diff --git a/docs/clipboard_20260505_022416.png b/docs/clipboard_20260505_022416.png deleted file mode 100644 index 0521851..0000000 Binary files a/docs/clipboard_20260505_022416.png and /dev/null differ diff --git a/frontend/src/components/FileSystem/components/ConnectionIndicator.vue b/frontend/src/components/FileSystem/components/ConnectionIndicator.vue index 5ac8c79..b39415d 100644 --- a/frontend/src/components/FileSystem/components/ConnectionIndicator.vue +++ b/frontend/src/components/FileSystem/components/ConnectionIndicator.vue @@ -77,7 +77,6 @@ @@ -541,6 +540,9 @@ function handleDelete(p: { id: string; name: string }) { .fav-section > .section-content-wrap { flex: 1; min-height: 0; + max-height: none; + display: flex; + flex-direction: column; } .fav-section > .section-content-wrap.collapsed { diff --git a/frontend/src/components/FileSystem/components/Toolbar.vue b/frontend/src/components/FileSystem/components/Toolbar.vue index 5f450f0..b0c0c53 100644 --- a/frontend/src/components/FileSystem/components/Toolbar.vue +++ b/frontend/src/components/FileSystem/components/Toolbar.vue @@ -168,7 +168,7 @@ interface Emits { (e: 'goToPath', path: string): void (e: 'openFile', path: string): void (e: 'navigateToZipDirectory', path: string): void - (e: 'connectionChanged'): void + (e: 'connectionChanged', id: string): void } const emit = defineEmits() @@ -186,8 +186,8 @@ watch(() => props.openConnectionDialog, (v) => { } }) -const onConnectionChanged = async (_id: string) => { - emit('connectionChanged') +const onConnectionChanged = (id: string) => { + emit('connectionChanged', id) } const onEditProfile = (id: string) => { diff --git a/frontend/src/components/FileSystem/composables/usePathNavigation.ts b/frontend/src/components/FileSystem/composables/usePathNavigation.ts index ff550c7..0f4d7a7 100644 --- a/frontend/src/components/FileSystem/composables/usePathNavigation.ts +++ b/frontend/src/components/FileSystem/composables/usePathNavigation.ts @@ -59,7 +59,9 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) { * 导航到指定路径 */ const navigate = async (path: string) => { - if (!path || path === filePath.value) return + if (!path || path === filePath.value) { + return + } try { const normalizedPath = normalizePathSeparators(path) diff --git a/frontend/src/components/FileSystem/index.vue b/frontend/src/components/FileSystem/index.vue index 5abead7..b758b53 100644 --- a/frontend/src/components/FileSystem/index.vue +++ b/frontend/src/components/FileSystem/index.vue @@ -16,6 +16,7 @@ @drop="handleDrop" @drag-end="handleDragEnd" @open-connection-dialog="handleOpenConnectionDialog" + @server-select="handleConnectionChanged" /> @@ -93,6 +94,9 @@ @close="hideContextMenu" /> + + + { - // 操作成功处理 - }, - onError: (operation, error) => { - // 注意:具体的操作(如重命名、删除等)已经有自己的错误处理 - // 这里只记录日志,避免重复显示错误消息 - console.error(`[FileOperation] ${operation} 失败:`, error) - } -}) +// 文件操作(纯代理层,copy/move 经此调用) +const fileOps = useFileOperations() + +// 文件剪贴板(复制/剪切,内存态单例) +const { clipboard, clipboardEmpty, setCopy, setCut, clear: clearClipboard } = useFileClipboard() + +// 传输进度面板 +const transferStore = useTransferStore() // 收藏夹 const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin, updateFavoritePath, onLongPressStart, onLongPressCancel, onDragStart, onDragOver, onDrop, onDragEnd } = useFavorites() @@ -351,13 +358,21 @@ const sidebarConfig = computed(() => ({ draggingState: draggingState.value })) +// 被剪切的文件路径(仅剪切来源为当前目录时高亮) +const cutFilePaths = computed(() => + clipboard.value?.mode === 'cut' && clipboard.value.fromDir === filePath.value + ? clipboard.value.items.map(i => i.path) + : [] +) + // 文件列表面板配置 const fileListPanelConfig = computed(() => ({ fileList: filteredFileList.value, fileLoading: fileLoading.value, selectedFileItem: selectedFileItem.value, editingFilePath: editingFilePath.value, - editingFileName: editingFileName.value + editingFileName: editingFileName.value, + cutPaths: cutFilePaths.value })) // Markdown 渲染状态(异步加载 marked 后触发重渲染) @@ -441,7 +456,10 @@ const fileEditorPanelConfig = computed(() => { fileMtime: selectedFileItem.value?.modified_time || '', // 多 tab previewTabs: multiPreview.tabs.value, - activeTabId: multiPreview.activeTabId.value + activeTabId: multiPreview.activeTabId.value, + // 预览插件(优先于内置 v-if 链;.drawio/.dio 命中,其余为 null) + activePlugin: currentFileName ? resolvePreviewHandler(currentFileName) : null, + activePluginComponent: currentFileName ? resolvePreviewComponent(currentFileName) : null } }) @@ -462,8 +480,9 @@ const handleRefresh = async () => { // 文件变化事件处理:自动刷新预览(有未保存修改时跳过) const handleFileChanged = async (event: any) => { - // Wails v3 On 回调参数是 WailsEvent 对象,路径在 event.data - const changedPath = event?.data ?? '' + const payload = event?.data + const changedPath = typeof payload === 'string' ? payload : payload?.path ?? '' + const mtime = typeof payload === 'object' ? payload?.mtime : undefined if (!changedPath || !selectedFileItem.value) return // 标准化路径比较 @@ -473,43 +492,51 @@ const handleFileChanged = async (event: any) => { // 有未保存修改时不覆盖用户编辑 if (originalContent.value !== undefined && fileContent.value !== originalContent.value) return + // 非文本文件(图片/视频等):刷新预览 URL 并追加文件变更时间打破浏览器缓存 + if (isMediaPreviewable(selectedFileItem.value.name)) { + const url = await resolveFileUrl(changedPath) + const sep = url.includes('?') ? '&' : '?' + previewUrl.value = `${url}${sep}_t=${mtime ?? Date.now()}` + Message.success('文件已更新') + return + } + + // 文本文件:重新加载内容 await loadFileContent(changedPath) Message.success('文件已更新') } -// 程序化切换 profile 时抑制自动导航(如打开收藏/tab切换) -let _suppressAutoNav = false -let _lastActiveId = connectionManager.activeProfile?.id - -// 连接切换后重置路径并刷新文件列表(仅在活跃连接实际变化时触发) -connectionManager.onStateChange(async (state) => { - const currentId = connectionManager.activeProfile?.id - if (state !== 'connected') return - if (currentId === _lastActiveId) return - _lastActiveId = currentId - if (_suppressAutoNav) return +// 连接切换唯一入口:谁切换连接谁负责刷新常用路径并设根路径加载。 +// 同路径也强制重载:navigate 同路径早退时列表会永远不加载 +const switchConnection = async (profileId: string) => { + await connectionManager.connect(profileId) await loadCommonPaths() const targetPath = connectionManager.isRemote() ? '/' : 'C:/' - filePath.value = targetPath - await loadDirectory(targetPath) -}) + if (filePath.value === targetPath) { + await loadDirectory(targetPath) + } else { + await navigate(targetPath) + } +} + +// 侧边栏服务器行/连接指示器选择:统一走 switchConnection,失败在此提示 +const handleConnectionChanged = async (profileId: string) => { + try { + await switchConnection(profileId) + } catch (err) { + Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`) + } +} const handleSearchKeywordUpdate = (keyword: string) => { searchKeyword.value = keyword } -// 用户主动切换连接时重置到根路径 -const handleConnectionChanged = async () => { - await loadCommonPaths() - await navigate(connectionManager.isRemote() ? '/' : 'C:/') -} - const handleGoToPath = async (path: string) => { // 历史记录下拉选择时,检查是否需要切换 profile const targetProfileId = getProfileIdForPath(path) if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) { - _suppressAutoNav = true - try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false } + await connectionManager.connect(targetProfileId) } await navigate(path) } @@ -615,7 +642,6 @@ const handleOpenFavorite = async (file: FavoriteFile) => { const needSwitch = file.profileId && file.profileId !== currentProfileId if (needSwitch) { - _suppressAutoNav = true try { await connectionManager.connect(file.profileId) await loadCommonPaths() @@ -623,8 +649,6 @@ const handleOpenFavorite = async (file: FavoriteFile) => { console.error('切换连接失败:', e) Message.error(`无法连接: ${e instanceof Error ? e.message : String(e)}`) return - } finally { - _suppressAutoNav = false } } else if (!file.profileId) { // 无 profileId(旧数据),按路径格式自动切换连接 @@ -633,22 +657,17 @@ const handleOpenFavorite = async (file: FavoriteFile) => { const isCurrentlyRemote = connectionManager.isRemote() if (shouldBeRemote !== isCurrentlyRemote) { - _suppressAutoNav = true - try { - if (shouldBeRemote) { - const remoteProfile = connectionManager.profiles.find(p => - p.type === 'remote' || p.type === 'sftp' || p.type === 'oss' - ) - if (remoteProfile) { - await connectionManager.connect(remoteProfile.id) - } - } else { - connectionManager.disconnect() + if (shouldBeRemote) { + const remoteProfile = connectionManager.profiles.find(p => + p.type === 'remote' || p.type === 'sftp' || p.type === 'oss' + ) + if (remoteProfile) { + await connectionManager.connect(remoteProfile.id) } - await loadCommonPaths() - } finally { - _suppressAutoNav = false + } else { + connectionManager.disconnect() } + await loadCommonPaths() } } @@ -868,7 +887,9 @@ const handleContextMenu = (event: MouseEvent, file: FileItem | null = null) => { x: event.clientX, y: event.clientY, context: file ? 'file' : 'blank', - selectedFile: file || undefined + selectedFile: file || undefined, + isSftp: connectionManager.isSftp(), + clipboardEmpty: clipboardEmpty.value } } @@ -887,6 +908,24 @@ const handleContextMenuAction = async (action: string, payload?: any) => { case 'openWithSystem': Message.info('系统打开功能开发中...') break + case 'copy': + if (payload) { + handleCopyFiles([payload]) + } + break + case 'cut': + if (payload) { + handleCutFiles([payload]) + } + break + case 'download': + if (payload) { + await handleDownloadToLocal(payload) + } + break + case 'paste': + await handlePasteClipboard() + break case 'rename': if (payload) { handleStartEditing(payload.path, payload.name) @@ -901,6 +940,139 @@ const handleContextMenuAction = async (action: string, payload?: any) => { hideContextMenu() } +// ========== 文件剪贴板(复制/剪切/粘贴) ========== + +/** 复制到文件剪贴板(仅本机面板;远程面板给出提示而非静默) */ +const handleCopyFiles = (items: FileItem[]) => { + if (connectionManager.isRemote()) { + Message.info('当前面板暂不支持复制') + return + } + setCopy(items, filePath.value) + Message.success(`已复制 ${items.length} 个项目`) +} + +/** 剪切到文件剪贴板(仅本机面板;远程面板给出提示而非静默) */ +const handleCutFiles = (items: FileItem[]) => { + if (connectionManager.isRemote()) { + Message.info('当前面板暂不支持剪切') + return + } + setCut(items, filePath.value) + Message.success(`已剪切 ${items.length} 个项目`) +} + +/** 目标目录内的不重名文件名:「名称 - 副本」「名称 - 副本 (2)」(扩展名前插入) */ +const uniqueNameInList = (name: string): string => { + const exists = (n: string) => fileList.value.some(f => f.name.toLowerCase() === n.toLowerCase()) + if (!exists(name)) return name + const dot = name.lastIndexOf('.') + const base = dot > 0 ? name.slice(0, dot) : name + const ext = dot > 0 ? name.slice(dot) : '' + let candidate = `${base} - 副本${ext}` + let i = 2 + while (exists(candidate)) { + candidate = `${base} - 副本 (${i})${ext}` + i++ + } + return candidate +} + +/** 粘贴文件剪贴板(copy → 复制,cut → 移动;仅本地面板) */ +const handlePasteClipboard = async () => { + const clip = clipboard.value + if (!clip || clip.items.length === 0) { + Message.info('剪贴板为空') + return + } + if (connectionManager.isRemote()) { + Message.info('当前面板暂不支持粘贴,可使用拖拽上传') + return + } + const targetDir = filePath.value + if (!targetDir) return + + let failed = 0 + for (const item of clip.items) { + const dstName = uniqueNameInList(item.name) + const dstPath = targetDir + getPathSeparator(targetDir) + dstName + try { + if (clip.mode === 'copy') { + await fileOps.copy(item.path, dstPath) + } else { + await fileOps.move(item.path, dstPath) + } + } catch (error) { + failed++ + console.error('[Clipboard] 粘贴失败:', item.path, error) + Message.error(`粘贴失败: ${item.name} - ${error instanceof Error ? error.message : error}`) + } + } + + if (clip.mode === 'cut' && failed === 0) { + clearClipboard() + } + await loadDirectory(targetDir) + if (failed === 0) { + Message.success(`已粘贴 ${clip.items.length} 个项目`) + } +} + +/** SFTP 文件/目录下载到本机(目录选择器 → 流式下载) */ +const handleDownloadToLocal = async (file: FileItem) => { + try { + const dstDir = await pickDirectory() + if (!dstDir) return + + const localPath = await connectionManager.getTransport().downloadToFile(file.path, dstDir) + Message.success(`已下载到: ${localPath}`) + } catch (error) { + Message.error(`下载失败: ${error instanceof Error ? error.message : error}`) + } +} + +// ========== 外部文件拖入(OS 资源管理器 → 文件列表) ========== + +const handleExternalDrop = async (paths: string[]) => { + if (!paths || paths.length === 0) return + const targetDir = filePath.value + if (!targetDir) return + + const transport = connectionManager.getTransport() + try { + if (connectionManager.isSftp()) { + // SFTP 面板:上传到服务器当前目录(目录递归) + for (const p of paths) { + await transport.uploadFromFile(p, targetDir) + } + await loadDirectory(targetDir) + Message.success(`已上传 ${paths.length} 个项目`) + } else if (connectionManager.isRemote()) { + Message.info('当前面板暂不支持拖入文件') + } else { + // 本机面板:复制进当前目录(重名自动副本) + for (const p of paths) { + const name = getFileName(p) + if (!name) continue + const dstName = uniqueNameInList(name) + await transport.copyPath(p, targetDir + getPathSeparator(targetDir) + dstName) + } + await loadDirectory(targetDir) + Message.success(`已复制 ${paths.length} 个项目`) + } + } catch (error) { + Message.error(`拖入失败: ${error instanceof Error ? error.message : error}`) + await loadDirectory(targetDir) + } +} + +/** 解析 external-file-drop 事件 payload(事件体或 .data 均可) */ +const handleExternalDropEvent = (event: unknown) => { + const data: any = parseWailsEventData(event) + const files = Array.isArray(data) ? data : (data?.files ?? []) + handleExternalDrop(files.map(String)) +} + // ========== 创建文件/文件夹 ========== /** @@ -1148,13 +1320,10 @@ const isMediaPreviewable = (filename: string): boolean => { const activateTab = async (tab: PreviewTab) => { // 如果 tab 属于不同 profile,自动切换连接 if (tab.profileId && tab.profileId !== connectionManager.activeProfile?.id) { - _suppressAutoNav = true try { await connectionManager.connect(tab.profileId) } catch (e) { console.error('切换连接失败:', e) - } finally { - _suppressAutoNav = false } } selectedFileItem.value = tab.fileItem @@ -1270,12 +1439,24 @@ const selectFile = async (path: string) => { } } +// 加载顺序令牌:快速连续点击文件时,先发起的慢请求(远程下载/大文件) +// 若后完成会覆盖新文件的 previewUrl,导致图片预览 src 指向错误文件(显示为破损小方框)。 +// 每次进入 loadFileContent 递增令牌,await 返回后校验,过期请求直接丢弃。 +let loadFileSeq = 0 + const loadFileContent = async (path: string) => { // 切换文件时先取消前一个文件的监听 unwatchFile().catch(() => {}) + const seq = ++loadFileSeq + try { await updatePreviewUrl(path) + // await 期间用户已点开其它文件 → 本次结果是过期的,丢弃,不得污染全局状态 + if (seq !== loadFileSeq) return + + // 所有文件类型都启动监听(包括图片/视频等非文本文件) + watchFile(path).catch(() => {}) const fileName = getFileName(path) const fileItem = selectedFileItem.value @@ -1317,6 +1498,8 @@ const loadFileContent = async (path: string) => { if (!ext || !FILE_EXTENSIONS.IMAGE.includes(ext)) { try { const detected = await detectByContent(path, fileSize) + // await 期间已切换到其它文件 → 丢弃 + if (seq !== loadFileSeq) return if (detected && detected.category === 'image') { // 内容检测确认为图片,清空文本内容,让预览URL生效 clearContent() @@ -1330,7 +1513,6 @@ const loadFileContent = async (path: string) => { // 其他情况:加载为文本文件 await loadFile(path) - watchFile(path).catch(() => {}) } catch (error) { Message.error(`读取文件失败: ${error}`) } @@ -1483,10 +1665,16 @@ onMounted(async () => { window.addEventListener('paste', handlePaste) On('file-changed', handleFileChanged) + // 传输进度事件(复制/移动/下载/上传) + transferStore.setupEventListeners() + + // 外部文件拖入(Wails EnableFileDrop:OS 级拖放经 Go 转发为 external-file-drop 事件) + On('external-file-drop', handleExternalDropEvent) + // 2. 加载系统路径 await loadCommonPaths() - // SFTP 连接是异步的,未就绪时跳过初始加载,由 onStateChange('connected') 触发 + // SFTP 握手未就绪时只设根路径不加载:此时列表请求会回落本地 transport,显示幻影本地文件 if (connectionManager.isSftp() && connectionManager.state !== 'connected') { filePath.value = '/' return @@ -1511,14 +1699,20 @@ onMounted(async () => { // 会话恢复(不阻塞首屏) async function restoreSession() { + // 卡死自愈熔断:上次启动若触发了卡死自愈,本次主动跳过自动恢复(防 reload-loop) + if (isAutoRestoreDisabled()) { + clearAutoRestoreDisabled() + console.warn('[restoreSession] 检测到上次卡死自愈标记,本次跳过自动恢复历史文件') + return + } + const session = multiPreview.restoreSession() if (session.paths.length > 0) { const activeId = session.activePath ? session.profileMap.get(session.activePath.replace(/\\/g, '/').toLowerCase()) : undefined if (activeId && activeId !== connectionManager.activeProfile?.id) { - _suppressAutoNav = true - try { await connectionManager.connect(activeId) } catch (e) { console.error('session恢复连接失败:', e) } finally { _suppressAutoNav = false } + try { await connectionManager.connect(activeId) } catch (e) { console.error('session恢复连接失败:', e) } } for (const path of session.paths) { @@ -1562,6 +1756,8 @@ onUnmounted(() => { window.removeEventListener('click', hideContextMenu) window.removeEventListener('paste', handlePaste) Off('file-changed') + Off('external-file-drop') + transferStore.removeEventListeners() unwatchFile() // 应用关闭时立即持久化当前会话 cacheCurrentTabState() @@ -1606,12 +1802,7 @@ const handleKeyDown = async (event: KeyboardEvent) => { try { // 如果当前在远程连接,先切回本地 if (connectionManager.isRemote()) { - _suppressAutoNav = true - try { - await connectionManager.connect('local-default') - } finally { - _suppressAutoNav = false - } + await connectionManager.connect('local-default') await loadCommonPaths() } await navigate(drivePath) @@ -1650,6 +1841,34 @@ const handleKeyDown = async (event: KeyboardEvent) => { } } + // Ctrl+C / Ctrl+X / Ctrl+V 文件剪贴板(输入框/行内编辑态放行系统默认行为) + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && ['c', 'x', 'v'].includes(event.key.toLowerCase())) { + const target = event.target as HTMLElement + const inEditable = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable + if (inEditable || editingFilePath.value) return + + const key = event.key.toLowerCase() + if (key === 'v') { + // 剪贴板有文件项时优先生效(远程面板由粘贴逻辑 toast 提示),否则放行走「粘贴剪贴板图片」路径 + if (clipboard.value && clipboard.value.items.length > 0) { + event.preventDefault() + await handlePasteClipboard() + } + return + } + // 复制/剪切仅本机面板生效;SFTP 面板静默(菜单本就不提供), + // HTTP 远程/OSS 面板放行到 handler 给出提示 + if (connectionManager.isSftp()) return + if (selectedFileItem.value) { + event.preventDefault() + if (key === 'c') { + handleCopyFiles([selectedFileItem.value]) + } else { + handleCutFiles([selectedFileItem.value]) + } + } + } + // Ctrl+S 保存 if ((event.ctrlKey || event.metaKey) && event.key === 's') { event.preventDefault() @@ -1692,8 +1911,7 @@ const handleKeyDown = async (event: KeyboardEvent) => { try { const targetProfileId = await back() if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) { - _suppressAutoNav = true - try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false } + await connectionManager.connect(targetProfileId) } } finally { isNavigating.value = false @@ -1708,8 +1926,7 @@ const handleKeyDown = async (event: KeyboardEvent) => { try { const targetProfileId = await forward() if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) { - _suppressAutoNav = true - try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false } + await connectionManager.connect(targetProfileId) } } finally { isNavigating.value = false diff --git a/frontend/src/components/UpdateNotification.vue b/frontend/src/components/UpdateNotification.vue index 53a9399..797038d 100644 --- a/frontend/src/components/UpdateNotification.vue +++ b/frontend/src/components/UpdateNotification.vue @@ -8,6 +8,7 @@ import { Modal, Message, Progress } from '@arco-design/web-vue' import { useUpdateStore } from '../stores/update' import { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions' import { sanitizeHtml } from '@/utils/fileUtils' +import { parseWailsEventData } from '@/utils/wailsEvent' import { DownloadUpdate } from '../wailsjs/v3-bindings/u-desk/app' import { On, Off } from '@wailsio/events' @@ -292,7 +293,7 @@ const handleDownload = async () => { // 下载完成处理(本地覆盖:关闭弹窗) const onDownloadComplete = async (event) => { - const data = typeof event === 'string' ? JSON.parse(event) : event + const data = parseWailsEventData(event) if (data.error) { closeProgressModal() diff --git a/frontend/src/components/UpdatePanel.vue b/frontend/src/components/UpdatePanel.vue index c91e8d6..6b53ceb 100644 --- a/frontend/src/components/UpdatePanel.vue +++ b/frontend/src/components/UpdatePanel.vue @@ -120,6 +120,7 @@ import { IconHistory } from '@arco-design/web-vue/es/icon' import { useUpdateStore } from '../stores/update' import { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions' import { sanitizeHtml } from '@/utils/fileUtils' +import { parseWailsEventData } from '@/utils/wailsEvent' import { GetCurrentVersion, GetUpdateConfig, InstallUpdate } from '../wailsjs/v3-bindings/u-desk/app' import { On, Off } from '@wailsio/events' @@ -228,12 +229,7 @@ const handleInstall = async () => { // 监听下载完成事件(本地覆盖:记录下载文件路径) const onDownloadComplete = (event) => { - let data: any - try { - data = typeof event === 'string' ? JSON.parse(event) : event - } catch { - return - } + const data = parseWailsEventData(event) if (data.success && data.file_path) { downloadedFile.value = data.file_path diff --git a/frontend/src/stores/update.ts b/frontend/src/stores/update.ts index e865eaa..b794a0a 100644 --- a/frontend/src/stores/update.ts +++ b/frontend/src/stores/update.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { Message } from '@arco-design/web-vue' import { formatBytes as formatFileSize } from '@/utils/fileUtils' +import { parseWailsEventData } from '@/utils/wailsEvent' import { CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate } from '../wailsjs/v3-bindings/u-desk/app' @@ -35,14 +36,6 @@ export const useUpdateStore = defineStore('update', () => { const MIN_DISPLAY_TIME = 5000 // 5 秒最小显示时间 // ==================== 工具函数 ==================== - const parseEventData = (event: unknown) => { - try { - return typeof event === 'string' ? JSON.parse(event) : (event as Record) - } catch { - return {} - } - } - const formatSpeed = (bytesPerSecond: number): string => { return formatFileSize(bytesPerSecond) + '/s' } @@ -167,7 +160,7 @@ export const useUpdateStore = defineStore('update', () => { } lastUpdateTime = now - const data = parseEventData(event) + const data = parseWailsEventData(event) progressInfo.value = { speed: (data.speed as number) || 0, @@ -184,7 +177,7 @@ export const useUpdateStore = defineStore('update', () => { * 下载完成处理 */ const onDownloadComplete = (event: unknown) => { - const data = parseEventData(event) + const data = parseWailsEventData(event) // 错误处理 if (data.error) { diff --git a/internal/filesystem/encoding.go b/internal/filesystem/encoding.go deleted file mode 100644 index b19fe38..0000000 --- a/internal/filesystem/encoding.go +++ /dev/null @@ -1,24 +0,0 @@ -package filesystem - -import ( - "bytes" - "io" - "unicode/utf8" - - "golang.org/x/text/encoding/simplifiedchinese" - "golang.org/x/text/transform" -) - -// BytesToString 智能编码转换:UTF-8 直接返回,否则尝试 GBK → UTF-8 -func BytesToString(data []byte) string { - if utf8.Valid(data) { - return string(data) - } - // 尝试 GBK 解码 - reader := transform.NewReader(bytes.NewReader(data), simplifiedchinese.GBK.NewDecoder()) - decoded, err := io.ReadAll(reader) - if err != nil || !utf8.Valid(decoded) { - return string(data) // 转换失败或结果无效,返回原始内容 - } - return string(decoded) -} diff --git a/internal/filesystem/site_resource.go b/internal/filesystem/site_resource.go deleted file mode 100644 index efaddc8..0000000 --- a/internal/filesystem/site_resource.go +++ /dev/null @@ -1,47 +0,0 @@ -package filesystem - -import ( - "regexp" - "strings" -) - -var ( - htmlResRegex = regexp.MustCompile(`(?:src|href|data|poster)=["']([^"']+)["']`) - htmlCssUrlRe = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`) -) - -// ExtractHtmlResources 从 HTML 内容提取资源路径 -func ExtractHtmlResources(html string) []string { - seen := make(map[string]bool) - var resources []string - add := func(v string) { - v = strings.TrimSpace(v) - if v != "" && !seen[v] { - seen[v] = true - resources = append(resources, v) - } - } - for _, m := range htmlResRegex.FindAllStringSubmatch(html, -1) { - if len(m) > 1 { - add(m[1]) - } - } - for _, m := range htmlCssUrlRe.FindAllStringSubmatch(html, -1) { - if len(m) > 1 { - add(m[1]) - } - } - return resources -} - -// ShouldSkipResource 判断资源路径是否应跳过 -func ShouldSkipResource(p string) bool { - return strings.HasPrefix(p, "data:") || - strings.HasPrefix(p, "http://") || - strings.HasPrefix(p, "https://") || - strings.HasPrefix(p, "//") || - strings.HasPrefix(p, "#") || - strings.HasPrefix(p, "javascript:") || - strings.HasPrefix(p, "mailto:") || - strings.HasPrefix(p, "blob:") -}