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:")
-}