新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放
This commit is contained in:
@@ -8,15 +8,13 @@ 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 { OssTransport } from '@/api/oss-transport'
|
||||
import { getFileServerBaseURL } from '@/api/file-server'
|
||||
import {
|
||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
||||
isHtmlFile, isMarkdownFile,
|
||||
isTextEditable, isConfigFile
|
||||
} from '@/utils/fileTypeHelpers'
|
||||
import type { FilePreviewMetadata, FileType } from '@/types/file-system'
|
||||
import type { FileType } from '@/types/file-system'
|
||||
|
||||
// 内容检测大小限制(与后端一致)
|
||||
const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB
|
||||
@@ -25,26 +23,73 @@ const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB
|
||||
const contentDetectCache = new Map<string, { timestamp: number; result: any }>()
|
||||
const CACHE_TTL = 60000 // 1分钟缓存
|
||||
|
||||
export interface UseFilePreviewOptions {
|
||||
filePath?: string
|
||||
isBrowsingZip?: boolean
|
||||
}
|
||||
// ====== URL 工具函数(导出供外部使用) ======
|
||||
|
||||
function getLocalServerURL(): string {
|
||||
return getFileServerBaseURL()
|
||||
}
|
||||
|
||||
function resolveFileServerBase(): string {
|
||||
// 单一数据源:从 connectionManager 实时读取,不缓存
|
||||
if (!connectionManager.isRemote()) return getLocalServerURL()
|
||||
/** 解析文件服务器 base URL(区分本地/远程模式) */
|
||||
export function resolveFileServerBase(): string {
|
||||
if (!connectionManager.isRemote()) return getFileServerBaseURL()
|
||||
const base = connectionManager.getFileServerBaseURL()
|
||||
if (!base) return getLocalServerURL()
|
||||
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs
|
||||
if (!base) return getFileServerBaseURL()
|
||||
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||
}
|
||||
|
||||
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
||||
/** 拼接本地文件服务器 URL(远程文件下载到临时目录后使用) */
|
||||
export function buildLocalUrl(localPath: string): string {
|
||||
const base = getFileServerBaseURL()
|
||||
let normalized = normalizeFilePath(localPath, true)
|
||||
if (normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
return `${base}${sep}localfs/${normalized}`
|
||||
}
|
||||
|
||||
/** 统一 URL 解析核心(传入 transport 实例,消除 instanceof 分支) */
|
||||
async function resolveWithTransport(
|
||||
transport: import('@/api/transport').FsTransport,
|
||||
path: string,
|
||||
stream: boolean
|
||||
): Promise<string> {
|
||||
// 需要下载到本地的传输类型
|
||||
if (transport.downloadForPreview) {
|
||||
// OSS 支持签名 URL 流式播放
|
||||
if (stream && transport.getSignedUrl) {
|
||||
return await transport.getSignedUrl(path)
|
||||
}
|
||||
const tempPath = await transport.downloadForPreview(path)
|
||||
if (tempPath !== path) return buildLocalUrl(tempPath)
|
||||
// downloadForPreview 返回原路径 → 本地/HTTP,直接拼 URL
|
||||
}
|
||||
// 本地/HTTP: 直接拼文件服务器 URL
|
||||
const base = resolveFileServerBase()
|
||||
let normalized = normalizeFilePath(path, true)
|
||||
if (normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
return `${base}${sep}${connectionManager.isRemote() ? '' : 'localfs/'}${normalized}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一文件 URL 解析(使用当前活跃 transport)
|
||||
*/
|
||||
export async function resolveFileUrl(path: string, stream = false): Promise<string> {
|
||||
if (!path) return ''
|
||||
return resolveWithTransport(connectionManager.getTransport(), path, stream)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按指定 profileId 解析文件 URL(播放列表恢复用)
|
||||
* 不切换当前活跃连接,直接从连接池取对应 transport
|
||||
*/
|
||||
export async function resolveFileUrlForProfile(
|
||||
path: string, profileId: string | undefined, stream = false
|
||||
): Promise<string> {
|
||||
if (!path) return ''
|
||||
if (profileId) {
|
||||
const transport = connectionManager.getTransportFor(profileId)
|
||||
if (transport) return resolveWithTransport(transport, path, stream)
|
||||
}
|
||||
return resolveFileUrl(path, stream)
|
||||
}
|
||||
|
||||
export function useFilePreview() {
|
||||
|
||||
// 预览 URL
|
||||
const previewUrl = ref('')
|
||||
@@ -55,46 +100,24 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
|
||||
/**
|
||||
* 获取预览 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)
|
||||
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
if (normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}`
|
||||
return `${base}${sep}${connectionManager.isRemote() ? '' : 'localfs/'}${normalized}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过内容检测文件类型(用于小文件)
|
||||
*/
|
||||
const detectByContent = async (path: string, fileSize?: number): Promise<{ category: string; ext: string } | null> => {
|
||||
// 如果文件太大,跳过内容检测
|
||||
if (fileSize !== undefined && fileSize > CONTENT_DETECT_MAX_SIZE) {
|
||||
return null
|
||||
}
|
||||
if (fileSize !== undefined && fileSize > CONTENT_DETECT_MAX_SIZE) return null
|
||||
|
||||
// 检查缓存
|
||||
const cached = contentDetectCache.get(path)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.result
|
||||
}
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) return cached.result
|
||||
|
||||
try {
|
||||
const result = await detectFileTypeByContent(path)
|
||||
@@ -108,28 +131,15 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
|
||||
/**
|
||||
* 更新预览 URL
|
||||
* SFTP/OSS:下载到本地临时目录后用本地文件服务器预览
|
||||
*/
|
||||
const updatePreviewUrl = async (path: string) => {
|
||||
if (!path) { previewUrl.value = ''; return }
|
||||
const transport = connectionManager.getTransport()
|
||||
|
||||
// SFTP / OSS:下载到本地临时目录后用本地文件服务器预览
|
||||
if (transport instanceof SftpTransport || transport instanceof OssTransport) {
|
||||
try {
|
||||
const tempPath = await transport.downloadForPreview(path)
|
||||
// 临时文件通过本地文件服务器提供,始终用 localfs 路径
|
||||
const base = getLocalServerURL()
|
||||
const normalized = normalizeFilePath(tempPath, true)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
previewUrl.value = `${base}${sep}localfs/${normalized}`
|
||||
return
|
||||
} catch {
|
||||
// 下载失败,回退
|
||||
}
|
||||
try {
|
||||
previewUrl.value = await resolveFileUrl(path)
|
||||
} catch (e) {
|
||||
console.warn('[Preview] 下载失败:', path, e)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
previewUrl.value = getPreviewUrl(path)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,22 +161,11 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
return 'Binary' as FileType
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否可预览
|
||||
*/
|
||||
const isPreviewable = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
return isPreviewableType(filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否可编辑
|
||||
*/
|
||||
const isEditable = (filename: string, fileSize: number): boolean => {
|
||||
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) return false
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = getExt(filename)
|
||||
return FILE_EXTENSIONS.CODE.includes(ext) ||
|
||||
@@ -187,71 +186,28 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
imageLoading.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片加载失败
|
||||
*/
|
||||
const onImageError = () => {
|
||||
imageLoading.value = false
|
||||
currentImageDimensions.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始加载图片
|
||||
*/
|
||||
const startImageLoad = () => {
|
||||
imageLoading.value = true
|
||||
currentImageDimensions.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取媒体元数据
|
||||
*/
|
||||
const getMediaMetadata = async (url: string): Promise<FilePreviewMetadata> => {
|
||||
const metadata: FilePreviewMetadata = {}
|
||||
|
||||
// 对于图片,使用 Image 对象
|
||||
if (url.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
metadata.width = img.naturalWidth
|
||||
metadata.height = img.naturalHeight
|
||||
resolve(metadata)
|
||||
}
|
||||
img.onerror = () => resolve(metadata)
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
// 对于视频/音频,可以使用 Video/Audio 对象
|
||||
// 但由于跨域等问题,这里简化处理
|
||||
return metadata
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
previewUrl,
|
||||
imageLoading,
|
||||
currentImageDimensions,
|
||||
|
||||
// URL 相关
|
||||
getPreviewUrl,
|
||||
updatePreviewUrl,
|
||||
|
||||
// 文件类型判断(同步,基于扩展名)
|
||||
resolveFileUrl,
|
||||
getFileType,
|
||||
isPreviewable,
|
||||
isEditable,
|
||||
|
||||
// 内容检测(异步,基于文件内容)
|
||||
detectByContent,
|
||||
|
||||
// 事件处理
|
||||
onImageLoad,
|
||||
onImageError,
|
||||
startImageLoad,
|
||||
|
||||
// 工具方法
|
||||
getMediaMetadata
|
||||
startImageLoad
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user