重构:Wails v3 迁移 + 前端目录规范化 + Sidebar滚动优化

- web/ → frontend/ 目录重命名(Wails v3 标准结构)
- main.go: Middleware 修复 custom.js 404 + DevTools 延迟启动
- Sidebar: 收藏夹内部独立滚动 + 帮助区块固定底部
- useFavorites.ts: longPressTimer const→let 修复 TypeError
- App.vue: Arco Tabs padding-top 覆盖
- build: config.yml / Taskfile.yml 对齐官方模板,devtools build tag
- 新增 v3 bindings、vite.config.js、跨平台构建配置

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
lxy
2026-05-01 11:03:53 +08:00
co-authored by Claude Opus 4.7
parent 44847e0d40
commit f54bf1c28d
185 changed files with 7768 additions and 914 deletions
@@ -0,0 +1,223 @@
/**
* 文件预览 Composable
* 提供文件预览 URL 生成、媒体元数据获取等功能
*/
import { ref } from 'vue'
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 {
isImageFile, isVideoFile, isAudioFile, isPdfFile,
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
isTextEditable, isConfigFile
} from '@/utils/fileTypeHelpers'
import type { FilePreviewMetadata, FileType } from '@/types/file-system'
// 内容检测大小限制(与后端一致)
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
}
function getLocalServerURL(): string {
return 'http://localhost:8073'
}
function resolveFileServerBase(): string {
// 单一数据源:从 connectionManager 实时读取,不缓存
if (!connectionManager.isRemote()) return getLocalServerURL()
const base = connectionManager.getFileServerBaseURL()
if (!base) return getLocalServerURL()
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
}
export function useFilePreview(options: UseFilePreviewOptions = {}) {
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
// 预览 URL
const previewUrl = ref('')
// 媒体加载状态
const imageLoading = ref(false)
const currentImageDimensions = ref('')
/**
* 获取预览 URL(本地/远程自适应,每次实时计算)
* 本地: http://localhost:8073/localfs/{encoded_path}
* 远程: {baseUrl}/api/v1/proxy/localfs/{raw_path}(Cookie 自动携带认证)
*/
const getPreviewUrl = (path: string): string => {
if (!path) return ''
const isRemote = connectionManager.isRemote()
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}`
}
/**
* 通过内容检测文件类型(用于小文件)
*/
const detectByContent = async (path: string, fileSize?: number): Promise<{ category: string; ext: string } | 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
}
try {
const result = await detectFileTypeByContent(path)
const data = { category: result.category, ext: result.extension }
contentDetectCache.set(path, { timestamp: Date.now(), result: data })
return data
} catch {
return null
}
}
/**
* 更新预览 URL
*/
const updatePreviewUrl = async (path: string) => {
previewUrl.value = getPreviewUrl(path)
}
/**
* 获取文件类型
*/
const getFileType = (filename: string): FileType => {
if (!filename || typeof filename !== 'string') return 'Binary' as FileType
if (isImageFile(filename)) return 'Image' as FileType
if (isVideoFile(filename)) return 'Video' as FileType
if (isAudioFile(filename)) return 'Audio' as FileType
if (isPdfFile(filename)) return 'Pdf' as FileType
if (isHtmlFile(filename)) return 'Html' as FileType
if (isMarkdownFile(filename)) return 'Markdown' as FileType
if (FILE_EXTENSIONS.CODE.includes(getExt(filename))) return 'Code' as FileType
if (isConfigFile(filename)) return 'Code' as FileType
if (isTextEditable(filename)) return 'Text' as FileType
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 (!filename || typeof filename !== 'string') return false
const ext = getExt(filename)
return FILE_EXTENSIONS.CODE.includes(ext) ||
isTextEditable(filename) ||
isConfigFile(filename) ||
isHtmlFile(filename) ||
isMarkdownFile(filename)
}
/**
* 图片加载完成
*/
const onImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement
if (img.naturalWidth && img.naturalHeight) {
currentImageDimensions.value = `${img.naturalWidth} × ${img.naturalHeight}`
}
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,
// 文件类型判断(同步,基于扩展名)
getFileType,
isPreviewable,
isEditable,
// 内容检测(异步,基于文件内容)
detectByContent,
// 事件处理
onImageLoad,
onImageError,
startImageLoad,
// 工具方法
getMediaMetadata
}
}