fix:view pdf ppt

This commit is contained in:
yujiangping
2026-03-16 19:21:43 +08:00
parent fab9272124
commit 1e63dd8d2d
2 changed files with 324 additions and 144 deletions

View File

@@ -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",

View File

@@ -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<DocumentPreviewProps> = ({
const [htmlContent, setHtmlContent] = useState<string>('');
const [excelData, setExcelData] = useState<{ sheetName: string; data: any[][] }[]>([]);
// PDF 状态
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
const [pdfCurrentPage, setPdfCurrentPage] = useState(1);
const [pdfTotalPages, setPdfTotalPages] = useState(0);
const [pdfScale, setPdfScale] = useState(1.5);
const pdfCanvasRef = useRef<HTMLCanvasElement>(null);
const pdfRenderingRef = useRef(false);
// PPT 状态
const [pptSlides, setPptSlides] = useState<string[]>([]);
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<DocumentPreviewProps> = ({
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<DocumentPreviewProps> = ({
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<ArrayBuffer> => {
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<DocumentPreviewProps> = ({
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('<27>PNG')) {
handleError('文件内容是图片,但扩展名是文本');
return;
}
setTextContent(text);
setLoading(false);
} catch (err: any) {
@@ -145,25 +264,7 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
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<DocumentPreviewProps> = ({
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<DocumentPreviewProps> = ({
}
};
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 (
<div className={`rb:relative rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded rb:border rb:border-gray-200 ${className}`} style={{ width, height }}>
<Alert
message="PowerPoint 文档预览"
description={
<div className="rb:text-center">
<p className="rb:mb-4">PPT 线</p>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleDownload}
>
</Button>
</div>
}
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;
}) => (
<div className="rb:flex rb:items-center rb:justify-center rb:gap-3 rb:py-2 rb:px-4 rb:bg-white rb:border-t rb:border-gray-200 rb:select-none">
<Button
size="small"
icon={<LeftOutlined />}
disabled={currentPage <= 1}
onClick={() => onPageChange(currentPage - 1)}
/>
<span className="rb:text-sm rb:text-gray-600 rb:flex rb:items-center rb:gap-1">
<InputNumber
size="small"
min={1}
max={totalPages}
value={currentPage}
onChange={(val) => val && onPageChange(val)}
style={{ width: 56 }}
/>
</div>
);
}
<span>/ {totalPages}</span>
</span>
<Button
size="small"
icon={<RightOutlined />}
disabled={currentPage >= totalPages}
onClick={() => onPageChange(currentPage + 1)}
/>
{extraControls}
</div>
);
if (!isPreviewable()) {
return (
@@ -260,13 +373,13 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
}
return (
<div className={`rb:relative ${className}`} style={{ width, height }}>
<div className={`rb:relative rb:flex rb:flex-col ${className}`} style={{ width, height }}>
{loading && (
<div className="rb:absolute rb:inset-0 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:z-10">
<Spin size="large" tip="加载文档预览中..." />
</div>
)}
{error && (
<div className="rb:absolute rb:inset-0 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:z-10">
<Alert
@@ -275,9 +388,7 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
<div>
<p className="rb:mb-2"></p>
{errorMessage && (
<p className="rb:text-sm rb:text-red-600 rb:mb-3">
{errorMessage}
</p>
<p className="rb:text-sm rb:text-red-600 rb:mb-3">{errorMessage}</p>
)}
<p className="rb:text-sm rb:text-gray-600 rb:mb-3"></p>
<ul className="rb:list-disc rb:pl-5 rb:text-sm rb:text-gray-600 rb:mb-3">
@@ -287,12 +398,8 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
<li></li>
</ul>
<div className="rb:mt-4 rb:flex rb:gap-2">
<Button icon={<ReloadOutlined />} onClick={handleRetry}>
</Button>
<Button icon={<DownloadOutlined />} onClick={handleDownload}>
</Button>
<Button icon={<ReloadOutlined />} onClick={handleRetry}></Button>
<Button icon={<DownloadOutlined />} onClick={handleDownload}></Button>
</div>
</div>
}
@@ -301,43 +408,48 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
/>
</div>
)}
{/* 图片预览 */}
{isImageFile() && !error && !loading && (
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-gray-50 rb:flex rb:items-center rb:justify-center">
<img
src={fileUrl}
alt={fileName || '图片预览'}
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-50 rb:flex rb:items-center rb:justify-center">
<img
src={fileUrl}
alt={fileName || '图片预览'}
className="rb:max-w-full rb:max-h-full rb:object-contain"
onError={() => handleError('图片加载失败')}
/>
</div>
)}
{/* Markdown 预览 */}
{isMarkdownFile() && !error && !loading && (
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
<RbMarkdown content={textContent} />
</div>
)}
{/* 文本预览 */}
{isTextFile() && !error && !loading && (
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
<pre className="rb:whitespace-pre-wrap rb:text-sm rb:text-gray-800 rb:font-mono">
{textContent}
</pre>
</div>
)}
{/* Word 预览 */}
{isWordFile() && !error && !loading && (
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
<div
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
<div
className="rb:prose rb:max-w-none"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
)}
{/* Excel 预览 */}
{isExcelFile() && !error && !loading && (
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
{excelData.map((sheet, index) => (
<div key={index} className="rb:mb-6">
<h3 className="rb:text-lg rb:font-semibold rb:mb-3">{sheet.sheetName}</h3>
@@ -361,17 +473,84 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
</div>
)}
{/* PDF 预览 - 带分页和缩放 */}
{isPdfFile() && !error && !loading && (
<iframe
src={fileUrl}
width="100%"
height="100%"
title={fileName || 'PDF 预览'}
className="rb:border-0"
style={{ border: 'none' }}
onLoad={handleLoad}
onError={handleError}
/>
<>
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-100 rb:flex rb:justify-center rb:p-4">
<canvas ref={pdfCanvasRef} className="rb:shadow-lg" />
</div>
{pdfTotalPages > 0 && (
<PaginationBar
currentPage={pdfCurrentPage}
totalPages={pdfTotalPages}
onPageChange={handlePdfPageChange}
extraControls={
<div className="rb:flex rb:items-center rb:gap-1 rb:ml-4">
<Button
size="small"
icon={<ZoomOutOutlined />}
disabled={pdfScale <= 0.5}
onClick={() => handlePdfZoom(-0.25)}
/>
<span className="rb:text-sm rb:text-gray-600 rb:min-w-[48px] rb:text-center">
{Math.round(pdfScale * 100)}%
</span>
<Button
size="small"
icon={<ZoomInOutlined />}
disabled={pdfScale >= 3}
onClick={() => handlePdfZoom(0.25)}
/>
</div>
}
/>
)}
</>
)}
{/* PPT/PPTX 预览 */}
{isPptFile() && !error && !loading && (
<>
{pptSlides.length > 0 ? (
/* 本地渲染模式(服务端返回了可解析的格式) */
<>
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-100 rb:flex rb:justify-center rb:items-center rb:p-4">
<img
src={pptSlides[pptCurrentPage - 1]}
alt={`Slide ${pptCurrentPage}`}
className="rb:max-w-full rb:max-h-full rb:object-contain rb:shadow-lg"
/>
</div>
<PaginationBar
currentPage={pptCurrentPage}
totalPages={pptTotalPages}
onPageChange={(page) => {
if (page >= 1 && page <= pptTotalPages) setPptCurrentPage(page);
}}
/>
</>
) : (
/* Office Online Viewer fallback */
<div className="rb:w-full rb:flex-1 rb:flex rb:flex-col">
<iframe
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fileUrl)}`}
width="100%"
height="100%"
title={fileName || 'PPT 预览'}
className="rb:border-0 rb:flex-1"
style={{ border: 'none' }}
onLoad={() => setLoading(false)}
onError={() => handleError('PPT 在线预览加载失败')}
/>
<div className="rb:flex rb:items-center rb:justify-center rb:gap-3 rb:py-2 rb:px-4 rb:bg-white rb:border-t rb:border-gray-200">
<span className="rb:text-sm rb:text-gray-500">使 Office Online </span>
<Button size="small" icon={<DownloadOutlined />} onClick={handleDownload}>
</Button>
</div>
</div>
)}
</>
)}
</div>
);