重构: 连接切换链收敛与代码清理
- 切换连接统一switchConnection入口,删watcher与抑制机制 - 删除活跃服务器后回落本地并加载 - 侧边栏与指示器点击统一上抛,重复点击可刷新 - Wails事件解析归一,查重谓词抽公共 - 删空壳别名文件与过期文档截图
@@ -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. 清理过期的下载缓存
|
||||
// 根据配置初始化模块(条件初始化)
|
||||
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()
|
||||
|
||||
g, ctx := errgroup.WithContext(shutdownCtx)
|
||||
|
||||
// 并行关闭所有子系统(互不依赖,共享 5s 总超时)
|
||||
g.Go(func() error {
|
||||
if a.filesystem != nil {
|
||||
slog.Info("文件系统服务正在关闭")
|
||||
if err := a.filesystem.Close(shutdownCtx); err != nil {
|
||||
if err := a.filesystem.Close(ctx); err != nil {
|
||||
slog.Error("文件系统服务关闭失败", "error", err)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
slog.Info("文件系统服务已关闭")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
slog.Info("文件服务器正在关闭")
|
||||
if err := filesystem.ShutdownLocalFileServer(); err != nil {
|
||||
slog.Error("文件服务器关闭失败", "error", err)
|
||||
} else {
|
||||
slog.Info("文件服务器已关闭")
|
||||
return err
|
||||
}
|
||||
slog.Info("文件服务器已关闭")
|
||||
return nil
|
||||
})
|
||||
|
||||
// 关闭所有 SFTP 连接 + 清理临时文件
|
||||
g.Go(func() error {
|
||||
if a.sftpService != nil {
|
||||
sftp.GetManager().Shutdown()
|
||||
}
|
||||
storage.CleanupExpiredCache()
|
||||
storage.CleanupSiteCacheDirs()
|
||||
return nil
|
||||
})
|
||||
|
||||
// 关闭所有 OSS 连接
|
||||
g.Go(func() error {
|
||||
osssvc.GetManager().Shutdown()
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
slog.Error("部分子系统关闭失败", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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/ 下,如不再使用应清理
|
||||
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 594 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 677 KiB |
|
Before Width: | Height: | Size: 493 KiB |
@@ -77,7 +77,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, shallowRef, onMounted, onUnmounted, provide, inject, watch, type Ref } from 'vue'
|
||||
import { IconCloud, IconFolder, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import { listDir } from '@/api/system'
|
||||
import { sortFileList } from '@/utils/fileUtils'
|
||||
@@ -256,17 +255,16 @@ function handleClickOutside(e: MouseEvent) {
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
// 清理目录悬停延迟 timer,避免卸载后回调仍执行
|
||||
if (dirHoverTimer.value) clearDelay(dirHoverTimer.value)
|
||||
if (dirCloseTimer.value) clearDelay(dirCloseTimer.value)
|
||||
})
|
||||
|
||||
// === 原有方法 ===
|
||||
async function handleSelect(p: { id: string }) {
|
||||
// 连接动作由父级统一入口处理,这里只上抛所选服务器
|
||||
function handleSelect(p: { id: string }) {
|
||||
showMenu.value = false
|
||||
try {
|
||||
await connectionManager.connect(p.id)
|
||||
emit('select', p.id)
|
||||
} catch (err) {
|
||||
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMore(p: { id: string }) {
|
||||
@@ -281,14 +279,22 @@ function handleEdit(p: { id: string }) {
|
||||
|
||||
function handleDelete(p: { id: string; name: string }) {
|
||||
if (!window.confirm(`确定删除「${p.name}」?`)) return
|
||||
const wasActive = p.id === activeId.value
|
||||
connectionManager.removeProfile(p.id)
|
||||
moreOpenId.value = null
|
||||
// 删除的是活跃服务器时连接已回落本地,上抛切换让视图加载本地根目录
|
||||
if (wasActive) emit('select', 'local-default')
|
||||
}
|
||||
|
||||
function dotClass(p: { type: string }): string {
|
||||
if (p.type === 'sftp') return 'sftp'
|
||||
if (p.type === 'remote') return 'remote'
|
||||
if (p.type === 'oss') return 'oss'
|
||||
function dotClass(p: { id: string; type: string }): string {
|
||||
// 优先反映实际连接状态;未连接/出错才回落到类型色(disconnected 用灰,不与 error 红混淆)
|
||||
if (p.type !== 'local') {
|
||||
const st = connectionManager.getProfileState(p.id).state
|
||||
if (st === 'connected') return 'connected'
|
||||
if (st === 'connecting') return 'connecting'
|
||||
if (st === 'error') return 'error'
|
||||
return 'disconnected-idle'
|
||||
}
|
||||
return 'local'
|
||||
}
|
||||
|
||||
@@ -347,7 +353,7 @@ watch(() => props.filePath, () => {
|
||||
|
||||
.dot.connected { background: var(--color-success-6); }
|
||||
.dot.connecting { background: var(--color-warning-6); animation: pulse 1.5s infinite; }
|
||||
.dot.disconnected { background: var(--color-danger-6); }
|
||||
.dot.disconnected-idle { background: var(--color-text-4); }
|
||||
.dot.error { background: var(--color-danger-6); }
|
||||
.dot.local { background: var(--color-text-3); }
|
||||
.dot.remote { background: var(--color-primary-6); }
|
||||
|
||||
@@ -172,7 +172,6 @@ import { connectionManager } from '@/api/connection-manager'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { IconStar, IconClose, IconPushpin, IconDown, IconRight, IconStorage, IconComputer, IconDesktop, IconPlus, IconCheckCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { getFileIcon } from '@/utils/fileUtils'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
|
||||
@@ -351,6 +350,7 @@ interface Emits {
|
||||
(e: 'drop', event: DragEvent, targetIndex: number): void
|
||||
(e: 'dragEnd'): void
|
||||
(e: 'openConnectionDialog', editId?: string): void
|
||||
(e: 'serverSelect', id: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -472,15 +472,11 @@ function stateText(profileId: string): string {
|
||||
return '未连接'
|
||||
}
|
||||
|
||||
async function handleSelect(p: { id: string; type: string }) {
|
||||
// 同一服务器重复点击也上抛切换事件,由父级强制重载列表
|
||||
function handleSelect(p: { id: string; type: string }) {
|
||||
moreOpenId.value = null
|
||||
moreProfile.value = null
|
||||
if (p.id === activeId.value) return
|
||||
try {
|
||||
await connectionManager.connect(p.id)
|
||||
} catch (err) {
|
||||
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
emit('serverSelect', p.id)
|
||||
}
|
||||
|
||||
function handleEdit(p: { id: string }) {
|
||||
@@ -491,9 +487,12 @@ function handleEdit(p: { id: string }) {
|
||||
|
||||
function handleDelete(p: { id: string; name: string }) {
|
||||
if (!window.confirm(`确定删除「${p.name}」?`)) return
|
||||
const wasActive = p.id === activeId.value
|
||||
connectionManager.removeProfile(p.id)
|
||||
moreOpenId.value = null
|
||||
moreProfile.value = null
|
||||
// 删除的是活跃服务器时连接已回落本地,补发切换让视图加载本地根目录
|
||||
if (wasActive) emit('serverSelect', 'local-default')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Emits>()
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
@drop="handleDrop"
|
||||
@drag-end="handleDragEnd"
|
||||
@open-connection-dialog="handleOpenConnectionDialog"
|
||||
@server-select="handleConnectionChanged"
|
||||
/>
|
||||
|
||||
<!-- 右侧工作区:面包屑工具栏 + 文件列表/编辑器 -->
|
||||
@@ -93,6 +94,9 @@
|
||||
@close="hideContextMenu"
|
||||
/>
|
||||
|
||||
<!-- 传输进度面板(复制/移动/下载/上传共用) -->
|
||||
<TransferPanel />
|
||||
|
||||
<!-- 输入对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="inputDialogVisible"
|
||||
@@ -122,6 +126,7 @@ import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
import { renderMarkdown, parseMarkdown, isMarkedReady, preloadMarked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useTransferStore } from '@/stores/transfer'
|
||||
|
||||
// 导入子组件
|
||||
import Toolbar from './components/Toolbar.vue'
|
||||
@@ -129,12 +134,15 @@ import Sidebar from './components/Sidebar.vue'
|
||||
import FileListPanel from './components/FileListPanel.vue'
|
||||
import FileEditorPanel from './components/FileEditorPanel.vue'
|
||||
import ContextMenu from './components/ContextMenu.vue'
|
||||
import TransferPanel from '@/components/TransferPanel.vue'
|
||||
|
||||
// 导入 Composables
|
||||
import { useFileOperations } from './composables/useFileOperations'
|
||||
import { useFileClipboard } from './composables/useFileClipboard'
|
||||
import { useFavorites } from './composables/useFavorites'
|
||||
import { usePathNavigation } from './composables/usePathNavigation'
|
||||
import { useFilePreview, resolveFileUrl, resolveFileServerBase } from './composables/useFilePreview'
|
||||
import { resolvePreviewHandler, resolvePreviewComponent } from '@/plugin/registry'
|
||||
import { useFileEdit } from './composables/useFileEdit'
|
||||
import { useCommonPaths } from './composables/useCommonPaths'
|
||||
import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMultiPreview'
|
||||
@@ -142,9 +150,11 @@ import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMult
|
||||
// 导入工具函数
|
||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
|
||||
import { listDir, saveBase64File, watchFile, unwatchFile } from '@/api/system'
|
||||
import { listDir, saveBase64File, watchFile, unwatchFile, pickDirectory } from '@/api/system'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { isAutoRestoreDisabled, clearAutoRestoreDisabled } from '@/utils/freezeWatchdog'
|
||||
import { parseWailsEventData } from '@/utils/wailsEvent'
|
||||
import { createResizeHandler } from '@/utils/resize'
|
||||
|
||||
// 导入类型
|
||||
@@ -284,17 +294,14 @@ const fileListPanelRef = ref()
|
||||
|
||||
// ========== Composables 初始化 ==========
|
||||
|
||||
// 文件操作
|
||||
const fileOps = useFileOperations({
|
||||
onSuccess: (operation, data) => {
|
||||
// 操作成功处理
|
||||
},
|
||||
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
|
||||
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,8 +657,6 @@ 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'
|
||||
@@ -646,9 +668,6 @@ const handleOpenFavorite = async (file: FavoriteFile) => {
|
||||
connectionManager.disconnect()
|
||||
}
|
||||
await loadCommonPaths()
|
||||
} finally {
|
||||
_suppressAutoNav = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
} 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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:")
|
||||
}
|
||||