📄 文件预览功能:文档的魔镜

28 阅读5分钟

难度系数:⭐⭐⭐⭐
实用指数:💯💯💯💯💯


📖 开篇:一次糟糕的文件查看体验

小明在OA系统查看领导批复的文件:

点击"查看文件" → 自动下载到本地 → 找到下载文件 
→ 双击打开 → "您没有安装Office" → 下载WPS 
→ 安装WPS → 再次打开文件 → 终于看到了...

小明:

"查看个文件要5分钟?现在都2024年了!😭"


同一天,小红在石墨文档查看文件:

点击"预览" → 立即在浏览器打开 → 完美显示 ✨

小红:

"这才是现代化的体验!😊"


产品经理找到你:

"小王,我们也要做文件预览功能!要支持所有格式!"

你:

"所有格式?Word、Excel、PPT、PDF、图片、视频、音频、CAD图纸、PSD... 😱"

产品经理:

"对啊,有问题吗?🙄"


今天,我们就来实现一个强大的文件预览系统! 🎯


🎯 文件预览的应用场景

场景文件类型预览需求
OA系统 📋Word/Excel/PPT/PDF审批文档预览
网盘系统 📦图片/视频/音频/文档在线查看
合同系统 📝PDF/扫描件合同预览
设计系统 🎨PSD/AI/Sketch设计稿预览
开发平台 💻代码/Markdown代码高亮预览
电商平台 🛒商品图片大图预览

🎨 文件预览方案全景图

┌──────────────────────────────────────────────────────────┐
│              文件预览解决方案                              │
└──────────────────────────────────────────────────────────┘

方案1: 浏览器原生支持
  - 图片(JPG/PNG/GIF/WebP)
  - PDF(Chrome/Edge内置)
  - 视频(MP4/WebM)
  - 音频(MP3/WAV)
  ✅ 无需转换,速度快

方案2: 前端库渲染
  - Office文档 → office-viewer
  - PDF → pdf.js
  - Markdown → marked.js
  - 代码 → highlight.js
  ✅ 无需后端支持

方案3: 格式转换
  - Office → PDF/HTML
  - CAD → 图片
  - PSD → 图片
  ✅ 支持复杂格式

方案4: 第三方服务
  - 微软Office Online
  - Google Docs Viewer
  - 永中Office
  ⚠️ 有限制,收费

方案5: 自建预览服务
  - LibreOffice
  - OpenOffice
  - unoconv
  ✅ 完全可控

🖼️ 方案一:图片预览

1. 基础图片预览

<!DOCTYPE html>
<html>
<head>
    <title>图片预览</title>
    <style>
        .image-preview {
            max-width: 100%;
            max-height: 600px;
            cursor: zoom-in;
        }
        
        .modal {
            display: none;
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0,0,0,0.9);
            z-index: 9999;
        }
        
        .modal img {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            max-width: 90%;
            max-height: 90%;
        }
        
        .close-btn {
            position: absolute;
            top: 20px;
            right: 40px;
            color: white;
            font-size: 40px;
            cursor: pointer;
        }
    </style>
</head>
<body>
    <img src="/api/file/view/1" class="image-preview" onclick="openModal(this.src)">
    
    <div id="modal" class="modal" onclick="closeModal()">
        <span class="close-btn">&times;</span>
        <img id="modal-img" src="">
    </div>
    
    <script>
        function openModal(src) {
            document.getElementById('modal').style.display = 'block';
            document.getElementById('modal-img').src = src;
        }
        
        function closeModal() {
            document.getElementById('modal').style.display = 'none';
        }
    </script>
</body>
</html>

2. 高级图片预览(Viewer.js)

<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/viewerjs@1.11.0/dist/viewer.min.css">
    <script src="https://cdn.jsdelivr.net/npm/viewerjs@1.11.0/dist/viewer.min.js"></script>
</head>
<body>
    <div id="images">
        <img src="image1.jpg" alt="图片1">
        <img src="image2.jpg" alt="图片2">
        <img src="image3.jpg" alt="图片3">
    </div>
    
    <script>
        const viewer = new Viewer(document.getElementById('images'), {
            toolbar: true,        // 显示工具栏
            navbar: true,         // 显示缩略图
            title: true,          // 显示标题
            tooltip: true,        // 显示缩放比例
            movable: true,        // 可拖动
            zoomable: true,       // 可缩放
            rotatable: true,      // 可旋转
            scalable: true,       // 可翻转
            transition: true,     // 过渡动画
            fullscreen: true,     // 全屏
            keyboard: true        // 键盘控制
        });
    </script>
</body>
</html>

3. 后端接口

@RestController
@RequestMapping("/api/file")
public class FileViewController {
    
    @Autowired
    private FileService fileService;
    
    /**
     * 图片预览
     */
    @GetMapping("/view/{fileId}")
    public void viewImage(@PathVariable Long fileId,
                         HttpServletResponse response) throws IOException {
        
        // 1. 获取文件信息
        FileInfo fileInfo = fileService.getById(fileId);
        
        if (fileInfo == null) {
            response.setStatus(HttpStatus.NOT_FOUND.value());
            return;
        }
        
        // 2. 检查权限
        if (!hasPermission(fileId)) {
            response.setStatus(HttpStatus.FORBIDDEN.value());
            return;
        }
        
        // 3. 读取文件
        File file = new File(fileInfo.getFilePath());
        
        // 4. 设置响应头
        response.setContentType(fileInfo.getContentType());
        response.setHeader("Content-Disposition", "inline; filename=" + 
            URLEncoder.encode(fileInfo.getFileName(), "UTF-8"));
        
        // 5. 输出文件
        try (FileInputStream fis = new FileInputStream(file);
             OutputStream os = response.getOutputStream()) {
            
            IOUtils.copy(fis, os);
            os.flush();
        }
    }
    
    /**
     * 缩略图(压缩)
     */
    @GetMapping("/thumbnail/{fileId}")
    public void thumbnail(@PathVariable Long fileId,
                         @RequestParam(defaultValue = "200") int width,
                         @RequestParam(defaultValue = "200") int height,
                         HttpServletResponse response) throws IOException {
        
        FileInfo fileInfo = fileService.getById(fileId);
        File file = new File(fileInfo.getFilePath());
        
        // 使用Thumbnailator压缩图片
        response.setContentType("image/jpeg");
        
        Thumbnails.of(file)
            .size(width, height)
            .outputFormat("jpg")
            .toOutputStream(response.getOutputStream());
    }
}

📄 方案二:PDF预览

1. Chrome内置预览

<!-- 最简单的方式:直接用iframe -->
<iframe src="/api/file/view/1.pdf" 
        width="100%" 
        height="600px">
</iframe>

<!-- 或者用embed -->
<embed src="/api/file/view/1.pdf" 
       type="application/pdf" 
       width="100%" 
       height="600px">

2. PDF.js预览(推荐⭐⭐⭐⭐⭐)

<!DOCTYPE html>
<html>
<head>
    <title>PDF预览</title>
    <script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js"></script>
    <style>
        #pdf-container {
            text-align: center;
        }
        
        canvas {
            border: 1px solid #ccc;
            margin: 10px auto;
            display: block;
        }
        
        .controls {
            margin: 20px;
            text-align: center;
        }
        
        button {
            padding: 10px 20px;
            margin: 0 5px;
            font-size: 16px;
        }
    </style>
</head>
<body>
    <div class="controls">
        <button onclick="prevPage()">上一页</button>
        <span><span id="page-num"></span> 页 / 共 <span id="page-count"></span></span>
        <button onclick="nextPage()">下一页</button>
        
        <button onclick="zoomIn()">放大</button>
        <button onclick="zoomOut()">缩小</button>
    </div>
    
    <div id="pdf-container"></div>
    
    <script>
        // 设置PDF.js的workerSrc
        pdfjsLib.GlobalWorkerOptions.workerSrc = 
            'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
        
        let pdfDoc = null;
        let pageNum = 1;
        let pageRendering = false;
        let pageNumPending = null;
        let scale = 1.5;  // 缩放比例
        
        /**
         * 加载PDF
         */
        async function loadPDF(url) {
            try {
                pdfDoc = await pdfjsLib.getDocument(url).promise;
                document.getElementById('page-count').textContent = pdfDoc.numPages;
                
                // 渲染第一页
                renderPage(pageNum);
            } catch (error) {
                console.error('PDF加载失败:', error);
            }
        }
        
        /**
         * 渲染指定页
         */
        async function renderPage(num) {
            pageRendering = true;
            
            try {
                // 获取页面
                const page = await pdfDoc.getPage(num);
                
                // 创建canvas
                const canvas = document.createElement('canvas');
                const ctx = canvas.getContext('2d');
                
                // 计算尺寸
                const viewport = page.getViewport({ scale: scale });
                canvas.width = viewport.width;
                canvas.height = viewport.height;
                
                // 渲染
                const renderContext = {
                    canvasContext: ctx,
                    viewport: viewport
                };
                
                await page.render(renderContext).promise;
                
                // 显示
                const container = document.getElementById('pdf-container');
                container.innerHTML = '';
                container.appendChild(canvas);
                
                // 更新页码
                document.getElementById('page-num').textContent = num;
                
                pageRendering = false;
                
                // 如果有待渲染的页面
                if (pageNumPending !== null) {
                    renderPage(pageNumPending);
                    pageNumPending = null;
                }
            } catch (error) {
                console.error('页面渲染失败:', error);
                pageRendering = false;
            }
        }
        
        /**
         * 上一页
         */
        function prevPage() {
            if (pageNum <= 1) return;
            pageNum--;
            queueRenderPage(pageNum);
        }
        
        /**
         * 下一页
         */
        function nextPage() {
            if (pageNum >= pdfDoc.numPages) return;
            pageNum++;
            queueRenderPage(pageNum);
        }
        
        /**
         * 队列渲染
         */
        function queueRenderPage(num) {
            if (pageRendering) {
                pageNumPending = num;
            } else {
                renderPage(num);
            }
        }
        
        /**
         * 放大
         */
        function zoomIn() {
            scale += 0.25;
            renderPage(pageNum);
        }
        
        /**
         * 缩小
         */
        function zoomOut() {
            if (scale <= 0.5) return;
            scale -= 0.25;
            renderPage(pageNum);
        }
        
        // 加载PDF文件
        loadPDF('/api/file/view/1');
    </script>
</body>
</html>

📊 方案三:Office文档预览

1. Office转PDF(推荐方案)

LibreOffice转换

@Service
public class OfficeConvertService {
    
    @Value("${libreoffice.home}")
    private String libreOfficeHome;
    
    /**
     * Office文档转PDF
     */
    public File convertToPdf(File sourceFile) throws IOException, InterruptedException {
        String outputDir = sourceFile.getParent();
        
        // 构建命令
        String[] cmd = {
            libreOfficeHome + "/program/soffice",
            "--headless",                    // 无界面模式
            "--convert-to", "pdf",           // 转换为PDF
            "--outdir", outputDir,           // 输出目录
            sourceFile.getAbsolutePath()     // 源文件
        };
        
        // 执行转换
        Process process = Runtime.getRuntime().exec(cmd);
        int exitCode = process.waitFor();
        
        if (exitCode != 0) {
            throw new RuntimeException("转换失败,exitCode=" + exitCode);
        }
        
        // 返回PDF文件
        String pdfFileName = sourceFile.getName().replaceAll("\.[^.]+$", ".pdf");
        File pdfFile = new File(outputDir, pdfFileName);
        
        log.info("✅ 转换成功:{} → {}", sourceFile.getName(), pdfFile.getName());
        
        return pdfFile;
    }
    
    /**
     * Office文档转HTML
     */
    public File convertToHtml(File sourceFile) throws IOException, InterruptedException {
        String outputDir = sourceFile.getParent();
        
        String[] cmd = {
            libreOfficeHome + "/program/soffice",
            "--headless",
            "--convert-to", "html",
            "--outdir", outputDir,
            sourceFile.getAbsolutePath()
        };
        
        Process process = Runtime.getRuntime().exec(cmd);
        process.waitFor();
        
        String htmlFileName = sourceFile.getName().replaceAll("\.[^.]+$", ".html");
        return new File(outputDir, htmlFileName);
    }
}

预览接口

@RestController
@RequestMapping("/api/preview")
public class PreviewController {
    
    @Autowired
    private FileService fileService;
    
    @Autowired
    private OfficeConvertService convertService;
    
    /**
     * Office文档预览(自动转PDF)
     */
    @GetMapping("/office/{fileId}")
    public void previewOffice(@PathVariable Long fileId,
                             HttpServletResponse response) throws IOException {
        
        // 1. 获取文件
        FileInfo fileInfo = fileService.getById(fileId);
        File sourceFile = new File(fileInfo.getFilePath());
        
        // 2. 检查是否已转换
        String pdfPath = fileInfo.getFilePath().replaceAll("\.[^.]+$", ".pdf");
        File pdfFile = new File(pdfPath);
        
        if (!pdfFile.exists() || pdfFile.lastModified() < sourceFile.lastModified()) {
            // 转换为PDF
            try {
                pdfFile = convertService.convertToPdf(sourceFile);
            } catch (Exception e) {
                log.error("转换失败", e);
                response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
                return;
            }
        }
        
        // 3. 输出PDF
        response.setContentType("application/pdf");
        response.setHeader("Content-Disposition", "inline");
        
        try (FileInputStream fis = new FileInputStream(pdfFile);
             OutputStream os = response.getOutputStream()) {
            IOUtils.copy(fis, os);
        }
    }
}

2. 前端Office预览(office-viewer)

<!DOCTYPE html>
<html>
<head>
    <script src="https://cdn.jsdelivr.net/npm/@microsoft/office-js@1/dist/office.js"></script>
</head>
<body>
    <div id="office-container" style="width: 100%; height: 600px;"></div>
    
    <script>
        // 使用Office Online预览(需要公网可访问的URL)
        const fileUrl = 'https://example.com/files/document.docx';
        const officeUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fileUrl)}`;
        
        const iframe = document.createElement('iframe');
        iframe.src = officeUrl;
        iframe.width = '100%';
        iframe.height = '600px';
        
        document.getElementById('office-container').appendChild(iframe);
    </script>
</body>
</html>

🎬 方案四:视频/音频预览

1. HTML5原生播放器

<!-- 视频播放 -->
<video controls width="800">
    <source src="/api/file/view/video.mp4" type="video/mp4">
    <source src="/api/file/view/video.webm" type="video/webm">
    您的浏览器不支持视频播放
</video>

<!-- 音频播放 -->
<audio controls>
    <source src="/api/file/view/audio.mp3" type="audio/mpeg">
    <source src="/api/file/view/audio.ogg" type="audio/ogg">
    您的浏览器不支持音频播放
</audio>

2. Video.js播放器(推荐)

<!DOCTYPE html>
<html>
<head>
    <link href="https://vjs.zencdn.net/8.6.1/video-js.css" rel="stylesheet">
    <script src="https://vjs.zencdn.net/8.6.1/video.min.js"></script>
</head>
<body>
    <video id="my-video" class="video-js" controls preload="auto" 
           width="800" height="450" 
           data-setup='{}'>
        <source src="/api/file/view/video.mp4" type="video/mp4">
        <p class="vjs-no-js">
            请启用JavaScript以播放视频
        </p>
    </video>
    
    <script>
        const player = videojs('my-video', {
            controls: true,
            autoplay: false,
            preload: 'auto',
            playbackRates: [0.5, 1, 1.5, 2],  // 倍速播放
            fluid: true  // 响应式
        });
        
        // 监听播放事件
        player.on('play', function() {
            console.log('开始播放');
        });
        
        player.on('ended', function() {
            console.log('播放结束');
        });
    </script>
</body>
</html>

3. 后端流式传输

@RestController
@RequestMapping("/api/video")
public class VideoStreamController {
    
    /**
     * 视频流式传输(支持断点续传)
     */
    @GetMapping("/stream/{fileId}")
    public void streamVideo(@PathVariable Long fileId,
                           @RequestHeader(value = "Range", required = false) String range,
                           HttpServletResponse response) throws IOException {
        
        // 1. 获取文件
        FileInfo fileInfo = fileService.getById(fileId);
        File file = new File(fileInfo.getFilePath());
        
        long fileLength = file.length();
        long start = 0;
        long end = fileLength - 1;
        
        // 2. 解析Range请求头
        if (range != null && range.startsWith("bytes=")) {
            String[] ranges = range.substring(6).split("-");
            start = Long.parseLong(ranges[0]);
            
            if (ranges.length > 1 && !ranges[1].isEmpty()) {
                end = Long.parseLong(ranges[1]);
            }
        }
        
        long contentLength = end - start + 1;
        
        // 3. 设置响应头
        response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);  // 206
        response.setContentType("video/mp4");
        response.setHeader("Accept-Ranges", "bytes");
        response.setHeader("Content-Range", 
            String.format("bytes %d-%d/%d", start, end, fileLength));
        response.setContentLengthLong(contentLength);
        
        // 4. 输出文件流
        try (RandomAccessFile raf = new RandomAccessFile(file, "r");
             OutputStream os = response.getOutputStream()) {
            
            raf.seek(start);
            
            byte[] buffer = new byte[8192];
            long bytesRemaining = contentLength;
            
            while (bytesRemaining > 0) {
                int bytesToRead = (int) Math.min(buffer.length, bytesRemaining);
                int bytesRead = raf.read(buffer, 0, bytesToRead);
                
                if (bytesRead == -1) break;
                
                os.write(buffer, 0, bytesRead);
                bytesRemaining -= bytesRead;
            }
            
            os.flush();
        }
    }
}

💻 方案五:代码预览

1. Highlight.js代码高亮

<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
    <style>
        pre {
            margin: 0;
            border-radius: 5px;
        }
        
        code {
            font-family: 'Consolas', 'Monaco', monospace;
            font-size: 14px;
        }
    </style>
</head>
<body>
    <pre><code class="language-java" id="code-content"></code></pre>
    
    <script>
        // 从后端获取代码
        fetch('/api/file/content/1')
            .then(res => res.text())
            .then(code => {
                document.getElementById('code-content').textContent = code;
                hljs.highlightAll();
            });
    </script>
</body>
</html>

2. Monaco Editor(VSCode编辑器)

<!DOCTYPE html>
<html>
<head>
    <style>
        #monaco-container {
            width: 100%;
            height: 600px;
            border: 1px solid #ccc;
        }
    </style>
</head>
<body>
    <div id="monaco-container"></div>
    
    <script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs/loader.js"></script>
    <script>
        require.config({ 
            paths: { 
                'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs' 
            }
        });
        
        require(['vs/editor/editor.main'], function() {
            // 从后端获取代码
            fetch('/api/file/content/1')
                .then(res => res.text())
                .then(code => {
                    monaco.editor.create(document.getElementById('monaco-container'), {
                        value: code,
                        language: 'java',
                        theme: 'vs-dark',
                        readOnly: true,  // 只读模式
                        automaticLayout: true,
                        minimap: {
                            enabled: true
                        },
                        scrollBeyondLastLine: false
                    });
                });
        });
    </script>
</body>
</html>

🚀 完整预览系统

1. 统一预览接口

@RestController
@RequestMapping("/api/preview")
public class UnifiedPreviewController {
    
    @Autowired
    private Map<String, FilePreviewHandler> handlerMap;
    
    /**
     * 统一预览入口
     */
    @GetMapping("/{fileId}")
    public void preview(@PathVariable Long fileId,
                       HttpServletResponse response) throws IOException {
        
        // 1. 获取文件信息
        FileInfo fileInfo = fileService.getById(fileId);
        
        if (fileInfo == null) {
            response.setStatus(HttpStatus.NOT_FOUND.value());
            return;
        }
        
        // 2. 根据文件类型选择处理器
        String fileType = getFileType(fileInfo.getFileName());
        FilePreviewHandler handler = handlerMap.get(fileType + "Handler");
        
        if (handler == null) {
            response.setStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE.value());
            response.getWriter().write("不支持的文件类型");
            return;
        }
        
        // 3. 执行预览
        handler.preview(fileInfo, response);
    }
    
    private String getFileType(String fileName) {
        String ext = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
        
        if (Arrays.asList("jpg", "jpeg", "png", "gif", "bmp", "webp").contains(ext)) {
            return "image";
        } else if ("pdf".equals(ext)) {
            return "pdf";
        } else if (Arrays.asList("doc", "docx", "xls", "xlsx", "ppt", "pptx").contains(ext)) {
            return "office";
        } else if (Arrays.asList("mp4", "avi", "mov", "webm").contains(ext)) {
            return "video";
        } else if (Arrays.asList("mp3", "wav", "ogg").contains(ext)) {
            return "audio";
        } else if (Arrays.asList("txt", "java", "py", "js", "html", "css").contains(ext)) {
            return "text";
        }
        
        return "unknown";
    }
}

2. 预览处理器接口

public interface FilePreviewHandler {
    void preview(FileInfo fileInfo, HttpServletResponse response) throws IOException;
}

// 图片预览处理器
@Component("imageHandler")
public class ImagePreviewHandler implements FilePreviewHandler {
    
    @Override
    public void preview(FileInfo fileInfo, HttpServletResponse response) throws IOException {
        File file = new File(fileInfo.getFilePath());
        
        response.setContentType(fileInfo.getContentType());
        response.setHeader("Content-Disposition", "inline");
        
        try (FileInputStream fis = new FileInputStream(file);
             OutputStream os = response.getOutputStream()) {
            IOUtils.copy(fis, os);
        }
    }
}

// Office预览处理器
@Component("officeHandler")
public class OfficePreviewHandler implements FilePreviewHandler {
    
    @Autowired
    private OfficeConvertService convertService;
    
    @Override
    public void preview(FileInfo fileInfo, HttpServletResponse response) throws IOException {
        // 转换为PDF再预览
        File sourceFile = new File(fileInfo.getFilePath());
        File pdfFile = convertService.convertToPdf(sourceFile);
        
        response.setContentType("application/pdf");
        response.setHeader("Content-Disposition", "inline");
        
        try (FileInputStream fis = new FileInputStream(pdfFile);
             OutputStream os = response.getOutputStream()) {
            IOUtils.copy(fis, os);
        }
    }
}

3. 前端统一组件

<template>
  <div class="file-preview">
    <!-- 图片预览 -->
    <div v-if="fileType === 'image'" class="image-preview">
      <img :src="previewUrl" alt="预览">
    </div>
    
    <!-- PDF预览 -->
    <div v-else-if="fileType === 'pdf'" class="pdf-preview">
      <iframe :src="previewUrl" frameborder="0"></iframe>
    </div>
    
    <!-- Office预览 -->
    <div v-else-if="fileType === 'office'" class="office-preview">
      <iframe :src="previewUrl" frameborder="0"></iframe>
    </div>
    
    <!-- 视频预览 -->
    <div v-else-if="fileType === 'video'" class="video-preview">
      <video controls :src="previewUrl"></video>
    </div>
    
    <!-- 音频预览 -->
    <div v-else-if="fileType === 'audio'" class="audio-preview">
      <audio controls :src="previewUrl"></audio>
    </div>
    
    <!-- 代码预览 -->
    <div v-else-if="fileType === 'text'" class="code-preview">
      <pre><code v-html="highlightedCode"></code></pre>
    </div>
    
    <!-- 不支持预览 -->
    <div v-else class="unsupported">
      <p>不支持在线预览此文件</p>
      <el-button @click="download">下载查看</el-button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import { getFileInfo } from '@/api/file'
import hljs from 'highlight.js'

const props = defineProps({
  fileId: {
    type: Number,
    required: true
  }
})

const fileInfo = ref(null)
const fileContent = ref('')

const fileType = computed(() => {
  if (!fileInfo.value) return 'unknown'
  
  const ext = fileInfo.value.fileName.split('.').pop().toLowerCase()
  
  if (['jpg', 'jpeg', 'png', 'gif'].includes(ext)) return 'image'
  if (ext === 'pdf') return 'pdf'
  if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) return 'office'
  if (['mp4', 'avi', 'mov'].includes(ext)) return 'video'
  if (['mp3', 'wav'].includes(ext)) return 'audio'
  if (['txt', 'java', 'js', 'py'].includes(ext)) return 'text'
  
  return 'unknown'
})

const previewUrl = computed(() => {
  return `/api/preview/${props.fileId}`
})

const highlightedCode = computed(() => {
  if (fileType.value !== 'text') return ''
  return hljs.highlightAuto(fileContent.value).value
})

onMounted(async () => {
  fileInfo.value = await getFileInfo(props.fileId)
  
  if (fileType.value === 'text') {
    const res = await fetch(`/api/file/content/${props.fileId}`)
    fileContent.value = await res.text()
  }
})

const download = () => {
  window.open(`/api/file/download/${props.fileId}`)
}
</script>

<style scoped>
.file-preview {
  width: 100%;
  height: 600px;
}

iframe, img, video {
  width: 100%;
  height: 100%;
}

.unsupported {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
}
</style>

🎯 性能优化

1. 缓存转换结果

@Service
public class PreviewCacheService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 获取缓存的预览文件
     */
    public File getCachedPreview(Long fileId, String targetFormat) {
        String cacheKey = "preview:" + fileId + ":" + targetFormat;
        String cachePath = redisTemplate.opsForValue().get(cacheKey);
        
        if (cachePath != null) {
            File file = new File(cachePath);
            if (file.exists()) {
                return file;
            }
        }
        
        return null;
    }
    
    /**
     * 缓存预览文件
     */
    public void cachePreview(Long fileId, String targetFormat, File previewFile) {
        String cacheKey = "preview:" + fileId + ":" + targetFormat;
        redisTemplate.opsForValue().set(
            cacheKey, 
            previewFile.getAbsolutePath(), 
            Duration.ofDays(7)
        );
    }
}

2. 异步转换

@Service
public class AsyncConvertService {
    
    @Async("previewExecutor")
    public CompletableFuture<File> convertAsync(File sourceFile) {
        try {
            File pdfFile = officeConvertService.convertToPdf(sourceFile);
            return CompletableFuture.completedFuture(pdfFile);
        } catch (Exception e) {
            return CompletableFuture.failedFuture(e);
        }
    }
}

@Configuration
public class ExecutorConfig {
    
    @Bean(name = "previewExecutor")
    public Executor previewExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("preview-");
        executor.initialize();
        return executor;
    }
}

📝 总结

文件类型支持清单

文件类型预览方案推荐方案
图片原生imgViewer.js
PDFChrome内置PDF.js
Word/Excel/PPT转PDFLibreOffice+PDF.js
视频HTML5 videoVideo.js
音频HTML5 audio-
代码Highlight.jsMonaco Editor
MarkdownMarked.js-
TXT直接显示Highlight.js

关键要点 🎯

  1. 按需转换 - 首次访问时转换,缓存结果
  2. 异步处理 - 大文件转换不阻塞请求
  3. 流式传输 - 视频支持断点续传
  4. 权限控制 - 检查用户权限
  5. CDN加速 - 静态资源使用CDN

让文件预览如丝般顺滑! 🎉🎉🎉