Commit cc6ad537 by weiw

fix:文件预览

parent 126e2b3a
...@@ -46,13 +46,16 @@ ...@@ -46,13 +46,16 @@
"axios": "^1.7.3", "axios": "^1.7.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"docx-preview": "^0.3.6",
"github-markdown-css": "^5.8.0", "github-markdown-css": "^5.8.0",
"pdfjs-dist": "^5.4.149",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-draggable": "^4.4.6", "react-draggable": "^4.4.6",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-joyride": "^2.9.3", "react-joyride": "^2.9.3",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-pdf": "^10.1.0",
"react-photo-view": "^1.2.6", "react-photo-view": "^1.2.6",
"react-redux": "^9.1.2", "react-redux": "^9.1.2",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
...@@ -60,7 +63,8 @@ ...@@ -60,7 +63,8 @@
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"tailwind-merge": "^2.4.0" "tailwind-merge": "^2.4.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^2.24.1", "@antfu/eslint-config": "^2.24.1",
......
// src/components/FilePreviewModal/DocxPreview.tsx
import React, { useEffect, useRef } from 'react'
import { renderAsync } from 'docx-preview'
interface DocxPreviewProps {
src: string
className?: string
onRendered?: () => void
onError?: (error: any) => void
}
export const DocxPreview: React.FC<DocxPreviewProps> = ({ src, className = '', onRendered, onError }) => {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (src && containerRef.current) {
fetch(src)
.then(response => response.arrayBuffer())
.then((arrayBuffer) => {
const blob = new Blob([arrayBuffer], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
// 清空容器
if (containerRef.current) {
containerRef.current.innerHTML = ''
}
return renderAsync(blob, containerRef.current!, undefined, {
className,
inWrapper: true,
breakPages: true,
ignoreWidth: false,
ignoreHeight: false,
ignoreFonts: false,
})
})
.then(() => {
onRendered?.()
})
.catch((error) => {
console.error('DOCX 渲染失败:', error)
onError?.(error)
})
}
}, [src, className, onRendered, onError])
return (
<div
ref={containerRef}
className={className}
style={{
overflow: 'auto',
height: '100%',
padding: '1rem',
backgroundColor: '#fff',
maxWidth: '100%',
boxSizing: 'border-box',
}}
/>
)
}
// src/components/FilePreviewModal/ExcelPreview.tsx
import { useEffect, useState } from 'react'
import * as XLSX from 'xlsx'
interface ExcelPreviewProps {
src: string
options?: { xls?: boolean }
className?: string
onRendered?: () => void
onError?: (error: any) => void
}
// 将默认 props 提取为常量
const DEFAULT_OPTIONS = { xls: false }
export const ExcelPreview: React.FC<ExcelPreviewProps> = ({
src,
options = DEFAULT_OPTIONS,
className = '',
onRendered,
onError,
}) => {
const [html, setHtml] = useState<string>('')
useEffect(() => {
if (src) {
fetch(src)
.then(response => response.arrayBuffer())
.then((arrayBuffer) => {
try {
const workbook = XLSX.read(arrayBuffer, {
type: 'array',
cellStyles: true,
cellHTML: true,
})
const firstSheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[firstSheetName]
// 转换为 HTML 并添加基本样式
const htmlString = XLSX.utils.sheet_to_html(worksheet, {
editable: false,
})
// 添加基本样式使表格更美观并避免横向滚动
const styledHtml = htmlString
.replace('<table', '<table style="width: 100%; table-layout: fixed; border-collapse: collapse;"')
.replace(/<td/g, '<td style="border: 1px solid #d1d5db; padding: 4px 8px; word-wrap: break-word; overflow-wrap: break-word;"')
.replace(/<th/g, '<th style="border: 1px solid #d1d5db; padding: 4px 8px; background-color: #f3f4f6; font-weight: bold; word-wrap: break-word; overflow-wrap: break-word;"')
setHtml(styledHtml)
onRendered?.()
}
catch (error) {
onError?.(error)
}
})
.catch((error) => {
onError?.(error)
})
}
}, [src, options, onRendered, onError])
return (
<div className={`${className} overflow-auto p-4 bg-white`}>
<div
dangerouslySetInnerHTML={{ __html: html || '<p>无法加载 Excel 内容</p>' }}
style={{ minWidth: '100%' }}
/>
</div>
)
}
// src/components/FilePreviewModal/PdfPreview.tsx
import React, { useState } from 'react'
import { Document, Page, pdfjs } from 'react-pdf'
// 设置 PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`
interface PdfPreviewProps {
src: string
className?: string
onLoaded?: () => void
onError?: (error: any) => void
}
export const PdfPreview: React.FC<PdfPreviewProps> = ({ src, className = '', onLoaded, onError }) => {
const [numPages, setNumPages] = useState<number | null>(null)
const [pageNumber, setPageNumber] = useState<number>(1)
const [containerWidth, setContainerWidth] = useState<number>(800)
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages)
setPageNumber(1)
onLoaded?.()
}
function changePage(offset: number) {
setPageNumber(prevPageNumber => prevPageNumber + offset)
}
function previousPage() {
changePage(-1)
}
function nextPage() {
changePage(1)
}
return (
<div className={`${className} flex flex-col h-full`}>
<div className="flex-grow overflow-auto flex items-center justify-center bg-gray-50 p-2">
<div
className="w-full flex justify-center"
ref={(el) => {
if (el) {
setContainerWidth(el.clientWidth)
}
}}
>
<Document
file={src}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onError}
className="flex flex-col items-center"
>
<Page
pageNumber={pageNumber}
width={Math.min(containerWidth - 32, 800)} // 减去padding
className="shadow-md"
/>
</Document>
</div>
</div>
{numPages !== null && numPages > 1 && (
<div className="pdf-navigation flex justify-center items-center py-3 bg-white border-t">
<button
type="button" // 添加 type 属性
onClick={previousPage}
disabled={pageNumber <= 1}
className="px-3 py-1 mx-1 bg-gray-200 rounded disabled:opacity-50 text-sm"
>
上一页
</button>
<span className="mx-3 text-sm">
{' '}
{pageNumber}
{' '}
页,共
{' '}
{numPages}
{' '}
</span>
<button
type="button" // 添加 type 属性
onClick={nextPage}
disabled={pageNumber >= numPages}
className="px-3 py-1 mx-1 bg-gray-200 rounded disabled:opacity-50 text-sm"
>
下一页
</button>
</div>
)}
</div>
)
}
// src/components/FilePreviewModal/index.tsx
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Spinner } from '@heroui/react'
import { useEffect, useState } from 'react'
import { DocxPreview } from './DocxPreview'
import { ExcelPreview } from './ExcelPreview'
import { PdfPreview } from './PdfPreview'
interface FilePreviewModalProps {
isOpen: boolean
onClose: () => void
doc: any
docUrl?: string
}
export const FilePreviewModal: React.FC<FilePreviewModalProps> = ({ isOpen, onClose, doc, docUrl }) => {
const [loading, setLoading] = useState(false)
const [fileType, setFileType] = useState<string>('')
// 确定文件类型
useEffect(() => {
if (doc?.documentAlias) {
const name = doc.documentAlias.toLowerCase()
if (name.endsWith('.docx'))
setFileType('docx')
else if (name.endsWith('.doc'))
setFileType('doc')
else if (name.endsWith('.xlsx'))
setFileType('xlsx')
else if (name.endsWith('.xls'))
setFileType('xls')
else if (name.endsWith('.pdf'))
setFileType('pdf')
else setFileType('')
}
}, [doc])
const handleDownload = () => {
if (docUrl) {
const link = document.createElement('a')
link.href = docUrl
link.download = doc?.documentAlias || 'document'
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
const handleDocumentRendered = () => {
setLoading(false)
}
const handleDocumentError = (error: any) => {
console.error('文档预览出错:', error)
setLoading(false)
}
const renderPreview = () => {
if (!docUrl) {
return (
<div className="flex flex-col items-center justify-center h-96">
<p>无法预览该文件</p>
</div>
)
}
switch (fileType) {
case 'docx':
return (
<div className="h-[70vh] max-h-[70vh] overflow-auto">
<DocxPreview
src={docUrl}
className="w-full min-h-full"
onRendered={handleDocumentRendered}
onError={handleDocumentError}
/>
</div>
)
case 'doc':
return (
<div className="flex flex-col items-center justify-center h-96">
<div className="text-center">
<p className="mb-4">DOC 格式文件无法在线预览</p>
<div className="flex gap-3 justify-center">
<Button color="primary" onPress={() => window.open(docUrl, '_blank')} size="sm">
在新窗口打开
</Button>
<Button color="secondary" onPress={handleDownload} size="sm">
下载文件
</Button>
</div>
</div>
</div>
)
case 'xls':
return (
<div className="h-[70vh] max-h-[70vh] overflow-auto">
<ExcelPreview
src={docUrl}
options={{ xls: true }}
className="w-full min-h-full"
onRendered={handleDocumentRendered}
onError={handleDocumentError}
/>
</div>
)
case 'xlsx':
return (
<div className="h-[70vh] max-h-[70vh] overflow-auto">
<ExcelPreview
src={docUrl}
className="w-full min-h-full"
onRendered={handleDocumentRendered}
onError={handleDocumentError}
/>
</div>
)
case 'pdf':
return (
<div className="h-[70vh] max-h-[70vh] overflow-auto">
<PdfPreview
src={docUrl}
className="w-full min-h-full"
onLoaded={handleDocumentRendered}
onError={handleDocumentError}
/>
</div>
)
default:
return (
<div className="flex flex-col items-center justify-center h-96">
<div className="text-center">
<p className="mb-4">该文件格式无法在线预览</p>
<Button color="primary" onPress={() => window.open(docUrl, '_blank')} size="sm">
在新窗口打开
</Button>
</div>
</div>
)
}
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="3xl"
classNames={{
base: 'max-h-[90vh] max-w-[50vw]',
body: 'py-4',
header: 'border-b border-divider',
footer: 'border-t border-divider',
}}
>
<ModalContent>
<ModalHeader className="flex items-center justify-between">
<span className="text-lg font-semibold truncate max-w-md">{doc?.documentAlias || '文件预览'}</span>
</ModalHeader>
<ModalBody className="p-0">
{loading && fileType !== '' && (
<div className="flex justify-center items-center h-64">
<Spinner size="lg" />
</div>
)}
{renderPreview()}
</ModalBody>
<ModalFooter className="flex justify-end gap-2">
{docUrl && (
<Button color="primary" onPress={handleDownload} size="sm">
下载文件
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
)
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment