Private
Public Access
1
0

优化:Office/CSV 预览增强 + 清理冗余代码

Office 预览优化:
- 重构 Excel/Word 预览,使用本地文件服务器直接加载
- 添加 CSV 文件预览支持(表格形式展示)
- 优化加载状态和错误提示 UI

CSV 文件支持:
- 后端添加 CSV/TSV 文件类型和 MIME 映射
- 前端添加 isCsvFile 类型判断

代码清理:
- 移除未使用的 ReadFileAsBase64 API
This commit is contained in:
2026-02-27 18:15:17 +08:00
parent c5e6ff3ba6
commit 1eaf61cf41
10 changed files with 934 additions and 260 deletions

View File

@@ -9,11 +9,40 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
// 预编译正则表达式(避免每次调用重复编译)
var (
// CSS 相关
cssImportRegex = regexp.MustCompile(`@import\s+(?:url\s*\(\s*)?["']([^"']+)["']\s*\)?\s*;`)
cssUrlRegex = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
// HTML 标签
htmlLinkTagRegex = regexp.MustCompile(`<link\s+([^>]*)>`)
htmlScriptTagRegex = regexp.MustCompile(`<script\s+([^>]*)>`)
htmlImgTagRegex = regexp.MustCompile(`<img\s+([^>]*)>`)
htmlVideoTagRegex = regexp.MustCompile(`<video\s+([^>]*)>`)
htmlSourceTagRegex = regexp.MustCompile(`<source\s+([^>]*)>`)
htmlAudioTagRegex = regexp.MustCompile(`<audio\s+([^>]*)>`)
htmlIframeTagRegex = regexp.MustCompile(`<iframe\s+([^>]*)>`)
htmlObjectTagRegex = regexp.MustCompile(`<object\s+([^>]*)>`)
htmlEmbedTagRegex = regexp.MustCompile(`<embed\s+([^>]*)>`)
// HTML 属性
htmlSrcsetRegex = regexp.MustCompile(`srcset=["']([^"']+)["']`)
htmlStyleAttrRegex = regexp.MustCompile(`style=["']([^"']+)["']`)
htmlStyleTagRegex = regexp.MustCompile(`<style([^>]*)>([\s\S]*?)</style>`)
// 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 服务器) // LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
type LocalFileServer struct { type LocalFileServer struct {
server *http.Server server *http.Server
@@ -36,6 +65,9 @@ func StartLocalFileServer() (string, error) {
// 注册 /localfs/ 路由 // 注册 /localfs/ 路由
mux.HandleFunc("/localfs/", handleLocalFileRequest) mux.HandleFunc("/localfs/", handleLocalFileRequest)
// 注册 HTML 预览专用路由
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
// 创建服务器(固定端口) // 创建服务器(固定端口)
server := &http.Server{ server := &http.Server{
Addr: "localhost:18765", Addr: "localhost:18765",
@@ -124,7 +156,7 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
return return
} }
// 🔒 修复:文件类型白名单检查 // 🔒 文件类型白名单检查
ext := strings.ToLower(filepath.Ext(filePath)) ext := strings.ToLower(filepath.Ext(filePath))
if !isAllowedFileType(ext) { if !isAllowedFileType(ext) {
log.Printf("[LocalFileHandler] 不允许的文件类型: %s", ext) log.Printf("[LocalFileHandler] 不允许的文件类型: %s", ext)
@@ -153,6 +185,50 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
return 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) file, err := os.Open(filePath)
if err != nil { if err != nil {
@@ -268,7 +344,6 @@ func HandleLocalFile(w http.ResponseWriter, r *http.Request) {
} }
// isAllowedFileType 检查文件类型是否在白名单中 // isAllowedFileType 检查文件类型是否在白名单中
// 使用统一的文件类型管理器
func isAllowedFileType(ext string) bool { func isAllowedFileType(ext string) bool {
return defaultFileTypeManager.IsAllowed(ext) return defaultFileTypeManager.IsAllowed(ext)
} }
@@ -304,3 +379,462 @@ func ShutdownLocalFileServer() error {
} }
return nil 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 检查是否为绝对 URLhttp://, 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. 处理 <style> 标签内的内容
result = replaceHtmlStyleTagContent(result, baseDir)
// 5. 处理 ES6 模块语句(使用预编译正则)
result = es6ImportFromRegex.ReplaceAllStringFunc(result, func(match string) string {
submatches := es6ImportFromRegex.FindStringSubmatch(match)
if len(submatches) < 3 {
return match
}
modulePath := submatches[2]
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") {
return match
}
return fmt.Sprintf(`import %s from "%s"`, submatches[1], resolveHtmlPathToUrl(baseDir, modulePath))
})
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:") {
return match
}
return fmt.Sprintf(`import("%s")`, resolveHtmlPathToUrl(baseDir, modulePath))
})
return result
}
// replaceHtmlTagAttribute 替换 HTML 标签中的属性路径
func replaceHtmlTagAttribute(html string, pattern *regexp.Regexp, attrName string, baseDir string) string {
return pattern.ReplaceAllStringFunc(html, func(match string) string {
// 提取属性值
attrRegex := regexp.MustCompile(fmt.Sprintf(`%s=["']([^"']+)["']`, attrName))
attrMatch := attrRegex.FindStringSubmatch(match)
if attrMatch == nil {
return match
}
relativePath := attrMatch[1]
if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") {
return match
}
newUrl := resolveHtmlPathToUrl(baseDir, relativePath)
return strings.Replace(match, attrMatch[0], fmt.Sprintf(`%s="%s"`, attrName, newUrl), 1)
})
}
// replaceHtmlSrcset 处理 srcset 属性
func replaceHtmlSrcset(html string, baseDir string) string {
return htmlSrcsetRegex.ReplaceAllStringFunc(html, func(match string) string {
submatches := htmlSrcsetRegex.FindStringSubmatch(match)
if len(submatches) < 2 {
return match
}
srcset := submatches[1]
var newItems []string
for _, item := range strings.Split(srcset, ",") {
parts := strings.Fields(strings.TrimSpace(item))
if len(parts) == 0 {
continue
}
url := parts[0]
descriptor := ""
if len(parts) > 1 {
descriptor = " " + strings.Join(parts[1:], " ")
}
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
newItems = append(newItems, item)
} else {
newItems = append(newItems, resolveHtmlPathToUrl(baseDir, url)+descriptor)
}
}
return fmt.Sprintf(`srcset="%s"`, strings.Join(newItems, ", "))
})
}
// replaceHtmlInlineStyleUrls 处理内联 style 属性中的 url()
func replaceHtmlInlineStyleUrls(html string, baseDir string) string {
return htmlStyleAttrRegex.ReplaceAllStringFunc(html, func(match string) string {
submatches := htmlStyleAttrRegex.FindStringSubmatch(match)
if len(submatches) < 2 {
return match
}
styleContent := submatches[1]
newStyle := cssUrlRegex.ReplaceAllStringFunc(styleContent, func(urlMatch string) string {
urlSubmatches := cssUrlRegex.FindStringSubmatch(urlMatch)
if len(urlSubmatches) < 2 {
return urlMatch
}
url := strings.TrimSpace(urlSubmatches[1])
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
return urlMatch
}
return fmt.Sprintf(`url("%s")`, resolveHtmlPathToUrl(baseDir, url))
})
return fmt.Sprintf(`style="%s"`, newStyle)
})
}
// replaceHtmlStyleTagContent 处理 <style> 标签内的内容
func replaceHtmlStyleTagContent(html string, baseDir string) string {
return htmlStyleTagRegex.ReplaceAllStringFunc(html, func(match string) string {
submatches := htmlStyleTagRegex.FindStringSubmatch(match)
if len(submatches) < 3 {
return match
}
attrs := submatches[1]
content := submatches[2]
// 处理 @import使用预编译正则
content = cssImportRegex.ReplaceAllStringFunc(content, func(m string) string {
sm := cssImportRegex.FindStringSubmatch(m)
if len(sm) < 2 {
return m
}
url := sm[1]
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
return m
}
return fmt.Sprintf(`@import url("%s");`, resolveHtmlPathToUrl(baseDir, url))
})
// 处理 url()(使用预编译正则)
content = cssUrlRegex.ReplaceAllStringFunc(content, func(m string) string {
sm := cssUrlRegex.FindStringSubmatch(m)
if len(sm) < 2 {
return m
}
url := strings.TrimSpace(sm[1])
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
return m
}
return fmt.Sprintf(`url("%s")`, resolveHtmlPathToUrl(baseDir, url))
})
return fmt.Sprintf(`<style%s>%s</style>`, 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 := `
<script>
document.addEventListener('click', function(e) {
var target = e.target;
while (target && target.tagName !== 'A') {
target = target.parentElement;
}
if (target && target.tagName === 'A') {
var href = target.getAttribute('href');
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('#') && !href.startsWith('javascript:')) {
e.preventDefault();
e.stopPropagation();
window.parent.postMessage({ type: 'openLocalFile', path: href }, '*');
}
}
}, true);
</script>
`
// 在 </body> 前插入
if strings.Contains(htmlContent, "</body>") {
return strings.Replace(htmlContent, "</body>", script+"</body>", 1)
}
// 没有 body 标签,在末尾插入
return htmlContent + script
}

View File

@@ -284,13 +284,16 @@ func getAllowedExtensions() map[string]bool {
".ppt": true, ".ppt": true,
".pptx": true, ".pptx": true,
// 文本 // 文本
".txt": true, ".txt": true,
".md": true, ".md": true,
".json": true, ".json": true,
".xml": true, ".xml": true,
".html": true, ".html": true,
".css": true, ".css": true,
".js": true, ".js": true,
// 表格
".csv": true,
".tsv": true,
} }
} }
@@ -367,5 +370,8 @@ func getMIMETypeMapping() map[string]string {
".json": "application/json", ".json": "application/json",
".xml": "application/xml", ".xml": "application/xml",
".md": "text/markdown", ".md": "text/markdown",
// 表格
".csv": "text/csv; charset=utf-8",
".tsv": "text/tab-separated-values; charset=utf-8",
} }
} }

View File

@@ -136,7 +136,7 @@ func (s *FileSystemService) ReadFile(path string) (string, error) {
return string(data), nil return string(data), nil
} }
// Write 写入文件内容(实现 FileService 接口 // Write 写入文件内容(实现 FileService 接口)
func (s *FileSystemService) Write(path, content string) error { func (s *FileSystemService) Write(path, content string) error {
return s.WriteFile(path, content) return s.WriteFile(path, content)
} }

View File

@@ -1 +1 @@
0b56c4ddab241d0ca843efcc544c131c 11e4d92d4ca3da6546d1516713da71a8

View File

@@ -156,8 +156,7 @@
<iframe <iframe
v-if="!config.isEditMode" v-if="!config.isEditMode"
class="html-preview-content" class="html-preview-content"
:srcdoc="htmlContentWithTheme" :src="htmlPreviewUrl"
:key="getCurrentTheme()"
></iframe> ></iframe>
<!-- 编辑模式 --> <!-- 编辑模式 -->
@@ -287,7 +286,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch, nextTick, defineAsyncComponent, ref, onUnmounted } from 'vue' import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted, onUnmounted } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy } from '@arco-design/web-vue/es/icon' import { IconSave, IconEdit, IconEye, IconUndo, IconCopy } from '@arco-design/web-vue/es/icon'
import { getFileName } from '@/utils/fileUtils' import { getFileName } from '@/utils/fileUtils'
@@ -335,50 +334,13 @@ interface Emits {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// 获取当前主题 // HTML 预览 URL使用后端接口
const getCurrentTheme = () => { const htmlPreviewUrl = computed(() => {
return document.body.getAttribute('arco-theme') || 'light' if (!props.config.currentFileFullPath || !props.config.isHtmlFile) {
} return ''
}
// 生成带主题样式的 HTML 内容 const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
const htmlContentWithTheme = computed(() => { return `http://localhost:18765/localfs/html-preview?path=${encodedPath}`
if (!props.config.rendered || props.config.isEditMode) return ''
const theme = getCurrentTheme()
const bgColor = theme === 'dark' ? '#1a1a1a' : '#ffffff'
const textColor = theme === 'dark' ? '#e8e8e8' : '#333333'
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
padding: 20px;
background-color: ${bgColor};
color: ${textColor};
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
}
a { color: ${theme === 'dark' ? '#4e9af1' : '#0066cc'}; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid ${theme === 'dark' ? '#444' : '#ddd'}; padding: 8px; }
th { background-color: ${theme === 'dark' ? '#333' : '#f2f2f2'}; }
code { background-color: ${theme === 'dark' ? '#333' : '#f4f4f4'}; padding: 2px 6px; border-radius: 3px; }
pre { background-color: ${theme === 'dark' ? '#2a2a2a' : '#f4f4f4'}; padding: 12px; border-radius: 6px; overflow-x: auto; }
pre code { background-color: transparent; padding: 0; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>${props.config.rendered}</body>
</html>
`
}) })
// 计算属性:判断文件是否在当前目录 // 计算属性:判断文件是否在当前目录
@@ -626,10 +588,35 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
} }
}, { immediate: true }) }, { immediate: true })
// 处理 HTML iframe 发送的消息(链接点击)
const handleHtmlIframeMessage = (event: MessageEvent) => {
// 安全检查:接受来自本地文件服务器或同源的消息
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:18765
const allowedOrigins = [
window.location.origin,
'null', // about:blank 或 data: URL
'http://localhost:18765', // 本地文件服务器
]
if (!allowedOrigins.includes(event.origin)) {
return
}
const data = event.data
if (data && data.type === 'openLocalFile' && data.path) {
// 直接传递路径,由父组件处理相对路径解析
emit('openLocalFile', data.path)
}
}
// 监听 iframe 的 postMessage
onMounted(() => {
window.addEventListener('message', handleHtmlIframeMessage)
})
onUnmounted(() => { onUnmounted(() => {
if (markdownPreviewRef.value) { if (markdownPreviewRef.value) {
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick) markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
} }
window.removeEventListener('message', handleHtmlIframeMessage)
}) })
</script> </script>

View File

@@ -123,6 +123,14 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
return ['docx', 'doc'].includes(ext) return ['docx', 'doc'].includes(ext)
} }
/**
* 判断是否为 CSV/TSV 文件
*/
const isCsvFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
return ['csv', 'tsv'].includes(ext)
}
/** /**
* 判断是否为二进制文件(基于扩展名) * 判断是否为二进制文件(基于扩展名)
* 注意媒体文件图片、视频、音频、PDF不是二进制文件它们可以预览 * 注意媒体文件图片、视频、音频、PDF不是二进制文件它们可以预览
@@ -138,8 +146,8 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
FILE_EXTENSIONS.AUDIO.includes(ext) || FILE_EXTENSIONS.AUDIO.includes(ext) ||
['pdf', 'html', 'htm', 'md', 'markdown'].includes(ext) ['pdf', 'html', 'htm', 'md', 'markdown'].includes(ext)
// Office 文件(可预览) // Office 文件和 CSV(可预览)
const isOfficeFile = ['xlsx', 'xls', 'docx', 'doc'].includes(ext) const isOfficeFile = ['xlsx', 'xls', 'docx', 'doc', 'csv', 'tsv'].includes(ext)
// 文本或代码文件(可编辑) // 文本或代码文件(可编辑)
const isTextFile = FILE_EXTENSIONS.TEXT.includes(ext) || const isTextFile = FILE_EXTENSIONS.TEXT.includes(ext) ||
@@ -231,15 +239,14 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
// 记录加载时间戳,用于过滤过期更新 // 记录加载时间戳,用于过滤过期更新
lastLoadTime.value = Date.now() lastLoadTime.value = Date.now()
// 清空内容,避免显示之前文件的内容 // 注意:不再清空内容,避免 HTML 预览切换时闪烁
fileContent.value = '' // 新内容加载完成后会直接替换旧内容
originalContent.value = ''
const filename = getFilePath(path) const filename = getFilePath(path)
const ext = getFileExtension(filename) const ext = getFileExtension(filename)
// Office 文件直接读取内容进行预览,跳过二进制检测 // Office 文件和 CSV 文件直接读取内容进行预览,跳过二进制检测
if (isExcelFile(filename) || isWordFile(filename)) { if (isExcelFile(filename) || isWordFile(filename) || isCsvFile(filename)) {
const content = await readFile(path) const content = await readFile(path)
fileContent.value = content fileContent.value = content
originalContent.value = content originalContent.value = content
@@ -426,9 +433,9 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
const saveDraft = () => { const saveDraft = () => {
if (!currentFilePath.value) return if (!currentFilePath.value) return
// Office 文件不支持草稿功能 // Office 文件和 CSV 不支持草稿功能
const path = getFilePath(currentFilePath.value) const path = getFilePath(currentFilePath.value)
if (isExcelFile(path) || isWordFile(path)) { if (isExcelFile(path) || isWordFile(path) || isCsvFile(path)) {
return return
} }
@@ -450,8 +457,8 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
* 加载草稿 * 加载草稿
*/ */
const loadDraft = (path: string) => { const loadDraft = (path: string) => {
// Office 文件不支持草稿功能,并清除已有的草稿 // Office 文件和 CSV 不支持草稿功能,并清除已有的草稿
if (isExcelFile(path) || isWordFile(path)) { if (isExcelFile(path) || isWordFile(path) || isCsvFile(path)) {
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}` const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}`
try { try {
localStorage.removeItem(key) localStorage.removeItem(key)
@@ -657,6 +664,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
isPdfFile, isPdfFile,
isExcelFile, isExcelFile,
isWordFile, isWordFile,
isCsvFile,
isBinaryFileByExt, isBinaryFileByExt,
isFileInCurrentDirectory isFileInCurrentDirectory
} }

View File

@@ -368,9 +368,10 @@ const handleOpenFile = async (path: string) => {
// 是目录,导航进入 // 是目录,导航进入
await navigate(path) await navigate(path)
} else { } else {
// 是文件,选中并加载 // 是文件,先加载内容,再更新选中状态(避免闪烁)
selectedFileItem.value = targetFile
await loadFileContent(path) await loadFileContent(path)
// 内容加载完成后再更新选中状态,确保 fileContent 和 selectedFileItem 同步
selectedFileItem.value = targetFile
} }
} else { } else {
// 未找到,尝试直接导航(可能是目录) // 未找到,尝试直接导航(可能是目录)

View File

@@ -173,6 +173,8 @@ export interface FileEditorPanelConfig {
isExcelFile: boolean isExcelFile: boolean
/** 是否为 Word 文件 */ /** 是否为 Word 文件 */
isWordFile: boolean isWordFile: boolean
/** 是否为 CSV/TSV 文件 */
isCsvFile: boolean
/** Office 文件加载中 */ /** Office 文件加载中 */
officeLoading: boolean officeLoading: boolean
/** Office 文件加载错误 */ /** Office 文件加载错误 */

View File

@@ -1,221 +1,345 @@
/** /**
* Office 文件预览处理器 * Office 文件预览处理器
* 使用动态导入减小初始包体积
*/ */
// 获取文件扩展名(统一方法)
function getExt(fileName) {
return fileName?.split('.').pop()?.toLowerCase() || ''
}
// 每批加载行数
const BATCH_ROWS = 200
const LOAD_MORE_THRESHOLD = 100
// Excel 预览处理器 // Excel 预览处理器
export async function previewExcel(file, container) { export async function previewExcel(file, container) {
try { const XLSX = await import('xlsx')
// 动态导入 xlsx 库 const arrayBuffer = await file.arrayBuffer()
const XLSX = await import('xlsx') const workbook = XLSX.read(arrayBuffer, { type: 'array' })
// 读取文件 // 渲染标签页
const arrayBuffer = await file.arrayBuffer() const tabs = workbook.SheetNames.map((name, i) =>
const workbook = XLSX.read(arrayBuffer, { type: 'array' }) `<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${name}</button>`
).join('')
// 获取第一个工作表 container.innerHTML = `
const firstSheetName = workbook.SheetNames[0] <div class="excel-preview">
const worksheet = workbook.Sheets[firstSheetName] <div class="excel-tabs">${tabs}</div>
<div class="excel-info"></div>
<div class="excel-content"></div>
</div>
<style>
.excel-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
.excel-tabs{display:flex;flex-wrap:wrap;gap:4px;padding:8px 12px;background:var(--color-fill-1);border-bottom:1px solid var(--color-border-2)}
.excel-tab{padding:6px 14px;border:none;background:transparent;color:var(--color-text-2);font-size:13px;border-radius:4px;cursor:pointer;transition:all .2s}
.excel-tab:hover{background:var(--color-fill-3)}
.excel-tab.active{background:rgb(var(--primary-6));color:#fff;font-weight:500}
.excel-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
.excel-content{flex:1;overflow:auto;padding:12px}
.excel-content table{width:100%;border-collapse:collapse;font-size:13px}
.excel-content td,.excel-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap}
.excel-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;left:0;z-index:2}
.excel-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
.excel-content th.row-num{z-index:3}
.excel-content tr:hover td{background:var(--color-fill-1)}
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
</style>
`
// 转换为 HTML 表格 const contentEl = container.querySelector('.excel-content')
const html = XLSX.utils.sheet_to_html(worksheet, { const infoEl = container.querySelector('.excel-info')
editable: false, const tabsEl = container.querySelector('.excel-tabs')
header: '',
footer: ''
})
// 渲染到容器 // 当前 sheet 状态
container.innerHTML = ` let currentSheet = { idx: 0, data: null, renderedRows: 0, totalRows: 0 }
<div class="excel-preview">
<div class="excel-sheet-info">
<span class="sheet-name">📊 ${firstSheetName}</span>
<span class="sheet-count">${workbook.SheetNames.length} 个工作表</span>
</div>
<div class="excel-table-wrapper">
${html}
</div>
</div>
<style>
.excel-preview {
padding: 20px;
height: 100%;
overflow: auto;
}
.excel-sheet-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--color-fill-2);
border-radius: 6px;
margin-bottom: 16px;
}
.sheet-name {
font-weight: 600;
font-size: 14px;
}
.sheet-count {
font-size: 12px;
color: var(--color-text-3);
}
.excel-table-wrapper {
overflow: auto;
border: 1px solid var(--color-border-2);
border-radius: 6px;
}
.excel-table-wrapper table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.excel-table-wrapper td,
.excel-table-wrapper th {
border: 1px solid var(--color-border-2);
padding: 6px 10px;
text-align: left;
white-space: nowrap;
}
.excel-table-wrapper th {
background: var(--color-fill-2);
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
.excel-table-wrapper tr:hover {
background: var(--color-fill-1);
}
</style>
`
return { success: true } // 获取 sheet 数据
} catch (error) { const getSheetData = (idx) => {
console.error('Excel 预览失败:', error) const ws = workbook.Sheets[workbook.SheetNames[idx]]
return { success: false, error: error.message } return XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })
} }
// 渲染表格(带行号)
const renderTable = (data, startRow = 0) => {
const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
let html = '<table><thead><tr><th class="row-num">#</th>'
if (data[0]) {
data[0].forEach((cell, i) => {
html += `<th>${escapeHtml(cell)}</th>`
})
}
html += '</tr></thead><tbody>'
for (let i = 1; i < data.length && i <= startRow + BATCH_ROWS; i++) {
html += `<tr><td class="row-num">${i}</td>`
if (data[i]) {
data[i].forEach(cell => {
html += `<td>${escapeHtml(cell)}</td>`
})
}
html += '</tr>'
}
html += '</tbody></table>'
return html
}
// 追加行
const appendRows = (data, fromRow, toRow) => {
const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const tbody = contentEl.querySelector('tbody')
if (!tbody) return
for (let i = fromRow; i <= toRow && i < data.length; i++) {
let html = `<tr><td class="row-num">${i}</td>`
if (data[i]) {
data[i].forEach(cell => {
html += `<td>${escapeHtml(cell)}</td>`
})
}
html += '</tr>'
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 预览处理器 // Word 预览处理器
export async function previewWord(file, container) { 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
? `<details class="word-warnings"><summary>转换警告 (${result.messages.length})</summary><ul>${result.messages.map(m => `<li>${m.message}</li>`).join('')}</ul></details>`
: ''
container.innerHTML = `
<div class="word-preview">
<div class="word-content">${result.value}</div>
${warnings}
</div>
<style>
.word-preview{padding:20px;height:100%;overflow:auto;line-height:1.6;color:var(--color-text-1)}
.word-content h1,.word-content h2,.word-content h3{margin:1em 0 .5em;font-weight:600}
.word-content h1{font-size:2em}.word-content h2{font-size:1.5em}.word-content h3{font-size:1.25em}
.word-content p{margin:.5em 0}
.word-content ul,.word-content ol{margin:.5em 0;padding-left:2em}
.word-content table{border-collapse:collapse;width:100%;margin:1em 0}
.word-content td,.word-content th{border:1px solid var(--color-border-2);padding:6px 10px}
.word-content th{background:var(--color-fill-2);font-weight:600}
.word-content img{max-width:100%;height:auto}
.word-content a{color:rgb(var(--primary-6));text-decoration:none}
.word-content a:hover{text-decoration:underline}
.word-warnings{margin-top:20px;padding:12px;background:var(--color-warning-light-1);border:1px solid var(--color-warning-3);border-radius:6px;font-size:12px}
.word-warnings summary{cursor:pointer;font-weight:600}
.word-warnings ul{margin:8px 0 0 20px}
</style>
`
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 { try {
// 动态导入 mammoth 库 if (!container) {
const mammoth = await import('mammoth') return { success: false, error: '容器不存在' }
}
// 读取文件并转换为 HTML const text = await file.text()
const arrayBuffer = await file.arrayBuffer() const lines = text.split(/\r?\n/).filter(line => line.trim())
const result = await mammoth.convertToHtml({ arrayBuffer }) 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// 渲染到容器
container.innerHTML = ` container.innerHTML = `
<div class="word-preview"> <div class="csv-preview">
<div class="word-content"> <div class="csv-info">📋 ${file.name}</div>
${result.value} <div class="csv-content"></div>
</div>
${result.messages.length > 0 ? `
<div class="word-warnings">
<details>
<summary>转换警告 (${result.messages.length})</summary>
<ul>
${result.messages.map(msg => `<li>${msg.message}</li>`).join('')}
</ul>
</details>
</div>
` : ''}
</div> </div>
<style> <style>
.word-preview { .csv-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
padding: 20px; .csv-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
height: 100%; .csv-content{flex:1;overflow:auto;padding:12px}
overflow: auto; .csv-content table{width:100%;border-collapse:collapse;font-size:13px}
} .csv-content td,.csv-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap}
.word-content { .csv-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;left:0;z-index:2}
line-height: 1.6; .csv-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
color: var(--color-text-1); .csv-content th.row-num{z-index:3}
} .csv-content tr:hover td{background:var(--color-fill-1)}
.word-content h1, .csv-content tr:hover .row-num{background:var(--color-fill-2)}
.word-content h2,
.word-content h3,
.word-content h4,
.word-content h5,
.word-content h6 {
margin: 1em 0 0.5em;
font-weight: 600;
}
.word-content h1 { font-size: 2em; }
.word-content h2 { font-size: 1.5em; }
.word-content h3 { font-size: 1.25em; }
.word-content p {
margin: 0.5em 0;
}
.word-content ul,
.word-content ol {
margin: 0.5em 0;
padding-left: 2em;
}
.word-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.word-content td,
.word-content th {
border: 1px solid var(--color-border-2);
padding: 6px 10px;
}
.word-content th {
background: var(--color-fill-2);
font-weight: 600;
}
.word-content img {
max-width: 100%;
height: auto;
}
.word-content a {
color: rgb(var(--primary-6));
text-decoration: none;
}
.word-content a:hover {
text-decoration: underline;
}
.word-warnings {
margin-top: 20px;
padding: 12px;
background: var(--color-warning-light-1);
border: 1px solid var(--color-warning-3);
border-radius: 6px;
font-size: 12px;
}
.word-warnings summary {
cursor: pointer;
font-weight: 600;
}
.word-warnings ul {
margin: 8px 0 0 20px;
}
</style> </style>
` `
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 = '<table>'
// 表头(第一行)
if (startRow === 0 && rows[0]) {
html += '<thead><tr><th class="row-num">#</th>'
rows[0].forEach(cell => { html += `<th>${escapeHtml(cell)}</th>` })
html += '</tr></thead><tbody>'
}
// 数据行
for (let i = Math.max(1, startRow); i < endRow; i++) {
html += `<tr><td class="row-num">${i}</td>`
if (rows[i]) {
rows[i].forEach(cell => { html += `<td>${escapeHtml(cell)}</td>` })
}
html += '</tr>'
}
html += '</tbody></table>'
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 = `<tr><td class="row-num">${i}</td>`
if (rows[i]) {
rows[i].forEach(cell => { html += `<td>${escapeHtml(cell)}</td>` })
}
html += '</tr>'
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 } return { success: true }
} catch (error) { } catch (err) {
console.error('Word 预览失败:', error) console.error('[previewCsv] 错误:', err)
return { success: false, error: error.message } 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)
}

View File

@@ -18,7 +18,9 @@ export const PREVIEWABLE_TYPES = [
...FILE_EXTENSIONS.AUDIO, ...FILE_EXTENSIONS.AUDIO,
'pdf', 'html', 'htm', 'md', 'markdown', 'pdf', 'html', 'htm', 'md', 'markdown',
// Office 文件支持预览 // 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) => { export const isOfficeFile = (path) => {
return isExcelFile(path) || isWordFile(path) || ['ppt', 'pptx'].includes(getExt(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)
}