修复: 粘贴图失效与SFTP串台回退等一批问题

粘贴图根修(Ctrl+V直读系统剪贴板,不再依赖不可靠的paste事件)/SFTP串台双刀(拨号期禁回退本机+盘符路径自动回根)/PDF导出目录设置与文案带落盘路径/性能批(连接保存下载防重/md渲染防抖+预热/hljs按需加载/审计锁外置/快捷方式解析超时/webview-watch可停)/前端62类型错清零(含keyPath蛇形字段炸弹)/目录图标字段错位修复
This commit is contained in:
lxy
2026-10-24 13:48:54 +08:00
parent 4675ae381d
commit 75a42c0b6a
48 changed files with 1072 additions and 220 deletions
+5 -3
View File
@@ -56,8 +56,8 @@
</a-layout-header>
<a-layout-content class="content">
<!-- 动态渲染 Tab 内容 -->
<!-- 使用 KeepAlive 缓存组件状态,避免切换时重新加载 -->
<KeepAlive include="FileSystem">
<!-- 缓存全部顶层 tab:include 白名单会让 Markdown 编辑器切走时丢状态,故不再过滤 -->
<KeepAlive>
<component :is="getComponent(activeTab)"/>
</KeepAlive>
</a-layout-content>
@@ -104,7 +104,9 @@ import {
WindowMinimize, WindowToggleAlwaysOnTop,
WindowMaximize, WindowIsMaximized, WindowClose
} from './wailsjs/v3-bindings/u-desk/app'
import { OffAll } from '@wailsio/events'
import { Events } from '@wailsio/runtime'
const { OffAll } = Events
// 存储键
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
+52 -2
View File
@@ -10,6 +10,8 @@ import { SftpTransport } from './sftp-transport'
import { OssTransport } from './oss-transport'
import { getFileServerBaseURL } from './file-server'
import { errMessage } from '@/utils/errors'
import { parseWailsEventData } from '@/utils/wailsEvent'
import { Events } from '@wailsio/runtime'
import {
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
SftpGetSystemInfo, GetLocalSystemInfo,
@@ -62,12 +64,32 @@ class ConnectionManagerImpl {
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
/** 每个 profile 的独立连接状态(连接菜单圆点用) */
private _profileStates = new Map<string, { state: ConnectionState; error?: string }>()
/** 每个 profile 的连接阶段(dial/auth/channel,冷连接分步反馈用) */
private _profileStages = new Map<string, string>()
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
private _sysInfoCache = new Map<string, SystemInfo>()
constructor() {
this.initDefaultLocal()
this.loadFromDB()
// 单例常驻,事件监听注册一次即可,无需退订
Events.On('sftp:connect-stage', this.onConnectStage)
}
/**
* 后端连接阶段事件:conn_id 为 host:port,映射到同地址的 sftp profile
*/
private onConnectStage = (event: unknown) => {
const data = parseWailsEventData(event) as { conn_id?: string; stage?: string }
if (!data?.conn_id || !data?.stage) return
let matched = false
for (const p of this._profiles) {
if (p.type === 'sftp' && `${p.host}:${p.port || 22}` === data.conn_id) {
matched = true
this._profileStages.set(p.id, data.stage)
}
}
if (matched) this.notifyChange()
}
private initDefaultLocal() {
@@ -139,7 +161,7 @@ class ConnectionManagerImpl {
port: profile.port,
username: profile.username || 'root',
password: profile.password || '',
keyPath: profile.keyPath || '',
key_path: profile.keyPath || '',
type: profile.type,
provider: profile.provider || '',
token: profile.token || '',
@@ -148,7 +170,7 @@ class ConnectionManagerImpl {
bucket: profile.bucket || '',
region: profile.region || '',
endpoint: profile.endpoint || '',
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
last_connected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
})
const realId = Number((result as any)?.id ?? 0)
return realId > 0 ? realId : undefined
@@ -168,6 +190,10 @@ class ConnectionManagerImpl {
/** 更新单个 profile 的连接状态(对齐 Rust 版,供连接菜单圆点着色) */
private setProfileState(profileId: string, state: ConnectionState, error?: string) {
this._profileStates.set(profileId, { state, error })
// 离开 connecting 即连接成功或失败,阶段文案随之失效
if (state !== 'connecting') {
this._profileStages.delete(profileId)
}
this.notifyChange()
}
@@ -176,6 +202,11 @@ class ConnectionManagerImpl {
return this._profileStates.get(profileId) ?? { state: 'disconnected' }
}
/** 获取单个 profile 的连接阶段(未在连接中时为空串) */
getProfileStage(profileId: string): string {
return this._profileStages.get(profileId) ?? ''
}
private notifyChange() {
this._stateChangeCallbacks.forEach(cb => cb(this._state))
}
@@ -201,6 +232,10 @@ class ConnectionManagerImpl {
if (this._activeId && this._pool.has(this._activeId)) {
return this._pool.get(this._activeId)!
}
// 拨号窗口期禁止静默回退本机 transport:否则远端面板标签+本机数据错位串台
if (this._activeId && this._activeId !== 'local-default') {
throw new Error('连接建立中,请稍候重试')
}
return this._pool.get('local-default')!
}
@@ -276,12 +311,27 @@ class ConnectionManagerImpl {
return info
}
/** per-profileId 的 in-flight connect 去重:同配置连点共享同一次连接(成功/失败都清理) */
private _connectInflight = new Map<string, Promise<void>>()
/**
* 切换到指定 profile(连接池模式)
* 池中有 → 直接复用,瞬间切换
* 池中无 → 新建连接并存入池
*/
async connect(profileId: string): Promise<void> {
// 同 profile 已在连接中:复用 in-flight Promise,避免并发拨号重复建连/状态互踩
const inflight = this._connectInflight.get(profileId)
if (inflight) return inflight
const p = this.doConnect(profileId).finally(() => {
this._connectInflight.delete(profileId)
})
this._connectInflight.set(profileId, p)
return p
}
private async doConnect(profileId: string): Promise<void> {
const profile = this._profiles.find(p => p.id === profileId)
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
+43 -9
View File
@@ -24,16 +24,29 @@ export class SftpTransport implements FsTransport {
}
async connect(): Promise<string> {
const result = await SftpConnect({
host: this.profile.host,
port: this.profile.port || 22,
username: this.profile.username || 'root',
password: this.profile.password || '',
key_path: this.profile.keyPath || '',
key_passphrase: '',
// 前端反馈层 9s 超时:后端连接继续由既有逻辑管理(不主动断),
// 仅避免 UI 在网络不可达时无限挂起,用户能立刻拿到失败反馈
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('连接超时,请检查网络')), 9000)
})
this.connID = result
return result
try {
const result = await Promise.race([
SftpConnect({
host: this.profile.host,
port: this.profile.port || 22,
username: this.profile.username || 'root',
password: this.profile.password || '',
key_path: this.profile.keyPath || '',
key_passphrase: '',
}),
timeout,
])
this.connID = result
return result
} finally {
if (timer) clearTimeout(timer)
}
}
async isAlive(): Promise<boolean> {
@@ -56,23 +69,34 @@ export class SftpTransport implements FsTransport {
return this.connID
}
/** 远程路径守卫:本机盘符路径串入 SFTP(tab 恢复/面板状态错位)会在服务器端报 No such file */
private assertRemotePath(path: string): void {
if (/^[A-Za-z]:[\\/]/.test(path)) {
throw new Error(`本机路径不可用于远程操作: ${path}`)
}
}
// ====== 文件列表与信息 ======
async listDir(path: string): Promise<FileItem[]> {
this.assertRemotePath(path)
return transformFileList(await SftpListDir(this.requireConn(), path))
}
async getFileInfo(path: string): Promise<Record<string, any>> {
this.assertRemotePath(path)
return SftpGetFileInfo(this.requireConn(), path)
}
// ====== 文件读写 ======
async readFile(path: string): Promise<string> {
this.assertRemotePath(path)
return SftpReadFile(this.requireConn(), path)
}
async writeFile(path: string, content: string): Promise<void> {
this.assertRemotePath(path)
await SftpWriteFile({
session_id: this.requireConn(),
path,
@@ -82,24 +106,30 @@ export class SftpTransport implements FsTransport {
async saveBase64File(path: string, content: string): Promise<void> {
if (!content) throw new Error('无效的 base64 内容')
this.assertRemotePath(path)
await SftpWriteBase64File(this.requireConn(), path, content)
}
// ====== 文件操作 ======
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
this.assertRemotePath(dirPath)
return SftpCreateFile(this.requireConn(), joinPath(dirPath, filename))
}
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
this.assertRemotePath(parentPath)
return SftpCreateDir(this.requireConn(), joinPath(parentPath, dirname))
}
async deletePath(path: string): Promise<FileOperationResult> {
this.assertRemotePath(path)
return SftpDeletePath(this.requireConn(), path)
}
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
this.assertRemotePath(oldPath)
this.assertRemotePath(newPath)
return SftpRenamePath({
session_id: this.requireConn(),
old_path: oldPath,
@@ -121,11 +151,13 @@ export class SftpTransport implements FsTransport {
/** 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径 */
async downloadToFile(remotePath: string, localDstDir: string): Promise<string> {
this.assertRemotePath(remotePath)
return SftpDownloadToFile(this.requireConn(), remotePath, localDstDir)
}
/** 上传本地文件/目录到远程目录(流式,目录递归) */
async uploadFromFile(localPath: string, remoteDir: string): Promise<void> {
this.assertRemotePath(remoteDir)
await SftpUploadFromFile(this.requireConn(), localPath, remoteDir)
}
@@ -190,11 +222,13 @@ export class SftpTransport implements FsTransport {
/** 下载远程文件到本地临时目录用于预览(缓存由 Go 后端管理) */
async downloadForPreview(remotePath: string): Promise<string> {
this.assertRemotePath(remotePath)
return SftpDownloadToTemp(this.requireConn(), remotePath)
}
/** 下载 HTML 及其网站资源到临时目录用于预览 */
async downloadSiteForPreview(remotePath: string): Promise<string> {
this.assertRemotePath(remotePath)
return SftpDownloadSiteForPreview(this.requireConn(), remotePath)
}
+5 -5
View File
@@ -5,7 +5,7 @@
import type { FileItem } from '@/types/file-system'
import { connectionManager } from './connection-manager'
import { PickDirectory } from '../wailsjs/v3-bindings/u-desk/app'
import { PickDirectory, GetCPUInfo, GetMemoryInfo, GetDiskInfo, GetEnvVars } from '../wailsjs/v3-bindings/u-desk/app'
/** 转换后端蛇形字段为前端驼峰格式 */
function toFileItem(file: any): FileItem {
@@ -22,17 +22,17 @@ export async function getSystemInfo() { return t().getFileInfo('/') }
export async function getCPUInfo() {
if (connectionManager.isRemote()) return {}
try { return (await window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
try { return (await GetCPUInfo()) ?? {} } catch { return {} }
}
export async function getMemoryInfo() {
if (connectionManager.isRemote()) return {}
try { return (await window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
try { return (await GetMemoryInfo()) ?? {} } catch { return {} }
}
export async function getDiskInfo() {
if (connectionManager.isRemote()) return {}
try { return (await window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
try { return (await GetDiskInfo()) ?? {} } catch { return {} }
}
export async function listDir(path: string): Promise<FileItem[]> {
@@ -88,7 +88,7 @@ export async function uploadFromFile(localPath: string, remoteDir: string): Prom
}
export async function getEnvVars(): Promise<Record<string, string>> {
try { return (await window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
try { return (await GetEnvVars()) ?? {} } catch { return {} }
}
export async function listZipContents(zipPath: string): Promise<FileItem[]> {
+9 -6
View File
@@ -167,16 +167,19 @@ const createEditor = (docContent = '') => {
view = new EditorView({ state, parent: editorContainer.value })
// 滚动时防抖保存位置
view.scrollDOM.addEventListener('scroll', () => {
if (saveScrollTimer) clearTimeout(saveScrollTimer)
saveScrollTimer = setTimeout(saveScrollPosition, 200)
}, { passive: true })
// 滚动时防抖保存位置(具名引用:removeEventListener 必须传同一函数引用才生效)
view.scrollDOM.addEventListener('scroll', onEditorScroll, { passive: true })
// 初始化语言
initLanguage()
}
// 滚动防抖保存位置(注册/移除共用同一具名引用)
const onEditorScroll = () => {
if (saveScrollTimer) clearTimeout(saveScrollTimer)
saveScrollTimer = setTimeout(saveScrollPosition, 200)
}
// ==================== 生命周期 ====================
onMounted(() => {
@@ -196,7 +199,7 @@ onBeforeUnmount(() => {
if (emitTimeout) clearTimeout(emitTimeout)
if (saveScrollTimer) clearTimeout(saveScrollTimer)
if (view?.scrollDOM) {
view.scrollDOM.removeEventListener('scroll', saveScrollPosition)
view.scrollDOM.removeEventListener('scroll', onEditorScroll)
}
view?.destroy()
view = null
@@ -9,7 +9,8 @@
@click.stop="toggleMenu"
@mouseenter="onDirHover"
@mouseleave="onDirLeave">
<span class="label">{{ label }}</span>
<a-spin v-if="activeStage" :size="12" />
<span class="label">{{ activeStage || label }}</span>
<!-- 悬停弹出:根目录子目录列表 -->
<Transition name="dropdown-fade">
@@ -54,6 +55,7 @@
>
<span :class="['dot', dotClass(p)]"></span>
<span class="menu-name">{{ p.name }}</span>
<span v-if="stageLabel(p)" class="menu-stage">{{ stageLabel(p) }}</span>
<span
v-if="p.type !== 'local'"
class="more-btn"
@@ -120,6 +122,27 @@ const label = computed(() => {
return p.name
})
// 连接阶段的中文文案,未知阶段回落到连接中
const STAGE_TEXTS: Record<string, string> = { dial: '连接中', auth: '认证中', channel: '建立通道' }
function stageText(stage: string): string {
return STAGE_TEXTS[stage] ?? '连接中'
}
// 活跃连接进行中的阶段文案(空串表示非连接中)
const activeStage = computed(() => {
const p = profiles.value.find(p => p.id === activeId.value)
if (!p || p.type === 'local') return ''
if (connectionManager.getProfileState(p.id).state !== 'connecting') return ''
return stageText(connectionManager.getProfileStage(p.id))
})
// 菜单条目进行中的阶段文案(空串表示非连接中)
function stageLabel(p: { id: string; type: string }): string {
if (p.type === 'local') return ''
if (connectionManager.getProfileState(p.id).state !== 'connecting') return ''
return stageText(connectionManager.getProfileStage(p.id))
}
connectionManager.onStateChange(() => {
profiles.value = connectionManager.profiles
activeId.value = connectionManager.activeProfile?.id ?? ''
@@ -148,8 +171,8 @@ const dirError = ref('')
const dirChildren = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
const dirLastLoadedPath = ref('')
const dirHoverTimer = ref<NodeJS.Timeout | null>(null)
const dirCloseTimer = ref<NodeJS.Timeout | null>(null)
const dirHoverTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const dirCloseTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const rootPath = computed(() => {
const active = connectionManager.activeProfile
@@ -261,8 +284,10 @@ onUnmounted(() => {
})
// === 原有方法 ===
// 连接动作由父级统一入口处理,这里只上抛所选服务器
function handleSelect(p: { id: string }) {
// 连接动作由父级统一入口处理,这里只上抛所选服务器;
// connecting 中忽略(防 UI 层重复触发,connect 侧另有 in-flight 去重兜底)
function handleSelect(p: { id: string; type?: string }) {
if (p.type && p.type !== 'local' && connectionManager.getProfileState(p.id).state === 'connecting') return
showMenu.value = false
emit('select', p.id)
}
@@ -407,6 +432,12 @@ watch(() => props.filePath, () => {
flex: 1;
}
.menu-stage {
flex-shrink: 0;
font-size: 12px;
color: var(--color-text-3);
}
.more-btn {
margin-left: auto;
opacity: 0;
@@ -7,7 +7,7 @@
@click.stop
>
<!-- 空白区域菜单 -->
<template v-if="config.context === 'blank'">
<template v-if="config.context === 'empty'">
<div class="context-menu-item" @click="handleCreateFile">
<span class="context-menu-icon">📄</span>
<span>新建文件</span>
@@ -31,7 +31,7 @@
</template>
<!-- 文件菜单 -->
<template v-else-if="config.context === 'file' && config.selectedFile">
<template v-else-if="config.context === 'file-list' && config.selectedFile">
<div class="context-menu-item" @click="handleCreateFile">
<span class="context-menu-icon">📄</span>
<span>新建文件</span>
@@ -27,7 +27,9 @@
// 关键:viewer base 必须用本地文件服务器(getFileServerBaseURL),与 /drawio-viewer/ 路由及 .drawio 原文同源。
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { On, Off } from '@wailsio/events'
import { Events } from '@wailsio/runtime'
const { On, Off } = Events
import { parseWailsEventData } from '@/utils/wailsEvent'
import { resolveFileUrl } from '../composables/useFilePreview'
import { getFileServerBaseURL } from '@/api/file-server'
@@ -84,8 +84,8 @@ const error = ref('')
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
const style = ref<{ top: string; left: string }>({ top: '0px', left: '0px' })
const hoverTimer = ref<NodeJS.Timeout | null>(null)
const leaveTimer = ref<NodeJS.Timeout | null>(null)
const hoverTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const leaveTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const hoveringMenu = ref(false)
const menuKey = `menu-${props.item.path}-${props.level}`
@@ -168,8 +168,8 @@ const onClick = (event: MouseEvent) => {
// 阻止事件冒泡,避免触发父级 breadcrumb-segment 的点击
event.stopPropagation()
const eventType = props.item.isDir ? 'navigate' : 'openFile'
emit(eventType, props.item.path)
if (props.item.isDir) emit('navigate', props.item.path)
else emit('openFile', props.item.path)
}
const emitNavigate = (path: string) => emit('navigate', path)
@@ -143,7 +143,7 @@
</a-button>
</a-tooltip>
<a-tooltip v-if="config.canSaveFile" position="left" content="保存 (Ctrl+S)">
<a-button type="primary" size="small" @click="handleSave">
<a-button type="primary" size="small" :loading="config.isSaving" @click="handleSave">
<template #icon><icon-save /></template>
</a-button>
</a-tooltip>
@@ -450,7 +450,9 @@ import { useThemeStore } from '@/stores/theme'
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
import { isDirty } from '../composables/useMultiPreview'
import { On, Off } from '@wailsio/events'
import { Events } from '@wailsio/runtime'
const { On, Off } = Events
import { ExportHtmlFileToPDF } from '@bindings/u-desk/app'
import { connectionManager } from '@/api/connection-manager'
import { listDir, readFile } from '@/api/system'
@@ -545,7 +547,7 @@ const htmlPreviewLocalPath = ref('')
watch(
() => [props.config.currentFileFullPath, props.config.isHtmlFile],
async ([path, isHtml]) => {
async ([path, isHtml]: [string, boolean]) => {
htmlPreviewUrl.value = ''
htmlPreviewLocalPath.value = ''
if (!path || !isHtml) return
@@ -929,8 +931,8 @@ const handleExportHtmlPDF = async () => {
try {
const res = await ExportHtmlFileToPDF(srcPath, baseName)
if (res?.success) {
// 后端导出成功已自动打开资源管理器选中文件,弹窗只做明确指向,不依赖用户记住路径
Message.success('PDF 已导出,已在桌面打开所在位置')
// 导出成功只落盘不打开(用户要求),提示带落盘路径让产物位置明确
Message.success({ content: `PDF 已导出到: ${res?.path || '桌面'}`, duration: 6000 })
} else {
Message.error(res?.message || 'PDF 导出失败')
}
@@ -11,7 +11,7 @@
<div class="col-setting-title">列设置</div>
<div class="col-setting-item" style="cursor: default;">
<span class="drag-handle"></span>
<a-checkbox :model-value="showHeader" @change="(val: boolean) => { showHeader = val; localStorage.setItem(SHOW_HEADER_KEY, String(val)) }">
<a-checkbox :model-value="showHeader" @change="(val: boolean) => toggleShowHeader(val)">
显示表头
</a-checkbox>
</div>
@@ -175,6 +175,12 @@ const colSettings = ref<ColumnConfig[]>(loadColSettings())
// 默认隐藏表头(localStorage 无值时默认不显示)
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) === 'true')
/** 切换表头显示并持久化(模板表达式取不到 localStorage,经此方法写入) */
const toggleShowHeader = (val: boolean) => {
showHeader.value = val
localStorage.setItem(SHOW_HEADER_KEY, String(val))
}
// 手动持久化(避免 deep watch 频繁写入)
function saveColSettings() {
localStorage.setItem(COL_SETTINGS_KEY, JSON.stringify(colSettings.value))
@@ -235,12 +241,12 @@ function buildColumn(key: string, editPath: string | undefined) {
]),
width: 32,
bodyCellClass: 'col-icon',
render: ({ record }: { record: FileItem }) => {
render: ({ record }: { record: any }) => {
const ext = getExt(record.name)
return h('span', {
class: 'file-item-icon',
title: ext ? `.${ext.toUpperCase()} : ${record.name}` : record.name
}, getFileIcon(record))
}, getFileIcon({ is_dir: record.isDir, name: record.name }))
}
}
@@ -255,7 +261,7 @@ function buildColumn(key: string, editPath: string | undefined) {
]),
dataIndex: 'name',
ellipsis: true,
render: ({ record }: { record: FileItem }) => {
render: ({ record }: { record: any }) => {
const isEditing = editPath === record.path
if (isEditing) {
return h(Input, {
@@ -287,7 +293,7 @@ function buildColumn(key: string, editPath: string | undefined) {
dataIndex: 'modified_time',
width: 125,
align: 'right' as const,
render: ({ record }: { record: FileItem }) => {
render: ({ record }: { record: any }) => {
if (editPath === record.path || !record.modified_time) return null
return h('span', { class: 'file-item-time' }, formatFileTime(record.modified_time))
}
@@ -305,7 +311,7 @@ function buildColumn(key: string, editPath: string | undefined) {
dataIndex: 'size',
width: 70,
align: 'right' as const,
render: ({ record }: { record: FileItem }) => {
render: ({ record }: { record: any }) => {
if (record.isDir || editPath === record.path) return null
return h('span', { class: 'file-item-size' }, formatBytes(record.size))
}
@@ -315,7 +321,7 @@ function buildColumn(key: string, editPath: string | undefined) {
return {
title: '',
width: 28,
render: ({ record }: { record: FileItem }) => {
render: ({ record }: { record: any }) => {
if (editPath === record.path) return null
const favorited = props.favorites.includes(record.path)
return h(Button, {
@@ -7,7 +7,7 @@
class="breadcrumb-segment"
:class="{ 'is-hoverable': index < segments.length - 1 || segments.length === 1 }"
@mouseenter="onHover(segment, index)"
@mouseleave="onLeave"
@mouseleave="onMenuLeave"
@click="onClick(segment)"
>
<span class="segment-text">{{ segment.name }}</span>
@@ -125,8 +125,8 @@ const segments = computed<PathSegment[]>(() => {
})
const activeIndex = ref<number | null>(null)
const hoverTimer = ref<NodeJS.Timeout | null>(null)
const closeTimer = ref<NodeJS.Timeout | null>(null)
const hoverTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const closeTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
const loading = ref(false)
const error = ref('')
@@ -639,10 +639,12 @@ function stateText(profileId: string): string {
return '未连接'
}
// 同一服务器重复点击也上抛切换事件,由父级强制重载列表
// 同一服务器重复点击也上抛切换事件,由父级强制重载列表;
// 但 connecting 中直接忽略(connect 已有 in-flight 去重,这里防 UI 层重复触发)
function handleSelect(p: { id: string; type: string }) {
moreOpenId.value = null
moreProfile.value = null
if (p.type !== 'local' && connectionManager.getProfileState(p.id).state === 'connecting') return
emit('serverSelect', p.id)
}
@@ -152,7 +152,8 @@ provide('dropdownOwner', dropdownOwner)
// Props
interface Props {
config: ToolbarConfig
openConnectionDialog?: boolean
/** 连接对话框触发计数器:父组件自增触发打开(>0 即开) */
openConnectionDialog?: number
editProfileId?: string | null
}
@@ -220,6 +221,10 @@ const handleNavigateToZipRoot = () => {
emit('navigateToZipDirectory', '')
}
const handleNavigateToZipDirectory = (path: string) => {
emit('navigateToZipDirectory', path)
}
const handleToggleSidebar = () => {
emit('update:showSidebar', !props.config.showSidebar)
}
@@ -317,6 +317,9 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
* 保存文件内容
*/
const saveFile = async (path?: string, isShortcut: boolean = false) => {
// 防重入:上次保存尚未落盘时忽略本次触发(Ctrl+S 连按/按钮连点会并发写同一文件)
if (isSaving.value) return
// 获取目标路径(优先使用传入的 path,否则从 currentFilePath 中提取)
let targetPath = path
if (!targetPath && currentFilePath.value) {
+104 -27
View File
@@ -125,11 +125,13 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, watchEffect, nextTick } from 'vue'
import { getPathSeparator } from '@/utils/fileUtils'
import { Message, Modal } from '@arco-design/web-vue'
import { On, Off } from '@wailsio/events'
import { ReadClipboardFiles } from '@bindings/u-desk/app'
import { Events } from '@wailsio/runtime'
const { On, Off } = Events
import { ReadClipboardFiles, ReadClipboardImage } from '@bindings/u-desk/app'
import { renderMarkdown, parseMarkdown, isMarkedReady, preloadMarked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme'
import { useTransferStore } from '@/stores/transfer'
@@ -337,7 +339,7 @@ const contextMenuConfig = ref<ContextMenuConfig>({
visible: false,
x: 0,
y: 0,
context: 'blank',
context: 'empty',
selectedFile: undefined
})
@@ -379,7 +381,7 @@ const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, dete
useFilePreview()
// 文件编辑
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, isSaving, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
useFileEdit({
currentFilePath: selectedFileItem,
currentDirectory: filePath
@@ -506,6 +508,7 @@ const fileEditorPanelConfig = computed(() => {
officeLoading: false,
officeError: null,
canSaveFile: canSaveFile.value,
isSaving: isSaving.value,
canResetContent: canResetContent.value,
canPreviewFile: isEditableWithPreview(currentFileName),
imageLoading: imageLoading.value,
@@ -954,7 +957,7 @@ const handleContextMenu = (event: MouseEvent, file: FileItem | null = null) => {
visible: true,
x: event.clientX,
y: event.clientY,
context: file ? 'file' : 'blank',
context: file ? 'file-list' : 'empty',
selectedFile: file || undefined,
isSftp: connectionManager.isSftp(),
clipboardEmpty: clipboardEmpty.value
@@ -1094,6 +1097,11 @@ const handlePasteClipboard = async () => {
/** SFTP 文件/目录下载到本机(目录选择器 → 流式下载) */
const handleDownloadToLocal = async (file: FileItem) => {
// 防状态错位串台:本机路径混入 SFTP 下载会让远程端 stat 盘符路径报"No such file"
if (!connectionManager.isSftp()) {
Message.warning('本机文件无需下载,请使用复制/剪切')
return
}
try {
const dstDir = await pickDirectory()
if (!dstDir) return
@@ -1613,6 +1621,11 @@ const loadFileContent = async (path: string) => {
}
const loadDirectory = async (path: string) => {
// SFTP 面板收到本机盘符路径=连接切换窗口期串台,重置回远程根目录
if (connectionManager.isSftp() && /^[A-Za-z]:[\\/]/.test(path)) {
Message.warning('远程面板不能浏览本机路径,已回到根目录')
return loadDirectory('/')
}
fileLoading.value = true
try {
fileList.value = await fileOps.listDirectory(path)
@@ -1943,11 +1956,16 @@ const handleKeyDown = async (event: KeyboardEvent) => {
const key = event.key.toLowerCase()
if (key === 'v') {
// 剪贴板有文件项时优先生效(远程面板由粘贴逻辑 toast 提示),否则放行走「粘贴剪贴板图片」路径
// 剪贴板有文件项时优先生效(远程面板由粘贴逻辑 toast 提示)
if (clipboard.value && clipboard.value.items.length > 0) {
event.preventDefault()
await handlePasteClipboard()
return
}
// 应用内剪贴板空:keydown 直读系统剪贴板图片。
// WebView2 无编辑焦点时 window paste 事件不可靠派发,只依赖它会全程静默(bug 根修)
event.preventDefault()
await pasteClipboardImage()
return
}
// 复制/剪切仅本机面板生效;SFTP 面板静默(菜单本就不提供),
@@ -2065,11 +2083,48 @@ const handlePaste = async (event: ClipboardEvent) => {
const imageItem = Array.from(items).find(item => item.type.startsWith('image/'))
if (imageItem) {
// 同一次 Ctrl+V 的 keydown 路径刚保存过时跳过(双保险链路防双写)
if (Date.now() - lastClipboardImageSaveAt < 1500) return
event.preventDefault()
await pasteImageToFile(imageItem)
}
}
// keydown 路径最近一次保存剪贴板图片的时间戳(与 DOM paste 事件链去重用)
let lastClipboardImageSaveAt = 0
// Ctrl+V keydown 直读系统剪贴板图片(后端 ReadClipboardImage,PNG base64)。
// 无图静默维持原行为;有图落盘当前目录,文件名 clipboard_YYYYMMDD_HHmmss.png 同款
const pasteClipboardImage = async (): Promise<void> => {
if (!filePath.value) return
try {
const base64 = await ReadClipboardImage()
if (!base64) return
lastClipboardImageSaveAt = Date.now()
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
const fileName = `clipboard_${ts}.png`
const fullPath = filePath.value + getPathSeparator(filePath.value) + fileName
await saveClipboardImage(fullPath, base64)
} catch { /* 剪贴板占用/格式不支持等,维持原静默 */ }
}
// 剪贴板图片落盘公共实现:保存 base64 到 fullPath,成功后提示并刷新列表
const saveClipboardImage = async (fullPath: string, base64Data: string) => {
const fileName = fullPath.replace(/^.*[\\/]/, '')
Message.loading({ content: `正在保存 ${fileName}...`, duration: 0 })
try {
await saveBase64File(fullPath, base64Data)
Message.clear()
Message.success(`已保存: ${fileName}`)
loadDirectory(filePath.value)
} catch (err: any) {
Message.clear()
Message.error('保存失败: ' + (err?.message || err))
}
}
// 将剪贴板图片保存为文件
const pasteImageToFile = async (item: DataTransferItem) => {
if (!filePath.value) {
@@ -2104,17 +2159,8 @@ const pasteImageToFile = async (item: DataTransferItem) => {
Message.error('图片数据格式无效')
return
}
Message.loading({ content: `正在保存 ${fileName}...`, duration: 0 })
try {
await saveBase64File(fullPath, base64Data)
Message.clear()
Message.success(`已保存: ${fileName}`)
loadDirectory(filePath.value)
} catch (err: any) {
Message.clear()
Message.error('保存失败: ' + (err?.message || err))
}
lastClipboardImageSaveAt = Date.now()
await saveClipboardImage(fullPath, base64Data)
}
reader.readAsDataURL(file)
} catch (err: any) {
@@ -2122,16 +2168,47 @@ const pasteImageToFile = async (item: DataTransferItem) => {
}
}
// 渲染 Mermaid 图表
watch(async () => {
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
await nextTick()
await nextTick()
await renderMermaidDiagrams()
// 强制容器重新计算高度
await nextTick()
// 渲染 Mermaid 图表:300ms 防抖 + in-flight 标志。
// mermaid.run 并发重入会互相打断产生半渲染/残留节点,连续触发热源(内容流式更新等)在此收敛
let mermaidRenderTimer: ReturnType<typeof setTimeout> | null = null
let mermaidRendering = false
let mermaidRenderPending = false
const runMermaidRender = async () => {
mermaidRenderTimer = null
if (mermaidRendering) {
mermaidRenderPending = true
return
}
}, { immediate: true })
mermaidRendering = true
try {
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
await nextTick()
await renderMermaidDiagrams()
// 强制容器重新计算高度
await nextTick()
}
} finally {
mermaidRendering = false
if (mermaidRenderPending) {
mermaidRenderPending = false
scheduleMermaidRender()
}
}
}
const scheduleMermaidRender = () => {
if (mermaidRenderTimer) clearTimeout(mermaidRenderTimer)
mermaidRenderTimer = setTimeout(runMermaidRender, 300)
}
// 同步读取依赖保持原有触发条件,执行体走防抖+防并发
// (watchEffect 即立即执行+依赖重跑,等价原 immediate 语义且类型正确)
watchEffect(() => {
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
scheduleMermaidRender()
}
})
// 主题变化时重新渲染 mermaid 图表(跟随暗色/亮色)
const themeStore = useThemeStore()
+61 -1
View File
@@ -140,6 +140,30 @@
<a-tab-pane key="update" title="版本更新">
<UpdatePanel @open-version-history="handleOpenVersionHistory" />
</a-tab-pane>
<!-- 导出设置 -->
<a-tab-pane key="export" title="导出设置">
<a-space direction="vertical" fill style="width: 100%">
<a-alert type="info" :show-icon="true">
设置 PDF 导出的保存位置;不设置时默认保存到桌面
</a-alert>
<a-space>
<a-input
readonly
:model-value="pdfExportDir || '默认(桌面)'"
style="width: 340px"
/>
<a-button type="primary" @click="handlePickExportDir">
<template #icon><icon-folder /></template>
选择目录
</a-button>
<a-button :disabled="!pdfExportDir" @click="handleResetExportDir">
<template #icon><icon-refresh /></template>
恢复默认
</a-button>
</a-space>
</a-space>
</a-tab-pane>
</a-tabs>
</a-drawer>
</template>
@@ -147,7 +171,9 @@
<script setup>
import { ref, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconDragArrow, IconCheck, IconRefresh } from '@arco-design/web-vue/es/icon'
import { IconDragArrow, IconCheck, IconRefresh, IconFolder } from '@arco-design/web-vue/es/icon'
import { GetPDFExportDir, SavePDFExportDir } from '@bindings/u-desk/app'
import { pickDirectory } from '@/api/system'
import UpdatePanel from './UpdatePanel.vue'
// Props
@@ -179,6 +205,40 @@ const localConfig = ref({
})
const saving = ref(false)
// PDF 导出目录设置(独立于 tab 配置保存链,选完即存)
const pdfExportDir = ref('')
const loadExportDir = async () => {
try {
pdfExportDir.value = (await GetPDFExportDir()) || ''
} catch {
// 读取失败按未设置处理,走默认桌面
pdfExportDir.value = ''
}
}
const handlePickExportDir = async () => {
const dir = await pickDirectory()
if (!dir) return
try {
await SavePDFExportDir(dir)
pdfExportDir.value = dir
Message.success('导出目录已保存')
} catch (e) {
Message.error(`保存导出目录失败: ${e instanceof Error ? e.message : e}`)
}
}
const handleResetExportDir = async () => {
try {
await SavePDFExportDir('')
pdfExportDir.value = ''
Message.success('已恢复默认导出目录')
} catch (e) {
Message.error(`恢复默认失败: ${e instanceof Error ? e.message : e}`)
}
}
watch(visible, (val) => {
if (val) loadExportDir()
})
const draggedIndex = ref(null)
// 隐藏的 Tabs(不在 visibleTabs 中)
+1 -1
View File
@@ -13,7 +13,7 @@
</div>
<a-progress
:percent="percent(item)"
:status="item.status === 'error' ? 'exception' : item.status === 'done' ? 'success' : 'normal'"
:status="item.status === 'error' ? 'danger' : item.status === 'done' ? 'success' : 'normal'"
:show-text="false"
size="small"
/>
+3 -3
View File
@@ -42,7 +42,7 @@ export function useTimeout(options: TimeoutOptions = {}) {
const { autoCleanup = true } = options
// 使用 Set 存储所有定时器 ID
const timers = ref<Set<NodeJS.Timeout>>(new Set())
const timers = ref<Set<ReturnType<typeof setTimeout>>>(new Set())
/**
* 设置定时器(自动管理生命周期)
@@ -53,7 +53,7 @@ export function useTimeout(options: TimeoutOptions = {}) {
const setTimeout = <T = void>(
callback: () => T,
delay: number
): NodeJS.Timeout => {
): ReturnType<typeof setTimeout> => {
const timer = window.setTimeout(() => {
try {
callback()
@@ -73,7 +73,7 @@ export function useTimeout(options: TimeoutOptions = {}) {
* 清除特定定时器
* @param timer - 要清除的定时器 ID
*/
const clearTimeout = (timer: NodeJS.Timeout) => {
const clearTimeout = (timer: ReturnType<typeof setTimeout>) => {
window.clearTimeout(timer)
timers.value.delete(timer)
}
+12
View File
@@ -5,6 +5,7 @@ import './style.css'
import App from './App.vue'
import { useThemeStore } from './stores/theme'
import { startFreezeWatchdog } from './utils/freezeWatchdog'
import { preloadMarked } from './utils/markedExtensions'
import '@/plugin/built-in' // 注册内置预览插件(drawio 等)
const app = createApp(App)
@@ -21,3 +22,14 @@ startFreezeWatchdog()
app.mount('#app')
// 空闲预热 markdown 渲染链(marked + highlight.js 各语言模块)与 mermaid 模块:
// 只拉取模块不执行渲染,首屏不受影响;requestIdleCallback 缺失时 setTimeout 3s 兜底。
// 首次打开 md 文件时 ensureMarked/loadMermaid 的动态 import 直接命中缓存,实现秒开
const idlePreload = typeof window.requestIdleCallback === 'function'
? (cb) => window.requestIdleCallback(() => cb())
: (cb) => setTimeout(cb, 3000)
idlePreload(() => {
preloadMarked()
import('mermaid').catch(() => {})
})
+3 -1
View File
@@ -1,6 +1,8 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { On, Off } from '@wailsio/events'
import { Events } from '@wailsio/runtime'
const { On, Off } = Events
import { parseWailsEventData } from '@/utils/wailsEvent'
/** 传输条目(与后端 transfer-progress 事件 payload 对应) */
+3 -17
View File
@@ -6,7 +6,9 @@ import { parseWailsEventData } from '@/utils/wailsEvent'
import {
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate, InstallUpdateWithHash
} from '../wailsjs/v3-bindings/u-desk/app'
import { On, Off } from '@wailsio/events'
import { Events } from '@wailsio/runtime'
const { On, Off } = Events
// 本次应用运行内已自动弹过的版本(升级后自动恢复提醒)
let sessionNotifiedVersion = ''
@@ -63,15 +65,6 @@ export const useUpdateStore = defineStore('update', () => {
if (version) {
sessionNotifiedVersion = version
}
if (!force) {
// 自动发现时的系统通知
try {
window.runtime?.SendNotification?.({
title: 'U-Desk',
body: `发现新版本 ${version},点击查看`
})
} catch { /* 通知不可用时忽略 */ }
}
}
/**
@@ -246,13 +239,6 @@ export const useUpdateStore = defineStore('update', () => {
const fileSize = (data.file_size as number) || 0
// 系统通知:下载完成
try {
window.runtime?.SendNotification?.({
title: 'U-Desk',
body: `更新包下载完成 (${formatFileSize(fileSize)}),正在安装...`
})
} catch { /* 通知不可用时忽略 */ }
progressInfo.value = {
speed: 0,
downloaded: fileSize,
+2
View File
@@ -195,6 +195,8 @@ export interface FileEditorPanelConfig {
officeError: string | null
/** 是否可以保存 */
canSaveFile: boolean
/** 保存中(保存按钮 loading,防重复点击) */
isSaving: boolean
/** 是否可以重置 */
canResetContent: boolean
/** 是否可以预览 */
+64 -14
View File
@@ -3,6 +3,62 @@ import { getHljsLanguage } from './languageMap'
let mermaidInstance: typeof import('mermaid').default | null = null
let mermaidTheme: string | null = null
// languageMap.ts(commonLangNames 直出 + 扩展名映射值)能产出的 hljs 语言全集。
// 用 lib/core + 逐语言异步 import 替代全量包(全量 ~190 种语言严重拖大首包);
// shell/cmd/html 是 bash/dos/xml 模块自带的别名,plaintext 为 hljs 内建,无需单独注册
const HLJS_LANGUAGES: Record<string, () => Promise<{ default: unknown }>> = {
javascript: () => import('highlight.js/lib/languages/javascript'),
typescript: () => import('highlight.js/lib/languages/typescript'),
python: () => import('highlight.js/lib/languages/python'),
java: () => import('highlight.js/lib/languages/java'),
go: () => import('highlight.js/lib/languages/go'),
rust: () => import('highlight.js/lib/languages/rust'),
c: () => import('highlight.js/lib/languages/c'),
cpp: () => import('highlight.js/lib/languages/cpp'),
csharp: () => import('highlight.js/lib/languages/csharp'),
php: () => import('highlight.js/lib/languages/php'),
ruby: () => import('highlight.js/lib/languages/ruby'),
swift: () => import('highlight.js/lib/languages/swift'),
kotlin: () => import('highlight.js/lib/languages/kotlin'),
scala: () => import('highlight.js/lib/languages/scala'),
dart: () => import('highlight.js/lib/languages/dart'),
lua: () => import('highlight.js/lib/languages/lua'),
r: () => import('highlight.js/lib/languages/r'),
bash: () => import('highlight.js/lib/languages/bash'),
powershell: () => import('highlight.js/lib/languages/powershell'),
dos: () => import('highlight.js/lib/languages/dos'),
sql: () => import('highlight.js/lib/languages/sql'),
yaml: () => import('highlight.js/lib/languages/yaml'),
json: () => import('highlight.js/lib/languages/json'),
xml: () => import('highlight.js/lib/languages/xml'),
markdown: () => import('highlight.js/lib/languages/markdown'),
css: () => import('highlight.js/lib/languages/css'),
scss: () => import('highlight.js/lib/languages/scss'),
less: () => import('highlight.js/lib/languages/less'),
ini: () => import('highlight.js/lib/languages/ini'),
makefile: () => import('highlight.js/lib/languages/makefile'),
dockerfile: () => import('highlight.js/lib/languages/dockerfile'),
cmake: () => import('highlight.js/lib/languages/cmake'),
latex: () => import('highlight.js/lib/languages/latex'),
objectivec: () => import('highlight.js/lib/languages/objectivec'),
autohotkey: () => import('highlight.js/lib/languages/autohotkey'),
diff: () => import('highlight.js/lib/languages/diff'),
graphql: () => import('highlight.js/lib/languages/graphql'),
nginx: () => import('highlight.js/lib/languages/nginx'),
perl: () => import('highlight.js/lib/languages/perl'),
haskell: () => import('highlight.js/lib/languages/haskell'),
elixir: () => import('highlight.js/lib/languages/elixir'),
erlang: () => import('highlight.js/lib/languages/erlang'),
clojure: () => import('highlight.js/lib/languages/clojure'),
ocaml: () => import('highlight.js/lib/languages/ocaml'),
vbnet: () => import('highlight.js/lib/languages/vbnet'),
wasm: () => import('highlight.js/lib/languages/wasm'),
fsharp: () => import('highlight.js/lib/languages/fsharp'),
groovy: () => import('highlight.js/lib/languages/groovy'),
julia: () => import('highlight.js/lib/languages/julia'),
matlab: () => import('highlight.js/lib/languages/matlab'),
}
// 懒加载 marked + hljs,首次调用时才初始化
let _marked: typeof import('marked').marked | null = null
let _initPromise: Promise<typeof import('marked').marked> | null = null
@@ -12,22 +68,16 @@ async function ensureMarked(): Promise<typeof import('marked').marked> {
if (_initPromise) return _initPromise
_initPromise = (async () => {
const [markedModule, hljs, ...extras] = await Promise.all([
const [markedModule, hljs, ...langModules] = await Promise.all([
import('marked'),
import('highlight.js'),
import('highlight.js/lib/languages/powershell'),
import('highlight.js/lib/languages/dos'),
import('highlight.js/lib/languages/autohotkey'),
import('highlight.js/lib/languages/latex'),
import('highlight.js/lib/languages/dockerfile'),
import('highlight.js/lib/languages/cmake'),
import('highlight.js/lib/languages/scala'),
import('highlight.js/lib/languages/dart'),
import('highlight.js/lib/core'),
...Object.values(HLJS_LANGUAGES).map(load => load()),
])
// 注册额外语言到 hljs
const langNames = ['powershell', 'dos', 'autohotkey', 'latex', 'dockerfile', 'cmake', 'scala', 'dart']
extras.forEach((mod, i) => {
hljs.default.registerLanguage(langNames[i], (mod as any).default)
// 注册语言到 hljs core(Promise.all 保序,键序与模块序一一对应)
const hljsCore = hljs.default
const langNames = Object.keys(HLJS_LANGUAGES)
langNames.forEach((name, i) => {
hljsCore.registerLanguage(name, (langModules[i] as any).default)
})
const marked = markedModule.marked