新增:SFTP直连+连接池+autoConnect+文件服务器端口自动回退
- SFTP模块:连接/断开/文件CRUD/系统信息采集/base64二进制写入 - 连接池:多服务器同时在线,瞬间切换profile - autoConnect:启动时自动连接所有非本地服务器 - 端口自动回退:listenWithFallback消除TOCTOU,解决端口冲突崩溃 - 文件服务器URL集中管理:file-server.ts消除8+处硬编码端口 - Sidebar设置面板:添加服务器/自动连接/自动刷新开关 - 修复:validateFilePath越界panic、正则预编译 - 修复:注释准确性(RemoveAll/端口8073/动态端口文档)
This commit is contained in:
@@ -93,8 +93,8 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||
import {IconSettings, IconPushpin} from '@arco-design/web-vue/es/icon'
|
||||
import MarkdownEditor from './views/markdown-editor/index.vue'
|
||||
import VersionHistory from './views/version/index.vue'
|
||||
import MarkdownEditor from './components/MarkdownEditorPage.vue'
|
||||
import VersionHistory from './components/VersionHistory.vue'
|
||||
import ThemeToggle from './components/ThemeToggle.vue'
|
||||
import FileSystem from './components/FileSystem/index.vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
|
||||
@@ -1,39 +1,61 @@
|
||||
/**
|
||||
* 连接管理器 — 管理本地/远程传输层切换
|
||||
* 连接管理器 — 管理本地/远程/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 { getFileServerBaseURL } from './file-server'
|
||||
import {
|
||||
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
|
||||
SftpGetSystemInfo, GetLocalSystemInfo,
|
||||
} from '@bindings/u-desk/app'
|
||||
|
||||
export type ConnectionType = 'local' | 'remote'
|
||||
export type ConnectionType = 'local' | 'remote' | 'sftp'
|
||||
|
||||
export interface ConnectionProfile {
|
||||
id: string
|
||||
id: string | number
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
token: string
|
||||
type: ConnectionType
|
||||
username?: string
|
||||
password?: string
|
||||
keyPath?: string
|
||||
lastConnected?: number
|
||||
}
|
||||
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
const PROFILES_KEY = 'fs_connection_profiles'
|
||||
const ACTIVE_KEY = 'fs_active_connection'
|
||||
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 {
|
||||
private _transport: FsTransport | null = null
|
||||
/** 连接池:profileId → transport 实例 */
|
||||
private _pool = new Map<string, FsTransport>()
|
||||
private _profiles: ConnectionProfile[] = []
|
||||
private _activeId: string | null = null
|
||||
private _state: ConnectionState = 'disconnected'
|
||||
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
|
||||
private _connectSeq = 0
|
||||
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
|
||||
private _sysInfoCache = new Map<string, SystemInfo>()
|
||||
|
||||
constructor() {
|
||||
this.loadProfiles()
|
||||
this.initDefaultLocal()
|
||||
this.loadFromDB()
|
||||
}
|
||||
|
||||
private initDefaultLocal() {
|
||||
@@ -48,26 +70,68 @@ class ConnectionManagerImpl {
|
||||
if (!this._profiles.find(p => p.id === localProfile.id)) {
|
||||
this._profiles.unshift(localProfile)
|
||||
}
|
||||
// 默认连接本地
|
||||
if (!this._activeId) {
|
||||
this._activeId = localProfile.id
|
||||
}
|
||||
this.applyActive()
|
||||
// local 直接入池,无需连接
|
||||
this._pool.set('local-default', new WailsTransport())
|
||||
this.setState('connected')
|
||||
}
|
||||
|
||||
private loadProfiles() {
|
||||
/** 从 SQLite 加载配置 */
|
||||
private async loadFromDB() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILES_KEY)
|
||||
if (raw) this._profiles = JSON.parse(raw)
|
||||
this._activeId = localStorage.getItem(ACTIVE_KEY)
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private saveProfiles() {
|
||||
localStorage.setItem(PROFILES_KEY, JSON.stringify(this._profiles))
|
||||
if (this._activeId) {
|
||||
localStorage.setItem(ACTIVE_KEY, this._activeId)
|
||||
}
|
||||
/** 保存/更新单个 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 || '',
|
||||
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) {
|
||||
@@ -83,117 +147,236 @@ class ConnectionManagerImpl {
|
||||
this._stateChangeCallbacks.push(cb)
|
||||
}
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this._state
|
||||
onSystemInfoChange(cb: (profileId: string, info: SystemInfo) => void) {
|
||||
this._sysInfoChangeCallbacks.push(cb)
|
||||
}
|
||||
|
||||
get profiles(): ConnectionProfile[] {
|
||||
return [...this._profiles]
|
||||
}
|
||||
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._transport) {
|
||||
this.applyActive()
|
||||
if (this._activeId && this._pool.has(this._activeId)) {
|
||||
return this._pool.get(this._activeId)!
|
||||
}
|
||||
return this._transport!
|
||||
return this._pool.get('local-default')!
|
||||
}
|
||||
|
||||
/** 获取指定 profile 的 transport(用于跨 profile 操作如采集系统信息) */
|
||||
getTransportFor(profileId: string): FsTransport | null {
|
||||
return this._pool.get(profileId) ?? null
|
||||
}
|
||||
|
||||
getFileServerBaseURL(): string {
|
||||
if (this._transport instanceof HttpTransport) {
|
||||
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}`
|
||||
}
|
||||
// Wails 模式返回空字符串,让 useFilePreview 走原有逻辑
|
||||
if (t instanceof SftpTransport) { return getFileServerBaseURL() }
|
||||
return ''
|
||||
}
|
||||
|
||||
isSftp(): boolean { return this.activeProfile?.type === 'sftp' }
|
||||
|
||||
isRemote(): boolean {
|
||||
return this.activeProfile?.type === 'remote'
|
||||
const t = this.activeProfile?.type
|
||||
return t === 'remote' || t === 'sftp'
|
||||
}
|
||||
|
||||
connect(profileId: string): void {
|
||||
getSystemInfo(profileId: string): SystemInfo | undefined {
|
||||
return this._sysInfoCache.get(profileId)
|
||||
}
|
||||
|
||||
async fetchSystemInfo(profileId: string): Promise<SystemInfo> {
|
||||
const profile = this._profiles.find(p => p.id === profileId)
|
||||
if (!profile) return
|
||||
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 === 'remote') {
|
||||
const t = this.getTransportFor(profileId)
|
||||
if (t instanceof HttpTransport) {
|
||||
const baseUrl = this.getFileServerBaseURL()
|
||||
if (baseUrl) {
|
||||
const token = profile.token || ''
|
||||
const headers: Record<string, string> = { '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<void> {
|
||||
const profile = this._profiles.find(p => p.id === profileId)
|
||||
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
|
||||
|
||||
this._activeId = profileId
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
|
||||
// 池中已有,直接复用
|
||||
if (this._pool.has(profileId)) {
|
||||
this.setState('connected')
|
||||
return
|
||||
}
|
||||
|
||||
// 新建连接并入池
|
||||
await this.buildAndPool(profileId, profile)
|
||||
}
|
||||
|
||||
/** 断开指定 profile 并从池移除 */
|
||||
disconnectProfile(profileId: string): void {
|
||||
if (profileId === 'local-default') return
|
||||
const t = this._pool.get(profileId)
|
||||
if (t instanceof SftpTransport) { t.disconnect() }
|
||||
this._pool.delete(profileId)
|
||||
}
|
||||
|
||||
/** 断开所有远程连接(保留 local) */
|
||||
disconnectAll(): void {
|
||||
for (const [id, t] of this._pool) {
|
||||
if (id !== 'local-default' && t instanceof SftpTransport) {
|
||||
t.disconnect()
|
||||
}
|
||||
}
|
||||
this._pool.clear()
|
||||
this._pool.set('local-default', new WailsTransport())
|
||||
this._activeId = 'local-default'
|
||||
this.setState('connected')
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this._activeId = 'local-default'
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
this.disconnectAll()
|
||||
}
|
||||
|
||||
addProfile(profile: Omit<ConnectionProfile, 'id'>): ConnectionProfile {
|
||||
const newProfile: ConnectionProfile = {
|
||||
...profile,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
async addProfile(profile: Omit<ConnectionProfile, 'id'>): Promise<ConnectionProfile> {
|
||||
const newProfile: ConnectionProfile = { ...profile, id: crypto.randomUUID() }
|
||||
this._profiles.push(newProfile)
|
||||
this.saveProfiles()
|
||||
await this.persistProfile(newProfile)
|
||||
this.notifyChange()
|
||||
return newProfile
|
||||
}
|
||||
|
||||
updateProfile(id: string, updates: Partial<ConnectionProfile>): void {
|
||||
async updateProfile(id: string, updates: Partial<ConnectionProfile>): Promise<void> {
|
||||
const idx = this._profiles.findIndex(p => p.id === id)
|
||||
if (idx >= 0) {
|
||||
this._profiles[idx] = { ...this._profiles[idx], ...updates }
|
||||
this.saveProfiles()
|
||||
// 仅当连接参数变化时重新应用(避免 lastConnected 等元数据更新触发死循环)
|
||||
const REAPPLY_KEYS = ['host', 'port', 'token'] as const
|
||||
const needsReapply = REAPPLY_KEYS.some(k => k in updates)
|
||||
if (needsReapply && id === this._activeId) {
|
||||
this.applyActive()
|
||||
await this.persistProfile(this._profiles[idx])
|
||||
// 连接参数变更 → 淘汰旧连接,下次 connect 时重建
|
||||
const EVICT_KEYS = ['host', 'port', 'token', 'username', 'password', 'keyPath'] 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()
|
||||
}
|
||||
}
|
||||
|
||||
removeProfile(id: string): void {
|
||||
if (id === 'local-default') return // 不允许删除本地配置
|
||||
async removeProfile(id: string): Promise<void> {
|
||||
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')
|
||||
}
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
await this.removePersisted(id)
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
private applyActive() {
|
||||
const profile = this.activeProfile
|
||||
const seq = ++this._connectSeq
|
||||
if (!profile || profile.type === 'local') {
|
||||
this._transport = new WailsTransport()
|
||||
/** 新建 transport 并存入连接池 */
|
||||
private async buildAndPool(profileId: string, profile: ConnectionProfile): Promise<void> {
|
||||
if (profile.type === 'local') {
|
||||
this._pool.set(profileId, new WailsTransport())
|
||||
this.setState('connected')
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
if (profile.type === 'sftp') {
|
||||
if (!profile.password && !profile.keyPath) {
|
||||
this._pool.set(profileId, new WailsTransport())
|
||||
this.setState('error')
|
||||
return
|
||||
}
|
||||
this.setState('connecting')
|
||||
try {
|
||||
this._transport = new HttpTransport(profile.host, profile.port, profile.token)
|
||||
// 快速连通性检查(用轻量 ping 代替 getCommonPaths)
|
||||
this._transport.getFileInfo('/').then(() => {
|
||||
if (seq !== this._connectSeq) return // 已被后续连接覆盖
|
||||
this.setState('connected')
|
||||
this.updateProfile(profile.id!, { lastConnected: Date.now() })
|
||||
}).catch(() => {
|
||||
if (seq !== this._connectSeq) return
|
||||
this.setState('error')
|
||||
})
|
||||
} catch {
|
||||
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
|
||||
}
|
||||
|
||||
// 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<string, any>): Record<string, any> {
|
||||
const result: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
result[k.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
39
frontend/src/api/file-server.ts
Normal file
39
frontend/src/api/file-server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 文件服务器 URL 集中管理
|
||||
* 单一数据源:从后端 GetFileServerURL() 获取动态端口
|
||||
* 所有模块统一引用此处,消除硬编码端口号
|
||||
*/
|
||||
|
||||
import { GetFileServerURL } from '@bindings/u-desk/app'
|
||||
|
||||
const FALLBACK_URL = 'http://localhost:2652'
|
||||
|
||||
let _cachedURL: string | null = null
|
||||
let _initPromise: Promise<string> | null = null
|
||||
|
||||
/** 初始化(调用一次即可,内部缓存) */
|
||||
export function initFileServerURL(): Promise<string> {
|
||||
if (_cachedURL) return Promise.resolve(_cachedURL)
|
||||
if (_initPromise) return _initPromise
|
||||
_initPromise = GetFileServerURL().then(url => {
|
||||
_cachedURL = url
|
||||
return url
|
||||
}).catch(() => {
|
||||
_cachedURL = FALLBACK_URL
|
||||
return FALLBACK_URL
|
||||
})
|
||||
return _initPromise
|
||||
}
|
||||
|
||||
/** 同步获取(需先调用过 initFileServerURL,否则返回 fallback) */
|
||||
export function getFileServerBaseURL(): string {
|
||||
return _cachedURL || FALLBACK_URL
|
||||
}
|
||||
|
||||
/** 获取带 /localfs 后缀的完整 base */
|
||||
export function getLocalFsBaseURL(): string {
|
||||
return `${getFileServerBaseURL()}/localfs`
|
||||
}
|
||||
|
||||
/** 启动时自动初始化 */
|
||||
initFileServerURL().catch(() => {})
|
||||
199
frontend/src/api/sftp-transport.ts
Normal file
199
frontend/src/api/sftp-transport.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,113 @@
|
||||
<template>
|
||||
<a-modal :visible="props.visible" :title="editingId ? '编辑连接' : '添加服务器'" unmount-on-close @cancel="emit('update:visible', false)" @before-ok="handleOk" :ok-loading="submitting">
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; max-width: 400px">
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; max-width: 420px">
|
||||
<!-- 连接类型 -->
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">类型</label>
|
||||
<a-radio-group v-model="form.type" type="button" size="small">
|
||||
<a-radio value="sftp">SFTP</a-radio>
|
||||
<a-radio value="remote">HTTP Agent</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 名称 -->
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">名称</label>
|
||||
<a-input v-model="form.name" placeholder="如:生产服务器" style="flex: 1" />
|
||||
</div>
|
||||
|
||||
<!-- 地址 -->
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">地址</label>
|
||||
<a-input v-model="form.host" placeholder="192.168.1.100" style="flex: 1" />
|
||||
<a-input-number v-model="form.port" :min="1" :max="65535" placeholder="端口" style="width: 90px" hide-button />
|
||||
<a-input v-model="form.host" :placeholder="form.type === 'sftp' ? '192.168.1.100' : '192.168.1.100'" style="flex: 1" />
|
||||
<a-input-number v-model="form.port" :min="1" :max="65535" :placeholder="form.type === 'sftp' ? '22' : '9876'" style="width: 90px" hide-button />
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">Token</label>
|
||||
<div style="flex: 1">
|
||||
<a-input v-model="form.token" type="password" placeholder="留空则不认证" allow-clear />
|
||||
<div style="font-size: 11px; color: var(--color-text-3); margin-top: 2px">API 认证令牌(与服务器配置一致)</div>
|
||||
|
||||
<!-- SFTP 认证字段 -->
|
||||
<template v-if="form.type === 'sftp'">
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">用户</label>
|
||||
<a-input v-model="form.username" placeholder="root" style="flex: 1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">密码</label>
|
||||
<a-input v-model="form.password" type="password" placeholder="登录密码(或使用密钥)" allow-clear style="flex: 1" />
|
||||
</div>
|
||||
<div v-if="editingId && form.type === 'sftp' && !form.password" style="font-size: 11px; color: var(--color-text-3); margin-left: 44px">
|
||||
未填写密码,将使用密钥认证(若密钥也为空则连接将失败)
|
||||
</div>
|
||||
|
||||
<!-- 暂时隐藏密钥栏
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">密钥</label>
|
||||
<a-input v-model="form.keyPath" placeholder="私钥文件路径(可选,优先于密码)" style="flex: 1" />
|
||||
<a-button size="small" @click="selectKeyFile">浏览</a-button>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 11px; color: var(--color-text-3)">
|
||||
支持密码或私钥文件认证,两者填一即可
|
||||
</div>
|
||||
-->
|
||||
</template>
|
||||
|
||||
<!-- HTTP Agent Token 字段 -->
|
||||
<template v-if="form.type === 'remote'">
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">Token</label>
|
||||
<div style="flex: 1">
|
||||
<a-input v-model="form.token" type="password" placeholder="留空则不认证" allow-clear />
|
||||
<div style="font-size: 11px; color: var(--color-text-3); margin-top: 2px">API 认证令牌(与服务器配置一致)</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { reactive, ref, watch, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { Dialogs } from '@wailsio/runtime'
|
||||
import { GetEnvVars } from '@bindings/u-desk/app'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import type { ConnectionType } from '@/api/connection-manager'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:visible', val: boolean): void; (e: 'close'): void }>()
|
||||
|
||||
const editingId = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
let cachedSshDir = ''
|
||||
|
||||
onMounted(async () => {
|
||||
const env = await GetEnvVars()
|
||||
cachedSshDir = `${env.USERPROFILE || env.HOME || ''}\\.ssh`
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 9876,
|
||||
port: 22,
|
||||
token: '',
|
||||
type: 'sftp' as ConnectionType,
|
||||
username: 'root',
|
||||
password: '',
|
||||
keyPath: '',
|
||||
})
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (!val) return
|
||||
editingId.value = null
|
||||
Object.assign(form, { name: '', host: '', port: 9876, token: '' })
|
||||
Object.assign(form, {
|
||||
name: '', host: '', port: 22, token: '',
|
||||
type: 'sftp' as ConnectionType,
|
||||
username: 'root', password: '', keyPath: '',
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => form.type, (t) => {
|
||||
form.port = t === 'sftp' ? 22 : 9876
|
||||
})
|
||||
|
||||
async function handleOk(): Promise<boolean> {
|
||||
@@ -52,23 +117,46 @@ async function handleOk(): Promise<boolean> {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
connectionManager.updateProfile(editingId.value, { ...form })
|
||||
await connectionManager.updateProfile(editingId.value, { ...form })
|
||||
Message.success('已更新')
|
||||
} else {
|
||||
connectionManager.addProfile({ ...form, type: 'remote' })
|
||||
await connectionManager.addProfile({ ...form, type: form.type })
|
||||
Message.success('已添加')
|
||||
}
|
||||
emit('update:visible', false)
|
||||
return true
|
||||
} catch (err) {
|
||||
Message.error(err instanceof Error ? err.message : '保存失败')
|
||||
return false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectKeyFile() {
|
||||
const path = await Dialogs.OpenFile({
|
||||
Title: '选择私钥文件',
|
||||
Directory: cachedSshDir,
|
||||
CanChooseFiles: true,
|
||||
CanChooseDirectories: false,
|
||||
})
|
||||
if (path) form.keyPath = path
|
||||
}
|
||||
|
||||
function editProfile(id: string) {
|
||||
const profile = connectionManager.profiles.find(p => p.id === id)
|
||||
if (!profile) return
|
||||
editingId.value = id
|
||||
Object.assign(form, { name: profile.name, host: profile.host, port: profile.port, token: profile.token || '' })
|
||||
Object.assign(form, {
|
||||
name: profile.name,
|
||||
host: profile.host,
|
||||
port: profile.port,
|
||||
token: profile.token || '',
|
||||
type: profile.type || 'remote',
|
||||
username: profile.username || 'root',
|
||||
password: profile.password || '',
|
||||
keyPath: profile.keyPath || '',
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ editProfile })
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
:class="['menu-item', { active: p.id === activeId }]"
|
||||
@click="handleSelect(p)"
|
||||
>
|
||||
<span :class="['dot', p.type === 'remote' ? 'remote' : 'local']"></span>
|
||||
<span :class="['dot', dotClass(p)]"></span>
|
||||
<span class="menu-name">{{ p.name }}</span>
|
||||
<span
|
||||
v-if="p.type === 'remote'"
|
||||
v-if="p.type !== 'local'"
|
||||
class="more-btn"
|
||||
title="更多操作"
|
||||
@click.stop="toggleMore(p)"
|
||||
@@ -40,8 +40,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { IconCloud } from '@arco-design/web-vue/es/icon'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
|
||||
const emit = defineEmits<{ (e: 'add'): void; (e: 'select', id: string): void; (e: 'edit', id: string): void }>()
|
||||
@@ -51,8 +52,8 @@ const moreOpenId = ref<string | null>(null)
|
||||
const profiles = shallowRef(connectionManager.profiles)
|
||||
const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
||||
|
||||
// 是否有远程 profile(决定显示模式)
|
||||
const hasRemote = computed(() => profiles.value.some(p => p.type === 'remote'))
|
||||
// 是否有远程/SFTP profile(决定显示模式)
|
||||
const hasRemote = computed(() => profiles.value.some(p => p.type !== 'local'))
|
||||
|
||||
// 防抖:避免 connecting→connected 快速切换导致闪烁
|
||||
const displayState = ref(connectionManager.state)
|
||||
@@ -86,12 +87,19 @@ function handleClickOutside(e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
if (_stateTimer) clearTimeout(_stateTimer)
|
||||
})
|
||||
|
||||
function handleSelect(p: { id: string }) {
|
||||
connectionManager.connect(p.id)
|
||||
async function handleSelect(p: { id: string }) {
|
||||
showMenu.value = false
|
||||
emit('select', p.id)
|
||||
try {
|
||||
await connectionManager.connect(p.id)
|
||||
emit('select', p.id)
|
||||
} catch (err) {
|
||||
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMore(p: { id: string }) {
|
||||
@@ -105,9 +113,16 @@ function handleEdit(p: { id: string }) {
|
||||
}
|
||||
|
||||
function handleDelete(p: { id: string; name: string }) {
|
||||
if (!window.confirm(`确定删除「${p.name}」?`)) return
|
||||
connectionManager.removeProfile(p.id)
|
||||
moreOpenId.value = null
|
||||
}
|
||||
|
||||
function dotClass(p: { type: string }): string {
|
||||
if (p.type === 'sftp') return 'sftp'
|
||||
if (p.type === 'remote') return 'remote'
|
||||
return 'local'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -160,6 +175,7 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
.dot.error { background: var(--color-danger-6); }
|
||||
.dot.local { background: var(--color-text-3); }
|
||||
.dot.remote { background: var(--color-primary-6); }
|
||||
.dot.sftp { background: #7c3aed; }
|
||||
|
||||
.label {
|
||||
max-width: 70px;
|
||||
|
||||
@@ -441,9 +441,9 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
// HTML 预览 URL(实时从 connectionManager 读取,不缓存)
|
||||
function resolveHtmlPreviewBase(): string {
|
||||
if (!connectionManager.isRemote()) return 'http://localhost:8073'
|
||||
if (!connectionManager.isRemote()) return 'http://localhost:2652'
|
||||
const base = connectionManager.getFileServerBaseURL()
|
||||
if (!base) return 'http://localhost:8073'
|
||||
if (!base) return 'http://localhost:2652'
|
||||
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs(htmlPreviewUrl 会替换为 html-preview)
|
||||
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||
}
|
||||
@@ -828,11 +828,11 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
|
||||
// 处理 HTML iframe 发送的消息(链接点击)
|
||||
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||
// 安全检查:接受来自本地文件服务器或同源的消息
|
||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
|
||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:2652
|
||||
const allowedOrigins = [
|
||||
window.location.origin,
|
||||
'null',
|
||||
resolveHtmlPreviewBase(), // 动态:本地 localhost:8073 或远程代理地址
|
||||
resolveHtmlPreviewBase(), // 动态:本地 localhost:2652 或远程代理地址
|
||||
]
|
||||
if (!allowedOrigins.includes(event.origin)) {
|
||||
return
|
||||
|
||||
@@ -2,44 +2,61 @@
|
||||
<transition name="slide">
|
||||
<div v-show="config.visible" class="sidebar">
|
||||
<!-- 服务器区块 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section" :class="{ 'section-on-top': settingsOpen || moreOpenId }">
|
||||
<div class="section-header" @click="serverCollapsed = !serverCollapsed">
|
||||
<span class="section-title">🖥️ 服务器</span>
|
||||
<a-tag :color="statusTagColor" size="small">{{ statusLabel }}</a-tag>
|
||||
<icon-down v-if="!serverCollapsed" class="section-toggle" />
|
||||
<icon-right v-else class="section-toggle" />
|
||||
</div>
|
||||
<div class="section-content server-content" :class="{ collapsed: serverCollapsed }">
|
||||
<div class="server-info">
|
||||
<div class="server-row">
|
||||
<span class="server-label">模式</span>
|
||||
<a-tag :color="isRemote ? 'blue' : 'green'" size="small">{{ isRemote ? '远程' : '本地' }}</a-tag>
|
||||
</div>
|
||||
<div v-if="activeProfile" class="server-row">
|
||||
<span class="server-label">服务器</span>
|
||||
<span class="server-val">{{ activeProfile.name }}</span>
|
||||
<!-- 表头 -->
|
||||
<div class="server-table-head">
|
||||
<span class="col-name">名称</span>
|
||||
<span class="col-metric">磁盘</span>
|
||||
<span class="col-metric">CPU</span>
|
||||
<span class="col-metric">内存</span>
|
||||
<span class="col-action settings-btn" @click.stop="settingsOpen = !settingsOpen" title="管理">···</span>
|
||||
</div>
|
||||
<!-- 表格行 -->
|
||||
<div
|
||||
v-for="p in profiles"
|
||||
:key="p.id"
|
||||
class="server-table-row"
|
||||
:class="{ active: p.id === activeId }"
|
||||
@click="handleSelect(p)"
|
||||
>
|
||||
<span class="col-name" :title="stateText(p.id)"><span :class="['dot', stateDotClass(p.id)]" />{{ p.name }}</span>
|
||||
<span class="col-metric" :title="metricTooltip(p.id, 'disk')">{{ sysInfo(p.id)?.diskUsage || '-' }}</span>
|
||||
<span class="col-metric" :title="metricTooltip(p.id, 'cpu')">{{ sysInfo(p.id)?.cpuUsage || '-' }}</span>
|
||||
<span class="col-metric" :title="metricTooltip(p.id, 'mem')">{{ sysInfo(p.id)?.memUsage || '-' }}</span>
|
||||
<span
|
||||
v-if="p.type !== 'local'"
|
||||
class="col-action more-btn"
|
||||
title="更多操作"
|
||||
@click.stop="toggleMore(p.id)"
|
||||
>···</span>
|
||||
<span v-else class="col-action"></span>
|
||||
<!-- 更多操作子菜单 -->
|
||||
<div v-if="moreOpenId === p.id" class="more-menu" @click.stop>
|
||||
<div class="more-item" @click="handleEdit(p)">编辑</div>
|
||||
<div class="more-item danger" @click="handleDelete(p)">删除</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-actions">
|
||||
<a-button
|
||||
v-if="!isRemote"
|
||||
type="outline"
|
||||
size="mini"
|
||||
long
|
||||
@click.stop="handleConnectRemote"
|
||||
>
|
||||
连接远程
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="outline"
|
||||
status="danger"
|
||||
size="mini"
|
||||
long
|
||||
@click.stop="handleDisconnect"
|
||||
>
|
||||
断开连接
|
||||
</a-button>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 管理设置面板(放在 section-content/overflow 容器外部) -->
|
||||
<div v-if="settingsOpen" class="settings-panel" @click.stop>
|
||||
<div class="settings-item" @click="$emit('openConnectionDialog'); settingsOpen = false">
|
||||
<icon-plus /> 添加服务器
|
||||
</div>
|
||||
<div class="settings-item" @click="toggleAutoConnect">
|
||||
<icon-check-circle :style="{ opacity: autoConnect ? 1 : 0.3 }" />
|
||||
启动时自动连接所有服务器
|
||||
</div>
|
||||
<div class="settings-item" @click="toggleAutoRefresh">
|
||||
<icon-check-circle :style="{ opacity: autoRefresh ? 1 : 0.3 }" />
|
||||
自动刷新系统信息 (15s)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,11 +145,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
||||
import type { SystemInfo } from '@/api/connection-manager'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import { IconStar, IconClose, IconPushpin, IconDown, IconRight } from '@arco-design/web-vue/es/icon'
|
||||
import { IconStar, IconClose, IconPushpin, IconDown, IconRight, IconStorage, IconComputer, IconDesktop, IconPlus, IconCheckCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { getFileIcon } from '@/utils/fileUtils'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
@@ -146,26 +165,70 @@ const serverCollapsed = ref(false)
|
||||
const favCollapsed = ref(false)
|
||||
const helpCollapsed = ref(false)
|
||||
|
||||
// 服务器响应式状态(connectionManager 非响应式,需手动桥接)
|
||||
const connState = ref(connectionManager.state)
|
||||
const isRemote = ref(connectionManager.isRemote())
|
||||
const activeProfile = ref(connectionManager.activeProfile)
|
||||
// 管理设置
|
||||
const settingsOpen = ref(false)
|
||||
const autoConnect = ref(localStorage.getItem('desk:autoConnect') !== 'false')
|
||||
const autoRefresh = ref(localStorage.getItem('desk:autoRefresh') === 'true')
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function toggleAutoConnect() {
|
||||
autoConnect.value = !autoConnect.value
|
||||
localStorage.setItem('desk:autoConnect', String(autoConnect.value))
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefresh.value = !autoRefresh.value
|
||||
localStorage.setItem('desk:autoRefresh', String(autoRefresh.value))
|
||||
if (autoRefresh.value) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh()
|
||||
refreshTimer = setInterval(() => {
|
||||
profiles.value.forEach(p => { connectionManager.fetchSystemInfo(p.id).catch(() => {}) })
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (autoRefresh.value) startAutoRefresh()
|
||||
})
|
||||
onUnmounted(() => stopAutoRefresh())
|
||||
|
||||
// 点击外部关闭更多菜单
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const el = e.target as HTMLElement
|
||||
if (!el.closest('.server-table-row')) {
|
||||
moreOpenId.value = null
|
||||
}
|
||||
if (!el.closest('.settings-panel') && !el.closest('.settings-btn')) {
|
||||
settingsOpen.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
|
||||
|
||||
// 服务器 Profile 列表状态
|
||||
const profiles = shallowRef(connectionManager.profiles)
|
||||
const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
||||
const moreOpenId = ref<string | null>(null)
|
||||
const sysInfoMap = ref<Record<string, SystemInfo>>({})
|
||||
|
||||
// 监听连接变化 + 系统信息变化
|
||||
connectionManager.onStateChange(() => {
|
||||
connState.value = connectionManager.state
|
||||
isRemote.value = connectionManager.isRemote()
|
||||
activeProfile.value = connectionManager.activeProfile
|
||||
profiles.value = connectionManager.profiles
|
||||
activeId.value = connectionManager.activeProfile?.id ?? ''
|
||||
})
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
connecting: '连接中...',
|
||||
connected: '已连接',
|
||||
error: '异常',
|
||||
}
|
||||
const statusLabel = computed(() => statusMap[connState.value] || connState.value)
|
||||
const statusTagColor = computed(() => {
|
||||
const map: Record<string, string> = { connecting: 'orangered', connected: 'blue', error: 'red' }
|
||||
return map[connState.value] || 'gray'
|
||||
connectionManager.onSystemInfoChange((profileId, info) => {
|
||||
sysInfoMap.value = { ...sysInfoMap.value, [profileId]: info }
|
||||
})
|
||||
|
||||
// 计算第一个和最后一个置顶项的索引
|
||||
@@ -198,7 +261,7 @@ interface Emits {
|
||||
(e: 'dragOver', event: DragEvent): void
|
||||
(e: 'drop', event: DragEvent, targetIndex: number): void
|
||||
(e: 'dragEnd'): void
|
||||
(e: 'openConnectionDialog'): void
|
||||
(e: 'openConnectionDialog', editId?: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -241,17 +304,91 @@ const handleDragEnd = () => {
|
||||
}
|
||||
|
||||
// 服务器操作
|
||||
const handleConnectRemote = () => {
|
||||
const remote = connectionManager.profiles.find(p => p.type === 'remote')
|
||||
if (remote) {
|
||||
connectionManager.connect(remote.id)
|
||||
} else {
|
||||
emit('openConnectionDialog')
|
||||
function sysInfo(profileId: string): SystemInfo | undefined {
|
||||
return sysInfoMap.value[profileId]
|
||||
}
|
||||
|
||||
function stateDotClass(profileId: string): string {
|
||||
if (profileId === activeId.value) {
|
||||
const state = connectionManager.state
|
||||
if (state === 'connected') return 'connected'
|
||||
if (state === 'connecting') return 'connecting'
|
||||
if (state === 'error') return 'error'
|
||||
return 'disconnected'
|
||||
}
|
||||
const info = sysInfoMap.value[profileId]
|
||||
if (info?._error) return 'error'
|
||||
if (info && Object.keys(info).length > 1) return 'connected'
|
||||
return 'disconnected'
|
||||
}
|
||||
|
||||
function metricTooltip(profileId: string, type: 'disk' | 'cpu' | 'mem'): string {
|
||||
const info = sysInfoMap.value[profileId]
|
||||
if (!info) return '-'
|
||||
if (type === 'disk') {
|
||||
const usage = info.diskUsage ? `${info.diskUsage}` : '-'
|
||||
const used = info.diskUsed != null ? formatBytes(info.diskUsed) : ''
|
||||
const total = info.diskTotal ? formatBytes(info.diskTotal) : ''
|
||||
return total ? `${used} / ${total} (${usage})` : usage || '-'
|
||||
}
|
||||
if (type === 'cpu') {
|
||||
const usage = info.cpuUsage ? `${info.cpuUsage}` : '-'
|
||||
const cores = info.cpuCores ? `${info.cpuCores} 核` : ''
|
||||
return cores ? `${usage} / ${cores}` : usage || '-'
|
||||
}
|
||||
if (type === 'mem') {
|
||||
const usage = info.memUsage ? `${info.memUsage}` : '-'
|
||||
const used = info.memUsed != null ? formatBytes(info.memUsed) : ''
|
||||
const total = info.memTotal ? formatBytes(info.memTotal) : ''
|
||||
return total ? `${used} / ${total} (${usage})` : usage || '-'
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1048576) return `${(n / 1024).toFixed(0)} KB`
|
||||
if (n < 1073741824) return `${(n / 1048576).toFixed(1)} MB`
|
||||
return `${(n / 1073741824).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
function stateText(profileId: string): string {
|
||||
if (profileId === activeId.value) {
|
||||
const s = connectionManager.state
|
||||
if (s === 'connected') return '已连接'
|
||||
if (s === 'connecting') return '连接中...'
|
||||
if (s === 'error') return '连接失败'
|
||||
return '未连接'
|
||||
}
|
||||
const info = sysInfoMap.value[profileId]
|
||||
if (info?._error) return info._errorMsg || '采集失败'
|
||||
if (info && Object.keys(info).length > 1) return '已连接'
|
||||
return '未连接'
|
||||
}
|
||||
|
||||
async function handleSelect(p: { id: string; type: string }) {
|
||||
moreOpenId.value = null
|
||||
if (p.id === activeId.value) return
|
||||
try {
|
||||
await connectionManager.connect(p.id)
|
||||
} catch (err) {
|
||||
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = () => {
|
||||
connectionManager.disconnect()
|
||||
function toggleMore(id: string) {
|
||||
moreOpenId.value = moreOpenId.value === id ? null : id
|
||||
}
|
||||
|
||||
function handleEdit(p: { id: string }) {
|
||||
moreOpenId.value = null
|
||||
emit('openConnectionDialog', p.id)
|
||||
}
|
||||
|
||||
function handleDelete(p: { id: string; name: string }) {
|
||||
if (!window.confirm(`确定删除「${p.name}」?`)) return
|
||||
connectionManager.removeProfile(p.id)
|
||||
moreOpenId.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -271,6 +408,11 @@ const handleDisconnect = () => {
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-section.section-on-top {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* 帮助区块固定在底部,不被推出窗口 */
|
||||
@@ -333,6 +475,8 @@ const handleDisconnect = () => {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px 0;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
/* 帮助内容 */
|
||||
@@ -365,37 +509,166 @@ const handleDisconnect = () => {
|
||||
|
||||
/* 服务器内容 */
|
||||
.server-content {
|
||||
padding: 8px 12px;
|
||||
padding: 2px 4px 6px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 0 0 6px 6px;
|
||||
border-left: 3px solid var(--color-primary-6);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
/* 表头 */
|
||||
.server-table-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
padding: 3px 4px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
user-select: none;
|
||||
}
|
||||
.server-table-head > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-row {
|
||||
/* 表格行 */
|
||||
.server-table-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.server-table-row:hover { background: var(--color-fill-2); }
|
||||
.server-table-row.active { background: var(--color-primary-light-1); }
|
||||
|
||||
.col-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.col-name .dot { flex-shrink: 0; }
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.connected { background: #00b42a; }
|
||||
.dot.connecting { background: #165dff; animation: pulse 1s infinite; }
|
||||
.dot.disconnected { background: var(--color-text-4); }
|
||||
.dot.error { background: #f53f3f; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.col-metric {
|
||||
width: 42px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.col-action {
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 更多按钮 */
|
||||
.more-btn {
|
||||
opacity: 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
letter-spacing: 1px;
|
||||
transition: opacity 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.server-table-row:hover .more-btn { opacity: 1; }
|
||||
|
||||
/* 更多操作子菜单 */
|
||||
.more-menu {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 100%;
|
||||
min-width: 80px;
|
||||
background: var(--color-bg-popup);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.more-item {
|
||||
padding: 5px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.more-item:hover { background: var(--color-fill-1); }
|
||||
.more-item.danger { color: var(--color-danger-6); }
|
||||
|
||||
/* 设置按钮 */
|
||||
.settings-btn {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
letter-spacing: 1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.settings-btn:hover { color: var(--color-primary-6); }
|
||||
|
||||
/* 设置面板 — 绝对定位浮在按钮下方 */
|
||||
.settings-panel {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50px;
|
||||
z-index: 20;
|
||||
min-width: 200px;
|
||||
background: var(--color-bg-popup);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
padding: 2px 0;
|
||||
}
|
||||
.settings-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
padding: 7px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.settings-item:hover { background: var(--color-fill-1); }
|
||||
|
||||
.server-label {
|
||||
/* 区块操作图标 */
|
||||
.section-action {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
min-width: 42px;
|
||||
}
|
||||
|
||||
.server-val {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
.server-actions :deep(.arco-btn) {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.section-action:hover { color: var(--color-primary-6); }
|
||||
|
||||
/* 收藏项 */
|
||||
.sidebar-item {
|
||||
|
||||
@@ -149,6 +149,7 @@ import ConnectionDialog from './ConnectionDialog.vue'
|
||||
interface Props {
|
||||
config: ToolbarConfig
|
||||
openConnectionDialog?: boolean
|
||||
editProfileId?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -172,7 +173,14 @@ const emit = defineEmits<Emits>()
|
||||
const showConnectionDialog = ref(false)
|
||||
const connectionDialogRef = ref<InstanceType<typeof ConnectionDialog>>()
|
||||
|
||||
watch(() => props.openConnectionDialog, (v) => { if (v > 0) showConnectionDialog.value = true })
|
||||
watch(() => props.openConnectionDialog, (v) => {
|
||||
if (v > 0) {
|
||||
showConnectionDialog.value = true
|
||||
if (props.editProfileId) {
|
||||
nextTick(() => connectionDialogRef.value?.editProfile(props.editProfileId))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const onConnectionChanged = async (_id: string) => {
|
||||
emit('connectionChanged')
|
||||
|
||||
@@ -15,6 +15,17 @@ export function useCommonPaths() {
|
||||
|
||||
const loadCommonPaths = async () => {
|
||||
try {
|
||||
// SFTP 未连接时返回默认路径,避免 requireConn 抛错;已连接则获取真实远程路径
|
||||
if (connectionManager.isSftp() && connectionManager.state !== 'connected') {
|
||||
const fallback: ShortcutPath[] = [
|
||||
{ name: `${PATH_ICONS.HOME} 主目录`, path: '/root' },
|
||||
{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' },
|
||||
{ name: '/tmp', path: '/tmp' },
|
||||
]
|
||||
commonPaths.value = fallback
|
||||
systemPaths.value = { home: '/root', tmp: '/tmp', root: '/' }
|
||||
return
|
||||
}
|
||||
const paths = await getCommonPaths()
|
||||
if (!paths) throw new Error('无法获取系统路径')
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { normalizeFilePath, getExt } from '@/utils/fileUtils'
|
||||
import { detectFileTypeByContent } from '@/api/system'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import { SftpTransport } from '@/api/sftp-transport'
|
||||
import { getFileServerBaseURL } from '@/api/file-server'
|
||||
import {
|
||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
||||
@@ -28,7 +30,7 @@ export interface UseFilePreviewOptions {
|
||||
}
|
||||
|
||||
function getLocalServerURL(): string {
|
||||
return 'http://localhost:8073'
|
||||
return getFileServerBaseURL()
|
||||
}
|
||||
|
||||
function resolveFileServerBase(): string {
|
||||
@@ -51,16 +53,28 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const currentImageDimensions = ref('')
|
||||
|
||||
/**
|
||||
* 获取预览 URL(本地/远程自适应,每次实时计算)
|
||||
* 本地: http://localhost:8073/localfs/{encoded_path}
|
||||
* 远程: {baseUrl}/api/v1/proxy/localfs/{raw_path}(Cookie 自动携带认证)
|
||||
* 获取预览 URL(本地/远程/SFTP 自适应,每次实时计算)
|
||||
* 本地: {fileServerBaseURL}/localfs/{encoded_path}
|
||||
* 远程(HTTP): {baseUrl}/api/v1/proxy/localfs/{raw_path}
|
||||
* SFTP: 下载到本地临时目录 → {fileServerBaseURL}/localfs/{temp_path}
|
||||
*/
|
||||
const getPreviewUrl = (path: string): string => {
|
||||
if (!path) return ''
|
||||
const isSftp = connectionManager.isSftp()
|
||||
const isRemote = connectionManager.isRemote()
|
||||
|
||||
// SFTP 模式:需要先下载到本地临时目录
|
||||
// 注意:这里返回的是同步路径,实际下载在 updatePreviewUrl 中异步完成
|
||||
// 对于 SFTP 模式,getPreviewUrl 返回的 URL 会在 updatePreviewUrl 中被覆盖为临时文件路径
|
||||
if (isSftp) {
|
||||
const base = getLocalServerURL()
|
||||
let normalized = normalizeFilePath(path, true)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
return `${base}${sep}localfs/${normalized}`
|
||||
}
|
||||
|
||||
const base = resolveFileServerBase()
|
||||
let normalized = normalizeFilePath(path, true)
|
||||
// 远程模式去掉前导 /,避免与 URL 基础路径拼接产生双斜杠(导致 307 重定向)
|
||||
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}`
|
||||
@@ -92,9 +106,25 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预览 URL
|
||||
* 更新预览 URL(SFTP 模式会先下载到本地临时目录)
|
||||
*/
|
||||
const updatePreviewUrl = async (path: string) => {
|
||||
if (!path) { previewUrl.value = ''; return }
|
||||
|
||||
// SFTP 模式:下载到本地临时目录后用本地文件服务器预览
|
||||
if (connectionManager.isSftp()) {
|
||||
const transport = connectionManager.getTransport()
|
||||
if (transport instanceof SftpTransport) {
|
||||
try {
|
||||
const tempPath = await transport.downloadForPreview(path)
|
||||
previewUrl.value = getPreviewUrl(tempPath)
|
||||
return
|
||||
} catch {
|
||||
// 下载失败,回退显示原始路径(会无法预览但不会崩溃)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previewUrl.value = getPreviewUrl(path)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
@drag-over="handleDragOver"
|
||||
@drop="handleDrop"
|
||||
@drag-end="handleDragEnd"
|
||||
@open-connection-dialog="triggerConnectionDialog++"
|
||||
@open-connection-dialog="handleOpenConnectionDialog"
|
||||
/>
|
||||
|
||||
<!-- 右侧工作区:面包屑工具栏 + 文件列表/编辑器 -->
|
||||
@@ -25,6 +25,7 @@
|
||||
ref="toolbarRef"
|
||||
:config="toolbarConfig"
|
||||
:open-connection-dialog="triggerConnectionDialog"
|
||||
:edit-profile-id="pendingEditProfileId"
|
||||
@update:file-path="handleFilePathUpdate"
|
||||
@update:show-sidebar="handleSidebarToggle"
|
||||
@refresh="handleRefresh"
|
||||
@@ -162,6 +163,17 @@ const fileLoading = ref(false)
|
||||
const selectedFileItem = ref<FileItem | null>(null)
|
||||
const toolbarRef = ref<InstanceType<typeof import('./components/Toolbar.vue').default> | null>(null)
|
||||
const triggerConnectionDialog = ref(0)
|
||||
const pendingEditProfileId = ref<string | null>(null)
|
||||
|
||||
function handleOpenConnectionDialog(editId?: string) {
|
||||
if (editId) {
|
||||
pendingEditProfileId.value = editId
|
||||
triggerConnectionDialog.value++
|
||||
} else {
|
||||
pendingEditProfileId.value = null
|
||||
triggerConnectionDialog.value++
|
||||
}
|
||||
}
|
||||
|
||||
// 排序状态(带 localStorage 持久化)
|
||||
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
|
||||
@@ -357,7 +369,7 @@ const computeRendered = computed(() => {
|
||||
const isRemote = connectionManager.isRemote()
|
||||
const base = isRemote
|
||||
? (connectionManager.getFileServerBaseURL().replace(/\/$/, '') + '/api/v1/proxy/localfs')
|
||||
: 'http://localhost:8073/localfs'
|
||||
: 'http://localhost:2652/localfs'
|
||||
setFileServerBase(base)
|
||||
|
||||
return marked.parse(content) as string
|
||||
@@ -420,10 +432,13 @@ const handleRefresh = async () => {
|
||||
await loadDirectory(filePath.value)
|
||||
}
|
||||
|
||||
// 连接切换后重置路径(仅监听状态变化以刷新快捷入口,不做自动导航)
|
||||
// 连接切换后重置路径并刷新文件列表
|
||||
connectionManager.onStateChange(async (state) => {
|
||||
if (state === 'connected') {
|
||||
await loadCommonPaths()
|
||||
const targetPath = connectionManager.isRemote() ? '/' : 'C:/'
|
||||
filePath.value = targetPath
|
||||
await loadDirectory(targetPath)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1278,6 +1293,12 @@ onMounted(async () => {
|
||||
// 加载系统路径(阻塞,确保快捷入口就绪)
|
||||
await loadCommonPaths()
|
||||
|
||||
// SFTP 连接是异步的,未就绪时跳过初始加载,由 onStateChange('connected') 触发
|
||||
if (connectionManager.isSftp() && connectionManager.state !== 'connected') {
|
||||
filePath.value = '/'
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径
|
||||
const startPath = connectionManager.isRemote() ? '/'
|
||||
: (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/')
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import MarkdownEditor from '../../components/MarkdownEditor.vue'
|
||||
import MarkdownEditor from './MarkdownEditor.vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
const markdownContent = ref('')
|
||||
@@ -104,7 +104,7 @@ renderer.heading = function(token: any) {
|
||||
// 当前 Markdown 文件所在目录(由调用方在渲染前设置)
|
||||
let _currentFileDir: string = ''
|
||||
// 文件服务器 Base URL(由调用方在渲染前设置)
|
||||
let _fileServerBase: string = 'http://localhost:8073/localfs'
|
||||
let _fileServerBase: string = 'http://localhost:2652/localfs'
|
||||
|
||||
/**
|
||||
* 设置当前 Markdown 文件所在目录(用于图片相对路径→文件服务器 URL 转换)
|
||||
@@ -121,7 +121,7 @@ export function getCurrentFileDir(): string {
|
||||
|
||||
/**
|
||||
* 设置文件服务器 Base URL(用于图片相对路径转换)
|
||||
* @param base 完整的 base URL 前缀,如 "http://localhost:8073/localfs" 或 "https://host:port/api/v1/proxy/localfs"
|
||||
* @param base 完整的 base URL 前缀,如 "http://localhost:2652/localfs" 或 "https://host:port/api/v1/proxy/localfs"
|
||||
*/
|
||||
export function setFileServerBase(base: string): void {
|
||||
_fileServerBase = base
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
<template>
|
||||
<div class="markdown-viewer-container">
|
||||
<div class="viewer-header">
|
||||
<div class="title">
|
||||
<icon-file-text />
|
||||
<span>{{ currentFile?.name || 'Markdown 文档' }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<PdfExportButton @export-complete="onExportComplete" />
|
||||
<a-button @click="handleBackToList" type="outline">
|
||||
<icon-arrow-left />
|
||||
返回列表
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viewer-content">
|
||||
<MarkdownEditor
|
||||
:content="fileContent"
|
||||
@content-change="handleContentChange"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 底部状态栏 -->
|
||||
<div class="status-bar">
|
||||
<div class="file-info">
|
||||
<span>{{ currentFile?.path }}</span>
|
||||
</div>
|
||||
<div class="content-info">
|
||||
<span>{{ wordCount }} 字符 | {{ lineCount }} 行</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import MarkdownEditor from '@/components/MarkdownEditor.vue'
|
||||
import PdfExportButton from '@/components/PdfExportButton.vue'
|
||||
import { useFileOperations } from '@/composables/useFileOperations'
|
||||
|
||||
export default {
|
||||
name: 'MarkdownViewer',
|
||||
components: {
|
||||
MarkdownEditor,
|
||||
PdfExportButton
|
||||
},
|
||||
props: {
|
||||
filePath: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['back'],
|
||||
setup(props, { emit }) {
|
||||
const fileOperations = useFileOperations()
|
||||
const fileContent = ref('')
|
||||
const currentFile = ref(null)
|
||||
const hasChanges = ref(false)
|
||||
const lastSavedContent = ref('')
|
||||
|
||||
// 计算属性
|
||||
const wordCount = computed(() => {
|
||||
return fileContent.value.length
|
||||
})
|
||||
|
||||
const lineCount = computed(() => {
|
||||
return fileContent.value.split('\n').length
|
||||
})
|
||||
|
||||
// 方法
|
||||
const loadFile = async () => {
|
||||
try {
|
||||
const response = await fileOperations.readFile(props.filePath)
|
||||
fileContent.value = response.content
|
||||
lastSavedContent.value = response.content
|
||||
hasChanges.value = false
|
||||
|
||||
// 获取文件信息
|
||||
const fileName = props.filePath.split('/').pop() || props.filePath.split('\\').pop()
|
||||
currentFile.value = {
|
||||
name: fileName,
|
||||
path: props.filePath
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error(`读取文件失败: ${error?.message ?? String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentChange = (content) => {
|
||||
hasChanges.value = content !== lastSavedContent.value
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await fileOperations.saveFile(props.filePath, fileContent.value)
|
||||
lastSavedContent.value = fileContent.value
|
||||
hasChanges.value = false
|
||||
Message.success('文件已保存')
|
||||
} catch (error) {
|
||||
Message.error(`保存文件失败: ${error?.message ?? String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const onExportComplete = () => {
|
||||
Message.success('PDF 导出完成')
|
||||
}
|
||||
|
||||
const handleBackToList = () => {
|
||||
emit('back')
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadFile()
|
||||
})
|
||||
|
||||
return {
|
||||
fileContent,
|
||||
currentFile,
|
||||
hasChanges,
|
||||
wordCount,
|
||||
lineCount,
|
||||
handleContentChange,
|
||||
handleSave,
|
||||
onExportComplete,
|
||||
handleBackToList
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: white;
|
||||
margin: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
background: white;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
font-family: monospace;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.content-info {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.viewer-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 6px 16px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user