新增: 文件操作增强与预览扩展

- 文件剪贴板复制剪切粘贴,重名自动副本
- SFTP下载到本机与外部文件拖入,目录递归
- 传输进度浮动面板,transfer-progress事件链
- Drawio预览,Excel工作线程,冻结看门狗
- cmd与bat自写batch语法高亮,psm1等映射补齐
This commit is contained in:
lxy
2026-09-15 22:26:12 +08:00
parent f1bc5b49f1
commit e7a2c5148e
59 changed files with 3185 additions and 2047 deletions
+87 -15
View File
@@ -18,7 +18,8 @@ export type ConnectionType = 'local' | 'remote' | 'sftp' | 'oss'
export type OssProvider = 'qiniu' | 'aliyun'
export interface ConnectionProfile {
id: string | number
/** 统一为字符串: 本地固定 'local-default',其余为数据库自增 id 的十进制字符串 */
id: string
name: string
host: string
port: number
@@ -58,6 +59,8 @@ class ConnectionManagerImpl {
private _activeId: string | null = null
private _state: ConnectionState = 'disconnected'
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
/** 每个 profile 的独立连接状态(连接菜单圆点用) */
private _profileStates = new Map<string, { state: ConnectionState; error?: string }>()
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
private _sysInfoCache = new Map<string, SystemInfo>()
@@ -91,8 +94,8 @@ class ConnectionManagerImpl {
try {
const list = await LoadConnectionProfiles()
if (list && list.length > 0) {
this._profiles = list.map((p: any) => {
const camel = snakeToCamel(p)
this._profiles = list.map((p: any): ConnectionProfile => {
const camel = snakeToCamel(p) as ConnectionProfile
return {
...camel,
id: String(p.id),
@@ -117,18 +120,18 @@ class ConnectionManagerImpl {
if (autoConnect !== 'false') {
for (const p of this._profiles) {
if (p.type !== 'local') {
this.buildAndPool(String(p.id), p).catch(() => {})
this.buildAndPool(String(p.id), p).catch(e => console.warn('[连接管理器] 自动连接失败:', p.name, e))
}
}
}
}, 500)
}
/** 保存/更新单个 profile 到 SQLite */
private async persistProfile(profile: ConnectionProfile) {
if (profile.type === 'local') return
/** 保存/更新单个 profile 到 SQLite,返回数据库真实 id(新建时) */
private async persistProfile(profile: ConnectionProfile): Promise<number | undefined> {
if (profile.type === 'local') return undefined
const id = profile.id !== 'local-default' ? Number(profile.id) : 0
await SaveConnectionProfile({
const result = await SaveConnectionProfile({
id: id > 0 ? id : undefined,
name: profile.name,
host: profile.host,
@@ -146,6 +149,8 @@ class ConnectionManagerImpl {
endpoint: profile.endpoint || '',
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
})
const realId = Number((result as any)?.id ?? 0)
return realId > 0 ? realId : undefined
}
/** 从 SQLite 删除 profile */
@@ -159,6 +164,17 @@ class ConnectionManagerImpl {
this.notifyChange()
}
/** 更新单个 profile 的连接状态(对齐 Rust 版,供连接菜单圆点着色) */
private setProfileState(profileId: string, state: ConnectionState, error?: string) {
this._profileStates.set(profileId, { state, error })
this.notifyChange()
}
getProfileState(profileId: string): { state: ConnectionState; error?: string } {
if (profileId === 'local-default') return { state: 'connected' }
return this._profileStates.get(profileId) ?? { state: 'disconnected' }
}
private notifyChange() {
this._stateChangeCallbacks.forEach(cb => cb(this._state))
}
@@ -252,7 +268,7 @@ class ConnectionManagerImpl {
}
}
}
} catch (e) { info._error = true; info._errorMsg = e instanceof Error ? e.message : String(e) }
} catch (e) { info._error = true; info._errorMsg = errMsg(e) }
this._sysInfoCache.set(profileId, info)
this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info))
@@ -268,11 +284,32 @@ class ConnectionManagerImpl {
const profile = this._profiles.find(p => p.id === profileId)
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
// 池中已有,直接复用
this.setProfileState(profileId, 'connecting')
// 池中已有,探测有效性(探测加超时:半开死连接上的网络探测会无限挂起,超时按死连接处理走重建)
if (this._pool.has(profileId)) {
this._activeId = profileId
this.setState('connected')
return
const t = this._pool.get(profileId)!
if (t.isAlive) {
let probeTimer: ReturnType<typeof setTimeout> | undefined
const alive = await Promise.race([
t.isAlive().catch(() => false),
new Promise<false>(resolve => { probeTimer = setTimeout(() => resolve(false), 2500) }),
])
if (probeTimer) clearTimeout(probeTimer)
if (alive) {
this._activeId = profileId
this.setProfileState(profileId, 'connected')
this.setState('connected')
return
}
} else {
this._activeId = profileId
this.setProfileState(profileId, 'connected')
this.setState('connected')
return
}
// 连接已死,清理后重建
this.disconnectProfile(profileId)
}
// 先设 activeId(确保回调读到正确的 activeProfile),失败时回退
@@ -282,6 +319,7 @@ class ConnectionManagerImpl {
await this.buildAndPool(profileId, profile)
} catch (err) {
this._activeId = prevActiveId
this.setProfileState(profileId, 'error', err instanceof Error ? err.message : String(err))
this.notifyChange()
throw err
}
@@ -294,6 +332,7 @@ class ConnectionManagerImpl {
if (t instanceof SftpTransport) { t.disconnect() }
if (t instanceof OssTransport) { t.disconnect() }
this._pool.delete(profileId)
this.setProfileState(profileId, 'disconnected')
}
/** 断开所有远程连接(保留 local) */
@@ -313,10 +352,26 @@ class ConnectionManagerImpl {
this.disconnectAll()
}
/** 查找同名且同地址(OSS 比对 bucket)的既有条目,local 不参与查重;查重谓词唯一实现,供对话框预判与 addProfile 共用避免两处漂移 */
findDuplicateProfile(profile: Pick<ConnectionProfile, 'type' | 'name' | 'host' | 'port' | 'bucket'>): ConnectionProfile | undefined {
return this._profiles.find(p =>
p.type !== 'local' && p.name === profile.name &&
(profile.type === 'oss' ? p.bucket === profile.bucket : p.host === profile.host && p.port === profile.port)
)
}
async addProfile(profile: Omit<ConnectionProfile, 'id'>): Promise<ConnectionProfile> {
// 防重复: 同名且同地址的配置已存在时直接返回既有条目(表单双击/重试等场景)
const dup = this.findDuplicateProfile(profile)
if (dup) return dup
const newProfile: ConnectionProfile = { ...profile, id: crypto.randomUUID() }
this._profiles.push(newProfile)
await this.persistProfile(newProfile)
// 持久化后回填数据库真实 id(自增主键),否则后续 updateProfile
// 会因 UUID 无法转数字而每次都 INSERT 新行 → 列表出现重复服务器
const realId = await this.persistProfile(newProfile)
if (realId !== undefined) {
newProfile.id = String(realId)
}
this.notifyChange()
return newProfile
}
@@ -354,25 +409,30 @@ class ConnectionManagerImpl {
private async buildAndPool(profileId: string, profile: ConnectionProfile): Promise<void> {
if (profile.type === 'local') {
this._pool.set(profileId, new WailsTransport())
this.setProfileState(profileId, 'connected')
this.setState('connected')
return
}
if (profile.type === 'sftp') {
if (!profile.password && !profile.keyPath) {
this.setProfileState(profileId, 'error', '未配置密码或私钥')
this.setState('error')
return
}
this.setProfileState(profileId, 'connecting')
this.setState('connecting')
try {
const t = new SftpTransport(profile)
await t.connect()
this._pool.set(profileId, t)
this.setProfileState(profileId, 'connected')
this.setState('connected')
this.updateProfile(profileId, { lastConnected: Date.now() })
this.fetchSystemInfo(profileId).catch(() => {})
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMsg(err))
this.setState('error')
throw err
}
@@ -381,15 +441,18 @@ class ConnectionManagerImpl {
// OSS
if (profile.type === 'oss') {
this.setProfileState(profileId, 'connecting')
this.setState('connecting')
try {
const t = new OssTransport(profile)
await t.connect()
this._pool.set(profileId, t)
this.setProfileState(profileId, 'connected')
this.setState('connected')
this.updateProfile(profileId, { lastConnected: Date.now() })
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMsg(err))
this.setState('error')
throw err
}
@@ -397,22 +460,26 @@ class ConnectionManagerImpl {
}
// remote / http agent
this.setProfileState(profileId, 'connecting')
this.setState('connecting')
try {
const t = new HttpTransport(profile.host, profile.port, profile.token)
this._pool.set(profileId, t)
// 验证连接可用性
t.getFileInfo('/').then(() => {
this.setProfileState(profileId, 'connected')
if (this._activeId !== profileId) return
this.setState('connected')
this.updateProfile(profileId, { lastConnected: Date.now() })
this.fetchSystemInfo(profileId).catch(() => {})
}).catch(() => {
}).catch((e) => {
this.setProfileState(profileId, 'error', errMsg(e))
if (this._activeId !== profileId) return
this.setState('error')
})
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMsg(err))
this.setState('error')
throw err
}
@@ -421,6 +488,11 @@ class ConnectionManagerImpl {
export const connectionManager = new ConnectionManagerImpl()
/** 任意抛出值转错误文案 */
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
/** snake_case → camelCase */
function snakeToCamel(obj: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {}
+24 -2
View File
@@ -10,9 +10,10 @@ export class HttpTransport implements FsTransport {
private baseUrl: string
private token: string
constructor(host: string, port: number, token: string) {
constructor(host: string, port: number | undefined, token: string) {
const scheme = port === 443 ? 'https' : 'http'
this.baseUrl = `${scheme}://${host}${port === 80 || port === 443 ? '' : ':' + port}`
const portSuffix = (!port || port === 80 || port === 443) ? '' : `:${port}`
this.baseUrl = `${scheme}://${host}${portSuffix}`
this.token = token
}
@@ -82,11 +83,32 @@ export class HttpTransport implements FsTransport {
return this.request<FileOperationResult>('PATCH', '/api/v1/fs/rename', { path: oldPath }, { new_path: newPath })
}
// ====== 复制/移动/传输(不支持)======
async copyPath(_src: string, _dst: string): Promise<void> {
throw new Error('远程模式暂不支持复制/粘贴')
}
async movePath(_src: string, _dst: string): Promise<void> {
throw new Error('远程模式暂不支持剪切/粘贴')
}
async downloadToFile(_path: string, _localDstDir: string): Promise<string> {
throw new Error('远程模式暂不支持下载到本机')
}
async uploadFromFile(_localPath: string, _remoteDir: string): Promise<void> {
throw new Error('远程模式暂不支持上传')
}
async listZipContents(zipPath: string): Promise<FileItem[]> {
// Wave 3 实现
throw new Error('ZIP 操作在远程模式暂未实现')
}
async watchFile(_path: string): Promise<void> { /* 远程 HTTP 模式不支持文件监听 */ }
async unwatchFile(): Promise<void> { /* 远程 HTTP 模式不支持文件监听 */ }
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
throw new Error('ZIP 操作在远程模式暂未实现')
}
-1
View File
@@ -2,5 +2,4 @@
* API 统一导出
*/
export * from './types'
export * from './system'
+28 -12
View File
@@ -5,6 +5,7 @@
import type {
FsTransport, FileItem, FileOperationResult, DetectTypeResult,
} from './transport'
import { transformFileList, joinPath, probeAlive } from './transport'
import type { ConnectionProfile } from './connection-manager'
import {
OssConnect, OssDisconnect, OssListDir, OssReadFile,
@@ -13,14 +14,6 @@ import {
OssGetCommonPaths, OssWriteBase64File, OssGetSignedURL,
} from '@bindings/u-desk/app'
function transformFile(file: any): FileItem {
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
}
function transformFileList(files: any[]): FileItem[] {
return files.map(transformFile)
}
export class OssTransport implements FsTransport {
private profile: ConnectionProfile
private connID: string | null = null
@@ -40,6 +33,10 @@ export class OssTransport implements FsTransport {
return result
}
async isAlive(): Promise<boolean> {
return probeAlive(this.connID, id => OssListDir(id, ''))
}
get sessionId(): string | null { return this.connID }
async disconnect(): Promise<void> {
@@ -84,13 +81,11 @@ export class OssTransport implements FsTransport {
// ====== 文件操作 ======
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
return OssCreateFile(this.requireConn(), fullPath)
return OssCreateFile(this.requireConn(), joinPath(dirPath, filename))
}
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
return OssCreateDir(this.requireConn(), fullPath)
return OssCreateDir(this.requireConn(), joinPath(parentPath, dirname))
}
async deletePath(path: string): Promise<FileOperationResult> {
@@ -105,6 +100,24 @@ export class OssTransport implements FsTransport {
})
}
// ====== 复制/移动/传输(不支持)======
async copyPath(_src: string, _dst: string): Promise<void> {
throw new Error('OSS 面板暂不支持复制/粘贴')
}
async movePath(_src: string, _dst: string): Promise<void> {
throw new Error('OSS 面板暂不支持剪切/粘贴')
}
async downloadToFile(_path: string, _localDstDir: string): Promise<string> {
throw new Error('OSS 下载到本机暂未实现')
}
async uploadFromFile(_localPath: string, _remoteDir: string): Promise<void> {
throw new Error('OSS 上传暂未实现')
}
// ====== ZIP 操作(不支持)======
async listZipContents(_zipPath: string): Promise<FileItem[]> {
@@ -177,4 +190,7 @@ export class OssTransport implements FsTransport {
async getSignedUrl(key: string): Promise<string> {
return OssGetSignedURL(this.requireConn(), key)
}
async watchFile(_path: string): Promise<void> { /* OSS 模式不支持文件监听 */ }
async unwatchFile(): Promise<void> { /* OSS 模式不支持文件监听 */ }
}
+33 -12
View File
@@ -5,22 +5,16 @@
import type {
FsTransport, FileItem, FileOperationResult, DetectTypeResult,
} from './transport'
import { transformFileList, joinPath, probeAlive } from './transport'
import type { ConnectionProfile } from './connection-manager'
import {
SftpConnect, SftpDisconnect, SftpListDir, SftpReadFile,
SftpWriteFile, SftpGetFileInfo, SftpCreateDir, SftpCreateFile,
SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, SftpDownloadSiteForPreview,
SftpDownloadToFile, SftpUploadFromFile,
SftpGetCommonPaths, SftpWriteBase64File,
} from '@bindings/u-desk/app'
function transformFile(file: any): FileItem {
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
}
function transformFileList(files: any[]): FileItem[] {
return files.map(transformFile)
}
export class SftpTransport implements FsTransport {
private profile: ConnectionProfile
private connID: string | null = null
@@ -42,6 +36,10 @@ export class SftpTransport implements FsTransport {
return result
}
async isAlive(): Promise<boolean> {
return probeAlive(this.connID, id => SftpGetFileInfo(id, '/'))
}
get sessionId(): string | null { return this.connID }
async disconnect(): Promise<void> {
@@ -90,13 +88,11 @@ export class SftpTransport implements FsTransport {
// ====== 文件操作 ======
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
return SftpCreateFile(this.requireConn(), fullPath)
return SftpCreateFile(this.requireConn(), joinPath(dirPath, filename))
}
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
return SftpCreateDir(this.requireConn(), fullPath)
return SftpCreateDir(this.requireConn(), joinPath(parentPath, dirname))
}
async deletePath(path: string): Promise<FileOperationResult> {
@@ -111,6 +107,28 @@ export class SftpTransport implements FsTransport {
})
}
// ====== 复制/移动(粘贴仅本地面板支持)======
async copyPath(_src: string, _dst: string): Promise<void> {
throw new Error('SFTP 面板暂不支持粘贴,可使用拖拽上传')
}
async movePath(_src: string, _dst: string): Promise<void> {
throw new Error('SFTP 面板暂不支持粘贴,可使用拖拽上传')
}
// ====== 传输 ======
/** 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径 */
async downloadToFile(remotePath: string, localDstDir: string): Promise<string> {
return SftpDownloadToFile(this.requireConn(), remotePath, localDstDir)
}
/** 上传本地文件/目录到远程目录(流式,目录递归) */
async uploadFromFile(localPath: string, remoteDir: string): Promise<void> {
await SftpUploadFromFile(this.requireConn(), localPath, remoteDir)
}
// ====== ZIP 操作(不支持)======
async listZipContents(_zipPath: string): Promise<FileItem[]> {
@@ -179,4 +197,7 @@ export class SftpTransport implements FsTransport {
async downloadSiteForPreview(remotePath: string): Promise<string> {
return SftpDownloadSiteForPreview(this.requireConn(), remotePath)
}
async watchFile(_path: string): Promise<void> { /* SFTP 模式不支持文件监听 */ }
async unwatchFile(): Promise<void> { /* SFTP 模式不支持文件监听 */ }
}
+44 -29
View File
@@ -3,18 +3,17 @@
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
*/
import type { File } from './types'
import type { FileItem } from '@/types/file-system'
import { connectionManager } from './connection-manager'
import { PickDirectory } from '../wailsjs/v3-bindings/u-desk/app'
/**
* 转换后端文件数据格式(蛇形 → 驼峰)
*/
function transformFile(file: any): File {
/** 转换后端蛇形字段为前端驼峰格式 */
function toFileItem(file: any): FileItem {
return { ...file, isDir: file.is_dir, modified_time: file.mod_time }
}
function transformFileList(files: any[]): File[] {
return files.map(transformFile)
function toFileItemList(files: any[]): FileItem[] {
return files.map(toFileItem)
}
const t = () => connectionManager.getTransport()
@@ -23,21 +22,21 @@ 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 window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
}
export async function getMemoryInfo() {
if (connectionManager.isRemote()) return {}
try { return await (window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
try { return (await window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
}
export async function getDiskInfo() {
if (connectionManager.isRemote()) return {}
try { return await (window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
try { return (await window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
}
export async function listDir(path: string): Promise<File[]> {
return transformFileList(await t().listDir(path))
export async function listDir(path: string): Promise<FileItem[]> {
return toFileItemList(await t().listDir(path))
}
export async function readFile(path: string): Promise<string> {
@@ -69,12 +68,31 @@ export async function renamePath(oldPath: string, newPath: string): Promise<any>
return t().renamePath(oldPath, String(newPath))
}
export async function getEnvVars(): Promise<Record<string, string>> {
try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
// 复制/移动(本机面板粘贴;进度经 transfer-progress 事件推送)
export async function copyPath(src: string, dst: string): Promise<void> {
await t().copyPath(src, dst)
}
export async function listZipContents(zipPath: string): Promise<File[]> {
return transformFileList(await t().listZipContents(zipPath))
export async function movePath(src: string, dst: string): Promise<void> {
await t().movePath(src, dst)
}
// 下载远程文件/目录到本机指定目录(SFTP),返回本地落地路径
export async function downloadToFile(path: string, localDstDir: string): Promise<string> {
return t().downloadToFile(path, localDstDir)
}
// 上传本地文件/目录到远程目录(SFTP)
export async function uploadFromFile(localPath: string, remoteDir: string): Promise<void> {
await t().uploadFromFile(localPath, remoteDir)
}
export async function getEnvVars(): Promise<Record<string, string>> {
try { return (await window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
}
export async function listZipContents(zipPath: string): Promise<FileItem[]> {
return toFileItemList(await t().listZipContents(zipPath))
}
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
@@ -85,8 +103,8 @@ export async function extractFileFromZipToTemp(zipPath: string, filePath: string
return t().extractFileFromZipToTemp(zipPath, filePath)
}
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
return transformFile(await t().getZipFileInfo(zipPath, filePath))
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem> {
return toFileItem(await t().getZipFileInfo(zipPath, filePath))
}
export async function openPath(path: string): Promise<void> {
@@ -109,19 +127,16 @@ export async function getCommonPaths() {
return t().getCommonPaths()
}
// 文件监听(仅本地模式,直接调用 Wails 绑定)
// 打开系统目录选择器(取消返回空串)
export async function pickDirectory(): Promise<string> {
return PickDirectory()
}
// 文件监听(通过 Transport 层,本地模式走 Wails IPC,远程模式空操作)
export async function watchFile(path: string): Promise<void> {
if (connectionManager.isRemote()) return
try {
const { WatchFile } = await import('../wailsjs/v3-bindings/u-desk/app')
await WatchFile(path)
} catch { /* 忽略绑定未生成的场景 */ }
await t().watchFile(path)
}
export async function unwatchFile(): Promise<void> {
if (connectionManager.isRemote()) return
try {
const { UnwatchFile } = await import('../wailsjs/v3-bindings/u-desk/app')
await UnwatchFile()
} catch { /* 忽略 */ }
await t().unwatchFile()
}
+49
View File
@@ -34,6 +34,9 @@ export type DetectTypeResult = {
}
export interface FsTransport {
/** 检测连接是否仍然存活 */
isAlive?(): Promise<boolean>
// 文件列表与信息
listDir(path: string): Promise<FileItem[]>
getFileInfo(path: string): Promise<Record<string, any>>
@@ -49,6 +52,14 @@ export interface FsTransport {
deletePath(path: string): Promise<FileOperationResult>
renamePath(oldPath: string, newPath: string): Promise<FileOperationResult>
// 复制/移动(本机面板粘贴;SFTP/OSS/HTTP 暂不支持)
copyPath(src: string, dst: string): Promise<void>
movePath(src: string, dst: string): Promise<void>
// 传输(SFTP 下载到本机 / 本地文件上传到 SFTP;进度经 transfer-progress 事件推送)
downloadToFile(path: string, localDstDir: string): Promise<string>
uploadFromFile(localPath: string, remoteDir: string): Promise<void>
// ZIP 操作(Wave 3)
listZipContents(zipPath: string): Promise<FileItem[]>
extractFileFromZip(zipPath: string, filePath: string): Promise<string>
@@ -76,4 +87,42 @@ export interface FsTransport {
downloadSiteForPreview?(path: string): Promise<string>
/** 获取预签名 URL(仅 OSS 实现,其他返回空串) */
getSignedUrl?(path: string): Promise<string>
// 文件监听(仅本地 Wails 模式有效)
watchFile(path: string): Promise<void>
unwatchFile(): Promise<void>
}
// ========== 共享工具函数 ==========
// 说明:后端蛇形 → 前端驼峰(isDir/modified_time)的统一转换在 system.ts 的
// toFileItem/toFileItemList 完成;下面两个函数仅做【类型适配】(运行时恒等),
// 把绑定层返回的松散类型断言为 FileItem,供各 transport 复用。
/** 类型适配:断言后端文件数据为 FileItem(运行时恒等) */
export function transformFile(file: any): FileItem {
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
}
export function transformFileList(files: any[]): FileItem[] {
return files.map(transformFile)
}
/** 路径拼接:确保父路径以 / 结尾后拼接文件名 */
export function joinPath(parent: string, name: string): string {
return parent.replace(/\/$/, '') + '/' + name
}
/** 连接存活探测:无会话 id 或探测调用失败均视为死连接(SFTP/OSS 共用) */
export async function probeAlive(
connId: string | null,
probe: (id: string) => Promise<unknown>,
): Promise<boolean> {
if (!connId) return false
try {
await probe(connId)
return true
} catch {
return false
}
}
-40
View File
@@ -1,40 +0,0 @@
/**
* API 类型定义
*/
// 系统信息
export interface SystemInfo {
os: string
arch: string
version: string
}
export interface CPU {
model: string
cores: number
usage: number
}
export interface Memory {
total: number
used: number
free: number
usage: number
}
export interface Disk {
path: string
total: number
used: number
free: number
usage: number
}
export interface File {
name: string
path: string
size: number
isDir: boolean
modified?: string
modified_time?: string
}
+32 -15
View File
@@ -3,25 +3,20 @@
*/
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
import { transformFile, transformFileList, joinPath } from './transport'
import {
ListDir, GetFileInfo, ReadFile, WriteFile, SaveBase64File,
CreateFile, CreateDir, DeletePath, RenamePath,
CreateFile, CreateDir, DeletePath, RenamePath, CopyPath, MovePath,
ListZipContents, ExtractFileFromZip, ExtractFileFromZipToTemp, GetZipFileInfo,
OpenPath, GetFileServerURL, ResolveShortcut, DetectFileTypeByContent,
GetCommonPaths, GetRecycleBinEntries, RestoreFromRecycleBin,
DeletePermanently, EmptyRecycleBin
DeletePermanently, EmptyRecycleBin,
WatchFile, UnwatchFile
} from '../wailsjs/v3-bindings/u-desk/app'
function transformFile(file: any): FileItem {
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
}
function transformFileList(files: any[]): FileItem[] {
return files.map(transformFile)
}
export class WailsTransport implements FsTransport {
async listDir(path: string): Promise<FileItem[]> {
// 蛇形→驼峰转换统一由 system.ts 的 toFileItemList 完成,此处仅类型适配
return transformFileList(await ListDir(path))
}
@@ -43,13 +38,11 @@ export class WailsTransport implements FsTransport {
}
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
return CreateFile(fullPath)
return CreateFile(joinPath(dirPath, filename))
}
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
return CreateDir(fullPath)
return CreateDir(joinPath(parentPath, dirname))
}
async deletePath(path: string): Promise<FileOperationResult> {
@@ -60,6 +53,22 @@ export class WailsTransport implements FsTransport {
return RenamePath({ oldPath: String(oldPath), newPath: String(newPath) })
}
async copyPath(src: string, dst: string): Promise<void> {
await CopyPath(String(src), String(dst))
}
async movePath(src: string, dst: string): Promise<void> {
await MovePath(String(src), String(dst))
}
async downloadToFile(path: string, _localDstDir: string): Promise<string> {
return path // 本地文件无需下载
}
async uploadFromFile(_localPath: string, _remoteDir: string): Promise<void> {
// 本地面板无上传语义(外部文件拖入走 copyPath)
}
async listZipContents(zipPath: string): Promise<FileItem[]> {
return transformFileList(await ListZipContents(zipPath))
}
@@ -73,7 +82,7 @@ export class WailsTransport implements FsTransport {
}
async getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem> {
return transform(await GetZipFileInfo(zipPath, filePath))
return transformFile(await GetZipFileInfo(zipPath, filePath))
}
async openPath(path: string): Promise<void> {
@@ -120,4 +129,12 @@ export class WailsTransport implements FsTransport {
async downloadForPreview(path: string): Promise<string> {
return path
}
async watchFile(path: string): Promise<void> {
await WatchFile(path)
}
async unwatchFile(): Promise<void> {
await UnwatchFile()
}
}
@@ -145,9 +145,12 @@ watch(() => props.visible, (val) => {
})
watch(() => form.type, (t) => {
if (t === 'sftp') form.port = 22
else if (t === 'remote') form.port = 9876
else form.port = 0
// 仅当前端口是其它类型的默认值(而非用户自定义值)时才重置,避免切类型丢失自定义端口
const DEFAULT_PORTS: Record<string, number> = { sftp: 22, remote: 9876, oss: 0 }
const isOtherDefault = Object.entries(DEFAULT_PORTS).some(([, v]) => form.port === v)
if (isOtherDefault) {
form.port = DEFAULT_PORTS[t] ?? 0
}
})
async function handleOk(): Promise<boolean> {
@@ -166,8 +169,10 @@ async function handleOk(): Promise<boolean> {
await connectionManager.updateProfile(editingId.value, { ...form })
Message.success('已更新')
} else {
// addProfile 遇同名同地址的已有配置会静默复用,提前查重以区分提示
const existed = !!connectionManager.findDuplicateProfile(form)
await connectionManager.addProfile({ ...form, type: form.type })
Message.success('已添加')
Message.success(existed ? '已存在同名配置,已复用' : '已添加')
}
emit('update:visible', false)
return true
@@ -18,6 +18,16 @@
<span>新建文件夹</span>
<span class="context-menu-shortcut">Ctrl+Shift+N</span>
</div>
<div
class="context-menu-item"
:class="{ disabled: config.clipboardEmpty }"
:title="config.clipboardEmpty ? '剪贴板为空' : undefined"
@click="handlePaste"
>
<span class="context-menu-icon">📋</span>
<span>粘贴</span>
<span class="context-menu-shortcut">Ctrl+V</span>
</div>
</template>
<!-- 文件菜单 -->
@@ -34,13 +44,35 @@
</div>
<div class="context-menu-divider"></div>
<div
v-if="!config.selectedFile.is_dir && isOfficeFile(config.selectedFile.name)"
v-if="!config.selectedFile.isDir && isOfficeFile(config.selectedFile.name)"
class="context-menu-item"
@click="handleOpenWithSystem"
>
<span class="context-menu-icon">🚀</span>
<span>系统默认程序打开</span>
</div>
<template v-if="config.isSftp">
<!-- SFTP 面板:下载到本机 -->
<div class="context-menu-divider"></div>
<div class="context-menu-item" @click="handleDownload">
<span class="context-menu-icon">⬇️</span>
<span>下载到本机...</span>
</div>
</template>
<template v-else>
<!-- 本机面板:复制/剪切 -->
<div class="context-menu-divider"></div>
<div class="context-menu-item" @click="handleCopy">
<span class="context-menu-icon">📋</span>
<span>复制</span>
<span class="context-menu-shortcut">Ctrl+C</span>
</div>
<div class="context-menu-item" @click="handleCut">
<span class="context-menu-icon">✂️</span>
<span>剪切</span>
<span class="context-menu-shortcut">Ctrl+X</span>
</div>
</template>
<div class="context-menu-divider"></div>
<div class="context-menu-item" @click="handleRename">
<span class="context-menu-icon">✏️</span>
@@ -111,26 +143,31 @@ const handleCreateDir = () => {
emit('close')
}
const handleOpenWithSystem = () => {
/** 文件类菜单项:带选中文件发出动作并关闭(未选中仅关闭) */
const emitFileAction = (action: string) => {
if (props.config.selectedFile) {
emit('action', 'openWithSystem', props.config.selectedFile)
emit('action', action, props.config.selectedFile)
}
emit('close')
}
const handleRename = () => {
if (props.config.selectedFile) {
emit('action', 'rename', props.config.selectedFile)
}
const handleOpenWithSystem = () => emitFileAction('openWithSystem')
const handleCopy = () => emitFileAction('copy')
const handleCut = () => emitFileAction('cut')
const handleDownload = () => emitFileAction('download')
const handlePaste = () => {
if (props.config.clipboardEmpty) return
emit('action', 'paste')
emit('close')
}
const handleDelete = () => {
if (props.config.selectedFile) {
emit('action', 'delete', props.config.selectedFile)
}
emit('close')
}
const handleRename = () => emitFileAction('rename')
const handleDelete = () => emitFileAction('delete')
</script>
<style scoped>
@@ -169,6 +206,15 @@ const handleDelete = () => {
background: rgb(var(--danger-1));
}
.context-menu-item.disabled {
color: var(--color-text-4);
cursor: not-allowed;
}
.context-menu-item.disabled:hover {
background: transparent;
}
.context-menu-icon {
font-size: 16px;
width: 20px;
@@ -0,0 +1,145 @@
<template>
<div class="drawio-preview">
<!-- 下载中(首次使用时下载 viewer) -->
<div v-if="downloading" class="drawio-loading">
<a-spin :loading="true" />
<p class="drawio-loading-text">
{{ hasProgress ? `正在下载 Draw.io 查看器 ${progress}%` : '正在准备 Draw.io 查看器...' }}
</p>
<a-progress v-if="hasProgress" :percent="progress" style="max-width: 360px; width: 80%" />
</div>
<!-- 错误 -->
<div v-else-if="errorMsg" class="drawio-error">
<a-alert type="error" message="Draw.io 预览失败" :description="errorMsg" />
<a-button size="small" @click="load">重试</a-button>
</div>
<!-- drawio viewer iframe(viewer 用 ?url= 自取 .drawio 原文) -->
<iframe v-else-if="viewerSrc" :src="viewerSrc" class="drawio-iframe" frameborder="0" />
</div>
</template>
<script setup lang="ts">
// DrawioPreview:drawio 文件预览插件组件。
// 流程:① 调 EnsureDrawioViewer 确保本地已缓存 viewer(首次触发下载,监听进度);
// ② 用 resolveFileUrl 拿 .drawio 原文 URL,拼 viewer iframe src(viewer 用 ?url= 自取原文)。
// 关键:viewer base 必须用本地文件服务器(getFileServerBaseURL),与 /drawio-viewer/ 路由及 .drawio 原文同源。
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { On, Off } from '@wailsio/events'
import { parseWailsEventData } from '@/utils/wailsEvent'
import { resolveFileUrl } from '../composables/useFilePreview'
import { getFileServerBaseURL } from '@/api/file-server'
import { EnsureDrawioViewer } from '@bindings/u-desk/app'
import type { FileEditorPanelConfig } from '@/types/file-system'
const props = defineProps<{ config: FileEditorPanelConfig }>()
const downloading = ref(false)
const hasProgress = ref(false)
const progress = ref(0)
const errorMsg = ref('')
const viewerSrc = ref('')
const EVENT_NAME = 'drawio-download-progress'
const onProgress = (event: unknown) => {
const d = parseWailsEventData(event)
const p = Number(d.progress)
if (!isNaN(p) && p >= 0) {
hasProgress.value = true
progress.value = Math.round(p)
}
}
// 构造 viewer iframe src
const buildViewerSrc = async (filePath: string): Promise<string> => {
// 1. .drawio 原文 URL(本地/远程自适应;远程先下载到 temp 再走 localfs)
const xmlUrl = await resolveFileUrl(filePath)
if (!xmlUrl) throw new Error('无法解析文件 URL(远程下载可能失败)')
// 2. viewer base = 本地文件服务器(与 /drawio-viewer/ 路由及原文同源)
const base = getFileServerBaseURL().replace(/\/$/, '')
// 3. chrome=0 隐藏工具栏,lightbox=1 只读灯箱,layers=1 显示图层
const params = new URLSearchParams({
url: xmlUrl,
chrome: '0',
lightbox: '1',
layers: '1',
})
return `${base}/drawio-viewer/index.html?${params.toString()}`
}
const load = async () => {
errorMsg.value = ''
viewerSrc.value = ''
const filePath = props.config.currentFileFullPath
if (!filePath) return
try {
// 1. 确保 viewer 缓存就绪(首次触发下载)
downloading.value = true
hasProgress.value = false
progress.value = 0
await EnsureDrawioViewer()
downloading.value = false
// 2. 构造 viewer URL 并渲染
viewerSrc.value = await buildViewerSrc(filePath)
} catch (e: any) {
downloading.value = false
console.error('[DrawioPreview] 加载失败:', e)
errorMsg.value = e?.message || String(e)
}
}
watch(() => props.config.currentFileFullPath, () => load(), { immediate: true })
// 进度监听只注册一次;Wails On 每次调用都会追加监听器,放在 load() 里会随文件切换累积
onMounted(() => {
On(EVENT_NAME, onProgress)
})
onUnmounted(() => {
Off(EVENT_NAME)
})
</script>
<style scoped>
.drawio-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--color-bg-1);
position: relative;
}
.drawio-iframe {
width: 100%;
height: 100%;
border: none;
flex: 1;
min-height: 0;
}
.drawio-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
padding: 24px;
}
.drawio-loading-text {
color: var(--color-text-2);
margin: 0;
}
.drawio-error {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
</style>
@@ -72,6 +72,7 @@ export interface BgmTrack { name: string; path: string; url: string; profileId?:
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import type { LrcLine } from '@/utils/lrcParser'
import { BgmGetPlaylist, BgmSavePlaylist } from '@bindings/u-desk/app'
import { Message } from '@arco-design/web-vue'
import { connectionManager } from '@/api/connection-manager'
const props = defineProps<{
@@ -143,7 +144,10 @@ const restorePlaylist = async (resolver?: (path: string) => Promise<string | und
} else {
playlist.value = entries.map(e => ({ name: e.name, path: e.path, url: '', profileId: (e as any).profile_id || '' }))
}
} catch (e) { console.debug('[BgmBar] 恢复播放列表失败:', e) }
} catch (e) {
console.debug('[BgmBar] 恢复播放列表失败:', e)
Message.warning('背景音乐播放列表恢复失败')
}
}
// 启动时自动恢复(url 延迟解析),并选中上次播放的曲目
@@ -1,10 +1,11 @@
<template>
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
<div class="file-editor-panel" :class="{ 'is-fullscreen': isFullscreen }" :style="{ width: width + '%' }">
<!-- 有选中文件时显示表头和内容 -->
<template v-if="config.currentFileName">
<div class="panel-header">
<span class="panel-title">
<template v-if="config.isImageView">🖼️ 图片预览</template>
<template v-if="config.activePlugin">{{ config.activePlugin.icon }} {{ config.activePlugin.name }}</template>
<template v-else-if="config.isImageView">🖼️ 图片预览</template>
<template v-else-if="config.isVideoView">🎬 视频预览</template>
<template v-else-if="config.isAudioView">🎵 音频预览</template>
<template v-else-if="config.isPdfFile">📕 PDF 预览</template>
@@ -47,8 +48,10 @@
</div>
<div class="editor-content thin-dark-scrollbar">
<!-- 插件预览(drawio 等,优先于内置 v-if 链) -->
<component v-if="config.activePluginComponent" :is="config.activePluginComponent" :config="config" />
<!-- 二进制文件提示 -->
<div v-if="config.isBinaryFile" class="binary-file-message">
<div v-else-if="config.isBinaryFile" class="binary-file-message">
<pre>{{ config.fileContent }}</pre>
</div>
@@ -464,21 +467,11 @@ const csvPreviewRef = ref<HTMLElement | null>(null)
// Markdown 预览容器引用
const markdownPreviewRef = ref<HTMLElement | null>(null)
// 全屏
const panelRef = ref<HTMLElement | null>(null)
// 全屏(仅应用窗口内全屏,非 OS 级 fullscreen)
const isFullscreen = ref(false)
function toggleFullscreen() {
if (!panelRef.value) return
if (!document.fullscreenElement) {
panelRef.value.requestFullscreen().then(() => { isFullscreen.value = true })
} else {
document.exitFullscreen().then(() => { isFullscreen.value = false })
}
}
function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
isFullscreen.value = !isFullscreen.value
}
function onKeyDown(e: KeyboardEvent) {
@@ -486,6 +479,10 @@ function onKeyDown(e: KeyboardEvent) {
e.preventDefault()
toggleFullscreen()
}
// ESC 退出窗口内全屏
if (e.key === 'Escape' && isFullscreen.value) {
isFullscreen.value = false
}
}
// Props
@@ -798,12 +795,12 @@ const handlePdfLoad = (event: Event) => {
}
}
// 带认证的 fetch(远程模式自动附加 Bearer token)
const authFetch = async (url: string): Promise<Response> => {
// 带认证的 fetch(远程模式自动附加 Bearer token),支持取消信号
const authFetch = async (url: string, signal?: AbortSignal): Promise<Response> => {
const token = connectionManager.activeProfile?.token
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
return fetch(url, { headers })
return fetch(url, { headers, signal })
}
// 打印窗口导出 PDF 公共函数
@@ -950,23 +947,34 @@ watch(() => [props.config.isCsvFile, props.config.currentFileFullPath, props.con
}, { immediate: true })
// Excel 预览加载(直接使用本地文件服务器,秒开)
// 取消上一个进行中的预览:快速切文件时 abort 旧下载 + worker 侧 wid 自动推进丢弃旧解析
let excelAbortController: AbortController | null = null
const loadExcelPreview = async (filePath: string) => {
if (!excelPreviewRef.value) return
// 取消上一次尚未完成的下载
if (excelAbortController) excelAbortController.abort()
const controller = new AbortController()
excelAbortController = controller
try {
excelPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
// 直接从本地文件服务器获取(不走 base64)
const fileUrl = props.config.previewUrl
const response = await authFetch(fileUrl)
const response = await authFetch(fileUrl, controller.signal)
const blob = await response.blob()
if (controller.signal.aborted) return
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
const result = await previewExcel(file, excelPreviewRef.value)
if (controller.signal.aborted) return
if (!result.success) {
throw new Error(result.error || '预览失败')
}
} catch (error) {
} catch (error: any) {
// 被后续切换主动取消 —— 静默
if (controller.signal.aborted || error?.name === 'AbortError') return
console.error('[loadExcelPreview] 错误:', error)
if (excelPreviewRef.value) {
excelPreviewRef.value.innerHTML = `
@@ -1088,6 +1096,20 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
}
}, { immediate: true })
// 切换文件时重置 Markdown 预览的滚动位置
// v-html 只替换内容,外层滚动容器的 scrollTop 不会自动归零,
// 否则从 A 文档跳进 B 文档时会停留在 A 的滚动位置
watch(
() => props.config.currentFileFullPath,
() => {
nextTick(() => {
if (markdownPreviewRef.value) {
markdownPreviewRef.value.scrollTop = 0
}
})
}
)
// 处理 HTML iframe 发送的消息(链接点击)
const handleHtmlIframeMessage = (event: MessageEvent) => {
// 安全检查:接受来自本地文件服务器或同源的消息
@@ -1110,7 +1132,6 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
// 监听 iframe 的 postMessage + 全屏事件
onMounted(() => {
window.addEventListener('message', handleHtmlIframeMessage)
document.addEventListener('fullscreenchange', onFullscreenChange)
document.addEventListener('keydown', onKeyDown)
On('toggle-bgm-bar', () => {
bgmHidden.value = !bgmHidden.value
@@ -1122,7 +1143,6 @@ onUnmounted(() => {
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
}
window.removeEventListener('message', handleHtmlIframeMessage)
document.removeEventListener('fullscreenchange', onFullscreenChange)
document.removeEventListener('keydown', onKeyDown)
Off('toggle-bgm-bar')
copyCleanup()
@@ -1140,9 +1160,11 @@ defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHid
position: relative;
}
.file-editor-panel:fullscreen {
width: 100vw !important;
height: 100vh;
.file-editor-panel.is-fullscreen {
position: fixed;
inset: 0;
width: 100% !important;
height: 100%;
z-index: 1000;
}
@@ -1162,7 +1184,7 @@ defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHid
}
/* 仅全屏模式下 header 可拖动窗口 */
.file-editor-panel:fullscreen .panel-header {
.file-editor-panel.is-fullscreen .panel-header {
--wails-draggable: drag;
}
@@ -1612,54 +1634,7 @@ defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHid
fill: var(--color-text-1);
}
/* ========== 代码高亮主题色(不依赖 hljs 主题 CSS) ========== */
/* 亮色模式 - GitHub 配色 */
.markdown-preview-content :deep(.hljs) {
color: #24292e;
background: #f6f8fa;
}
.markdown-preview-content :deep(.hljs-comment),
.markdown-preview-content :deep(.hljs-quote) { color: #6a737d; font-style: italic; }
.markdown-preview-content :deep(.hljs-keyword),
.markdown-preview-content :deep(.hljs-selector-tag),
.markdown-preview-content :deep(.hljs-subst) { color: #d73a49; }
.markdown-preview-content :deep(.hljs-string),
.markdown-preview-content :deep(.hljs-doctag) { color: #032f62; }
.markdown-preview-content :deep(.hljs-number),
.markdown-preview-content :deep(.hljs-literal),
.markdown-preview-content :deep(.hljs-variable),
.markdown-preview-content :deep(.hljs-template-variable),
.markdown-preview-content :deep(.hljs-tag .hljs-attr) { color: #005cc5; }
.markdown-preview-content :deep(.hljs-title),
.markdown-preview-content :deep(.hljs-section),
.markdown-preview-content :deep(.hljs-selector-id) { color: #6f42c1; font-weight: bold; }
.markdown-preview-content :deep(.hljs-type),
.markdown-preview-content :deep(.hljs-class .hljs-title) { color: #6f42c1; }
.markdown-preview-content :deep(.hljs-tag .hljs-keyword),
.markdown-preview-content :deep(.hljs-tag .hljs-title) { color: #22863a; }
.markdown-preview-content :deep(.hljs-bullet) { color: #e36209; }
.markdown-preview-content :deep(.hljs-symbol) { color: #005cc5; }
.markdown-preview-content :deep(.hljs-built_in),
.markdown-preview-content :deep(.hljs-type) { color: #005cc5; }
.markdown-preview-content :deep(.hljs-attr) { color: #e36209; }
.markdown-preview-content :deep(.hljs-meta) { color: #735c0f; }
.markdown-preview-content :deep(.hljs-addition) { color: #22863a; background-color: #f0fff4; }
.markdown-preview-content :deep(.hljs-deletion) { color: #b31d28; background-color: #ffeef0; }
/* 亮色 hljs 样式由全局 style.css 提供 */
/* ========== 深色模式适配 ========== */
@@ -1730,12 +1705,16 @@ body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-link) {
border: 1px solid color-mix(in srgb, var(--color-border-2) 40%, transparent);
border-right: none;
cursor: pointer;
transition: all 0.3s ease;
transition: opacity 0.15s ease, background-color 0.15s ease;
position: relative;
opacity: 0.68;
/* 未悬停时高透明避免遮挡内容,容器 hover / tab hover 恢复;
active 态与未保存 dirty 标记保持较高可见度(状态不能被压暗) */
opacity: 0.25;
}
.preview-tabs:hover .preview-tab {
.preview-tabs:hover .preview-tab,
.preview-tab:hover,
.preview-tab.active {
opacity: 1;
}
@@ -1746,6 +1725,7 @@ body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-link) {
.preview-tab.active {
background: var(--color-fill-2);
border-left-color: transparent;
opacity: 0.85;
}
.preview-tab.active::before {
@@ -50,6 +50,7 @@
<div
class="file-list-wrapper thin-dark-scrollbar"
data-file-drop-target
@contextmenu.prevent="handleWrapperContextMenu"
>
<!-- 文件列表(滚动区域) -->
@@ -378,7 +379,8 @@ const handleRowContextMenu = (record: FileItem, ev: Event) => {
const getRowClassName = (record: FileItem): string => [
props.config.selectedFileItem?.path === record.path && 'row-selected',
props.config.editingFilePath === record.path && 'row-editing'
props.config.editingFilePath === record.path && 'row-editing',
props.config.cutPaths?.includes(record.path) && 'row-cut'
].filter(Boolean).join(' ')
const handleWrapperContextMenu = (event: MouseEvent) => {
@@ -537,6 +539,17 @@ defineExpose({ focusEditingItem })
background: var(--color-fill-1);
}
/* 被剪切的文件行 */
.file-table :deep(.arco-table-tr.row-cut) {
opacity: 0.5;
}
/* 外部文件拖入悬停反馈(Wails 运行时自动添加的类) */
.file-list-wrapper.file-drop-target-active {
outline: 1px dashed rgb(var(--primary-6));
outline-offset: -2px;
}
/* ====== 列内容 ====== */
.col-icon { text-align: center; vertical-align: middle !important; }
.file-item-icon {
@@ -0,0 +1,41 @@
/**
* 文件剪贴板 Composable(应用内内存态单例)
* 复制/剪切 → 粘贴到其他目录;粘贴动作由 index.vue 执行
*/
import { ref, computed } from 'vue'
import type { FileItem } from '@/types/file-system'
export type ClipboardMode = 'copy' | 'cut'
interface FileClipboardState {
mode: ClipboardMode
items: FileItem[]
fromDir: string
}
// 模块级单例状态(跨组件共享)
const state = ref<FileClipboardState | null>(null)
export function useFileClipboard() {
const clipboard = computed(() => state.value)
const clipboardEmpty = computed(() => !state.value || state.value.items.length === 0)
/** 设置复制项 */
const setCopy = (items: FileItem[], fromDir: string) => {
state.value = { mode: 'copy', items: [...items], fromDir }
}
/** 设置剪切项 */
const setCut = (items: FileItem[], fromDir: string) => {
state.value = { mode: 'cut', items: [...items], fromDir }
}
/** 清空剪贴板 */
const clear = () => {
state.value = null
}
return { clipboard, clipboardEmpty, setCopy, setCut, clear }
}
@@ -10,9 +10,9 @@ import { getExt } from '@/utils/fileUtils'
import {
isImageFile, isVideoFile, isAudioFile, isPdfFile,
isExcelFile, isWordFile, isCsvFile,
isTextEditable, isConfigFile
isTextEditable, isConfigFile, isKnownBinary
} from '@/utils/fileTypeHelpers'
import { useFileOperations } from './useFileOperations'
import { readFile as readFileApi, writeFile as writeFileApi } from '@/api'
import type { FileItem } from '@/types/file-system'
export interface UseFileEditOptions {
@@ -47,15 +47,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
// 文件版本跟踪(用于防止切换文件后的过期更新)
const fileVersion = ref(0)
// 使用文件操作 composable
const { readFile, writeFile } = useFileOperations({
onSuccess: (operation, data) => {
// 可以在这里添加成功处理逻辑
},
onError: (operation, error) => {
Message.error(`${operation} 失败: ${error.message}`)
}
})
// 直接调用底层 API(错误由本文件 catch 统一处理)
/**
* 获取文件路径(从 FileItem 对象或字符串中提取)
@@ -67,13 +59,8 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
return ''
}
// 已知二进制扩展名(无需读取内容即可判定)
const KNOWN_BINARY_EXTS = new Set([
'exe', 'dll', 'so', 'bin', 'dat', 'db', 'sqlite', 'pdb', 'idb',
'lib', 'obj', 'o', 'a', 'class', 'pyc', 'pyo', 'wasm',
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
'msi', 'jar', 'war', 'ear', 'apk'
])
// 已知二进制扩展名判定委托给 fileTypeHelpers.isKnownBinary
// 不再本地维护列表,统一入口
/**
* 判断是否为二进制文件(基于扩展名)
@@ -85,7 +72,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
// 已知二进制扩展名 → 直接判定
if (KNOWN_BINARY_EXTS.has(ext)) return true
if (isKnownBinary(path)) return true
// 媒体文件(可预览,不算二进制)
const isMediaFile = isImageFile(path) ||
@@ -191,7 +178,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
// Office 文件直接读取内容进行预览,跳过二进制检测
if (isExcelFile(filename) || isWordFile(filename)) {
const content = await readFile(path)
const content = await readFileApi(path)
fileContent.value = content
originalContent.value = content
isEditMode.value = false
@@ -253,7 +240,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
}
// 对于无扩展名或未知类型文件,先尝试读取
const content = await readFile(path)
const content = await readFileApi(path)
// 检查文件大小
const fileSize = content.length // UTF-16 字符数
@@ -352,7 +339,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
isSaving.value = true
try {
await writeFile(targetPath, fileContent.value)
await writeFileApi(targetPath, fileContent.value)
originalContent.value = fileContent.value
// 清除草稿
@@ -1,9 +1,8 @@
/**
* 文件操作 Composable
* 提供文件读取、写入、删除等基础操作,包括 ZIP 文件浏览
* 文件操作 Composable(纯代理层)
* 直接委托底层 API,不做 try-catch,错误由调用方处理
*/
import { Message } from '@arco-design/web-vue'
import { getPathSeparator } from '@/utils/fileUtils'
import {
listDir,
@@ -13,255 +12,123 @@ import {
createFile,
createDir,
renamePath as renamePathApi,
copyPath as copyPathApi,
movePath as movePathApi,
listZipContents as listZipContentsApi,
extractFileFromZip,
extractFileFromZipToTemp as extractZipToTempApi,
getFileServerURL as getFileServerUrlApi
} from '@/api'
import type { FileItem, FileOperationResult } from '@/types/file-system'
import type { FileItem } from '@/types/file-system'
export interface UseFileOperationsOptions {
onSuccess?: (operation: string, data: any) => void
onError?: (operation: string, error: Error) => void
}
/**
* 文件操作结果
*/
export function useFileOperations(options: UseFileOperationsOptions = {}) {
const { onSuccess, onError } = options
const { onSuccess } = options
/**
* 列出目录内容
*/
/** 列出目录内容 */
const listDirectory = async (path: string): Promise<FileItem[]> => {
try {
const result = await listDir(path)
onSuccess?.('listDirectory', result)
return result
} catch (error) {
const err = error as Error
onError?.('listDirectory', err)
throw err
}
const result = await listDir(path)
onSuccess?.('listDirectory', result)
return result
}
/**
* 读取文件内容
*/
/** 读取文件内容 */
const readFile = async (path: string): Promise<string> => {
try {
const content = await readFileApi(path)
onSuccess?.('readFile', { path, size: content.length })
return content
} catch (error) {
const err = error as Error
onError?.('readFile', err)
throw err
}
const content = await readFileApi(path)
onSuccess?.('readFile', { path, size: content.length })
return content
}
/**
* 写入文件内容
*/
const writeFile = async (
path: string,
content: string,
createBackup: boolean = false
): Promise<void> => {
try {
await writeFileApi(path, content)
onSuccess?.('writeFile', { path, size: content.length })
} catch (error) {
const err = error as Error
onError?.('writeFile', err)
throw err
}
/** 写入文件内容 */
const writeFile = async (path: string, content: string): Promise<void> => {
await writeFileApi(path, content)
onSuccess?.('writeFile', { path, size: content.length })
}
/**
* 删除路径(文件或目录),返回被删除的文件信息
*/
/** 删除路径 */
const deletePath = async (path: string): Promise<FileItem> => {
try {
const result = await deletePathApi(path)
onSuccess?.('deletePath', { path, result })
return result as FileItem
} catch (error) {
const err = error as Error
onError?.('deletePath', err)
throw err
}
const result = await deletePathApi(path)
onSuccess?.('deletePath', { path, result })
return result as FileItem
}
/**
* 创建新文件,返回创建的文件信息
*/
const createNewFile = async (
dirPath: string,
filename: string
): Promise<FileItem> => {
try {
const result = await createFile(dirPath, filename)
onSuccess?.('createFile', { dirPath, filename, result })
return result as FileItem
} catch (error) {
const err = error as Error
onError?.('createFile', err)
throw err
}
/** 创建新文件 */
const createNewFile = async (dirPath: string, filename: string): Promise<FileItem> => {
const result = await createFile(dirPath, filename)
onSuccess?.('createFile', { dirPath, filename, result })
return result as FileItem
}
/**
* 创建新目录,返回创建的目录信息
*/
/** 创建新目录 */
const createNewDir = async (parentPath: string, dirname: string): Promise<FileItem> => {
try {
const result = await createDir(parentPath, dirname)
onSuccess?.('createDir', { parentPath, dirname, result })
return result as FileItem
} catch (error) {
const err = error as Error
onError?.('createDir', err)
throw err
}
const result = await createDir(parentPath, dirname)
onSuccess?.('createDir', { parentPath, dirname, result })
return result as FileItem
}
/**
* 重命名文件或目录,返回新文件信息
*/
/** 重命名 */
const rename = async (oldPath: string, newName: string): Promise<FileItem> => {
// 构造新路径
const separator = getPathSeparator(oldPath)
const parentPath = oldPath.substring(
0,
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
)
const newPath = parentPath + separator + newName
try {
const result = await renamePathApi(oldPath, newPath)
onSuccess?.('rename', { oldPath, newPath, result })
return result as FileItem
} catch (error) {
const err = error as Error
onError?.('rename', err)
throw err
}
const result = await renamePathApi(oldPath, newPath)
onSuccess?.('rename', { oldPath, newPath, result })
return result as FileItem
}
/**
* 复制文件或目录
*/
/** 复制(递归,进度经 transfer-progress 事件) */
const copy = async (fromPath: string, toPath: string): Promise<void> => {
try {
// TODO: 实现复制逻辑
Message.warning('复制功能暂未实现')
onSuccess?.('copy', { fromPath, toPath })
} catch (error) {
const err = error as Error
onError?.('copy', err)
throw err
}
await copyPathApi(fromPath, toPath)
onSuccess?.('copy', { fromPath, toPath })
}
/**
* 移动文件或目录
*/
/** 移动(跨盘自动回退复制+删除) */
const move = async (fromPath: string, toPath: string): Promise<void> => {
try {
// TODO: 实现移动逻辑
Message.warning('移动功能暂未实现')
onSuccess?.('move', { fromPath, toPath })
} catch (error) {
const err = error as Error
onError?.('move', err)
throw err
}
await movePathApi(fromPath, toPath)
onSuccess?.('move', { fromPath, toPath })
}
/**
* 列出 ZIP 文件内容
*/
/** 列出 ZIP 文件内容 */
const listZipContents = async (zipPath: string): Promise<FileItem[]> => {
try {
const result = await listZipContentsApi(zipPath)
onSuccess?.('listZipContents', { zipPath, count: result.length })
return result
} catch (error) {
const err = error as Error
onError?.('listZipContents', err)
throw err
}
const result = await listZipContentsApi(zipPath)
onSuccess?.('listZipContents', { zipPath, count: result.length })
return result
}
/**
* 从 ZIP 中提取文件内容(文本)
*/
/** 从 ZIP 中提取文件内容 */
const extractZipFile = async (zipPath: string, filePath: string): Promise<string> => {
try {
const content = await extractFileFromZip(zipPath, filePath)
onSuccess?.('extractZipFile', { zipPath, filePath, size: content.length })
return content
} catch (error) {
const err = error as Error
onError?.('extractZipFile', err)
throw err
}
const content = await extractFileFromZip(zipPath, filePath)
onSuccess?.('extractZipFile', { zipPath, filePath, size: content.length })
return content
}
/**
* 从 ZIP 中提取文件到临时目录(二进制文件,如图片)
*/
/** 从 ZIP 中提取文件到临时目录 */
const extractZipFileToTemp = async (zipPath: string, filePath: string): Promise<string> => {
try {
const tempPath = await extractZipToTempApi(zipPath, filePath)
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
return tempPath
} catch (error) {
const err = error as Error
onError?.('extractZipFileToTemp', err)
throw err
}
const tempPath = await extractZipToTempApi(zipPath, filePath)
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
return tempPath
}
/**
* 获取文件服务器 URL
*/
/** 获取文件服务器 URL */
const getFileServerURL = async (): Promise<string> => {
try {
const url = await getFileServerUrlApi()
onSuccess?.('getFileServerURL', { url })
return url
} catch (error) {
const err = error as Error
onError?.('getFileServerURL', err)
throw err
}
const url = await getFileServerUrlApi()
onSuccess?.('getFileServerURL', { url })
return url
}
return {
// 基础操作
listDirectory,
readFile,
writeFile,
deletePath,
// 创建操作
createNewFile,
createNewDir,
// 高级操作
rename,
copy,
move,
// ZIP 操作
listZipContents,
extractZipFile,
extractZipFileToTemp,
getFileServerURL
listDirectory, readFile, writeFile, deletePath,
createNewFile, createNewDir,
rename, copy, move,
listZipContents, extractZipFile, extractZipFileToTemp, getFileServerURL
}
}
// 注意:FileItem 类型已统一定义在 @/types/file-system.ts
+120
View File
@@ -0,0 +1,120 @@
<template>
<div v-if="transferStore.items.length" class="transfer-panel">
<div v-for="item in transferStore.items" :key="item.id" class="transfer-item">
<div class="transfer-item-info">
<span class="transfer-item-name" :title="item.name">
{{ item.name }}
</span>
<span class="transfer-item-meta">
<span v-if="item.status === 'error'" class="transfer-item-status error">失败</span>
<span v-else class="transfer-item-status">{{ percent(item) }}%</span>
<span class="transfer-item-close" title="关闭" @click="transferStore.dismiss(item.id)">×</span>
</span>
</div>
<a-progress
:percent="percent(item)"
:status="item.status === 'error' ? 'exception' : item.status === 'done' ? 'success' : 'normal'"
:show-text="false"
size="small"
/>
<div v-if="item.status === 'error' && item.error" class="transfer-item-error" :title="item.error">
{{ item.error }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTransferStore } from '../stores/transfer'
import type { TransferItem } from '../stores/transfer'
const transferStore = useTransferStore()
const percent = (item: TransferItem): number => {
if (item.total > 0) {
return Math.min(100, Math.round((item.copied / item.total) * 100))
}
// 总大小未知(空文件等)按完成度显示
return item.status === 'done' ? 100 : 0
}
</script>
<style scoped>
.transfer-panel {
position: fixed;
right: 16px;
bottom: 16px;
width: 280px;
max-height: 260px;
overflow-y: auto;
background: var(--color-bg-2);
border: 1px solid var(--color-border-2);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1100;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.transfer-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.transfer-item-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
}
.transfer-item-name {
color: var(--color-text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.transfer-item-meta {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.transfer-item-status {
color: var(--color-text-3);
min-width: 30px;
text-align: right;
}
.transfer-item-status.error {
color: rgb(var(--danger-6));
}
.transfer-item-close {
cursor: pointer;
color: var(--color-text-3);
font-size: 14px;
line-height: 1;
padding: 0 2px;
border-radius: 3px;
}
.transfer-item-close:hover {
color: var(--color-text-1);
background: var(--color-fill-2);
}
.transfer-item-error {
font-size: 11px;
color: rgb(var(--danger-6));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -1,354 +0,0 @@
/**
* 收藏夹管理 composable
*
* @module composables/useFavoriteFiles
* @description 封装收藏夹的增删改查逻辑,支持持久化存储
*/
import { ref, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
/**
* 收藏夹 composable
* @param {string} storageKey - localStorage 键名
* @param {Object} [options] - 配置选项
* @param {number} [options.maxLength=50] - 最大收藏数量
* @param {Function} [options.onAdd] - 添加收藏回调
* @param {Function} [options.onRemove] - 移除收藏回调
* @returns {UseFavoriteFilesReturn} 收藏夹操作API
*
* @example
* const {
* favoriteFiles,
* isFavorite,
* toggleFavorite,
* removeFavorite,
* clearAll
* } = useFavoriteFiles('app-favorites')
*
* // 在模板中使用
* <a-button @click="toggleFavorite(file)">
* <icon-star-fill v-if="isFavorite(file.path)" />
* <icon-star v-else />
* </a-button>
*/
export function useFavoriteFiles(storageKey, options = {}) {
const {
maxLength = 50,
onAdd = () => {},
onRemove = () => {},
} = options
// 收藏列表
const favoriteFiles = ref([])
const load = () => {
try {
const stored = localStorage.getItem(storageKey)
if (stored) {
favoriteFiles.value = JSON.parse(stored)
}
} catch (e) {
console.error('加载收藏列表失败:', e)
}
}
const save = (data) => {
try {
localStorage.setItem(storageKey, JSON.stringify(data || favoriteFiles.value))
} catch (e) {
console.error('保存收藏列表失败:', e)
}
}
/**
* 判断文件/目录是否已收藏
* @param {string} path - 文件/目录路径
* @returns {boolean} 是否已收藏
*/
const isFavorite = (path) => {
if (!path || !Array.isArray(favoriteFiles.value)) {
return false
}
return favoriteFiles.value.some(fav => fav.path === path)
}
/**
* 排序收藏列表(按创建时间倒序,最新的在上面)
*/
const sortFavorites = () => {
if (!Array.isArray(favoriteFiles.value)) {
return
}
favoriteFiles.value.sort((a, b) => {
const timeA = a.addedAt || 0
const timeB = b.addedAt || 0
return timeB - timeA // 倒序:最新的在上面
})
save()
}
/**
* 切换收藏状态
* @param {Object} item - 文件/目录信息
* @param {string} item.path - 路径
* @param {string} item.name - 名称
* @param {boolean} item.is_dir - 是否为目录
* @returns {boolean} 操作后是否为收藏状态
*/
const toggleFavorite = (item) => {
if (!item || !item.path) {
Message.warning('无效的文件信息')
return false
}
const index = favoriteFiles.value.findIndex(fav => fav.path === item.path)
if (index > -1) {
// 已收藏,执行取消收藏
favoriteFiles.value.splice(index, 1)
save(favoriteFiles.value) // 直接保存,不重新排序
onRemove(item)
Message.info(`已取消收藏: ${item.name}`)
return false
} else {
// 未收藏,执行添加收藏
if (favoriteFiles.value.length >= maxLength) {
Message.warning(`收藏夹已满,最多只能收藏 ${maxLength} 项`)
return false
}
favoriteFiles.value.push({
path: item.path,
name: item.name,
isDir: item.isDir || false,
addedAt: Date.now(),
})
save(favoriteFiles.value) // 直接保存,不重新排序(新项目添加到末尾)
onAdd(item)
Message.success(`已收藏: ${item.name}`)
return true
}
}
/**
* 移除收藏
* @param {string} path - 文件/目录路径
* @returns {boolean} 是否成功移除
*/
const removeFavorite = (path) => {
if (!path) {
Message.warning('请提供有效的路径')
return false
}
const index = favoriteFiles.value.findIndex(fav => fav.path === path)
if (index === -1) {
Message.warning('该路径不在收藏夹中')
return false
}
const item = favoriteFiles.value[index]
favoriteFiles.value.splice(index, 1)
save(favoriteFiles.value) // 直接保存,不重新排序
onRemove(item)
Message.info(`已取消收藏: ${item.name}`)
return true
}
/**
* 打开收藏的文件/目录
* @param {string} path - 文件/目录路径
* @param {Function} onOpen - 打开回调函数
* @returns {Promise<boolean>} 是否成功打开
*/
const openFavorite = async (path, onOpen) => {
if (!path || !onOpen) {
return false
}
const item = favoriteFiles.value.find(fav => fav.path === path)
if (!item) {
Message.warning('收藏项不存在')
return false
}
return await onOpen(item)
}
/**
* 清空所有收藏
* @param {boolean} [confirm=true] - 是否需要确认
* @returns {boolean} 是否成功清空
*/
const clearAll = (confirm = true) => {
const executeClear = () => {
const count = favoriteFiles.value.length
favoriteFiles.value = []
save([])
Message.success(`已清空 ${count} 个收藏项`)
return true
}
if (!confirm) {
return executeClear()
}
// 使用原生 confirm(简单场景)
if (window.confirm(`确定要清空所有 ${favoriteFiles.value.length} 个收藏项吗?`)) {
return executeClear()
}
return false
}
/**
* 获取收藏列表(按创建时间排序)
* @param {string} [order='desc'] - 排序方式:'asc'或'desc'
* @returns {Array} 排序后的收藏列表
*/
const getSortedFavorites = (order = 'desc') => {
const sorted = [...favoriteFiles.value]
sorted.sort((a, b) => {
const timeA = a.addedAt || 0
const timeB = b.addedAt || 0
return order === 'desc' ? timeB - timeA : timeA - timeB
})
return sorted
}
/**
* 按名称搜索收藏
* @param {string} keyword - 搜索关键词
* @returns {Array} 匹配的收藏列表
*/
const searchFavorites = (keyword) => {
if (!keyword || !keyword.trim()) {
return favoriteFiles.value
}
const lowerKeyword = keyword.toLowerCase().trim()
return favoriteFiles.value.filter(fav =>
fav.name.toLowerCase().includes(lowerKeyword) ||
fav.path.toLowerCase().includes(lowerKeyword)
)
}
/**
* 重新排序收藏列表(拖拽排序)
* @param {number} fromIndex - 源索引
* @param {number} toIndex - 目标索引
* @returns {boolean} 是否成功重排序
*/
const reorderFavorites = (fromIndex, toIndex) => {
if (!Array.isArray(favoriteFiles.value)) {
return false
}
if (fromIndex < 0 || fromIndex >= favoriteFiles.value.length ||
toIndex < 0 || toIndex >= favoriteFiles.value.length) {
return false
}
if (fromIndex === toIndex) {
return false
}
// 移动数组元素
const [movedItem] = favoriteFiles.value.splice(fromIndex, 1)
favoriteFiles.value.splice(toIndex, 0, movedItem)
// 保存新顺序
save(favoriteFiles.value)
return true
}
// 旧字段名 → 新字段名迁移(一次性,迁移后自动回写)
const migrateFieldNames = (list) => {
if (!Array.isArray(list)) return
const map = { is_dir: 'isDir', created_at: 'addedAt' }
let changed = false
list.forEach(item => {
for (const [old, newKey] of Object.entries(map)) {
if (old in item) {
if (!(newKey in item)) item[newKey] = item[old]
delete item[old]
changed = true
}
}
})
if (changed) save(list)
}
// 组件挂载时加载数据并迁移旧字段
onMounted(() => {
load()
migrateFieldNames(favoriteFiles.value)
})
return {
// 状态
favoriteFiles,
// 方法
isFavorite,
toggleFavorite,
removeFavorite,
openFavorite,
clearAll,
getSortedFavorites,
searchFavorites,
sortFavorites,
reorderFavorites,
load,
save,
}
}
/**
* @typedef {Object} UseFavoriteFilesReturn
* @property {Ref<Array>} favoriteFiles - 收藏列表(自动按时间倒序排列)
* @property {Function} isFavorite - 判断是否已收藏
* @property {Function} toggleFavorite - 切换收藏状态
* @property {Function} removeFavorite - 移除收藏
* @property {Function} openFavorite - 打开收藏项
* @property {Function} clearAll - 清空所有收藏
* @property {Function} getSortedFavorites - 获取排序后的列表
* @property {Function} searchFavorites - 搜索收藏
* @property {Function} sortFavorites - 手动排序收藏列表
* @property {Function} reorderFavorites - 拖拽重新排序
* @property {Function} load - 手动加载数据
* @property {Function} save - 手动保存数据
*/
/**
* 创建多个收藏夹管理实例
* @param {Object} config - 配置对象
* @returns {Object} 收藏夹管理实例集合
*
* @example
* const {
* filesystemFavs,
* deviceTestFavs
* } = createMultipleFavorites({
* filesystem: 'app-filesystem-favorites',
* deviceTest: 'app-device-test-favorites'
* })
*/
export function createMultipleFavorites(config) {
const result = {}
Object.keys(config).forEach(key => {
result[key] = useFavoriteFiles(config[key])
})
return result
}
@@ -1,373 +0,0 @@
/**
* 文件操作逻辑封装
*
* @module composables/useFileOperations
* @description 封装所有文件操作逻辑,提供统一的错误处理和状态管理
*/
import { ref, watch } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import {
listDir,
readFile as readFileApi,
writeFile as writeFileApi,
deletePath as deletePathApi,
} from '@/api'
/**
* LocalStorage 键名
*/
const STORAGE_KEY_LAST_PATH = 'app-filesystem-last-path'
/**
* 文件操作 composable
* @param {Object} [options] - 配置选项
* @param {Function} [options.onSuccess] - 操作成功回调
* @param {Function} [options.onError] - 操作失败回调
* @returns {UseFileOperationsReturn} 文件操作API
*
* @example
* const {
* filePath,
* fileContent,
* fileList,
* fileLoading,
* listDirectory,
* readFile,
* writeFile,
* deleteFile
* } = useFileOperations({
* onSuccess: (operation, data) => console.log(operation, data),
* onError: (operation, error) => console.error(operation, error)
* })
*/
export function useFileOperations(options = {}) {
const { onSuccess = () => {}, onError = () => {} } = options
// ========== 响应式状态 ==========
/**
* 当前文件/目录路径
* @type {Ref<string>}
*/
// 从 localStorage 恢复上次路径
const savedPath = localStorage.getItem(STORAGE_KEY_LAST_PATH)
const filePath = ref(savedPath || '')
/**
* 文件内容
* @type {Ref<string>}
*/
const fileContent = ref('')
/**
* 文件列表
* @type {Ref<Array>}
*/
const fileList = ref([])
/**
* 加载状态
* @type {Ref<boolean>}
*/
const fileLoading = ref(false)
/**
* 正在删除状态(防止并发删除)
* @type {Ref<boolean>}
*/
const isDeleting = ref(false)
// ========== 文件操作方法 ==========
/**
* 列出目录内容
* @param {string} [path] - 目录路径,不传则使用当前路径
* @returns {Promise<boolean>} 是否成功
*/
const listDirectory = async (path) => {
const targetPath = path || filePath.value
if (!targetPath) {
Message.error('请输入目录路径')
return false
}
fileLoading.value = true
try {
const result = await listDir(targetPath)
fileList.value = result
if (!path) {
// 如果没有传参,更新当前路径
filePath.value = targetPath
}
onSuccess('listDirectory', { path: targetPath, count: result.length })
return true
} catch (error) {
onError('listDirectory', error)
const errorMsg = error.message || error || '未知错误'
Message.error(`列出目录失败 [${targetPath}]: ${errorMsg}`)
return false
} finally {
fileLoading.value = false
}
}
/**
* 读取文件内容
* @param {string} [path] - 文件路径,不传则使用当前路径
* @returns {Promise<boolean>} 是否成功
*/
const readFile = async (path) => {
const targetPath = path || filePath.value
if (!targetPath) {
Message.error('请输入文件路径')
return false
}
fileLoading.value = true
try {
const content = await readFileApi(targetPath)
fileContent.value = content
if (!path) {
filePath.value = targetPath
}
onSuccess('readFile', { path: targetPath })
// 文件读取成功,静默无提示
return true
} catch (error) {
onError('readFile', error)
Message.error(`读取文件失败: ${error.message || error}`)
return false
} finally {
fileLoading.value = false
}
}
/**
* 写入文件内容
* @param {string} [content] - 要写入的内容,不传则使用当前内容
* @param {string} [path] - 文件路径,不传则使用当前路径
* @param {string} [fileName] - 文件名,用于成功提示显示
* @param {boolean} [isShortcut=false] - 是否是快捷键触发(快捷键不显示提示)
* @returns {Promise<boolean>} 是否成功
*/
const writeFile = async (content, path, fileName, isShortcut = false) => {
// 忽略事件对象(当点击按钮时 Vue 会传递事件对象)
const targetContent = (content !== undefined && typeof content === 'string') ? content : fileContent.value
const targetPath = (path !== undefined && typeof path === 'string') ? path : filePath.value
if (!targetPath) {
Message.error('请输入文件路径')
return false
}
fileLoading.value = true
try {
await writeFileApi(targetPath, targetContent)
if (content !== undefined) {
fileContent.value = targetContent
}
if (path) {
filePath.value = path
}
onSuccess('writeFile', { path: targetPath })
// 差异化反馈:快捷键不显示提示,按钮点击显示轻提示
if (!isShortcut) {
// 按钮点击保存:显示轻量 Toast 提示
if (fileName && typeof fileName === 'string') {
Message.success({
content: `✓ ${fileName} 已保存`,
duration: 1500, // 1.5秒后自动消失
position: 'bottom' // 底部显示,不打断操作
})
} else {
Message.success({
content: '文件已保存',
duration: 1500,
position: 'bottom'
})
}
}
// 快捷键保存:无提示(静默成功)
return true
} catch (error) {
onError('writeFile', error)
// 保存失败:总是显示醒目错误提示(需手动关闭)
Message.error({
content: `文件保存失败: ${error.message || error}`,
duration: 5000, // 错误提示显示更久
closable: true // 允许手动关闭
})
return false
} finally {
fileLoading.value = false
}
}
/**
* 删除文件或目录
* 🔒 安全修复:移除 confirm 参数,始终需要用户确认,防止绕过
* 🔒 安全修复:添加并发删除检查,防止批量删除攻击
* @param {string} [path] - 文件路径,不传则使用当前路径
* @returns {Promise<boolean>} 用户是否确认及操作是否成功
*/
const deleteFile = async (path) => {
const targetPath = path || filePath.value
if (!targetPath) {
Message.error('请输入文件路径')
return false
}
// 🔒 安全修复:添加调用频率限制(防止批量删除攻击)
if (isDeleting.value) {
Message.warning('正在删除中,请稍候...')
return false
}
const executeDelete = async () => {
isDeleting.value = true
fileLoading.value = true
try {
await deletePathApi(targetPath)
// 清空状态
filePath.value = ''
fileContent.value = ''
fileList.value = []
onSuccess('deleteFile', { path: targetPath })
Message.success('删除成功')
return true
} catch (error) {
onError('deleteFile', error)
Message.error(`删除失败: ${error.message || error}`)
return false
} finally {
fileLoading.value = false
isDeleting.value = false
}
}
// 🔒 安全修复:始终显示确认对话框,无法绕过
return new Promise((resolve) => {
Modal.confirm({
title: '⚠️ 确认删除',
content: `确定要删除 ${targetPath} 吗?此操作不可恢复!`,
okText: '确定删除',
cancelText: '取消',
okButtonProps: { status: 'danger' }, // 红色按钮提醒危险
onOk: async () => {
const result = await executeDelete()
resolve(result)
},
onCancel: () => {
resolve(false)
},
})
})
}
/**
* 选择文件(智能判断是文件还是目录)
* @param {string} path - 文件/目录路径
* @param {Array} fileListData - 文件列表数据
* @returns {Promise<boolean>} 是否成功
*/
const selectFile = async (path, fileListData) => {
if (!path) return false
filePath.value = path
// 从文件列表中查找该项
const item = fileListData.find(f => f.path === path)
if (!item) {
// 如果列表中找不到,尝试根据路径判断
// 简单判断:路径以 / 或 \ 结尾可能是目录
const isDir = path.endsWith('/') || path.endsWith('\\')
if (isDir) {
return await listDirectory(path)
} else {
return await readFile(path)
}
}
if (item.is_dir) {
// 是目录,列出内容
return await listDirectory(path)
} else {
// 是文件,读取内容
return await readFile(path)
}
}
/**
* 清空所有状态
*/
const clearAll = () => {
filePath.value = ''
fileContent.value = ''
fileList.value = []
}
// ========== 持久化 ==========
/**
* 监听路径变化,自动保存到 localStorage
* 用于下次启动时恢复上次访问的路径
*/
watch(filePath, (newPath) => {
try {
if (newPath) {
localStorage.setItem(STORAGE_KEY_LAST_PATH, newPath)
} else {
localStorage.removeItem(STORAGE_KEY_LAST_PATH)
}
} catch (e) {
console.warn('[useFileOperations] 保存路径失败:', e)
}
})
// ========== 返回公共API ==========
return {
// 状态
filePath,
fileContent,
fileList,
fileLoading,
// 方法
listDirectory,
readFile,
writeFile,
deleteFile,
selectFile,
clearAll,
}
}
/**
* @typedef {Object} UseFileOperationsReturn
* @property {Ref<string>} filePath - 当前文件路径
* @property {Ref<string>} fileContent - 文件内容
* @property {Ref<Array>} fileList - 文件列表
* @property {Ref<boolean>} fileLoading - 加载状态
* @property {Function} listDirectory - 列出目录
* @property {Function} readFile - 读取文件
* @property {Function} writeFile - 写入文件
* @property {Function} deleteFile - 删除文件
* @property {Function} selectFile - 智能选择文件
* @property {Function} clearAll - 清空所有状态
*/
-273
View File
@@ -1,273 +0,0 @@
/**
* 导航和路径管理 composable
*
* @module composables/useNavigation
* @description 封装文件系统的导航历史、路径操作等逻辑
*/
import { ref, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
/**
* 路径历史 localStorage 键
*/
const STORAGE_KEY_PATH_HISTORY = 'app-filesystem-path-history'
/**
* 导航管理 composable
* @param {Object} options - 配置选项
* @param {Ref<string>} options.filePath - 当前路径 ref
* @param {Function} options.onListDirectory - 列出目录的函数
* @param {Function} options.onExitZipMode - 退出 ZIP 模式的函数
* @returns {UseNavigationReturn} 导航操作 API
*/
export function useNavigation(options = {}) {
const {
filePath,
onListDirectory,
onExitZipMode,
} = options
// ========== 导航历史记录(支持后退/前进) ==========
/**
* 导航历史栈
* @type {Ref<Array<string>>}
*/
const navHistory = ref([])
/**
* 当前在历史栈中的位置
* @type {Ref<number>}
*/
const navIndex = ref(-1)
/**
* 是否正在导航(防止重复记录)
* @type {Ref<boolean>}
*/
const isNavigating = ref(false)
/**
* 路径历史记录(用于下拉列表)
* @type {Ref<Array<string>>}
*/
const pathHistory = ref([])
// ========== 计算属性 ==========
/**
* 是否可以后退
*/
const canGoBack = computed(() => navIndex.value > 0)
/**
* 是否可以前进
*/
const canGoForward = computed(() => navIndex.value < navHistory.value.length - 1)
// ========== 导航操作 ==========
/**
* 添加到路径历史记录
* @param {string} path - 路径
*/
const addToHistory = (path) => {
if (!path || path === filePath.value) return
// 去重:如果路径已在历史中,先删除
const index = pathHistory.value.indexOf(path)
if (index > -1) {
pathHistory.value.splice(index, 1)
}
// 添加到开头
pathHistory.value.unshift(path)
// 限制历史记录数量
if (pathHistory.value.length > 50) {
pathHistory.value = pathHistory.value.slice(0, 50)
}
// 持久化
try {
localStorage.setItem(STORAGE_KEY_PATH_HISTORY, JSON.stringify(pathHistory.value))
} catch (e) {
// 忽略 localStorage 错误
}
}
/**
* 推送到导航历史栈
* @param {string} path - 路径
*/
const pushNav = (path) => {
if (isNavigating.value) {
return
}
// 如果当前位置不在历史末尾,删除后续历史
if (navIndex.value < navHistory.value.length - 1) {
navHistory.value = navHistory.value.slice(0, navIndex.value + 1)
}
// 添加到历史
navHistory.value.push(path)
navIndex.value = navHistory.value.length - 1
// 同时添加到路径历史
addToHistory(path)
}
/**
* 后退
* @returns {Promise<boolean>} 是否成功
*/
const goBack = async () => {
if (!canGoBack.value) {
return false
}
isNavigating.value = true
try {
navIndex.value--
const path = navHistory.value[navIndex.value]
await onListDirectory(path)
return true
} catch (error) {
Message.error(`后退失败: ${error.message || error}`)
return false
} finally {
isNavigating.value = false
}
}
/**
* 前进
* @returns {Promise<boolean>} 是否成功
*/
const goForward = async () => {
if (!canGoForward.value) {
return false
}
isNavigating.value = true
try {
navIndex.value++
const path = navHistory.value[navIndex.value]
await onListDirectory(path)
return true
} catch (error) {
Message.error(`前进失败: ${error.message || error}`)
return false
} finally {
isNavigating.value = false
}
}
// ========== 路径操作 ==========
/**
* 路径选择(从下拉列表)
* @param {string} value - 选中的路径
*/
const onPathSelect = (value) => {
if (value && value !== filePath.value) {
goToPath(value)
}
}
/**
* 路径输入框回车事件
*/
const onPathEnter = () => {
const path = filePath.value?.trim()
if (path) {
goToPath(path)
}
}
/**
* 跳转到指定路径
* @param {string} path - 目标路径
* @returns {Promise<boolean>} 是否成功
*/
const goToPath = async (path) => {
if (!path) {
return false
}
// 退出 ZIP 模式
if (onExitZipMode) {
onExitZipMode()
}
return await onListDirectory(path)
}
/**
* 浏览目录(打开系统文件选择对话框)
*/
const browseDirectory = async () => {
Message.info('请手动输入目录路径')
}
// ========== 初始化 ==========
/**
* 加载路径历史记录
*/
const loadPathHistory = () => {
try {
const saved = localStorage.getItem(STORAGE_KEY_PATH_HISTORY)
if (saved) {
pathHistory.value = JSON.parse(saved)
}
} catch (e) {
console.warn('[useNavigation] 加载路径历史失败:', e)
}
}
// 初始化
loadPathHistory()
return {
// 状态
navHistory,
navIndex,
isNavigating,
pathHistory,
canGoBack,
canGoForward,
// 方法
addToHistory,
pushNav,
goBack,
goForward,
onPathSelect,
onPathEnter,
goToPath,
browseDirectory,
loadPathHistory,
}
}
/**
* @typedef {Object} UseNavigationReturn
* @property {Ref<Array<string>>} navHistory - 导航历史栈
* @property {Ref<number>} navIndex - 当前在历史栈中的位置
* @property {Ref<boolean>} isNavigating - 是否正在导航
* @property {Ref<Array<string>>} pathHistory - 路径历史记录(下拉列表)
* @property {ComputedRef<boolean>} canGoBack - 是否可以后退
* @property {ComputedRef<boolean>} canGoForward - 是否可以前进
* @property {Function} addToHistory - 添加到路径历史记录
* @property {Function} pushNav - 推送到导航历史栈
* @property {Function} goBack - 后退
* @property {Function} goForward - 前进
* @property {Function} onPathSelect - 路径选择
* @property {Function} onPathEnter - 路径输入框回车事件
* @property {Function} goToPath - 跳转到指定路径
* @property {Function} browseDirectory - 浏览目录
* @property {Function} loadPathHistory - 加载路径历史记录
*/
+5
View File
@@ -4,6 +4,8 @@ import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import { useThemeStore } from './stores/theme'
import { startFreezeWatchdog } from './utils/freezeWatchdog'
import '@/plugin/built-in' // 注册内置预览插件(drawio 等)
const app = createApp(App)
const pinia = createPinia()
@@ -14,5 +16,8 @@ app.use(pinia)
const themeStore = useThemeStore()
themeStore.initTheme()
// 启动主线程卡死看门狗(尽早注册 pong 回调;自愈绝不 reload,防 loop)
startFreezeWatchdog()
app.mount('#app')
@@ -0,0 +1,22 @@
// drawio 预览插件注册
// canHandle 严格匹配 .drawio / .dio 扩展名,不认 .xml(避免误吞配置文件,配置文件走代码编辑器)。
import type { Component } from 'vue'
import { registerPreviewHandler } from '../registry'
import type { FilePreviewHandlerDefinition } from '../types'
// 仅 .drawio / .dio;.drawio.png / .drawio.svg 本就走图片预览,不冲突
const DRAWIO_EXT_REGEX = /\.(drawio|dio)$/i
const drawioHandler: FilePreviewHandlerDefinition = {
id: 'drawio-preview',
name: 'Draw.io 查看器',
icon: '📊',
priority: 90,
canHandle: (filename: string): boolean => DRAWIO_EXT_REGEX.test(filename || ''),
getComponent: (): Promise<Component> =>
import('../../components/FileSystem/components/DrawioPreview.vue').then(m => m.default),
supportsEdit: false,
}
registerPreviewHandler(drawioHandler)
+4
View File
@@ -0,0 +1,4 @@
// 内置预览插件聚合:import 此文件即触发注册(副作用模块)
// main.js 顶部 import '@/plugin/built-in' 完成全部内置插件注册。
import './drawio-handler'
+59
View File
@@ -0,0 +1,59 @@
// 预览插件注册中心
// 模块级单例:registerPreviewHandler 注册,resolvePreviewHandler 按优先级解析。
// 现有 9 类预览不进此注册表(仍走 FileEditorPanel 的 v-if/v-else-if 链);
// 仅当 resolvePreviewHandler 返回非 null 时,模板的 <component> 分支命中,优先于老链。
import { defineAsyncComponent, type Component } from 'vue'
import type { FilePreviewHandlerDefinition } from './types'
// 按 priority 降序维护的注册表
const previewHandlers: FilePreviewHandlerDefinition[] = []
/**
* 注册一个文件预览处理器
* 重复 id 会覆盖旧注册(便于热替换/测试),注册后保持 priority 降序。
*/
export function registerPreviewHandler(handler: FilePreviewHandlerDefinition): void {
const idx = previewHandlers.findIndex(h => h.id === handler.id)
if (idx >= 0) {
previewHandlers[idx] = handler
} else {
previewHandlers.push(handler)
}
previewHandlers.sort((a, b) => b.priority - a.priority)
}
/**
* 解析文件名对应的预览处理器
* 按 priority 降序遍历,返回第一个 canHandle=true 的 handler,无匹配返回 null。
*/
export function resolvePreviewHandler(filename: string): FilePreviewHandlerDefinition | null {
if (!filename) return null
for (const handler of previewHandlers) {
try {
if (handler.canHandle(filename)) return handler
} catch (e) {
console.warn(`[PreviewRegistry] handler ${handler.id} canHandle 异常:`, e)
}
}
return null
}
// 组件缓存:同一插件的异步组件只创建一次,避免重复 defineAsyncComponent
const componentCache = new Map<string, Component>()
/**
* 解析文件名对应的预览组件(Vue 异步组件,内部缓存)。
* 供 <component :is> 直接使用;无匹配返回 null。
* 与 resolvePreviewHandler 分离:handler 用于标题(icon/name),component 用于渲染。
*/
export function resolvePreviewComponent(filename: string): Component | null {
const handler = resolvePreviewHandler(filename)
if (!handler?.getComponent) return null
let comp = componentCache.get(handler.id)
if (!comp) {
comp = defineAsyncComponent(handler.getComponent)
componentCache.set(handler.id, comp)
}
return comp
}
+22
View File
@@ -0,0 +1,22 @@
// 预览插件类型定义
// 对齐 docs/02-架构设计/插件化架构方案.md 3.2 节 FilePreviewHandlerDefinition,day-1 简化到 drawio 所需字段。
import type { Component } from 'vue'
/** 文件预览处理器定义 */
export interface FilePreviewHandlerDefinition {
/** 插件唯一标识,如 'drawio-preview' */
id: string
/** 展示名,用于面板标题 */
name: string
/** 图标(emoji 或图标名) */
icon: string
/** 优先级,越大越优先(resolvePreviewHandler 按 priority 降序遍历) */
priority: number
/** 判断是否能处理该文件名(严格扩展名匹配,避免吞其他类型) */
canHandle: (filename: string) => boolean
/** 异步加载预览组件(配合 defineAsyncComponent 懒加载,返回 Promise<Component>) */
getComponent?: () => Promise<Component>
/** 是否支持编辑(day-1 drawio 只读) */
supportsEdit?: boolean
}
+77
View File
@@ -0,0 +1,77 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { On, Off } from '@wailsio/events'
import { parseWailsEventData } from '@/utils/wailsEvent'
/** 传输条目(与后端 transfer-progress 事件 payload 对应) */
export interface TransferItem {
id: string
name: string
copied: number
total: number
status: 'running' | 'done' | 'error'
error?: string
}
/** 完成条目自动移除延迟 */
const DONE_REMOVE_DELAY = 2000
/**
* 传输进度 Store — 监听后端 transfer-progress 事件
* 下载/上传/本地复制共用,done 后 2s 自动移除,error 手动关闭
*/
export const useTransferStore = defineStore('transfer', () => {
const items = ref<TransferItem[]>([])
const handleProgress = (event: unknown) => {
const data = parseWailsEventData(event)
const id = String(data.id || '')
if (!id) return
const status = (data.status as TransferItem['status']) || 'running'
const index = items.value.findIndex(i => i.id === id)
if (index === -1) {
items.value.push({
id,
name: String(data.name || ''),
copied: Number(data.copied) || 0,
total: Number(data.total) || 0,
status,
error: data.error ? String(data.error) : undefined
})
} else {
const item = items.value[index]
// 结束事件只带状态,保留上一帧的名称/进度
if (data.name) item.name = String(data.name)
if (data.copied !== undefined) item.copied = Number(data.copied) || item.copied
if (data.total !== undefined) item.total = Number(data.total) || item.total
item.status = status
if (data.error) item.error = String(data.error)
}
if (status === 'done') {
const item = items.value.find(i => i.id === id)
if (item) {
item.copied = item.total
setTimeout(() => dismiss(id), DONE_REMOVE_DELAY)
}
}
}
/** 手动移除条目 */
const dismiss = (id: string) => {
items.value = items.value.filter(i => i.id !== id)
}
/** 设置事件监听 */
const setupEventListeners = () => {
On('transfer-progress', handleProgress)
}
/** 移除事件监听 */
const removeEventListeners = () => {
Off('transfer-progress')
}
return { items, setupEventListeners, removeEventListeners, dismiss }
})
+124
View File
@@ -428,4 +428,128 @@ body.resizing {
.arco-tooltip-content-white::before {
background: var(--color-bg-1) !important;
}
/* ========== 暗色模式覆盖 ========== */
/* --- hljs 代码高亮暗色 --- */
body[arco-theme*='dark'] .hljs {
background: #1e1e2e;
color: #cdd6f4;
}
body[arco-theme*='dark'] .hljs-comment,
body[arco-theme*='dark'] .hljs-quote {
color: #6c7086;
}
body[arco-theme*='dark'] .hljs-keyword,
body[arco-theme*='dark'] .hljs-selector-tag,
body[arco-theme*='dark'] .hljs-addition {
color: #cba6f7;
}
body[arco-theme*='dark'] .hljs-number,
body[arco-theme*='dark'] .hljs-string,
body[arco-theme*='dark'] .hljs-meta .hljs-string,
body[arco-theme*='dark'] .hljs-literal,
body[arco-theme*='dark'] .hljs-doctag,
body[arco-theme*='dark'] .hljs-regexp {
color: #a6e3a1;
}
body[arco-theme*='dark'] .hljs-title,
body[arco-theme*='dark'] .hljs-section,
body[arco-theme*='dark'] .hljs-name,
body[arco-theme*='dark'] .hljs-selector-id,
body[arco-theme*='dark'] .hljs-selector-class {
color: #89b4fa;
}
body[arco-theme*='dark'] .hljs-attribute,
body[arco-theme*='dark'] .hljs-attr,
body[arco-theme*='dark'] .hljs-variable,
body[arco-theme*='dark'] .hljs-template-variable,
body[arco-theme*='dark'] .hljs-class .hljs-title,
body[arco-theme*='dark'] .hljs-type {
color: #fab387;
}
body[arco-theme*='dark'] .hljs-symbol,
body[arco-theme*='dark'] .hljs-bullet,
body[arco-theme*='dark'] .hljs-subst,
body[arco-theme*='dark'] .hljs-meta,
body[arco-theme*='dark'] .hljs-meta .hljs-keyword,
body[arco-theme*='dark'] .hljs-selector-attr,
body[arco-theme*='dark'] .hljs-selector-pseudo,
body[arco-theme*='dark'] .hljs-link {
color: #f9e2af;
}
body[arco-theme*='dark'] .hljs-built_in,
body[arco-theme*='dark'] .hljs-deletion {
color: #f38ba8;
}
body[arco-theme*='dark'] .hljs-formula {
background: #313244;
}
body[arco-theme*='dark'] .hljs-addition {
background-color: rgba(166, 227, 161, 0.15);
}
body[arco-theme*='dark'] .hljs-deletion {
background-color: rgba(243, 139, 168, 0.15);
}
/* --- Markdown 预览暗色 --- */
body[arco-theme*='dark'] .markdown-body {
color: #cdd6f4;
}
body[arco-theme*='dark'] .markdown-body h1 {
border-bottom-color: #45475a;
}
body[arco-theme*='dark'] .markdown-body h2 {
border-bottom-color: #45475a;
}
body[arco-theme*='dark'] .markdown-body h6 {
color: #a6adc8;
}
body[arco-theme*='dark'] .markdown-body blockquote {
color: #a6adc8;
border-left-color: #45475a;
}
body[arco-theme*='dark'] .markdown-body code {
background-color: rgba(205, 214, 244, 0.1);
}
body[arco-theme*='dark'] .markdown-body pre {
background-color: #1e1e2e;
}
body[arco-theme*='dark'] .markdown-body table th,
body[arco-theme*='dark'] .markdown-body table td {
border-color: #45475a;
}
body[arco-theme*='dark'] .markdown-body table th {
background-color: #313244;
}
body[arco-theme*='dark'] .markdown-body table tr:nth-child(even) {
background-color: #1e1e2e;
}
body[arco-theme*='dark'] .markdown-body img {
background-color: transparent;
}
body[arco-theme*='dark'] .markdown-body hr {
background-color: #45475a;
}
+10
View File
@@ -147,6 +147,8 @@ export interface FileListPanelConfig {
editingFilePath: string
/** 编辑中的文件名 */
editingFileName: string
/** 被剪切的文件路径(行样式半透明) */
cutPaths?: string[]
}
/**
@@ -209,6 +211,10 @@ export interface FileEditorPanelConfig {
previewTabs?: any[]
/** 当前激活的 Tab ID */
activeTabId?: string | null
/** 预览插件处理器(用于面板标题;null 则走内置 v-if 链) */
activePlugin?: import('@/plugin/types').FilePreviewHandlerDefinition | null
/** 预览插件组件(用于渲染;null 则走内置 v-if 链) */
activePluginComponent?: import('vue').Component | null
}
/**
@@ -230,6 +236,10 @@ export interface ContextMenuConfig {
context: ContextMenuContext
/** 选中的文件(file-list 上下文) */
selectedFile?: FileItem
/** 是否 SFTP 面板(file 上下文显示「下载到本机」) */
isSftp?: boolean
/** 文件剪贴板是否为空(blank 上下文「粘贴」项禁用) */
clipboardEmpty?: boolean
}
/**
+30
View File
@@ -89,6 +89,36 @@ export async function loadLanguageExtension(language) {
extension = StreamLanguage.define(powerShell)
break
}
case 'batch': {
// 自写 DOS batch 流式模式:REM/:: 整行注释,%var% 变量,常用关键字
const { StreamLanguage } = await import('@codemirror/language')
const batchKeywords = new Set('echo off on set setlocal endlocal enabledelayedexpansion if not exist defined equ neq lss leq gtr geq for in do goto call exit exitb start del erase copy move ren rename md mkdir rd rmdir cd chdir type cls pause shift errorlevel title ver vol prompt pushd popd xcopy robocopy powershell cmd'.split(' '))
extension = StreamLanguage.define({
name: 'batch',
token(stream) {
if (stream.sol()) {
stream.eatSpace()
// 行首 REM(大小写不敏感)或 :: 开头的整行注释;:标签名 视作 keyword
if (stream.match(/^rem(?:\s|$)/i) || stream.match(/^::/)) {
stream.skipToEnd()
return 'comment'
}
if (stream.match(/^:[\w.-]+/)) return 'keyword'
}
if (stream.eatSpace()) return null
if (stream.match(/^"(?:[^"\r\n]|"")*"?/)) return 'string'
if (stream.eat('^')) return 'operator'
// !var! 延迟变量、%var% 环境变量、%~d0 类批处理参数
if (stream.match(/^!\w+!/) || stream.match(/^%\w+%/) || stream.match(/^%~[a-z]*[\d*]/i)) return 'atom'
if (stream.match(/^\w+/)) {
return batchKeywords.has(stream.current().toLowerCase()) ? 'keyword' : null
}
stream.next()
return null
}
})
break
}
case 'dart': {
const { StreamLanguage, clike: dart } = await import('@codemirror/legacy-modes/mode/clike')
extension = StreamLanguage.define(dart)
+1 -1
View File
@@ -76,7 +76,7 @@ export const FILE_EXTENSIONS = {
CODE: [
'js', 'ts', 'jsx', 'tsx', 'cts', 'mts', 'cjs', 'mjs',
'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'ps1',
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'cmd', 'ps1',
'flow', 'pch', 'cc', 'cxx', 'hpp', 'hxx', 'tcc', 'defs', 'makefile', 'mk', 'cmake',
'dockerfile', 'm', 'r', 'matlab'
],
+53
View File
@@ -0,0 +1,53 @@
/**
* Excel 解析 Worker
* 把 XLSX.read() 与 sheet_to_json() 这两个【同步阻塞】操作隔离到独立线程,
* 避免大表解析冻结 webview 主线程(导致整个窗口点击无响应)。
*
* 协议:
* 主线程 -> { type: 'parse', reqId, wid, buffer: ArrayBuffer } (buffer 会被 transfer)
* 主线程 -> { type: 'sheet', reqId, wid, idx }
* worker -> { type: 'ready', reqId, wid, sheetNames: string[] }
* worker -> { type: 'sheet', reqId, wid, idx, data: any[][] }
* worker -> { type: 'stale', reqId } // wid 不匹配当前已加载工作簿
* worker -> { type: 'error', reqId, message }
*
* wid(workbook id)由主线程递增分配;切到新文件时 wid 变化,
* 旧 wid 的 sheet 请求会被识别为 stale,防止乱序回包覆盖新内容。
*/
let XLSX = null
let workbook = null
let loadedWid = 0
async function ensureXlsx() {
if (!XLSX) XLSX = await import('xlsx')
return XLSX
}
self.onmessage = async (e) => {
const msg = e.data || {}
const { type, reqId, wid, idx, buffer } = msg
try {
if (type === 'parse') {
const X = await ensureXlsx()
workbook = X.read(buffer, { type: 'array' })
loadedWid = wid
self.postMessage({ type: 'ready', reqId, wid, sheetNames: workbook.SheetNames || [] })
return
}
if (type === 'sheet') {
// wid 不匹配(已切到别的文件)→ 丢弃,避免旧回包覆盖
if (!workbook || wid !== loadedWid) {
self.postMessage({ type: 'stale', reqId })
return
}
const X = await ensureXlsx()
const ws = workbook.Sheets[workbook.SheetNames[idx]]
const data = X.utils.sheet_to_json(ws, { header: 1, defval: '' })
self.postMessage({ type: 'sheet', reqId, wid, idx, data })
return
}
} catch (err) {
self.postMessage({ type: 'error', reqId, message: err && err.message ? err.message : String(err) })
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Excel 解析 Worker 单例客户端
*
* 职责:
* 1. 懒加载并复用单个 excel.worker.js;
* 2. 用 wid(generation)机制忽略过期解析结果——快速切换文件时,旧文件的
* 解析回包会被 isCurrent(wid) 判定为 stale 而丢弃;
* 3. Worker 不可用时退化为「主线程同步解析」,保证功能不丢(极少数老 webview)。
*
* 根治点:主线程不再出现 XLSX.read() 同步调用,从源头消除卡死。
*/
let worker = null
let useFallback = false
const pending = new Map() // reqId -> { resolve, reject }
let nextReqId = 1
let currentWid = 0 // 每次 parseWorkbook 自增;切文件 / 自愈时也会变
// ---- fallback(无 Worker 环境的同步退化实现)----
let fbXlsx = null
let fbWorkbook = null
let fbWid = 0
function getWorker() {
if (worker) return worker
if (useFallback) return null
try {
worker = new Worker(new URL('./excel.worker.js', import.meta.url), { type: 'module' })
worker.onmessage = (e) => {
const msg = e.data || {}
const p = pending.get(msg.reqId)
if (!p) return
pending.delete(msg.reqId)
if (msg.type === 'error') p.reject(new Error(msg.message || 'excel worker error'))
else p.resolve(msg)
}
worker.onerror = (e) => {
// worker 崩溃:reject 所有在途请求,并退化为同步
for (const p of pending.values()) p.reject(new Error(e.message || 'excel worker crash'))
pending.clear()
useFallback = true
worker = null
}
return worker
} catch (e) {
console.warn('[excelWorkerClient] Worker 创建失败,退化为同步解析:', e)
useFallback = true
return null
}
}
function send(type, payload, transfer = []) {
const w = getWorker()
if (!w) return Promise.reject(new Error('worker unavailable'))
const reqId = nextReqId++
return new Promise((resolve, reject) => {
pending.set(reqId, { resolve, reject })
w.postMessage({ type, reqId, ...payload }, transfer)
})
}
async function ensureFallbackXlsx() {
if (!fbXlsx) fbXlsx = await import('xlsx')
return fbXlsx
}
async function fallbackParse(buffer, wid) {
const X = await ensureFallbackXlsx()
fbWorkbook = X.read(buffer, { type: 'array' })
fbWid = wid
return { wid, sheetNames: fbWorkbook.SheetNames || [] }
}
/**
* 解析整个工作簿(在 worker 线程)。buffer 会被 transfer,主线程不再持有。
* @returns {Promise<{ wid: number, sheetNames: string[] }>}
*/
export async function parseWorkbook(buffer) {
const wid = ++currentWid
if (useFallback || !getWorker()) {
return fallbackParse(buffer, wid)
}
try {
const res = await send('parse', { wid, buffer }, [buffer])
return { wid, sheetNames: res.sheetNames || [] }
} catch (e) {
// worker 通道异常 → 本次及后续退化为同步
console.warn('[excelWorkerClient] parse 走 worker 失败,退化为同步:', e)
useFallback = true
return await fallbackParse(buffer, wid)
}
}
/**
* 取某个 sheet 的二维数据(在 worker 线程)。
* @returns {Promise<any[][]>}
*/
export async function getSheet(wid, idx) {
if (wid !== currentWid) throw new Error('stale')
if (useFallback) {
if (wid !== fbWid || !fbWorkbook) throw new Error('stale')
const X = await ensureFallbackXlsx()
const ws = fbWorkbook.Sheets[fbWorkbook.SheetNames[idx]]
return X.utils.sheet_to_json(ws, { header: 1, defval: '' })
}
const res = await send('sheet', { wid, idx })
if (res.type === 'stale') throw new Error('stale')
return res.data
}
/** 指定 wid 是否仍是当前(最新)解析任务,用于丢弃过期回包 */
export function isCurrent(wid) {
return wid === currentWid
}
/**
* 让所有在途解析结果立即作废(wid 推进)。
* 卡死自愈时调用,确保旧解析不会在恢复后覆盖界面。
*/
export function invalidateExcelPreview() {
currentWid++
}
+54 -33
View File
@@ -1,30 +1,36 @@
/**
* Office 文件预览处理器
*
* Excel 解析已迁移到 Web Worker(见 excel.worker.js + excelWorkerClient.js),
* 主线程不再出现 XLSX.read() 同步调用,避免大表解析冻结整个界面。
*/
import { escapeHtml } from './fileUtils'
import { isExcelFile, isWordFile, isOfficeFile, isCsvFile } from './fileTypeHelpers'
import { parseWorkbook, getSheet, isCurrent } from './excelWorkerClient'
// 每批加载行数
const BATCH_ROWS = 200
const LOAD_MORE_THRESHOLD = 100
// Excel 预览处理器
// Excel 预览处理器(解析在 Web Worker 中进行,不阻塞主线程)
export async function previewExcel(file, container) {
const XLSX = await import('xlsx')
// 1. 取二进制并交给 worker 解析(XLSX.read / sheet_to_json 全在 worker 线程)
const arrayBuffer = await file.arrayBuffer()
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
const { wid, sheetNames } = await parseWorkbook(arrayBuffer)
// 切到别的文件时 wid 会推进,丢弃本次结果
if (!isCurrent(wid)) return { success: true }
// 渲染标签页
const tabs = workbook.SheetNames.map((name, i) =>
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${name}</button>`
// 2. 渲染骨架(标签页 + 信息栏 + 内容区)
const tabs = sheetNames.map((name, i) =>
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${escapeHtml(name)}</button>`
).join('')
container.innerHTML = `
<div class="excel-preview">
<div class="excel-tabs">${tabs}</div>
<div class="excel-info"></div>
<div class="excel-content"></div>
<div class="excel-content"><div class="loading-hint">解析中...</div></div>
</div>
<style>
.excel-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
@@ -41,6 +47,7 @@ export async function previewExcel(file, container) {
.excel-content th.row-num{z-index:3;top:0;left:0}
.excel-content tr:hover td{background:var(--color-fill-1)}
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
.loading-hint{padding:24px;text-align:center;color:var(--color-text-3);font-size:13px}
</style>
`
@@ -49,19 +56,15 @@ export async function previewExcel(file, container) {
const tabsEl = container.querySelector('.excel-tabs')
// 当前 sheet 状态
let currentSheet = { idx: 0, data: null, renderedRows: 0, totalRows: 0 }
// 获取 sheet 数据
const getSheetData = (idx) => {
const ws = workbook.Sheets[workbook.SheetNames[idx]]
return XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })
}
let currentSheet = { idx: -1, data: null, renderedRows: 0, totalRows: 0 }
// 渲染令牌:切 sheet 自增,丢弃乱序回包
let renderToken = 0
// 渲染表格(带行号)
const renderTable = (data, startRow = 0) => {
let html = '<table><thead><tr><th class="row-num">#</th>'
if (data[0]) {
data[0].forEach((cell, i) => {
data[0].forEach((cell) => {
html += `<th>${escapeHtml(cell)}</th>`
})
}
@@ -80,7 +83,7 @@ export async function previewExcel(file, container) {
return html
}
// 追加行
// 追加行(从内存中的 data 追加,不再过 worker)
const appendRows = (data, fromRow, toRow) => {
const tbody = contentEl.querySelector('tbody')
if (!tbody) return
@@ -107,17 +110,35 @@ export async function previewExcel(file, container) {
}
}
// 切换 sheet
const loadSheet = (idx) => {
currentSheet = {
idx,
data: getSheetData(idx),
renderedRows: BATCH_ROWS,
totalRows: 0
}
currentSheet.totalRows = currentSheet.data.length
// 切换 sheet(worker 异步解析单个 sheet)
const loadSheet = async (idx) => {
const token = ++renderToken
const displayData = currentSheet.data.slice(0, BATCH_ROWS + 1)
// 标签高亮
tabsEl.querySelectorAll('.excel-tab').forEach(t => {
t.classList.toggle('active', parseInt(t.dataset.idx, 10) === idx)
})
// 该 sheet 尚未解析过 → 显示解析中
if (currentSheet.idx !== idx) {
contentEl.innerHTML = '<div class="loading-hint">解析中...</div>'
}
let data
try {
data = await getSheet(wid, idx)
} catch (e) {
if (isCurrent(wid) && token === renderToken) {
contentEl.innerHTML = `<div class="preview-error"><p>❌ Excel 解析失败</p><p class="error-detail">${escapeHtml(e && e.message ? e.message : String(e))}</p></div>`
}
return
}
// 丢弃过期 / 乱序回包
if (!isCurrent(wid) || token !== renderToken) return
currentSheet = { idx, data, renderedRows: BATCH_ROWS, totalRows: data.length }
const displayData = data.slice(0, BATCH_ROWS + 1)
contentEl.innerHTML = renderTable(displayData)
updateInfo()
}
@@ -127,7 +148,7 @@ export async function previewExcel(file, container) {
const { scrollTop, scrollHeight, clientHeight } = contentEl
if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
const { data, renderedRows, totalRows } = currentSheet
if (renderedRows < totalRows - 1) {
if (data && renderedRows < totalRows - 1) {
const newEnd = Math.min(renderedRows + BATCH_ROWS, totalRows - 1)
appendRows(data, renderedRows + 1, newEnd)
currentSheet.renderedRows = newEnd
@@ -136,17 +157,17 @@ export async function previewExcel(file, container) {
}
}
loadSheet(0)
// 标签页切换
tabsEl.onclick = (e) => {
const tab = e.target.closest('.excel-tab')
if (!tab) return
tabsEl.querySelectorAll('.excel-tab').forEach(t => t.classList.remove('active'))
tab.classList.add('active')
loadSheet(parseInt(tab.dataset.idx, 10))
const idx = parseInt(tab.dataset.idx, 10)
if (idx === currentSheet.idx) return
loadSheet(idx)
}
await loadSheet(0)
return { success: true }
}
@@ -233,7 +254,7 @@ export async function previewCsv(file, container) {
container.innerHTML = `
<div class="csv-preview">
<div class="csv-info">📋 ${file.name}</div>
<div class="csv-info">📋 ${escapeHtml(file.name)}</div>
<div class="csv-content"></div>
</div>
<style>
+5 -3
View File
@@ -29,13 +29,15 @@ export const PREVIEWABLE_TYPES = [
*/
export const KNOWN_BINARY_TYPES = [
// 可执行文件
'exe', 'dll', 'so', 'bin',
'exe', 'dll', 'so', 'bin', 'dat', 'db', 'sqlite',
// 压缩文件
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
// 编译/中间产物
'pdb', 'idb', 'lib', 'obj', 'o', 'a', 'class', 'pyc', 'pyo', 'wasm',
// 安装包
'msi', 'jar', 'war', 'ear', 'apk',
// Office PowerPoint(暂不支持预览)
'ppt', 'pptx',
// 其他二进制
'pdb', 'idb', 'lib', 'obj', 'o', 'a'
]
/**
+110
View File
@@ -0,0 +1,110 @@
/**
* 主线程卡死检测 + 事后自愈(主线程侧控制器)
*
* 能力边界(必须如实告知):
* - 主线程冻结「进行中」无法自救(回调被一起冻住);
* - 本模块只能「事后」检测到冻结(心跳断流),并在主线程恢复后做自愈;
* - 自愈动作绝不包含 location.reload(),否则会与「启动自动预览历史 Excel」
* 形成死循环(reload → 再次自动预览 → 再次冻结 → 再次 reload)。
*
* 自愈动作(每次冻结事件只执行一次,有 healing 防抖):
* 1. invalidateExcelPreview() —— 推进 wid,丢弃在途 Excel 解析回包;
* 2. 清掉持久化会话(LAST_OPENED_FILE + MULTI_PREVIEW_TABS),断开 reload-loop;
* 3. 置位 autorestore-disabled 标记,下次启动 restoreSession 主动跳过自动恢复;
* 4. toast 提示用户。
*/
import { STORAGE_KEYS } from './constants'
import { invalidateExcelPreview } from './excelWorkerClient'
const AUTORESTORE_DISABLED_KEY = 'app-filesystem-autorestore-disabled'
let started = false
let healing = false
export function isAutoRestoreDisabled(): boolean {
try {
return localStorage.getItem(AUTORESTORE_DISABLED_KEY) === '1'
} catch {
return false
}
}
export function clearAutoRestoreDisabled(): void {
try {
localStorage.removeItem(AUTORESTORE_DISABLED_KEY)
} catch {
/* ignore */
}
}
function setAutoRestoreDisabled(): void {
try {
localStorage.setItem(AUTORESTORE_DISABLED_KEY, '1')
} catch {
/* ignore */
}
}
function heal(freezeMs: number) {
if (healing) return // 同一次冻结可能触发多次 recovered,只处理一次
healing = true
console.error(`[freezeWatchdog] 检测到主线程冻结约 ${(freezeMs / 1000).toFixed(1)}s,执行自愈`)
// 1. 丢弃在途 Excel 解析
try {
invalidateExcelPreview()
} catch {
/* ignore */
}
// 2. 清持久化会话 —— 断开 reload-loop 的关键
try {
localStorage.removeItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
localStorage.removeItem(STORAGE_KEYS.FILESYSTEM.MULTI_PREVIEW_TABS)
} catch {
/* ignore */
}
// 3. 置位熔断标记:下次启动跳过自动恢复(防御纵深,即便上一步失败也不会 loop)
setAutoRestoreDisabled()
// 4. 提示用户(懒加载避免循环依赖)
import('@arco-design/web-vue')
.then(({ Message }) => {
Message.warning(`检测到界面卡顿 ${(freezeMs / 1000).toFixed(0)}s,已自动恢复并清理历史预览会话`)
})
.catch(() => {
/* ignore */
})
// 短暂冷却后释放防抖锁,允许后续「新的」冻结事件再次自愈
setTimeout(() => {
healing = false
}, 5000)
}
/** 启动看门狗(幂等,全进程只需启动一次) */
export function startFreezeWatchdog(): void {
if (started) return
if (typeof Worker === 'undefined') return // 环境不支持,静默跳过
try {
const w = new Worker(new URL('./freezeWatchdog.worker.js', import.meta.url), { type: 'module' })
w.onmessage = (e) => {
const msg = e.data || {}
if (msg.type === 'ping') {
// 主线程活着 → 立即回 pong(这一行在冻结期间跑不到,正是检测依据)
w.postMessage({ type: 'pong' })
} else if (msg.type === 'recovered') {
heal(msg.freezeMs || 0)
}
}
w.onerror = (e) => {
console.warn('[freezeWatchdog] worker 异常,看门狗停止:', e.message)
}
started = true
} catch (e) {
console.warn('[freezeWatchdog] 启动失败:', e)
}
}
@@ -0,0 +1,41 @@
/**
* 卡死看门狗 Worker
*
* 原理:主线程被【同步任务】阻塞时,连定时器回调都跑不了。
* 因此由独立 worker 线程周期性 ping 主线程;主线程收到立即 pong。
* 若 worker 在 FREEZE_MS 内收不到 pong,即可断定主线程被冻结。
*
* 注意:worker 线程本身不被主线程的同步阻塞影响,故能在冻结期间持续计时。
* 这里只有一个 2s 的低频定时器,不是忙循环,不会消耗 CPU。
*/
const PING_MS = 2000 // 每 2s 探测一次
const FREEZE_MS = 8000 // 超过 8s 没 pong 即判定冻结(正常异步 IO 不会触发)
let lastPongTs = Date.now()
let frozen = false
let freezeStartTs = 0
self.setInterval(() => {
self.postMessage({ type: 'ping' })
const sincePong = Date.now() - lastPongTs
if (!frozen && sincePong > FREEZE_MS) {
// 进入冻结态,记录起点(用最后一次 pong 时间近似冻结起点)
frozen = true
freezeStartTs = lastPongTs
}
}, PING_MS)
self.onmessage = (e) => {
const msg = e.data || {}
if (msg.type !== 'pong') return
const now = Date.now()
if (frozen) {
// 主线程恢复响应 → 报告本次冻结时长
frozen = false
self.postMessage({ type: 'recovered', freezeMs: now - freezeStartTs })
}
lastPongTs = now
}
+4 -1
View File
@@ -65,7 +65,10 @@ const extensionToLanguage: Record<string, { hljs?: string; cm?: string }> = {
shell: { hljs: 'bash', cm: 'shell' },
zsh: { hljs: 'bash', cm: 'shell' },
ps1: { hljs: 'powershell', cm: 'powershell' },
bat: { hljs: 'dos', cm: 'text' },
psm1: { hljs: 'powershell', cm: 'powershell' },
psd1: { hljs: 'powershell', cm: 'powershell' },
cmd: { hljs: 'dos', cm: 'batch' },
bat: { hljs: 'dos', cm: 'batch' },
ahk: { hljs: 'autohotkey', cm: 'text' },
lua: { hljs: 'lua', cm: 'text' },
r: { hljs: 'r', cm: 'text' },
+16
View File
@@ -0,0 +1,16 @@
/**
* Wails 事件 payload 解析(统一入口)
*
* 运行时 On 回调收到的是 WailsEvent 对象,真实载荷在其 .data 字段;
* 不同版本/路径下 payload 可能是 JSON 字符串或对象,这里统一兼容:
* 解包 .data → 字符串则 JSON.parse → 异常/空值回落空对象。
*/
export function parseWailsEventData(event: unknown): any {
const data = (event as any)?.data ?? event
try {
return typeof data === 'string' ? JSON.parse(data) : (data ?? {})
} catch {
return {}
}
}
@@ -53,6 +53,13 @@ export function ClearCache(): $CancellablePromise<void> {
return $Call.ByID(1413834504);
}
/**
* CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度
*/
export function CopyPath(src: string, dst: string): $CancellablePromise<void> {
return $Call.ByID(1297703531, src, dst);
}
/**
* CreateDir 创建目录
*/
@@ -175,6 +182,15 @@ export function GetCommonPaths(): $CancellablePromise<{ [_ in string]?: string }
});
}
/**
* EnsureDrawioViewer 确保 drawio viewer 已下载就绪(用于 .drawio/.dio 预览)
*/
export function EnsureDrawioViewer(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1345697171).then(($result: any) => {
return $$createType2($result);
});
}
/**
* GetCurrentVersion 获取当前版本号
*/
@@ -318,6 +334,13 @@ export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?:
});
}
/**
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
*/
export function MovePath(src: string, dst: string): $CancellablePromise<void> {
return $Call.ByID(2028899675, src, dst);
}
/**
* OpenPath 使用系统默认程序打开文件或目录
*/
@@ -448,6 +471,13 @@ export function OssWriteFile(connID: string, key: string, content: string): $Can
return $Call.ByID(39773277, connID, key, content);
}
/**
* PickDirectory 打开系统目录选择器,返回选中目录(取消时返回空串)
*/
export function PickDirectory(): $CancellablePromise<string> {
return $Call.ByID(1347829059);
}
/**
* ReadFile 读取文件
*/
@@ -608,6 +638,20 @@ export function SftpDownloadToTempCached(connID: string, remotePath: string, fil
return $Call.ByID(3935472409, connID, remotePath, fileSize, modTime);
}
/**
* SftpDownloadToFile 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本)
*/
export function SftpDownloadToFile(connID: string, remotePath: string, localDstDir: string): $CancellablePromise<string> {
return $Call.ByID(2935422351, connID, remotePath, localDstDir);
}
/**
* SftpUploadFromFile 上传本地文件/目录到远程目录(流式,目录递归,重名自动副本)
*/
export function SftpUploadFromFile(connID: string, localPath: string, remoteDir: string): $CancellablePromise<void> {
return $Call.ByID(3304594335, connID, localPath, remoteDir);
}
/**
* SftpGetCommonPaths 获取 SFTP 远程主机常用路径
*/