重构: 连接切换链收敛与代码清理
- 切换连接统一switchConnection入口,删watcher与抑制机制 - 删除活跃服务器后回落本地并加载 - 侧边栏与指示器点击统一上抛,重复点击可刷新 - Wails事件解析归一,查重谓词抽公共 - 删空壳别名文件与过期文档截图
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user