diff --git a/internal/filesystem/asset_handler.go b/internal/filesystem/asset_handler.go index 35a2fc5..6fe1a85 100644 --- a/internal/filesystem/asset_handler.go +++ b/internal/filesystem/asset_handler.go @@ -9,11 +9,40 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "sync" "time" ) +// 预编译正则表达式(避免每次调用重复编译) +var ( + // CSS 相关 + cssImportRegex = regexp.MustCompile(`@import\s+(?:url\s*\(\s*)?["']([^"']+)["']\s*\)?\s*;`) + cssUrlRegex = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`) + + // HTML 标签 + htmlLinkTagRegex = regexp.MustCompile(`]*)>`) + htmlScriptTagRegex = regexp.MustCompile(`]*)>`) + htmlImgTagRegex = regexp.MustCompile(`]*)>`) + htmlVideoTagRegex = regexp.MustCompile(`]*)>`) + htmlSourceTagRegex = regexp.MustCompile(`]*)>`) + htmlAudioTagRegex = regexp.MustCompile(`]*)>`) + htmlIframeTagRegex = regexp.MustCompile(`]*)>`) + htmlObjectTagRegex = regexp.MustCompile(`]*)>`) + htmlEmbedTagRegex = regexp.MustCompile(`]*)>`) + + // HTML 属性 + htmlSrcsetRegex = regexp.MustCompile(`srcset=["']([^"']+)["']`) + htmlStyleAttrRegex = regexp.MustCompile(`style=["']([^"']+)["']`) + htmlStyleTagRegex = regexp.MustCompile(`]*)>([\s\S]*?)`) + + // ES6 模块语句 + es6ImportFromRegex = regexp.MustCompile(`import\s+([\s\S]*?)\s+from\s+["']([^"']+)["']`) + es6DynamicImport = regexp.MustCompile(`import\s*\(\s*["']([^"']+)["']\s*\)`) + es6BareImport = regexp.MustCompile(`(?m)^\s*import\s+["']([^"']+)["']`) +) + // LocalFileServer 本地文件服务器(独立的 HTTP 服务器) type LocalFileServer struct { server *http.Server @@ -36,6 +65,9 @@ func StartLocalFileServer() (string, error) { // 注册 /localfs/ 路由 mux.HandleFunc("/localfs/", handleLocalFileRequest) + // 注册 HTML 预览专用路由 + mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest) + // 创建服务器(固定端口) server := &http.Server{ Addr: "localhost:18765", @@ -124,7 +156,7 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) { return } - // 🔒 修复:文件类型白名单检查 + // 🔒 文件类型白名单检查 ext := strings.ToLower(filepath.Ext(filePath)) if !isAllowedFileType(ext) { log.Printf("[LocalFileHandler] 不允许的文件类型: %s", ext) @@ -153,6 +185,50 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) { return } + // CSS 文件特殊处理:转换内容中的相对路径 + if ext == ".css" { + content, err := os.ReadFile(filePath) + if err != nil { + log.Printf("[LocalFileHandler] 读取CSS文件失败: %v", err) + http.Error(w, fmt.Sprintf("Failed to read CSS file: %v", err), http.StatusInternalServerError) + return + } + + // 获取 CSS 文件所在目录作为 basePath + basePath := filepath.Dir(filePath) + + // 转换内容中的相对路径 + transformedContent := transformCssContent(string(content), basePath) + + w.Header().Set("Content-Type", "text/css") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write([]byte(transformedContent)) + log.Printf("[LocalFileHandler] CSS文件转换完成: %s (原始=%d, 转换后=%d)", filePath, len(content), len(transformedContent)) + return + } + + // JavaScript 文件特殊处理:转换动态 import 路径 + if ext == ".js" || ext == ".mjs" { + content, err := os.ReadFile(filePath) + if err != nil { + log.Printf("[LocalFileHandler] 读取JS文件失败: %v", err) + http.Error(w, fmt.Sprintf("Failed to read JS file: %v", err), http.StatusInternalServerError) + return + } + + // 获取 JS 文件所在目录作为 basePath + basePath := filepath.Dir(filePath) + + // 转换动态 import 路径 + transformedContent := transformJsDynamicImports(string(content), basePath) + + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write([]byte(transformedContent)) + log.Printf("[LocalFileHandler] JS文件转换完成: %s (原始=%d, 转换后=%d)", filePath, len(content), len(transformedContent)) + return + } + // 打开文件 file, err := os.Open(filePath) if err != nil { @@ -268,7 +344,6 @@ func HandleLocalFile(w http.ResponseWriter, r *http.Request) { } // isAllowedFileType 检查文件类型是否在白名单中 -// 使用统一的文件类型管理器 func isAllowedFileType(ext string) bool { return defaultFileTypeManager.IsAllowed(ext) } @@ -304,3 +379,462 @@ func ShutdownLocalFileServer() error { } return nil } + +// transformCssContent 转换 CSS 内容中的相对路径 +// basePath: CSS 文件所在目录的绝对路径 +func transformCssContent(content string, basePath string) string { + // 1. 处理 @import 语句 + content = cssImportRegex.ReplaceAllStringFunc(content, func(match string) string { + submatches := cssImportRegex.FindStringSubmatch(match) + if len(submatches) < 2 { + return match + } + relativePath := submatches[1] + + // 跳过绝对 URL 和数据 URI + if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") { + return match + } + + absolutePath := resolveCssRelativePath(basePath, relativePath) + if absolutePath == "" { + return match + } + + return fmt.Sprintf(`@import url("%s");`, toLocalServerUrl(absolutePath)) + }) + + // 2. 处理 url() 语句 + content = cssUrlRegex.ReplaceAllStringFunc(content, func(match string) string { + submatches := cssUrlRegex.FindStringSubmatch(match) + if len(submatches) < 2 { + return match + } + relativePath := strings.TrimSpace(submatches[1]) + + // 跳过绝对 URL、数据 URI 和绝对路径 + if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") || strings.HasPrefix(relativePath, "/") || relativePath == "" { + return match + } + + absolutePath := resolveCssRelativePath(basePath, relativePath) + if absolutePath == "" { + return match + } + + return fmt.Sprintf(`url("%s")`, toLocalServerUrl(absolutePath)) + }) + + return content +} + +// resolveCssRelativePath 解析 CSS 中的相对路径为绝对路径 +func resolveCssRelativePath(basePath, relativePath string) string { + // 清理路径 + relativePath = strings.TrimSpace(relativePath) + if relativePath == "" { + return "" + } + + // 移除 ./ 前缀 + relativePath = strings.TrimPrefix(relativePath, "./") + + // 使用 filepath.Join 处理 ../ 等 + // 注意:需要先将 / 转换为 \ 以便 Windows 路径正确处理 + relativePath = strings.ReplaceAll(relativePath, "/", string(filepath.Separator)) + absolutePath := filepath.Join(basePath, relativePath) + + // 清理路径并转换回 / + absolutePath = filepath.Clean(absolutePath) + absolutePath = strings.ReplaceAll(absolutePath, "\\", "/") + + return absolutePath +} + +// toLocalServerUrl 将绝对路径转换为 /localfs/ URL(带 URL 编码) +func toLocalServerUrl(absolutePath string) string { + // 确保路径使用 / + absolutePath = strings.ReplaceAll(absolutePath, "\\", "/") + // 对路径进行 URL 编码(分段编码,保留 /) + parts := strings.Split(absolutePath, "/") + for i, part := range parts { + // Windows 驱动器字母(如 E:)需要特殊处理,冒号必须编码 + if len(part) == 2 && part[1] == ':' { + // 将 "E:" 转换为 "E%3A" + parts[i] = string(part[0]) + "%3A" + } else { + parts[i] = url.PathEscape(part) + } + } + return "/localfs/" + strings.Join(parts, "/") +} + +// isAbsoluteURL 检查是否为绝对 URL(http://, https://, //) +func isAbsoluteURL(path string) bool { + path = strings.ToLower(path) + return strings.HasPrefix(path, "http://") || + strings.HasPrefix(path, "https://") || + strings.HasPrefix(path, "//") +} + +// handleHtmlPreviewRequest 处理 HTML 预览请求 +// 参数: +// - path: HTML 文件绝对路径(URL 编码) +// - theme: 主题(light / dark) +func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) { + // CORS + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "*") + + // 处理 OPTIONS 预检请求 + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + // 只处理 GET 请求 + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 解析参数 + filePath := r.URL.Query().Get("path") + theme := r.URL.Query().Get("theme") + if theme == "" { + theme = "light" + } + + log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme) + + // 安全检查 + if !isSafePath(filePath) { + log.Printf("[HtmlPreview] 路径未通过安全检查: %s", filePath) + http.Error(w, "Unsafe path", http.StatusForbidden) + return + } + + // 检查路径遍历攻击 + if strings.Contains(filePath, "..") { + log.Printf("[HtmlPreview] 检测到路径遍历尝试: %s", filePath) + http.Error(w, "Path traversal detected", http.StatusForbidden) + return + } + + // 读取文件 + content, err := os.ReadFile(filePath) + if err != nil { + log.Printf("[HtmlPreview] 读取文件失败: %v", err) + http.Error(w, "File not found", http.StatusNotFound) + return + } + + // 获取文件所在目录(用于解析相对路径) + baseDir := filepath.Dir(filePath) + + // 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL) + processedContent := transformHtmlResourcePaths(string(content), baseDir) + + // 注入链接点击拦截脚本 + finalContent := injectLinkInterceptor(processedContent) + + // 返回处理后的 HTML + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Write([]byte(finalContent)) + + log.Printf("[HtmlPreview] 处理完成: %s (%d -> %d bytes)", filePath, len(content), len(finalContent)) +} + +// transformHtmlResourcePaths 转换 HTML 中的资源路径为本地文件服务器 URL +func transformHtmlResourcePaths(htmlContent string, baseDir string) string { + if baseDir == "" { + return htmlContent + } + + result := htmlContent + + // 1. 处理 HTML 标签资源 + tagConfigs := []struct { + pattern *regexp.Regexp + attr string + }{ + {htmlLinkTagRegex, "href"}, + {htmlScriptTagRegex, "src"}, + {htmlImgTagRegex, "src"}, + {htmlVideoTagRegex, "src"}, + {htmlVideoTagRegex, "poster"}, + {htmlSourceTagRegex, "src"}, + {htmlAudioTagRegex, "src"}, + {htmlIframeTagRegex, "src"}, + {htmlObjectTagRegex, "data"}, + {htmlEmbedTagRegex, "src"}, + } + + for _, config := range tagConfigs { + result = replaceHtmlTagAttribute(result, config.pattern, config.attr, baseDir) + } + + // 2. 处理 srcset 属性 + result = replaceHtmlSrcset(result, baseDir) + + // 3. 处理内联 style 属性中的 url() + result = replaceHtmlInlineStyleUrls(result, baseDir) + + // 4. 处理 `, attrs, content) + }) +} + +// resolveHtmlPathToUrl 解析路径并转换为本地文件服务器 URL +// 支持:相对路径 (./xxx, ../xxx)、绝对路径 (/xxx) +func resolveHtmlPathToUrl(baseDir string, path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + + // 统一使用 / 作为分隔符 + baseDir = strings.ReplaceAll(baseDir, "\\", "/") + + // 处理以 / 开头的绝对路径(相对于网站根目录) + // 对于 dist/index.html,/assets/... 应该被解析为 dist/assets/... + if strings.HasPrefix(path, "/") { + path = path[1:] // 移除开头的 / + } + + // 移除 ./ 前缀 + path = strings.TrimPrefix(path, "./") + + // 使用 filepath.Join 进行路径拼接(它会自动处理 ../) + pathBackslash := strings.ReplaceAll(path, "/", string(filepath.Separator)) + absolutePath := filepath.Join(baseDir, pathBackslash) + + // 清理路径并转换回 / + absolutePath = filepath.Clean(absolutePath) + absolutePath = strings.ReplaceAll(absolutePath, "\\", "/") + + return toLocalServerUrl(absolutePath) +} + +// transformJsDynamicImports 转换 JavaScript 文件中的 import 路径 +func transformJsDynamicImports(jsContent string, baseDir string) string { + if baseDir == "" { + return jsContent + } + + result := jsContent + + // 处理动态 import: import("...")(使用预编译正则) + result = es6DynamicImport.ReplaceAllStringFunc(result, func(match string) string { + submatches := es6DynamicImport.FindStringSubmatch(match) + if len(submatches) < 2 { + return match + } + modulePath := submatches[1] + if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") { + return match + } + return fmt.Sprintf(`import("%s")`, resolveHtmlPathToUrl(baseDir, modulePath)) + }) + + // 处理静态 import: import ... from "..."(使用预编译正则) + result = es6ImportFromRegex.ReplaceAllStringFunc(result, func(match string) string { + submatches := es6ImportFromRegex.FindStringSubmatch(match) + if len(submatches) < 3 { + return match + } + importClause := submatches[1] + modulePath := submatches[2] + if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") { + return match + } + return fmt.Sprintf(`import %s from "%s"`, importClause, resolveHtmlPathToUrl(baseDir, modulePath)) + }) + + // 处理裸 import: import "..."(使用预编译正则) + result = es6BareImport.ReplaceAllStringFunc(result, func(match string) string { + submatches := es6BareImport.FindStringSubmatch(match) + if len(submatches) < 2 { + return match + } + modulePath := submatches[1] + if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") { + return match + } + return fmt.Sprintf(`import "%s"`, resolveHtmlPathToUrl(baseDir, modulePath)) + }) + + return result +} + +// injectLinkInterceptor 注入链接点击拦截脚本 +func injectLinkInterceptor(htmlContent string) string { + script := ` + +` + + // 在 前插入 + if strings.Contains(htmlContent, "") { + return strings.Replace(htmlContent, "", script+"", 1) + } + // 没有 body 标签,在末尾插入 + return htmlContent + script +} diff --git a/internal/filesystem/config.go b/internal/filesystem/config.go index 3f67549..927d122 100644 --- a/internal/filesystem/config.go +++ b/internal/filesystem/config.go @@ -284,13 +284,16 @@ func getAllowedExtensions() map[string]bool { ".ppt": true, ".pptx": true, // 文本 - ".txt": true, - ".md": true, + ".txt": true, + ".md": true, ".json": true, ".xml": true, ".html": true, ".css": true, ".js": true, + // 表格 + ".csv": true, + ".tsv": true, } } @@ -367,5 +370,8 @@ func getMIMETypeMapping() map[string]string { ".json": "application/json", ".xml": "application/xml", ".md": "text/markdown", + // 表格 + ".csv": "text/csv; charset=utf-8", + ".tsv": "text/tab-separated-values; charset=utf-8", } } diff --git a/internal/filesystem/service.go b/internal/filesystem/service.go index 1b2fb05..5df086d 100644 --- a/internal/filesystem/service.go +++ b/internal/filesystem/service.go @@ -136,7 +136,7 @@ func (s *FileSystemService) ReadFile(path string) (string, error) { return string(data), nil } -// Write 写入文件内容(实现 FileService 接口) +// Write 写入文件内容(实现 FileService 接口) func (s *FileSystemService) Write(path, content string) error { return s.WriteFile(path, content) } diff --git a/web/package.json.md5 b/web/package.json.md5 index f9182b2..8f418c8 100644 --- a/web/package.json.md5 +++ b/web/package.json.md5 @@ -1 +1 @@ -0b56c4ddab241d0ca843efcc544c131c \ No newline at end of file +11e4d92d4ca3da6546d1516713da71a8 \ No newline at end of file diff --git a/web/src/components/FileSystem/components/FileEditorPanel.vue b/web/src/components/FileSystem/components/FileEditorPanel.vue index d3b326b..2d32744 100644 --- a/web/src/components/FileSystem/components/FileEditorPanel.vue +++ b/web/src/components/FileSystem/components/FileEditorPanel.vue @@ -156,8 +156,7 @@ @@ -287,7 +286,7 @@ diff --git a/web/src/components/FileSystem/composables/useFileEdit.ts b/web/src/components/FileSystem/composables/useFileEdit.ts index 9765f77..56145b5 100644 --- a/web/src/components/FileSystem/composables/useFileEdit.ts +++ b/web/src/components/FileSystem/composables/useFileEdit.ts @@ -123,6 +123,14 @@ export function useFileEdit(options: UseFileEditOptions = {}) { return ['docx', 'doc'].includes(ext) } + /** + * 判断是否为 CSV/TSV 文件 + */ + const isCsvFile = (filepath: any): boolean => { + const ext = getFileExtension(filepath) + return ['csv', 'tsv'].includes(ext) + } + /** * 判断是否为二进制文件(基于扩展名) * 注意:媒体文件(图片、视频、音频、PDF)不是二进制文件,它们可以预览 @@ -138,8 +146,8 @@ export function useFileEdit(options: UseFileEditOptions = {}) { FILE_EXTENSIONS.AUDIO.includes(ext) || ['pdf', 'html', 'htm', 'md', 'markdown'].includes(ext) - // Office 文件(可预览) - const isOfficeFile = ['xlsx', 'xls', 'docx', 'doc'].includes(ext) + // Office 文件和 CSV(可预览) + const isOfficeFile = ['xlsx', 'xls', 'docx', 'doc', 'csv', 'tsv'].includes(ext) // 文本或代码文件(可编辑) const isTextFile = FILE_EXTENSIONS.TEXT.includes(ext) || @@ -231,15 +239,14 @@ export function useFileEdit(options: UseFileEditOptions = {}) { // 记录加载时间戳,用于过滤过期更新 lastLoadTime.value = Date.now() - // 先清空内容,避免显示之前文件的内容 - fileContent.value = '' - originalContent.value = '' + // 注意:不再清空内容,避免 HTML 预览切换时闪烁 + // 新内容加载完成后会直接替换旧内容 const filename = getFilePath(path) const ext = getFileExtension(filename) - // Office 文件直接读取内容进行预览,跳过二进制检测 - if (isExcelFile(filename) || isWordFile(filename)) { + // Office 文件和 CSV 文件直接读取内容进行预览,跳过二进制检测 + if (isExcelFile(filename) || isWordFile(filename) || isCsvFile(filename)) { const content = await readFile(path) fileContent.value = content originalContent.value = content @@ -426,9 +433,9 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''} const saveDraft = () => { if (!currentFilePath.value) return - // Office 文件不支持草稿功能 + // Office 文件和 CSV 不支持草稿功能 const path = getFilePath(currentFilePath.value) - if (isExcelFile(path) || isWordFile(path)) { + if (isExcelFile(path) || isWordFile(path) || isCsvFile(path)) { return } @@ -450,8 +457,8 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''} * 加载草稿 */ const loadDraft = (path: string) => { - // Office 文件不支持草稿功能,并清除已有的草稿 - if (isExcelFile(path) || isWordFile(path)) { + // Office 文件和 CSV 不支持草稿功能,并清除已有的草稿 + if (isExcelFile(path) || isWordFile(path) || isCsvFile(path)) { const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}` try { localStorage.removeItem(key) @@ -657,6 +664,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''} isPdfFile, isExcelFile, isWordFile, + isCsvFile, isBinaryFileByExt, isFileInCurrentDirectory } diff --git a/web/src/components/FileSystem/index.vue b/web/src/components/FileSystem/index.vue index 3749de7..b22f235 100644 --- a/web/src/components/FileSystem/index.vue +++ b/web/src/components/FileSystem/index.vue @@ -368,9 +368,10 @@ const handleOpenFile = async (path: string) => { // 是目录,导航进入 await navigate(path) } else { - // 是文件,选中并加载 - selectedFileItem.value = targetFile + // 是文件,先加载内容,再更新选中状态(避免闪烁) await loadFileContent(path) + // 内容加载完成后再更新选中状态,确保 fileContent 和 selectedFileItem 同步 + selectedFileItem.value = targetFile } } else { // 未找到,尝试直接导航(可能是目录) diff --git a/web/src/types/file-system.ts b/web/src/types/file-system.ts index cdb4041..0bd8fc6 100644 --- a/web/src/types/file-system.ts +++ b/web/src/types/file-system.ts @@ -173,6 +173,8 @@ export interface FileEditorPanelConfig { isExcelFile: boolean /** 是否为 Word 文件 */ isWordFile: boolean + /** 是否为 CSV/TSV 文件 */ + isCsvFile: boolean /** Office 文件加载中 */ officeLoading: boolean /** Office 文件加载错误 */ diff --git a/web/src/utils/filePreviewHandlers.js b/web/src/utils/filePreviewHandlers.js index fd85c19..b33dfbd 100644 --- a/web/src/utils/filePreviewHandlers.js +++ b/web/src/utils/filePreviewHandlers.js @@ -1,221 +1,345 @@ /** * Office 文件预览处理器 - * 使用动态导入减小初始包体积 */ +// 获取文件扩展名(统一方法) +function getExt(fileName) { + return fileName?.split('.').pop()?.toLowerCase() || '' +} + +// 每批加载行数 +const BATCH_ROWS = 200 +const LOAD_MORE_THRESHOLD = 100 + // Excel 预览处理器 export async function previewExcel(file, container) { - try { - // 动态导入 xlsx 库 - const XLSX = await import('xlsx') + const XLSX = await import('xlsx') + const arrayBuffer = await file.arrayBuffer() + const workbook = XLSX.read(arrayBuffer, { type: 'array' }) - // 读取文件 - const arrayBuffer = await file.arrayBuffer() - const workbook = XLSX.read(arrayBuffer, { type: 'array' }) + // 渲染标签页 + const tabs = workbook.SheetNames.map((name, i) => + `` + ).join('') - // 获取第一个工作表 - const firstSheetName = workbook.SheetNames[0] - const worksheet = workbook.Sheets[firstSheetName] + container.innerHTML = ` +
+
${tabs}
+
+
+
+ + ` - // 转换为 HTML 表格 - const html = XLSX.utils.sheet_to_html(worksheet, { - editable: false, - header: '', - footer: '' - }) + const contentEl = container.querySelector('.excel-content') + const infoEl = container.querySelector('.excel-info') + const tabsEl = container.querySelector('.excel-tabs') - // 渲染到容器 - container.innerHTML = ` -
-
- 📊 ${firstSheetName} - ${workbook.SheetNames.length} 个工作表 -
-
- ${html} -
-
- - ` + // 当前 sheet 状态 + let currentSheet = { idx: 0, data: null, renderedRows: 0, totalRows: 0 } - return { success: true } - } catch (error) { - console.error('Excel 预览失败:', error) - return { success: false, error: error.message } + // 获取 sheet 数据 + const getSheetData = (idx) => { + const ws = workbook.Sheets[workbook.SheetNames[idx]] + return XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) } + + // 渲染表格(带行号) + const renderTable = (data, startRow = 0) => { + const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&').replace(//g, '>') + + let html = '' + if (data[0]) { + data[0].forEach((cell, i) => { + html += `` + }) + } + html += '' + + for (let i = 1; i < data.length && i <= startRow + BATCH_ROWS; i++) { + html += `` + if (data[i]) { + data[i].forEach(cell => { + html += `` + }) + } + html += '' + } + html += '
#${escapeHtml(cell)}
${i}${escapeHtml(cell)}
' + return html + } + + // 追加行 + const appendRows = (data, fromRow, toRow) => { + const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&').replace(//g, '>') + const tbody = contentEl.querySelector('tbody') + if (!tbody) return + + for (let i = fromRow; i <= toRow && i < data.length; i++) { + let html = `${i}` + if (data[i]) { + data[i].forEach(cell => { + html += `${escapeHtml(cell)}` + }) + } + html += '' + tbody.insertAdjacentHTML('beforeend', html) + } + } + + // 更新信息栏 + const updateInfo = () => { + const { renderedRows, totalRows } = currentSheet + if (renderedRows >= totalRows) { + infoEl.textContent = `共 ${totalRows} 行` + } else { + infoEl.textContent = `已加载 ${renderedRows}/${totalRows} 行(滚动加载更多)` + } + } + + // 切换 sheet + const loadSheet = (idx) => { + currentSheet = { + idx, + data: getSheetData(idx), + renderedRows: BATCH_ROWS, + totalRows: 0 + } + currentSheet.totalRows = currentSheet.data.length + + const displayData = currentSheet.data.slice(0, BATCH_ROWS + 1) + contentEl.innerHTML = renderTable(displayData) + updateInfo() + } + + // 滚动加载更多 + contentEl.onscroll = () => { + const { scrollTop, scrollHeight, clientHeight } = contentEl + if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) { + const { data, renderedRows, totalRows } = currentSheet + if (renderedRows < totalRows - 1) { + const newEnd = Math.min(renderedRows + BATCH_ROWS, totalRows - 1) + appendRows(data, renderedRows + 1, newEnd) + currentSheet.renderedRows = newEnd + updateInfo() + } + } + } + + loadSheet(0) + + // 标签页切换 + tabsEl.onclick = (e) => { + const tab = e.target.closest('.excel-tab') + if (!tab) return + tabsEl.querySelectorAll('.excel-tab').forEach(t => t.classList.remove('active')) + tab.classList.add('active') + loadSheet(parseInt(tab.dataset.idx, 10)) + } + + return { success: true } } // Word 预览处理器 export async function previewWord(file, container) { + const mammoth = await import('mammoth') + const arrayBuffer = await file.arrayBuffer() + const result = await mammoth.convertToHtml({ arrayBuffer }) + + const warnings = result.messages.length > 0 + ? `
转换警告 (${result.messages.length})
    ${result.messages.map(m => `
  • ${m.message}
  • `).join('')}
` + : '' + + container.innerHTML = ` +
+
${result.value}
+ ${warnings} +
+ + ` + + return { success: true } +} + +// 文件类型判断 +const OFFICE_EXTS = { xlsx: 1, xls: 1, docx: 1, doc: 1 } +const EXCEL_EXTS = { xlsx: 1, xls: 1 } +const WORD_EXTS = { docx: 1, doc: 1 } + +export const isOfficeFile = (name) => OFFICE_EXTS[getExt(name)] || false +export const isExcelFile = (name) => EXCEL_EXTS[getExt(name)] || false +export const isWordFile = (name) => WORD_EXTS[getExt(name)] || false +export const isCsvFile = (name) => ['csv', 'tsv'].includes(getExt(name)) + +// CSV/TSV 预览处理器(原生实现,支持滚动加载) +export async function previewCsv(file, container) { try { - // 动态导入 mammoth 库 - const mammoth = await import('mammoth') + if (!container) { + return { success: false, error: '容器不存在' } + } - // 读取文件并转换为 HTML - const arrayBuffer = await file.arrayBuffer() - const result = await mammoth.convertToHtml({ arrayBuffer }) + const text = await file.text() + const lines = text.split(/\r?\n/).filter(line => line.trim()) + if (lines.length === 0) { + throw new Error('文件为空') + } + + // 解析 CSV 行 + const parseLine = (line, delimiter) => { + const cells = [] + let cell = '' + let inQuotes = false + + for (let i = 0; i < line.length; i++) { + const char = line[i] + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + cell += '"' + i++ + } else { + inQuotes = !inQuotes + } + } else if (char === delimiter && !inQuotes) { + cells.push(cell) + cell = '' + } else { + cell += char + } + } + cells.push(cell) + return cells + } + + const delimiter = file.name.endsWith('.tsv') ? '\t' : ',' + const rows = lines.map(line => parseLine(line, delimiter)) + + const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&').replace(//g, '>') - // 渲染到容器 container.innerHTML = ` -
-
- ${result.value} -
- ${result.messages.length > 0 ? ` -
-
- 转换警告 (${result.messages.length}) -
    - ${result.messages.map(msg => `
  • ${msg.message}
  • `).join('')} -
-
-
- ` : ''} +
+
📋 ${file.name}
+
` + const contentEl = container.querySelector('.csv-content') + const infoEl = container.querySelector('.csv-info') + + // 状态 + let renderedRows = 0 + const totalRows = rows.length + + // 渲染表格 + const renderTable = (startRow = 0) => { + const endRow = Math.min(startRow + BATCH_ROWS, totalRows) + let html = '' + + // 表头(第一行) + if (startRow === 0 && rows[0]) { + html += '' + rows[0].forEach(cell => { html += `` }) + html += '' + } + + // 数据行 + for (let i = Math.max(1, startRow); i < endRow; i++) { + html += `` + if (rows[i]) { + rows[i].forEach(cell => { html += `` }) + } + html += '' + } + html += '
#${escapeHtml(cell)}
${i}${escapeHtml(cell)}
' + return html + } + + // 追加行 + const appendRows = (fromRow) => { + const endRow = Math.min(fromRow + BATCH_ROWS, totalRows) + const tbody = contentEl.querySelector('tbody') + if (!tbody) return + + for (let i = fromRow; i < endRow; i++) { + let html = `${i}` + if (rows[i]) { + rows[i].forEach(cell => { html += `${escapeHtml(cell)}` }) + } + html += '' + tbody.insertAdjacentHTML('beforeend', html) + } + return endRow + } + + // 更新信息 + const updateInfo = () => { + if (renderedRows >= totalRows) { + infoEl.textContent = `📋 ${file.name}(共 ${totalRows - 1} 行)` + } else { + infoEl.textContent = `📋 ${file.name}(已加载 ${renderedRows}/${totalRows - 1} 行)` + } + } + + // 初始渲染 + contentEl.innerHTML = renderTable(0) + renderedRows = Math.min(BATCH_ROWS, totalRows) + updateInfo() + + // 滚动加载 + contentEl.onscroll = () => { + const { scrollTop, scrollHeight, clientHeight } = contentEl + if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) { + if (renderedRows < totalRows) { + renderedRows = appendRows(renderedRows) + updateInfo() + } + } + } + return { success: true } - } catch (error) { - console.error('Word 预览失败:', error) - return { success: false, error: error.message } + } catch (err) { + console.error('[previewCsv] 错误:', err) + return { success: false, error: err?.message || String(err) } } } - -// 判断是否为 Office 文件 -export function isOfficeFile(fileName) { - const ext = fileName?.toLowerCase()?.split('.').pop() - return ['xlsx', 'xls', 'docx', 'doc'].includes(ext) -} - -// 判断是否为 Excel 文件 -export function isExcelFile(fileName) { - const ext = fileName?.toLowerCase()?.split('.').pop() - return ['xlsx', 'xls'].includes(ext) -} - -// 判断是否为 Word 文件 -export function isWordFile(fileName) { - const ext = fileName?.toLowerCase()?.split('.').pop() - return ['docx', 'doc'].includes(ext) -} diff --git a/web/src/utils/fileTypeHelpers.js b/web/src/utils/fileTypeHelpers.js index 4cb8479..69b1133 100644 --- a/web/src/utils/fileTypeHelpers.js +++ b/web/src/utils/fileTypeHelpers.js @@ -18,7 +18,9 @@ export const PREVIEWABLE_TYPES = [ ...FILE_EXTENSIONS.AUDIO, 'pdf', 'html', 'htm', 'md', 'markdown', // Office 文件支持预览 - 'xlsx', 'xls', 'docx', 'doc' + 'xlsx', 'xls', 'docx', 'doc', + // CSV/TSV 表格文件 + 'csv', 'tsv' ] /** @@ -191,3 +193,13 @@ export const isWordFile = (path) => { export const isOfficeFile = (path) => { return isExcelFile(path) || isWordFile(path) || ['ppt', 'pptx'].includes(getExt(path)) } + +/** + * 判断是否为 CSV/TSV 文件 + * @param {string} path - 文件路径 + * @returns {boolean} + */ +export const isCsvFile = (path) => { + const ext = getExt(path) + return ['csv', 'tsv'].includes(ext) +}