From fd1debe68124fb38f634c6a71b481045a2643e78 Mon Sep 17 00:00:00 2001 From: yujiangping Date: Fri, 13 Mar 2026 17:24:17 +0800 Subject: [PATCH] fix:knowbase view --- web/package.json | 2 + web/src/components/DocumentPreview/index.tsx | 349 +++++++++++-------- 2 files changed, 202 insertions(+), 149 deletions(-) diff --git a/web/package.json b/web/package.json index e2d5c898..1f457301 100644 --- a/web/package.json +++ b/web/package.json @@ -43,6 +43,7 @@ "i18next": "^25.6.0", "js-yaml": "^4.1.1", "lexical": "^0.39.0", + "mammoth": "^1.12.0", "mermaid": "^11.12.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -58,6 +59,7 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwindcss": "^4.1.14", + "xlsx": "^0.18.5", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/web/src/components/DocumentPreview/index.tsx b/web/src/components/DocumentPreview/index.tsx index 404d6e50..c57080fb 100644 --- a/web/src/components/DocumentPreview/index.tsx +++ b/web/src/components/DocumentPreview/index.tsx @@ -1,20 +1,18 @@ import { useState, useEffect, type FC } from 'react'; -import { Spin, Alert, Button } from 'antd'; -import { ReloadOutlined } from '@ant-design/icons'; +import { Spin, Alert, Button, Table } from 'antd'; +import { ReloadOutlined, DownloadOutlined } from '@ant-design/icons'; import RbMarkdown from '../Markdown'; -import { cookieUtils } from '@/utils/request' - -type PreviewMode = 'office' | 'google'; +import { cookieUtils } from '@/utils/request'; +import mammoth from 'mammoth'; +import * as XLSX from 'xlsx'; interface DocumentPreviewProps { fileUrl: string; fileName?: string; - fileExt?: string; // 文件扩展名(优先使用) + fileExt?: string; width?: string | number; height?: string | number; className?: string; - mode?: PreviewMode; // 预览模式 - showModeSwitch?: boolean; // 是否显示模式切换按钮 } const DocumentPreview: FC = ({ @@ -24,18 +22,19 @@ const DocumentPreview: FC = ({ width = '100%', height = '600px', className = '', - mode = 'office', - showModeSwitch = true, }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(false); - const [currentMode, setCurrentMode] = useState(mode); + const [errorMessage, setErrorMessage] = useState(''); const [textContent, setTextContent] = useState(''); + const [htmlContent, setHtmlContent] = useState(''); + const [excelData, setExcelData] = useState<{ sheetName: string; data: any[][] }[]>([]); - // 支持的文件类型 - const supportedTypes = ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.txt', '.md', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']; + // 支持预览的文件类型 + const previewableTypes = ['.pdf', '.txt', '.md', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx']; + // PPT 暂不支持 + const downloadOnlyTypes = ['.ppt', '.pptx']; - // 获取文件扩展名(优先使用 fileExt prop) const getFileExtension = () => { if (fileExt) { return fileExt.toLowerCase().startsWith('.') ? fileExt.toLowerCase() : `.${fileExt.toLowerCase()}`; @@ -45,67 +44,25 @@ const DocumentPreview: FC = ({ return match ? `.${match[1].toLowerCase()}` : ''; }; - // 检查是否为文本文件 - const isTextFile = () => { - const ext = getFileExtension(); - return ext === '.txt'; - }; - - // 检查是否为 Markdown 文件 - const isMarkdownFile = () => { - const ext = getFileExtension(); - return ext === '.md'; - }; - - // 检查是否为图片文件 + const isTextFile = () => getFileExtension() === '.txt'; + const isMarkdownFile = () => getFileExtension() === '.md'; const isImageFile = () => { - const ext = getFileExtension(); const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']; - return imageExts.includes(ext); - }; - - // 检查文件类型是否支持 - const isSupportedFile = () => { - const ext = getFileExtension(); - return ext && supportedTypes.includes(ext); + return imageExts.includes(getFileExtension()); }; + const isPdfFile = () => getFileExtension() === '.pdf'; + const isWordFile = () => ['.doc', '.docx'].includes(getFileExtension()); + const isExcelFile = () => ['.xls', '.xlsx'].includes(getFileExtension()); + const isPreviewable = () => previewableTypes.includes(getFileExtension()); + const isDownloadOnly = () => downloadOnlyTypes.includes(getFileExtension()); - // 检查是否为 PDF 文件 - const isPdfFile = () => { - const ext = getFileExtension(); - return ext === '.pdf'; - }; - - // 构建预览 URL - const getPreviewUrl = () => { - // 处理文件 URL,如果是完整的 URL,转换为代理路径 - let requestUrl = fileUrl; - - // 如果是完整的 https://devapi.mem.redbearai.com 开头的 URL,提取路径部分 - // 这样可以通过代理访问,避免 CORS 问题 - if (fileUrl.includes('devapi.mem.redbearai.com')) { - const url = new URL(fileUrl); - requestUrl = url.pathname; // 只取路径部分,例如 /api/files/xxx - } - - // 对于 PDF 文件,直接使用浏览器内置预览 - if (isPdfFile()) { - return requestUrl; - } - - // 确保 fileUrl 是完整的 URL(用于第三方预览服务) - let fullUrl = fileUrl; - if (!fileUrl.startsWith('http')) { - fullUrl = `${window.location.origin}${fileUrl.startsWith('/') ? '' : '/'}${fileUrl}`; - } - console.log('预览 URL:', fullUrl); - // 根据模式选择预览服务 - if (currentMode === 'google') { - return `https://docs.google.com/viewer?url=${encodeURIComponent(fullUrl)}&embedded=true`; - } - - // 默认使用 Microsoft Office Online Viewer - return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fullUrl)}`; + const handleDownload = () => { + const link = document.createElement('a'); + link.href = fileUrl; + link.download = fileName || 'document'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); }; const handleLoad = () => { @@ -113,20 +70,24 @@ const DocumentPreview: FC = ({ setError(false); }; - const handleError = () => { + const handleError = (msg?: string) => { setLoading(false); setError(true); + if (msg) setErrorMessage(msg); }; const handleRetry = () => { setLoading(true); setError(false); + setErrorMessage(''); if (isTextFile() || isMarkdownFile()) { - // 重新加载文本文件 loadTextFile(); + } else if (isWordFile()) { + loadWordFile(); + } else if (isExcelFile()) { + loadExcelFile(); } else { - // 强制重新加载 iframe const iframe = document.querySelector(`iframe[title="${fileName || '文档预览'}"]`) as HTMLIFrameElement; if (iframe) { iframe.src = iframe.src; @@ -134,82 +95,164 @@ const DocumentPreview: FC = ({ } }; - const handleSwitchMode = () => { - setCurrentMode(prev => prev === 'office' ? 'google' : 'office'); - setLoading(true); - setError(false); - }; - - // 加载文本文件内容 const loadTextFile = async () => { setLoading(true); setError(false); + setErrorMessage(''); try { - // 处理文件 URL,如果是完整的 URL,转换为代理路径 let requestUrl = fileUrl; - // 如果是完整的 https://devapi.mem.redbearai.com 开头的 URL,提取路径部分 if (fileUrl.includes('devapi.mem.redbearai.com')) { const url = new URL(fileUrl); - requestUrl = url.pathname; // 只取路径部分,例如 /api/files/xxx + requestUrl = url.pathname; } const response = await fetch(requestUrl, { - credentials: 'include', // 包含认证信息 + credentials: 'include', headers: { 'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`, }, }); if (!response.ok) { - throw new Error('Failed to load file'); + throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - // 检查响应的 Content-Type const contentType = response.headers.get('Content-Type') || ''; - console.log('文件 Content-Type:', contentType); - // 如果是图片类型,显示错误提示 if (contentType.startsWith('image/')) { - setError(true); - setTextContent(''); - setLoading(false); - console.error('文件实际是图片类型,但被标记为 txt'); + handleError('文件实际是图片类型,但被标记为文本文件'); return; } const text = await response.text(); - // 检查是否是二进制数据(如 PNG 文件头) if (text.startsWith('\x89PNG') || text.startsWith('�PNG')) { - setError(true); - setTextContent(''); - setLoading(false); - console.error('文件内容是 PNG 图片,但扩展名是 txt'); + handleError('文件内容是图片,但扩展名是文本'); return; } setTextContent(text); setLoading(false); - } catch (err) { + } catch (err: any) { console.error('加载文本文件失败:', err); - setError(true); - setLoading(false); + handleError(err.message || '加载文本文件失败'); + } + }; + + const loadWordFile = 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 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 result = await mammoth.convertToHtml({ arrayBuffer }); + setHtmlContent(result.value); + setLoading(false); + } catch (err: any) { + console.error('加载 Word 文件失败:', err); + handleError(err.message || '加载 Word 文件失败,文件可能已损坏'); + } + }; + + const loadExcelFile = 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 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 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) { + console.error('加载 Excel 文件失败:', err); + handleError(err.message || '加载 Excel 文件失败,文件可能已损坏'); } }; - // 当文件是 txt 或 md 时,加载文本内容 useEffect(() => { if (isTextFile() || isMarkdownFile()) { loadTextFile(); + } else if (isWordFile()) { + loadWordFile(); + } else if (isExcelFile()) { + loadExcelFile(); } }, [fileUrl]); - if (!isSupportedFile()) { + // PPT 文件只提供下载 + if (isDownloadOnly()) { + return ( +
+ +

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

+ +
+ } + type="info" + showIcon + /> + + ); + } + + if (!isPreviewable()) { return ( @@ -230,23 +273,26 @@ const DocumentPreview: FC = ({ message="预览失败" description={
-

无法加载文档预览,可能的原因:

-
    -
  • 文件需要认证访问,Office 预览服务无法访问
  • -
  • 文件 URL 无法公开访问(需要配置公开访问或临时签名 URL)
  • -
  • 文件大小超过限制(Office 预览通常限制 10MB)
  • -
  • 预览服务暂时不可用
  • +

    无法加载文档预览

    + {errorMessage && ( +

    + 错误详情:{errorMessage} +

    + )} +

    可能的原因:

    +
      +
    • 文件 URL 无法访问(401/403/404)
    • +
    • 认证 token 已过期
    • +
    • 文件格式损坏或不匹配
    • +
    • 网络连接问题
    -

    建议:请下载文件到本地查看

    - {showModeSwitch && !isPdfFile() && ( - - )} +
} @@ -256,26 +302,23 @@ const DocumentPreview: FC = ({ )} - {/* 图片文件预览 */} {isImageFile() && !error && !loading && (
{fileName setError(true)} + onError={() => handleError('图片加载失败')} />
)} - {/* Markdown 文件预览 */} {isMarkdownFile() && !error && !loading && (
)} - {/* 文本文件预览 */} {isTextFile() && !error && !loading && (
@@ -284,44 +327,52 @@ const DocumentPreview: FC = ({
         
)} - {/* PDF 文件预览(使用浏览器内置预览) */} + {isWordFile() && !error && !loading && ( +
+
+
+ )} + + {isExcelFile() && !error && !loading && ( +
+ {excelData.map((sheet, index) => ( +
+

{sheet.sheetName}

+ {sheet.data.length > 0 && ( + ({ key: idx, ...row }))} + columns={sheet.data[0]?.map((header: any, colIdx: number) => ({ + title: header || `列 ${colIdx + 1}`, + dataIndex: colIdx, + key: colIdx, + width: 150, + })) || []} + pagination={false} + scroll={{ x: 'max-content' }} + size="small" + bordered + /> + )} + + ))} + + )} + {isPdfFile() && !error && !loading && (