修复: 粘贴图失效与SFTP串台回退等一批问题
粘贴图根修(Ctrl+V直读系统剪贴板,不再依赖不可靠的paste事件)/SFTP串台双刀(拨号期禁回退本机+盘符路径自动回根)/PDF导出目录设置与文案带落盘路径/性能批(连接保存下载防重/md渲染防抖+预热/hljs按需加载/审计锁外置/快捷方式解析超时/webview-watch可停)/前端62类型错清零(含keyPath蛇形字段炸弹)/目录图标字段错位修复
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
|
||||
@@ -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,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[]> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 中)
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(() => {})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 对应) */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -195,6 +195,8 @@ export interface FileEditorPanelConfig {
|
||||
officeError: string | null
|
||||
/** 是否可以保存 */
|
||||
canSaveFile: boolean
|
||||
/** 保存中(保存按钮 loading,防重复点击) */
|
||||
isSaving: boolean
|
||||
/** 是否可以重置 */
|
||||
canResetContent: boolean
|
||||
/** 是否可以预览 */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user