/** * 连接管理器 — 管理本地/远程/SFTP 传输层切换 * 配置持久化:SQLite(后端),local profile 仅内存 */ import type { FsTransport } from './transport' import { WailsTransport } from './wails-transport' import { HttpTransport } from './http-transport' import { SftpTransport } from './sftp-transport' import { OssTransport } from './oss-transport' import { getFileServerBaseURL } from './file-server' import { LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile, SftpGetSystemInfo, GetLocalSystemInfo, } from '@bindings/u-desk/app' export type ConnectionType = 'local' | 'remote' | 'sftp' | 'qiniu' | 'aliyun' export interface ConnectionProfile { id: string | number name: string host: string port: number token: string type: ConnectionType username?: string password?: string keyPath?: string accessKey?: string secretKey?: string bucket?: string region?: string endpoint?: string lastConnected?: number } export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error' export interface SystemInfo { cpuCores?: number cpuUsage?: string memTotal?: number memUsed?: number memUsage?: string diskTotal?: number diskUsed?: number diskUsage?: string _error?: boolean _errorMsg?: string } class ConnectionManagerImpl { /** 连接池:profileId → transport 实例 */ private _pool = new Map() private _profiles: ConnectionProfile[] = [] private _activeId: string | null = null private _state: ConnectionState = 'disconnected' private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = [] private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = [] private _sysInfoCache = new Map() constructor() { this.initDefaultLocal() this.loadFromDB() } private initDefaultLocal() { const localProfile: ConnectionProfile = { id: 'local-default', name: '本地', host: '', port: 0, token: '', type: 'local', } if (!this._profiles.find(p => p.id === localProfile.id)) { this._profiles.unshift(localProfile) } if (!this._activeId) { this._activeId = localProfile.id } // local 直接入池,无需连接 this._pool.set('local-default', new WailsTransport()) this.setState('connected') } /** 从 SQLite 加载配置 */ private async loadFromDB() { try { const list = await LoadConnectionProfiles() if (list && list.length > 0) { this._profiles = list.map((p: any) => ({ ...p, id: String(p.id), lastConnected: p.lastConnected || p.last_connected ? new Date(p.lastConnected || p.last_connected).getTime() : undefined, })) const hasLocal = this._profiles.some(p => p.type === 'local') if (!hasLocal) { this._profiles.unshift({ id: 'local-default', name: '本地', host: '', port: 0, token: '', type: 'local', }) } } } catch { /* 首次使用 */ } this.notifyChange() this._profiles.forEach(p => { if (p.type === 'local') { this.fetchSystemInfo(p.id).catch(() => {}) } }) const autoConnect = localStorage.getItem('desk:autoConnect') if (autoConnect !== 'false') { for (const p of this._profiles) { if (p.type !== 'local') { this.buildAndPool(String(p.id), p).catch(() => {}) } } } } /** 保存/更新单个 profile 到 SQLite */ private async persistProfile(profile: ConnectionProfile) { if (profile.type === 'local') return const id = profile.id !== 'local-default' ? Number(profile.id) : 0 await SaveConnectionProfile({ id: id > 0 ? id : undefined, name: profile.name, host: profile.host, port: profile.port, username: profile.username || 'root', password: profile.password || '', keyPath: profile.keyPath || '', type: profile.type, token: profile.token || '', access_key: profile.accessKey || '', secret_key: profile.secretKey || '', bucket: profile.bucket || '', region: profile.region || '', endpoint: profile.endpoint || '', lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined, }) } /** 从 SQLite 删除 profile */ private async removePersisted(id: string) { if (id === 'local-default') return await DeleteConnectionProfile(Number(id)) } private setState(state: ConnectionState) { this._state = state this.notifyChange() } private notifyChange() { this._stateChangeCallbacks.forEach(cb => cb(this._state)) } onStateChange(cb: (state: ConnectionState) => void) { this._stateChangeCallbacks.push(cb) } onSystemInfoChange(cb: (profileId: string, info: SystemInfo) => void) { this._sysInfoChangeCallbacks.push(cb) } get state(): ConnectionState { return this._state } get profiles(): ConnectionProfile[] { return [...this._profiles] } get activeProfile(): ConnectionProfile | null { return this._profiles.find(p => p.id === this._activeId) ?? null } /** 获取当前激活的 transport(从池中取) */ getTransport(): FsTransport { if (this._activeId && this._pool.has(this._activeId)) { return this._pool.get(this._activeId)! } return this._pool.get('local-default')! } /** 获取指定 profile 的 transport(用于跨 profile 操作如采集系统信息) */ getTransportFor(profileId: string): FsTransport | null { return this._pool.get(profileId) ?? null } getFileServerBaseURL(): string { const t = this.getTransport() if (t instanceof HttpTransport) { const profile = this.activeProfile if (!profile) return '' const scheme = profile.port === 443 ? 'https' : 'http' const port = (profile.port === 80 || profile.port === 443) ? '' : `:${profile.port}` return `${scheme}://${profile.host}${port}` } if (t instanceof SftpTransport) { return getFileServerBaseURL() } return '' } isSftp(): boolean { return this.activeProfile?.type === 'sftp' } isRemote(): boolean { const t = this.activeProfile?.type return t === 'remote' || t === 'sftp' || t === 'qiniu' || t === 'aliyun' } getSystemInfo(profileId: string): SystemInfo | undefined { return this._sysInfoCache.get(profileId) } async fetchSystemInfo(profileId: string): Promise { const profile = this._profiles.find(p => p.id === profileId) if (!profile) return { _error: true } const info: SystemInfo = {} try { if (profile.type === 'local') { const data = await GetLocalSystemInfo() if (data) Object.assign(info, data) } else if (profile.type === 'sftp') { const t = this.getTransportFor(profileId) if (t instanceof SftpTransport && t.sessionId) { const data = await SftpGetSystemInfo(t.sessionId) if (data) Object.assign(info, snakeToCamel(data)) } } else if (profile.type === 'qiniu' || profile.type === 'aliyun') { // OSS 无系统信息可采集 info.diskUsage = '-' info.cpuUsage = '-' info.memUsage = '-' } else if (profile.type === 'remote') { const t = this.getTransportFor(profileId) if (t instanceof HttpTransport) { const baseUrl = this.getFileServerBaseURL() if (baseUrl) { const token = profile.token || '' const headers: Record = { 'Content-Type': 'application/json' } if (token) headers['Authorization'] = `Bearer ${token}` const res = await fetch(`${baseUrl}/api/v1/system/stats`, { headers }) if (res.ok) { const json = await res.json() const data = json.data ?? json if (data) Object.assign(info, snakeToCamel(data)) } } } } } catch (e) { info._error = true; info._errorMsg = e instanceof Error ? e.message : String(e) } this._sysInfoCache.set(profileId, info) this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info)) return info } /** * 切换到指定 profile(连接池模式) * 池中有 → 直接复用,瞬间切换 * 池中无 → 新建连接并存入池 */ async connect(profileId: string): Promise { const profile = this._profiles.find(p => p.id === profileId) if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`)) // 池中已有,直接复用 if (this._pool.has(profileId)) { this._activeId = profileId this.setState('connected') return } // 新建连接并入池(成功后再设 activeId) await this.buildAndPool(profileId, profile) this._activeId = profileId } /** 断开指定 profile 并从池移除 */ disconnectProfile(profileId: string): void { if (profileId === 'local-default') return const t = this._pool.get(profileId) if (t instanceof SftpTransport) { t.disconnect() } if (t instanceof OssTransport) { t.disconnect() } this._pool.delete(profileId) } /** 断开所有远程连接(保留 local) */ disconnectAll(): void { for (const [id, t] of this._pool) { if (id !== 'local-default') { if (t instanceof SftpTransport || t instanceof OssTransport) t.disconnect() } } this._pool.clear() this._pool.set('local-default', new WailsTransport()) this._activeId = 'local-default' this.setState('connected') } disconnect(): void { this.disconnectAll() } async addProfile(profile: Omit): Promise { const newProfile: ConnectionProfile = { ...profile, id: crypto.randomUUID() } this._profiles.push(newProfile) await this.persistProfile(newProfile) this.notifyChange() return newProfile } async updateProfile(id: string, updates: Partial): Promise { const idx = this._profiles.findIndex(p => p.id === id) if (idx >= 0) { this._profiles[idx] = { ...this._profiles[idx], ...updates } await this.persistProfile(this._profiles[idx]) // 连接参数变更 → 淘汰旧连接,下次 connect 时重建 const EVICT_KEYS = ['host', 'port', 'token', 'username', 'password', 'keyPath', 'accessKey', 'secretKey', 'bucket', 'region', 'endpoint'] as const if (EVICT_KEYS.some(k => k in updates)) { this.disconnectProfile(id) if (id === this._activeId) { this.connect(id).catch(err => console.warn('[SFTP] 编辑后重连失败:', err)) } } this.notifyChange() } } async removeProfile(id: string): Promise { if (id === 'local-default') return this._profiles = this._profiles.filter(p => p.id !== id) this.disconnectProfile(id) if (this._activeId === id) { this._activeId = 'local-default' this.setState('connected') } await this.removePersisted(id) this.notifyChange() } /** 新建 transport 并存入连接池 */ private async buildAndPool(profileId: string, profile: ConnectionProfile): Promise { if (profile.type === 'local') { this._pool.set(profileId, new WailsTransport()) this.setState('connected') return } if (profile.type === 'sftp') { if (!profile.password && !profile.keyPath) { this.setState('error') return } this.setState('connecting') try { const t = new SftpTransport(profile) await t.connect() this._pool.set(profileId, t) this.setState('connected') this.updateProfile(profileId, { lastConnected: Date.now() }) this.fetchSystemInfo(profileId).catch(() => {}) } catch (err) { this._pool.delete(profileId) this.setState('error') throw err } return } // OSS (qiniu / aliyun) if (profile.type === 'qiniu' || profile.type === 'aliyun') { this.setState('connecting') try { const t = new OssTransport(profile) await t.connect() this._pool.set(profileId, t) this.setState('connected') this.updateProfile(profileId, { lastConnected: Date.now() }) } catch (err) { this._pool.delete(profileId) this.setState('error') throw err } return } // remote / http agent this.setState('connecting') try { const t = new HttpTransport(profile.host, profile.port, profile.token) this._pool.set(profileId, t) // 验证连接可用性 t.getFileInfo('/').then(() => { if (this._activeId !== profileId) return this.setState('connected') this.updateProfile(profileId, { lastConnected: Date.now() }) this.fetchSystemInfo(profileId).catch(() => {}) }).catch(() => { if (this._activeId !== profileId) return this.setState('error') }) } catch (err) { this._pool.delete(profileId) this.setState('error') throw err } } } export const connectionManager = new ConnectionManagerImpl() /** snake_case → camelCase */ function snakeToCamel(obj: Record): Record { const result: Record = {} for (const [k, v] of Object.entries(obj)) { result[k.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = v } return result }