新增:SFTP直连+连接池+autoConnect+文件服务器端口自动回退

- SFTP模块:连接/断开/文件CRUD/系统信息采集/base64二进制写入
- 连接池:多服务器同时在线,瞬间切换profile
- autoConnect:启动时自动连接所有非本地服务器
- 端口自动回退:listenWithFallback消除TOCTOU,解决端口冲突崩溃
- 文件服务器URL集中管理:file-server.ts消除8+处硬编码端口
- Sidebar设置面板:添加服务器/自动连接/自动刷新开关
- 修复:validateFilePath越界panic、正则预编译
- 修复:注释准确性(RemoveAll/端口8073/动态端口文档)
This commit is contained in:
lxy
2026-05-04 15:33:19 +08:00
parent 6eaaa56eb6
commit 6bee55b96f
41 changed files with 2620 additions and 458 deletions
+199
View File
@@ -0,0 +1,199 @@
/**
* SFTP Transport — 通过 Wails IPC 调用 Go 后端 SFTP 客户端
*/
import type {
FsTransport, FileItem, FileOperationResult, DetectTypeResult,
} from './transport'
import type { ConnectionProfile } from './connection-manager'
import {
SftpConnect, SftpDisconnect, SftpListDir, SftpReadFile,
SftpWriteFile, SftpGetFileInfo, SftpCreateDir, SftpCreateFile,
SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, 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)
}
const PREVIEW_CACHE_MAX = 50
export class SftpTransport implements FsTransport {
private profile: ConnectionProfile
private connID: string | null = null
private previewCache = new Map<string, string>() // remotePath -> localTempPath (LRU)
private previewOrder: string[] = [] // LRU 排序键列表
constructor(profile: ConnectionProfile) {
this.profile = profile
}
async connect(): Promise<string> {
const result = await SftpConnect({
host: this.profile.host,
port: this.profile.port || 22,
username: this.profile.username || 'root',
password: this.profile.password || '',
key_path: this.profile.keyPath || '',
key_passphrase: '',
})
this.connID = result
return result
}
get sessionId(): string | null { return this.connID }
async disconnect(): Promise<void> {
if (this.connID) {
try { await SftpDisconnect(this.connID) } catch (e) {
console.warn('[SFTP] disconnect error:', e)
}
this.connID = null
}
this.previewCache.clear()
this.previewOrder = []
}
private requireConn(): string {
if (!this.connID) throw new Error('SFTP 未连接')
return this.connID
}
// ====== 文件列表与信息 ======
async listDir(path: string): Promise<FileItem[]> {
return transformFileList(await SftpListDir(this.requireConn(), path))
}
async getFileInfo(path: string): Promise<Record<string, any>> {
return SftpGetFileInfo(this.requireConn(), path)
}
// ====== 文件读写 ======
async readFile(path: string): Promise<string> {
return SftpReadFile(this.requireConn(), path)
}
async writeFile(path: string, content: string): Promise<void> {
await SftpWriteFile({
session_id: this.requireConn(),
path,
content,
})
}
async saveBase64File(path: string, content: string): Promise<void> {
if (!content) throw new Error('无效的 base64 内容')
await SftpWriteBase64File(this.requireConn(), path, content)
}
// ====== 文件操作 ======
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
return SftpCreateFile(this.requireConn(), fullPath)
}
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
return SftpCreateDir(this.requireConn(), fullPath)
}
async deletePath(path: string): Promise<FileOperationResult> {
return SftpDeletePath(this.requireConn(), path)
}
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
return SftpRenamePath({
session_id: this.requireConn(),
old_path: oldPath,
new_path: newPath,
})
}
// ====== ZIP 操作(不支持)======
async listZipContents(_zipPath: string): Promise<FileItem[]> {
throw new Error('ZIP 操作在 SFTP 模式暂未实现')
}
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
throw new Error('ZIP 操作在 SFTP 模式暂未实现')
}
async extractFileFromZipToTemp(_zipPath: string, _filePath: string): Promise<string> {
throw new Error('ZIP 操作在 SFTP 模式暂未实现')
}
async getZipFileInfo(_zipPath: string, _filePath: string): Promise<FileItem> {
throw new Error('ZIP 操作在 SFTP 模式暂未实现')
}
// ====== 系统操作 ======
async openPath(_path: string): Promise<void> {
throw new Error('SFTP 模式不支持打开本地路径')
}
async getFileServerURL(): Promise<string> {
const { getFileServerBaseURL } = await import('./file-server')
return getFileServerBaseURL()
}
getPreviewToken(): string {
return ''
}
async resolveShortcut(_lnkPath: string): Promise<any> {
return null
}
async detectFileTypeByContent(_path: string): Promise<DetectTypeResult> {
return { extension: '', category: 'unknown', mime_type: '', confidence: 0 }
}
async getCommonPaths(): Promise<Record<string, string>> {
return SftpGetCommonPaths(this.requireConn())
}
// ====== 回收站(无)======
async getRecycleBinEntries(): Promise<any[]> {
return []
}
async restoreFromRecycleBin(_path: string): Promise<void> {}
async deletePermanently(_path: string): Promise<void> {}
async emptyRecycleBin(): Promise<void> {}
// ====== 预览辅助 ======
/** 下载远程文件到本地临时目录用于预览(带 LRU 缓存,上限 50) */
async downloadForPreview(remotePath: string): Promise<string> {
// 命中:移到队尾(最近使用)
if (this.previewCache.has(remotePath)) {
this.previewOrder = this.previewOrder.filter(p => p !== remotePath)
this.previewOrder.push(remotePath)
return this.previewCache.get(remotePath)!
}
const localPath = await SftpDownloadToTemp(this.requireConn(), remotePath)
// 淘汰最旧条目
if (this.previewOrder.length >= PREVIEW_CACHE_MAX) {
const oldest = this.previewOrder.shift()!
this.previewCache.delete(oldest)
}
this.previewCache.set(remotePath, localPath)
this.previewOrder.push(remotePath)
return localPath
}
}