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"
"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(`<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 服务器)
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 检查是否为绝对 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
}