fix:knowbase view
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user