重构: 连接切换链收敛与代码清理

- 切换连接统一switchConnection入口,删watcher与抑制机制
- 删除活跃服务器后回落本地并加载
- 侧边栏与指示器点击统一上抛,重复点击可刷新
- Wails事件解析归一,查重谓词抽公共
- 删空壳别名文件与过期文档截图
This commit is contained in:
lxy
2026-09-15 22:26:36 +08:00
parent 6ea9d9ac99
commit 0475d55bff
23 changed files with 395 additions and 351 deletions
@@ -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)}`)
}
emit('select', p.id)
}
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)
+290 -73
View File
@@ -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
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
@@ -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()
+2 -6
View File
@@ -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
+3 -10
View File
@@ -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) {