feat(document-preview): add document preview page and improve chunk list UI

- Implement new document preview page with support for various file types
- Move file preview functionality from parsed-result to dedicated preview page
- Enhance chunk list UI with better visual hierarchy and styling
- Add document preview button in parsed-result page
- Improve error handling and loading states for both pages
This commit is contained in:
2025-10-16 17:21:21 +08:00
parent 5a0a9ef2a1
commit 0b97991a36
4 changed files with 553 additions and 209 deletions

View File

@@ -11,7 +11,17 @@ import {
Pagination,
CircularProgress,
Alert,
Divider,
Avatar,
IconButton,
Tooltip,
} from '@mui/material';
import {
Image as ImageIcon,
TextSnippet as TextIcon,
Visibility as VisibilityIcon,
VisibilityOff as VisibilityOffIcon,
} from '@mui/icons-material';
import type { IChunk } from '@/interfaces/database/knowledge';
interface ChunkListResultProps {
@@ -115,109 +125,215 @@ function ChunkListResult(props: ChunkListResultProps) {
</Typography>
</Box>
<Grid container spacing={2}>
<Grid container spacing={3}>
{chunks.map((chunk, index) => (
<Grid size={12} key={chunk.chunk_id}>
<Card variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="bold">
Chunk #{((page - 1) * pageSize) + index + 1}
</Typography>
<Stack direction="row" spacing={1}>
<Card
variant="outlined"
sx={{
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: 3,
transform: 'translateY(-2px)',
},
border: chunk.available_int === 1 ? '2px solid' : '1px solid',
borderColor: chunk.available_int === 1 ? 'success.main' : 'grey.300',
}}
>
<CardContent sx={{ p: 3 }}>
{/* 头部信息 */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar
sx={{
bgcolor: chunk.available_int === 1 ? 'success.main' : 'grey.400',
mr: 2,
width: 40,
height: 40,
}}
>
<TextIcon />
</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6" fontWeight="bold" color="text.primary">
Chunk #{((page - 1) * pageSize) + index + 1}
</Typography>
<Typography variant="caption" color="text.secondary">
ID: {chunk.chunk_id}
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
{chunk.image_id && (
<Tooltip title="包含图片">
<Avatar sx={{ bgcolor: 'info.main', width: 32, height: 32 }}>
<ImageIcon fontSize="small" />
</Avatar>
</Tooltip>
)}
<Chip
icon={chunk.available_int === 1 ? <VisibilityIcon /> : <VisibilityOffIcon />}
label={chunk.available_int === 1 ? '已启用' : '未启用'}
size="small"
color={chunk.available_int === 1 ? 'success' : 'default'}
variant={chunk.available_int === 1 ? 'filled' : 'outlined'}
/>
{chunk.image_id && (
<Chip
label="包含图片"
size="small"
color="info"
variant="outlined"
/>
)}
</Stack>
</Box>
<Typography
variant="body2"
sx={{
mb: 2,
maxHeight: '200px',
overflow: 'auto',
whiteSpace: 'pre-wrap',
backgroundColor: 'grey.50',
p: 2,
borderRadius: 1,
}}
>
{chunk.content_with_weight || '无内容'}
</Typography>
<Divider sx={{ mb: 2 }} />
{chunk.important_kwd && chunk.important_kwd.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
:
{/* 内容区域 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
</Typography>
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: '200px',
overflow: 'auto',
backgroundColor: 'grey.50',
borderRadius: 2,
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
backgroundColor: 'grey.100',
borderRadius: '3px',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'grey.400',
borderRadius: '3px',
},
}}
>
<Typography
variant="body2"
sx={{
whiteSpace: 'pre-wrap',
lineHeight: 1.6,
color: 'text.primary',
}}
>
{chunk.content_with_weight || '无内容'}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{chunk.important_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
label={keyword}
size="small"
variant="outlined"
color="primary"
/>
))}
</Box>
</Paper>
</Box>
{/* 图片显示区域 */}
{chunk.image_id && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
</Typography>
<Paper
variant="outlined"
sx={{
p: 2,
borderRadius: 2,
backgroundColor: 'background.paper',
textAlign: 'center',
}}
>
<Box
component="img"
src={`${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`}
alt="Chunk相关图片"
sx={{
maxWidth: '100%',
maxHeight: '300px',
borderRadius: 1,
objectFit: 'contain',
boxShadow: 1,
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
</Paper>
</Box>
)}
{chunk.question_kwd && chunk.question_kwd.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
:
{/* 关键词区域 */}
{((chunk.important_kwd ?? []).length > 0 || (chunk.question_kwd ?? []).length > 0 || (chunk.tag_kwd ?? []).length > 0) && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 2 }}>
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{chunk.question_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
label={keyword}
size="small"
variant="outlined"
color="secondary"
/>
))}
</Box>
</Box>
)}
{chunk.tag_kwd && chunk.tag_kwd.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{chunk.tag_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
label={keyword}
size="small"
variant="outlined"
color="info"
/>
))}
</Box>
{chunk.important_kwd && chunk.important_kwd.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{chunk.important_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
label={keyword}
size="small"
variant="filled"
color="primary"
sx={{ fontWeight: 'medium' }}
/>
))}
</Box>
</Box>
)}
{chunk.question_kwd && chunk.question_kwd.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{chunk.question_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
label={keyword}
size="small"
variant="filled"
color="secondary"
sx={{ fontWeight: 'medium' }}
/>
))}
</Box>
</Box>
)}
{chunk.tag_kwd && chunk.tag_kwd.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{chunk.tag_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
label={keyword}
size="small"
variant="filled"
color="info"
sx={{ fontWeight: 'medium' }}
/>
))}
</Box>
</Box>
)}
</Box>
)}
{/* 位置信息 */}
{chunk.positions && chunk.positions.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
: {chunk.positions.length}
</Typography>
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'grey.200' }}>
<Chip
label={`位置信息: ${chunk.positions.length} 个位置点`}
size="small"
variant="outlined"
color="default"
sx={{ fontWeight: 'medium' }}
/>
</Box>
)}
</CardContent>

View File

@@ -0,0 +1,309 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box,
Typography,
Paper,
CircularProgress,
Alert,
Button,
Breadcrumbs,
Link,
Card,
CardContent,
CardMedia,
} from '@mui/material';
import {
ArrowBack as ArrowBackIcon,
Download as DownloadIcon,
Visibility as VisibilityIcon,
} from '@mui/icons-material';
import knowledgeService from '@/services/knowledge_service';
import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge';
interface DocumentPreviewProps {}
function DocumentPreview(props: DocumentPreviewProps) {
const { kb_id, doc_id } = useParams<{ kb_id: string; doc_id: string }>();
const navigate = useNavigate();
const [kb, setKb] = useState<IKnowledge | null>(null);
const [documentObj, setDocumentObj] = useState<IKnowledgeFile | null>(null);
const [documentFile, setDocumentFile] = useState<Blob | null>(null);
const [fileUrl, setFileUrl] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [fileLoading, setFileLoading] = useState(false);
// 获取知识库和文档信息
useEffect(() => {
const fetchData = async () => {
if (!kb_id || !doc_id) {
setError('缺少必要的参数');
setLoading(false);
return;
}
try {
setLoading(true);
// 并行获取知识库信息和文档详情
const [kbResponse, docResponse] = await Promise.all([
knowledgeService.getKnowledgeDetail({ kb_id }),
knowledgeService.getDocumentInfos({ doc_ids: [doc_id] })
]);
if (kbResponse.data.data) {
setKb(kbResponse.data.data);
}
if (docResponse.data.data?.length > 0) {
setDocumentObj(docResponse.data.data[0]);
}
} catch (err) {
console.error('获取数据失败:', err);
setError('获取数据失败,请稍后重试');
} finally {
setLoading(false);
}
};
fetchData();
}, [kb_id, doc_id]);
// 异步加载文档文件
const loadDocumentFile = async () => {
if (!doc_id || fileLoading) return;
try {
setFileLoading(true);
setError(null);
const fileResponse = await knowledgeService.getDocumentFile({ doc_id });
if (fileResponse.data instanceof Blob) {
setDocumentFile(fileResponse.data);
const url = URL.createObjectURL(fileResponse.data);
setFileUrl(url);
} else {
setError('文件格式不支持预览');
}
} catch (err) {
console.error('获取文档文件失败:', err);
setError('获取文档文件失败,请稍后重试');
} finally {
setFileLoading(false);
}
};
// 清理文件URL
useEffect(() => {
return () => {
if (fileUrl) {
URL.revokeObjectURL(fileUrl);
}
};
}, [fileUrl]);
// 下载文件
const handleDownload = () => {
if (fileUrl && window.document) {
const link = window.document.createElement('a');
link.href = fileUrl;
link.download = documentObj?.name || 'document';
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
}
};
// 返回上一页
const handleGoBack = () => {
navigate(-1);
};
// 渲染文件预览
const renderFilePreview = () => {
if (!documentFile || !fileUrl) return null;
const fileType = documentFile.type;
if (fileType.startsWith('image/')) {
return (
<CardMedia
component="img"
image={fileUrl}
alt={documentObj?.name || '文档预览'}
sx={{
maxWidth: '100%',
maxHeight: '80vh',
objectFit: 'contain',
borderRadius: 1,
}}
/>
);
} else if (fileType === 'application/pdf') {
return (
<Box
component="iframe"
src={fileUrl}
sx={{
width: '100%',
height: '80vh',
border: 'none',
borderRadius: 1,
}}
/>
);
} else if (fileType.startsWith('text/')) {
return (
<Box
component="iframe"
src={fileUrl}
sx={{
width: '100%',
height: '80vh',
border: '1px solid',
borderColor: 'grey.300',
borderRadius: 1,
}}
/>
);
} else {
return (
<Alert severity="info" sx={{ mt: 2 }}>
线
</Alert>
);
}
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>
<CircularProgress />
</Box>
);
}
if (error && !documentObj) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">{error}</Alert>
<Button
startIcon={<ArrowBackIcon />}
onClick={handleGoBack}
sx={{ mt: 2 }}
>
</Button>
</Box>
);
}
return (
<Box sx={{ p: 3 }}>
{/* 面包屑导航 */}
<Breadcrumbs sx={{ mb: 2 }}>
<Link
component="button"
variant="body2"
onClick={() => navigate('/knowledge')}
sx={{ textDecoration: 'none' }}
>
</Link>
<Link
component="button"
variant="body2"
onClick={() => navigate(`/knowledge/${kb_id}`)}
sx={{ textDecoration: 'none' }}
>
{kb?.name || '知识库详情'}
</Link>
<Link
component="button"
variant="body2"
onClick={() => navigate(`/knowledge/${kb_id}/document/${doc_id}/chunks`)}
sx={{ textDecoration: 'none' }}
>
</Link>
<Typography color="text.primary"></Typography>
</Breadcrumbs>
{/* 页面标题和操作按钮 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h4" gutterBottom>
</Typography>
{documentObj && (
<Typography variant="subtitle1" color="text.secondary">
{documentObj.name}
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={handleGoBack}
variant="outlined"
>
</Button>
{!fileUrl && (
<Button
startIcon={<VisibilityIcon />}
onClick={loadDocumentFile}
variant="contained"
disabled={fileLoading}
>
{fileLoading ? '加载中...' : '预览文件'}
</Button>
)}
{fileUrl && (
<Button
startIcon={<DownloadIcon />}
onClick={handleDownload}
variant="contained"
>
</Button>
)}
</Box>
</Box>
{/* 错误提示 */}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* 文件加载状态 */}
{fileLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '200px' }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>...</Typography>
</Box>
)}
{/* 文件预览区域 */}
{fileUrl && (
<Paper elevation={1} sx={{ p: 2 }}>
<Card>
<CardContent>
{renderFilePreview()}
</CardContent>
</Card>
</Paper>
)}
</Box>
);
}
export default DocumentPreview;

View File

@@ -1,24 +1,21 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from "react-router-dom";
import {
Box,
Typography,
Breadcrumbs,
Link,
TextField,
import {
Box,
Typography,
Breadcrumbs,
Link,
TextField,
InputAdornment,
Paper,
Alert,
Card,
CardContent,
CardMedia
Button
} from "@mui/material";
import { Search as SearchIcon, ArrowBack as ArrowBackIcon } from '@mui/icons-material';
import { Search as SearchIcon, Visibility as VisibilityIcon } from '@mui/icons-material';
import { useChunkList } from '@/hooks/chunk-hooks';
import ChunkListResult from './components/ChunkListResult';
import knowledgeService from '@/services/knowledge_service';
import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge';
import type { IDocumentInfo } from '@/interfaces/database/document';
function ChunkParsedResult() {
const [searchParams] = useSearchParams();
@@ -28,8 +25,6 @@ function ChunkParsedResult() {
const [knowledgeBase, setKnowledgeBase] = useState<IKnowledge | null>(null);
const [document, setDocument] = useState<IKnowledgeFile | null>(null);
const [documentFile, setDocumentFile] = useState<Blob | null>(null);
const [fileUrl, setFileUrl] = useState<string>('');
const [searchKeyword, setSearchKeyword] = useState('');
// 使用chunk列表hook
@@ -69,22 +64,14 @@ function ChunkParsedResult() {
setDocument(docArr[0]);
}
}
// 获取文档文件
const fileResponse = await knowledgeService.getDocumentFile({ doc_id });
if (fileResponse.data) {
// 处理二进制文件数据
setDocumentFile(fileResponse.data);
// 创建文件URL用于预览
const url = URL.createObjectURL(fileResponse.data);
setFileUrl(url);
}
} catch (error) {
console.error('Failed to fetch data:', error);
}
};
fetchData();
}, [kb_id, doc_id]);
// 处理搜索
const handleSearch = (keyword: string) => {
setSearchKeyword(keyword);
@@ -92,91 +79,12 @@ function ChunkParsedResult() {
setCurrentPage(1);
};
fetchData();
// 清理函数释放URL对象
return () => {
if (fileUrl) {
URL.revokeObjectURL(fileUrl);
}
};
}, [kb_id, doc_id]);
// 渲染文件预览组件
const renderFilePreview = () => {
if (!document || !fileUrl) return null;
const fileExtension = document.name?.split('.').pop()?.toLowerCase();
// 图片文件预览
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(fileExtension || '')) {
return (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
</Typography>
<CardMedia
component="img"
sx={{
maxHeight: 400,
objectFit: 'contain',
border: '1px solid #e0e0e0',
borderRadius: 1
}}
image={fileUrl}
alt={document.name}
/>
</CardContent>
</Card>
);
// 处理查看文件
const handleViewFile = () => {
if (doc_id && kb_id) {
// 跳转到文件预览页面
navigate(`/chunk/document-preview/${kb_id}/${doc_id}`);
}
// PDF文件预览
if (fileExtension === 'pdf') {
return (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
PDF预览
</Typography>
<Box sx={{ height: 600, border: '1px solid #e0e0e0', borderRadius: 1 }}>
<iframe
src={fileUrl}
width="100%"
height="100%"
style={{ border: 'none' }}
title={document.name}
/>
</Box>
</CardContent>
</Card>
);
}
// 其他文件类型显示下载链接
return (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
: {document.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
: {fileExtension?.toUpperCase() || '未知'}
</Typography>
<Link
href={fileUrl}
download={document.name}
sx={{ mt: 2, display: 'inline-block' }}
>
</Link>
</CardContent>
</Card>
);
};
if (!kb_id || !doc_id) {
@@ -194,9 +102,9 @@ function ChunkParsedResult() {
{/* 面包屑导航 */}
<Box sx={{ mb: 3 }}>
<Breadcrumbs>
<Link
color="inherit"
href="#"
<Link
color="inherit"
href="#"
onClick={(e) => {
e.preventDefault();
navigate('/knowledge');
@@ -205,9 +113,9 @@ function ChunkParsedResult() {
>
</Link>
<Link
color="inherit"
href="#"
<Link
color="inherit"
href="#"
onClick={(e) => {
e.preventDefault();
navigate(`/knowledge/${kb_id}`);
@@ -220,27 +128,35 @@ function ChunkParsedResult() {
</Typography>
</Breadcrumbs>
</Box>
{/* 页面标题 */}
{/* 页面标题和文档信息 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h4" gutterBottom>
Chunk解析结果
</Typography>
<Typography variant="body1" color="text.secondary">
"{document?.name}" chunk数据
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box>
<Typography variant="h4" gutterBottom>
Chunk解析结果
</Typography>
<Typography variant="body1" color="text.secondary">
"{document?.name}" chunk数据
</Typography>
</Box>
<Button
variant="outlined"
startIcon={<VisibilityIcon />}
onClick={handleViewFile}
sx={{ ml: 2 }}
>
</Button>
</Box>
</Paper>
{/* 文件预览 */}
{renderFilePreview()}
{/* 搜索框 */}
<Paper sx={{ p: 3, mb: 3 }}>
<TextField
fullWidth
placeholder="搜索chunk内容..."
value={searchKeyword}
// onChange={(e) => handleSearch(e.target.value)}
onChange={(e) => handleSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">

View File

@@ -9,6 +9,7 @@ import { KnowledgeBaseList, KnowledgeBaseCreate, KnowledgeBaseDetail, KnowledgeB
import MCP from '../pages/MCP';
import FormFieldTest from '../pages/FormFieldTest';
import ChunkParsedResult from '@/pages/chunk/parsed-result';
import DocumentPreview from '@/pages/chunk/document-preview';
const AppRoutes = () => {
return (
@@ -38,6 +39,8 @@ const AppRoutes = () => {
{/* 处理chunk相关路由 需要传入 kb_id doc_id */}
<Route path="chunk">
<Route path="parsed-result" element={<ChunkParsedResult />} />
{/* 文档预览页面路由 */}
<Route path="/document-preview/:kb_id/:doc_id" element={<DocumentPreview />} />
</Route>
{/* 处理未匹配的路由 */}