fix:knowbase view

This commit is contained in:
yujiangping
2026-03-13 17:24:17 +08:00
parent 098a2e54ae
commit fd1debe681
2 changed files with 202 additions and 149 deletions

View File

@@ -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": {

View File

@@ -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<DocumentPreviewProps> = ({
@@ -24,18 +22,19 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
width = '100%',
height = '600px',
className = '',
mode = 'office',
showModeSwitch = true,
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [currentMode, setCurrentMode] = useState<PreviewMode>(mode);
const [errorMessage, setErrorMessage] = useState<string>('');
const [textContent, setTextContent] = useState<string>('');
const [htmlContent, setHtmlContent] = useState<string>('');
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<DocumentPreviewProps> = ({
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<DocumentPreviewProps> = ({
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<DocumentPreviewProps> = ({
}
};
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('<27>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 (
<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
/>
</div>
);
}
if (!isPreviewable()) {
return (
<Alert
message="不支持的文件类型"
description={`仅支持以下文件类型${supportedTypes.join(', ')}`}
description={`仅支持预览${previewableTypes.join(', ')}`}
type="warning"
showIcon
/>
@@ -230,23 +273,26 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
message="预览失败"
description={
<div>
<p></p>
<ul className="rb:list-disc rb:pl-5 rb:mt-2">
<li>访Office 访</li>
<li> URL 访访 URL</li>
<li>Office 10MB</li>
<li></li>
<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-gray-600 rb:mb-3"></p>
<ul className="rb:list-disc rb:pl-5 rb:text-sm rb:text-gray-600 rb:mb-3">
<li> URL 访401/403/404</li>
<li> token </li>
<li></li>
<li></li>
</ul>
<p className="rb:mt-2 rb:text-gray-600"></p>
<div className="rb:mt-4 rb:flex rb:gap-2">
<Button icon={<ReloadOutlined />} onClick={handleRetry}>
</Button>
{showModeSwitch && !isPdfFile() && (
<Button onClick={handleSwitchMode}>
{currentMode === 'office' ? 'Google' : 'Office'}
</Button>
)}
<Button icon={<DownloadOutlined />} onClick={handleDownload}>
</Button>
</div>
</div>
}
@@ -256,26 +302,23 @@ 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 || '图片预览'}
className="rb:max-w-full rb:max-h-full rb:object-contain"
onError={() => setError(true)}
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">
<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">
<pre className="rb:whitespace-pre-wrap rb:text-sm rb:text-gray-800 rb:font-mono">
@@ -284,44 +327,52 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
</div>
)}
{/* PDF 文件预览(使用浏览器内置预览) */}
{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
className="rb:prose rb:max-w-none"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
)}
{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">
{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>
{sheet.data.length > 0 && (
<Table
dataSource={sheet.data.slice(1).map((row, idx) => ({ 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
/>
)}
</div>
))}
</div>
)}
{isPdfFile() && !error && !loading && (
<iframe
src={getPreviewUrl()}
src={fileUrl}
width="100%"
height="100%"
title={fileName || 'PDF 预览'}
className="rb:border-0"
style={{ border: 'none' }}
onLoad={handleLoad}
onError={handleError}
/>
)}
{/* Office 文件预览 */}
{!isTextFile() && !isMarkdownFile() && !isImageFile() && !isPdfFile() && (
<>
{showModeSwitch && !loading && !error && (
<div className="rb:absolute rb:top-2 rb:right-2 rb:z-20">
<Button size="small" onClick={handleSwitchMode}>
{currentMode === 'office' ? 'Google' : 'Office'}
</Button>
</div>
)}
{!error && (
<iframe
src={getPreviewUrl()}
width="100%"
height="100%"
onLoad={handleLoad}
onError={handleError}
title={fileName || '文档预览'}
className="rb:border-0"
style={{ display: loading ? 'none' : 'block', border: 'none' }}
sandbox="allow-scripts allow-same-origin allow-popups"
/>
)}
</>
)}
</div>
);
};