diff --git a/web/package.json b/web/package.json index b9e3709e..b6840854 100644 --- a/web/package.json +++ b/web/package.json @@ -46,6 +46,7 @@ "lexical": "^0.39.0", "mammoth": "^1.12.0", "mermaid": "^11.12.1", + "pdfjs-dist": "^4.4.168", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^15.0.0", diff --git a/web/src/components/DocumentPreview/index.tsx b/web/src/components/DocumentPreview/index.tsx index c57080fb..8ab67be1 100644 --- a/web/src/components/DocumentPreview/index.tsx +++ b/web/src/components/DocumentPreview/index.tsx @@ -1,10 +1,32 @@ -import { useState, useEffect, type FC } from 'react'; -import { Spin, Alert, Button, Table } from 'antd'; -import { ReloadOutlined, DownloadOutlined } from '@ant-design/icons'; +/* + * @Description: + * @Version: 0.0.1 + * @Author: yujiangping + * @Date: 2026-03-16 19:01:12 + * @LastEditors: yujiangping + * @LastEditTime: 2026-03-16 19:17:47 + */ +import { useState, useEffect, useRef, useCallback, type FC } from 'react'; +import { Spin, Alert, Button, Table, InputNumber } from 'antd'; +import { + ReloadOutlined, + DownloadOutlined, + LeftOutlined, + RightOutlined, + ZoomInOutlined, + ZoomOutOutlined, +} from '@ant-design/icons'; import RbMarkdown from '../Markdown'; import { cookieUtils } from '@/utils/request'; import mammoth from 'mammoth'; import * as XLSX from 'xlsx'; +import * as pdfjsLib from 'pdfjs-dist'; + +// 设置 pdf.js worker +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.mjs', + import.meta.url, +).toString(); interface DocumentPreviewProps { fileUrl: string; @@ -30,11 +52,27 @@ const DocumentPreview: FC = ({ const [htmlContent, setHtmlContent] = useState(''); const [excelData, setExcelData] = useState<{ sheetName: string; data: any[][] }[]>([]); + // PDF 状态 + const [pdfDoc, setPdfDoc] = useState(null); + const [pdfCurrentPage, setPdfCurrentPage] = useState(1); + const [pdfTotalPages, setPdfTotalPages] = useState(0); + const [pdfScale, setPdfScale] = useState(1.5); + const pdfCanvasRef = useRef(null); + const pdfRenderingRef = useRef(false); + + // PPT 状态 + const [pptSlides, setPptSlides] = useState([]); + const [pptCurrentPage, setPptCurrentPage] = useState(1); + const [pptTotalPages, setPptTotalPages] = useState(0); + // 支持预览的文件类型 - const previewableTypes = ['.pdf', '.txt', '.md', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx']; - // PPT 暂不支持 - const downloadOnlyTypes = ['.ppt', '.pptx']; - + const previewableTypes = [ + '.pdf', '.txt', '.md', + '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', + '.doc', '.docx', '.xls', '.xlsx', + '.ppt', '.pptx', + ]; + const getFileExtension = () => { if (fileExt) { return fileExt.toLowerCase().startsWith('.') ? fileExt.toLowerCase() : `.${fileExt.toLowerCase()}`; @@ -43,7 +81,7 @@ const DocumentPreview: FC = ({ const match = name.match(/\.([^.]+)$/); return match ? `.${match[1].toLowerCase()}` : ''; }; - + const isTextFile = () => getFileExtension() === '.txt'; const isMarkdownFile = () => getFileExtension() === '.md'; const isImageFile = () => { @@ -53,8 +91,30 @@ const DocumentPreview: FC = ({ const isPdfFile = () => getFileExtension() === '.pdf'; const isWordFile = () => ['.doc', '.docx'].includes(getFileExtension()); const isExcelFile = () => ['.xls', '.xlsx'].includes(getFileExtension()); + const isPptFile = () => ['.ppt', '.pptx'].includes(getFileExtension()); const isPreviewable = () => previewableTypes.includes(getFileExtension()); - const isDownloadOnly = () => downloadOnlyTypes.includes(getFileExtension()); + + const getRequestUrl = (url: string) => { + if (url.includes('devapi.mem.redbearai.com')) { + const parsed = new URL(url); + return parsed.pathname; + } + return url; + }; + + const fetchFileBuffer = async (url: string): Promise => { + const requestUrl = getRequestUrl(url); + const response = await fetch(requestUrl, { + credentials: 'include', + headers: { + 'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`, + }, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.arrayBuffer(); + }; const handleDownload = () => { const link = document.createElement('a'); @@ -65,73 +125,132 @@ const DocumentPreview: FC = ({ document.body.removeChild(link); }; - const handleLoad = () => { - setLoading(false); - setError(false); - }; - const handleError = (msg?: string) => { setLoading(false); setError(true); if (msg) setErrorMessage(msg); }; - const handleRetry = () => { + // ========== PDF 渲染逻辑 ========== + const renderPdfPage = useCallback(async (doc: pdfjsLib.PDFDocumentProxy, pageNum: number, scale: number) => { + if (pdfRenderingRef.current || !pdfCanvasRef.current) return; + pdfRenderingRef.current = true; + try { + const page = await doc.getPage(pageNum); + const viewport = page.getViewport({ scale }); + const canvas = pdfCanvasRef.current; + const context = canvas.getContext('2d'); + if (!context) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = viewport.width * dpr; + canvas.height = viewport.height * dpr; + canvas.style.width = `${viewport.width}px`; + canvas.style.height = `${viewport.height}px`; + context.setTransform(dpr, 0, 0, dpr, 0, 0); + + await page.render({ canvasContext: context, viewport }).promise; + } finally { + pdfRenderingRef.current = false; + } + }, []); + + const loadPdfFile = useCallback(async () => { setLoading(true); setError(false); setErrorMessage(''); - - if (isTextFile() || isMarkdownFile()) { - loadTextFile(); - } else if (isWordFile()) { - loadWordFile(); - } else if (isExcelFile()) { - loadExcelFile(); - } else { - const iframe = document.querySelector(`iframe[title="${fileName || '文档预览'}"]`) as HTMLIFrameElement; - if (iframe) { - iframe.src = iframe.src; - } + try { + const arrayBuffer = await fetchFileBuffer(fileUrl); + const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; + setPdfDoc(doc); + setPdfTotalPages(doc.numPages); + setPdfCurrentPage(1); + await renderPdfPage(doc, 1, pdfScale); + setLoading(false); + } catch (err: any) { + console.error('加载 PDF 文件失败:', err); + handleError(err.message || '加载 PDF 文件失败'); + } + }, [fileUrl, pdfScale, renderPdfPage]); + + const handlePdfPageChange = async (page: number) => { + if (!pdfDoc || page < 1 || page > pdfTotalPages) return; + setPdfCurrentPage(page); + await renderPdfPage(pdfDoc, page, pdfScale); + }; + + const handlePdfZoom = async (delta: number) => { + const newScale = Math.max(0.5, Math.min(3, pdfScale + delta)); + setPdfScale(newScale); + if (pdfDoc) { + await renderPdfPage(pdfDoc, pdfCurrentPage, newScale); } }; + // ========== PPT/PPTX 预览逻辑(转 PDF 后用 pdfjs 渲染每页为图片) ========== + const loadPptFile = useCallback(async () => { + setLoading(true); + setError(false); + setErrorMessage(''); + try { + const arrayBuffer = await fetchFileBuffer(fileUrl); + // 尝试用 pdfjs 直接加载(某些服务端会返回转换后的 PDF) + // 如果失败,则使用 Office Online Viewer 作为 fallback + try { + const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; + // 成功解析为 PDF,逐页渲染为图片 + const slides: string[] = []; + for (let i = 1; i <= doc.numPages; i++) { + const page = await doc.getPage(i); + const viewport = page.getViewport({ scale: 2 }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) continue; + canvas.width = viewport.width; + canvas.height = viewport.height; + await page.render({ canvasContext: context, viewport }).promise; + slides.push(canvas.toDataURL('image/png')); + } + setPptSlides(slides); + setPptTotalPages(slides.length); + setPptCurrentPage(1); + setLoading(false); + } catch { + // 不是 PDF 格式,使用 Office Online Viewer + setPptSlides([]); + setPptTotalPages(0); + setLoading(false); + } + } catch (err: any) { + console.error('加载 PPT 文件失败:', err); + handleError(err.message || '加载 PPT 文件失败'); + } + }, [fileUrl]); + + // ========== 文本/Word/Excel 加载逻辑 ========== const loadTextFile = async () => { setLoading(true); setError(false); setErrorMessage(''); try { - let requestUrl = fileUrl; - - if (fileUrl.includes('devapi.mem.redbearai.com')) { - const url = new URL(fileUrl); - requestUrl = url.pathname; - } - + const requestUrl = getRequestUrl(fileUrl); const response = await fetch(requestUrl, { credentials: 'include', headers: { 'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`, }, }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); const contentType = response.headers.get('Content-Type') || ''; - if (contentType.startsWith('image/')) { handleError('文件实际是图片类型,但被标记为文本文件'); return; } - const text = await response.text(); - if (text.startsWith('\x89PNG') || text.startsWith('�PNG')) { handleError('文件内容是图片,但扩展名是文本'); return; } - setTextContent(text); setLoading(false); } catch (err: any) { @@ -145,25 +264,7 @@ const DocumentPreview: FC = ({ setError(false); setErrorMessage(''); try { - let requestUrl = fileUrl; - - if (fileUrl.includes('devapi.mem.redbearai.com')) { - const url = new URL(fileUrl); - requestUrl = url.pathname; - } - - const response = await fetch(requestUrl, { - credentials: 'include', - headers: { - 'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const arrayBuffer = await response.arrayBuffer(); + const arrayBuffer = await fetchFileBuffer(fileUrl); const result = await mammoth.convertToHtml({ arrayBuffer }); setHtmlContent(result.value); setLoading(false); @@ -178,33 +279,13 @@ const DocumentPreview: FC = ({ setError(false); setErrorMessage(''); try { - let requestUrl = fileUrl; - - if (fileUrl.includes('devapi.mem.redbearai.com')) { - const url = new URL(fileUrl); - requestUrl = url.pathname; - } - - const response = await fetch(requestUrl, { - credentials: 'include', - headers: { - 'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const arrayBuffer = await response.arrayBuffer(); + const arrayBuffer = await fetchFileBuffer(fileUrl); const workbook = XLSX.read(arrayBuffer, { type: 'array' }); - const sheets = workbook.SheetNames.map(sheetName => { const worksheet = workbook.Sheets[sheetName]; const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][]; return { sheetName, data }; }); - setExcelData(sheets); setLoading(false); } catch (err: any) { @@ -213,40 +294,72 @@ const DocumentPreview: FC = ({ } }; + const handleRetry = () => { + setLoading(true); + setError(false); + setErrorMessage(''); + if (isTextFile() || isMarkdownFile()) loadTextFile(); + else if (isWordFile()) loadWordFile(); + else if (isExcelFile()) loadExcelFile(); + else if (isPdfFile()) loadPdfFile(); + else if (isPptFile()) loadPptFile(); + }; + useEffect(() => { - if (isTextFile() || isMarkdownFile()) { - loadTextFile(); - } else if (isWordFile()) { - loadWordFile(); - } else if (isExcelFile()) { - loadExcelFile(); - } + if (isTextFile() || isMarkdownFile()) loadTextFile(); + else if (isWordFile()) loadWordFile(); + else if (isExcelFile()) loadExcelFile(); + else if (isPdfFile()) loadPdfFile(); + else if (isPptFile()) loadPptFile(); + else if (isImageFile()) setLoading(false); }, [fileUrl]); - // PPT 文件只提供下载 - if (isDownloadOnly()) { - return ( -
- -

PPT 文件暂不支持在线预览,请下载后查看

- -
- } - type="info" - showIcon + // PDF 翻页/缩放后重新渲染 + useEffect(() => { + if (pdfDoc && isPdfFile()) { + renderPdfPage(pdfDoc, pdfCurrentPage, pdfScale); + } + }, [pdfCurrentPage, pdfScale, pdfDoc]); + + // ========== 分页控制栏组件 ========== + const PaginationBar = ({ + currentPage, + totalPages, + onPageChange, + extraControls, + }: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + extraControls?: React.ReactNode; + }) => ( +
+
- ); - } + / {totalPages} + + - + + } @@ -301,43 +408,48 @@ const DocumentPreview: FC = ({ /> )} - + + {/* 图片预览 */} {isImageFile() && !error && !loading && ( -
- {fileName + {fileName handleError('图片加载失败')} />
)} + {/* Markdown 预览 */} {isMarkdownFile() && !error && !loading && ( -
+
)} + {/* 文本预览 */} {isTextFile() && !error && !loading && ( -
+
             {textContent}
           
)} + {/* Word 预览 */} {isWordFile() && !error && !loading && ( -
-
+
)} + {/* Excel 预览 */} {isExcelFile() && !error && !loading && ( -
+
{excelData.map((sheet, index) => (

{sheet.sheetName}

@@ -361,17 +473,84 @@ const DocumentPreview: FC = ({
)} + {/* PDF 预览 - 带分页和缩放 */} {isPdfFile() && !error && !loading && ( -