feat(breadcrumbs): implement reusable breadcrumbs component and update usage

refactor(chunk): enhance chunk list with bulk operations and improved UI
This commit is contained in:
2025-10-17 11:11:48 +08:00
parent 50628816e3
commit de3d196e11
12 changed files with 519 additions and 285 deletions

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumbs, Link, Typography, type SxProps, type Theme } from '@mui/material';
export interface BreadcrumbItem {
/** 显示文本 */
label: string;
/** 导航路径 */
path?: string;
/** 是否为最后一项(当前页面) */
isLast?: boolean;
/** 点击事件处理函数优先级高于path */
onClick?: () => void;
}
export interface BaseBreadcrumbsProps {
/** 面包屑项目列表 */
items: BreadcrumbItem[];
/** 自定义样式 */
sx?: SxProps<Theme>;
/** 分隔符 */
separator?: React.ReactNode;
/** 最大显示项目数 */
maxItems?: number;
/** 链接变体 */
linkVariant?: 'body1' | 'body2' | 'caption' | 'subtitle1' | 'subtitle2';
}
const BaseBreadcrumbs: React.FC<BaseBreadcrumbsProps> = ({
items,
sx,
separator,
maxItems,
linkVariant = 'body2'
}) => {
const navigate = useNavigate();
const handleItemClick = (item: BreadcrumbItem) => {
if (item.onClick) {
item.onClick();
} else if (item.path) {
navigate(item.path);
}
};
return (
<Breadcrumbs
sx={{ mb: 2, ...sx }}
separator={separator}
maxItems={maxItems}
>
{items.map((item, index) => {
if (item.isLast) {
return (
<Typography key={index} color="text.primary">
{item.label}
</Typography>
);
}
return (
<Link
key={index}
component="button"
variant={linkVariant}
onClick={() => handleItemClick(item)}
sx={{
textDecoration: 'none',
cursor: 'pointer',
border: 'none',
background: 'none',
padding: 0,
font: 'inherit',
color: 'inherit'
}}
>
{item.label}
</Link>
);
})}
</Breadcrumbs>
);
};
export default BaseBreadcrumbs;

View File

@@ -0,0 +1,2 @@
export { default as BaseBreadcrumbs } from './BaseBreadcrumbs';
export type { BreadcrumbItem, BaseBreadcrumbsProps } from './BaseBreadcrumbs';

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useCallback } from 'react';
import { import {
Box, Box,
Paper, Paper,
@@ -15,16 +15,32 @@ import {
Avatar, Avatar,
IconButton, IconButton,
Tooltip, Tooltip,
Checkbox,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
DialogContentText,
Toolbar,
FormControlLabel,
} from '@mui/material'; } from '@mui/material';
import { import {
Image as ImageIcon, Image as ImageIcon,
TextSnippet as TextIcon, TextSnippet as TextIcon,
Visibility as VisibilityIcon, Visibility as VisibilityIcon,
VisibilityOff as VisibilityOffIcon, VisibilityOff as VisibilityOffIcon,
Delete as DeleteIcon,
ToggleOn as EnableIcon,
ToggleOff as DisableIcon,
SelectAll as SelectAllIcon,
Clear as ClearIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import type { IChunk } from '@/interfaces/database/knowledge'; import type { IChunk } from '@/interfaces/database/knowledge';
import knowledgeService from '@/services/knowledge_service';
interface ChunkListResultProps { interface ChunkListResultProps {
doc_id: string;
chunks: IChunk[]; chunks: IChunk[];
total: number; total: number;
loading: boolean; loading: boolean;
@@ -32,11 +48,92 @@ interface ChunkListResultProps {
page: number; page: number;
pageSize: number; pageSize: number;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onRefresh?: () => void;
docName?: string; docName?: string;
} }
function ChunkListResult(props: ChunkListResultProps) { function ChunkListResult(props: ChunkListResultProps) {
const { chunks, total, loading, error, page, pageSize, onPageChange, docName } = props; const { doc_id, chunks, total, loading, error, page, pageSize, onPageChange, onRefresh, docName } = props;
// 选择状态
const [selectedChunks, setSelectedChunks] = useState<string[]>([]);
const [selectAll, setSelectAll] = useState(false);
// 操作状态
const [operationLoading, setOperationLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// 处理单个选择
const handleSelectChunk = useCallback((chunkId: string, checked: boolean) => {
setSelectedChunks(prev => {
if (checked) {
return [...prev, chunkId];
} else {
return prev.filter(id => id !== chunkId);
}
});
}, []);
// 处理全选
const handleSelectAll = useCallback((checked: boolean) => {
setSelectAll(checked);
if (checked) {
setSelectedChunks(chunks.map(chunk => chunk.chunk_id));
} else {
setSelectedChunks([]);
}
}, [chunks]);
// 清空选择
const handleClearSelection = useCallback(() => {
setSelectedChunks([]);
setSelectAll(false);
}, []);
// 启用/禁用chunks
const handleToggleChunks = useCallback(async (enable: boolean) => {
if (selectedChunks.length === 0) return;
try {
setOperationLoading(true);
await knowledgeService.switchChunk({
chunk_ids: selectedChunks,
available_int: enable ? 1 : 0,
doc_id: doc_id || ''
});
// delay 800 ms
await new Promise(resolve => setTimeout(resolve, 800));
// 清空选择并刷新
handleClearSelection();
onRefresh?.();
} catch (err) {
console.error('Failed to toggle chunks:', err);
} finally {
setOperationLoading(false);
}
}, [selectedChunks, handleClearSelection, onRefresh]);
// 删除chunks
const handleDeleteChunks = useCallback(async () => {
if (selectedChunks.length === 0) return;
try {
setOperationLoading(true);
await knowledgeService.removeChunk({
chunk_ids: selectedChunks
});
// 关闭对话框,清空选择并刷新
setDeleteDialogOpen(false);
handleClearSelection();
onRefresh?.();
} catch (err) {
console.error('Failed to delete chunks:', err);
} finally {
setOperationLoading(false);
}
}, [selectedChunks, handleClearSelection, onRefresh]);
if (loading) { if (loading) {
return ( return (
@@ -73,50 +170,85 @@ function ChunkListResult(props: ChunkListResultProps) {
} }
const totalPages = Math.ceil(total / pageSize); const totalPages = Math.ceil(total / pageSize);
const enabledCount = chunks.filter(chunk => chunk.available_int === 1).length;
const selectedEnabledCount = selectedChunks.filter(id =>
chunks.find(chunk => chunk.chunk_id === id)?.available_int === 1
).length;
const selectedDisabledCount = selectedChunks.length - selectedEnabledCount;
return ( return (
<Box> <Box>
{/* Chunk结果概览 */} {/* 批量操作工具栏 */}
<Paper sx={{ p: 3, mb: 3 }}> <Paper sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom> <Toolbar sx={{ px: 2, py: 1 }}>
Chunk详情 <FormControlLabel
</Typography> control={
{docName && ( <Checkbox
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> checked={selectAll}
: {docName} indeterminate={selectedChunks.length > 0 && selectedChunks.length < chunks.length}
</Typography> onChange={(e) => handleSelectAll(e.target.checked)}
/>
}
label={`全选 (已选择 ${selectedChunks.length} 个)`}
/>
<Box sx={{ flexGrow: 1 }} />
{selectedChunks.length > 0 && (
<Stack direction="row" spacing={1}>
{selectedDisabledCount > 0 && (
<Button
startIcon={<EnableIcon />}
onClick={() => handleToggleChunks(true)}
disabled={operationLoading}
color="success"
variant="outlined"
size="small"
>
({selectedDisabledCount})
</Button>
)} )}
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6 }}> {selectedEnabledCount > 0 && (
<Card> <Button
<CardContent> startIcon={<DisableIcon />}
<Typography variant="h4" color="primary"> onClick={() => handleToggleChunks(false)}
{total} disabled={operationLoading}
</Typography> color="warning"
<Typography variant="body2" color="text.secondary"> variant="outlined"
Chunk数量 size="small"
</Typography> >
</CardContent> ({selectedEnabledCount})
</Card> </Button>
</Grid> )}
<Grid size={{ xs: 12, sm: 6 }}>
<Card> <Button
<CardContent> startIcon={<DeleteIcon />}
<Typography variant="h4" color="secondary"> onClick={() => setDeleteDialogOpen(true)}
{chunks.filter(chunk => chunk.available_int === 1).length} disabled={operationLoading}
</Typography> color="error"
<Typography variant="body2" color="text.secondary"> variant="outlined"
Chunk size="small"
</Typography> >
</CardContent> ({selectedChunks.length})
</Card> </Button>
</Grid>
</Grid> <Button
startIcon={<ClearIcon />}
onClick={handleClearSelection}
variant="outlined"
size="small"
>
</Button>
</Stack>
)}
</Toolbar>
</Paper> </Paper>
{/* Chunk列表 */} {/* Chunk列表 */}
<Paper sx={{ p: 3, mb: 3 }}> <Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6"> <Typography variant="h6">
Chunk列表 ( {page} {totalPages} ) Chunk列表 ( {page} {totalPages} )
</Typography> </Typography>
@@ -125,7 +257,7 @@ function ChunkListResult(props: ChunkListResultProps) {
</Typography> </Typography>
</Box> </Box>
<Grid container spacing={3}> <Grid container spacing={2}>
{chunks.map((chunk, index) => ( {chunks.map((chunk, index) => (
<Grid size={12} key={chunk.chunk_id}> <Grid size={12} key={chunk.chunk_id}>
<Card <Card
@@ -133,38 +265,50 @@ function ChunkListResult(props: ChunkListResultProps) {
sx={{ sx={{
transition: 'all 0.2s ease-in-out', transition: 'all 0.2s ease-in-out',
'&:hover': { '&:hover': {
boxShadow: 3, boxShadow: 2,
transform: 'translateY(-2px)',
}, },
border: chunk.available_int === 1 ? '2px solid' : '1px solid', border: selectedChunks.includes(chunk.chunk_id) ? '2px solid' : '1px solid',
borderColor: chunk.available_int === 1 ? 'success.main' : 'grey.300', borderColor: selectedChunks.includes(chunk.chunk_id)
? 'primary.main'
: chunk.available_int === 1
? 'success.light'
: 'grey.300',
backgroundColor: selectedChunks.includes(chunk.chunk_id) ? 'action.selected' : 'background.paper',
}} }}
> >
<CardContent sx={{ p: 3 }}> <CardContent sx={{ p: 2 }}>
{/* 头部信息 */} {/* 头部信息 */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Checkbox
checked={selectedChunks.includes(chunk.chunk_id)}
onChange={(e) => handleSelectChunk(chunk.chunk_id, e.target.checked)}
sx={{ mr: 1 }}
/>
<Avatar <Avatar
sx={{ sx={{
bgcolor: chunk.available_int === 1 ? 'success.main' : 'grey.400', bgcolor: chunk.available_int === 1 ? 'success.main' : 'grey.400',
mr: 2, mr: 2,
width: 40, width: 32,
height: 40, height: 32,
}} }}
> >
<TextIcon /> <TextIcon fontSize="small" />
</Avatar> </Avatar>
<Box sx={{ flexGrow: 1 }}> <Box sx={{ flexGrow: 1 }}>
<Typography variant="h6" fontWeight="bold" color="text.primary"> <Typography variant="subtitle1" fontWeight="medium">
Chunk #{((page - 1) * pageSize) + index + 1} Chunk #{((page - 1) * pageSize) + index + 1}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
ID: {chunk.chunk_id} ID: {chunk.chunk_id}
</Typography> </Typography>
</Box> </Box>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
{chunk.image_id && ( {chunk.image_id && (
<Tooltip title="包含图片"> <Tooltip title="包含图片">
<Avatar sx={{ bgcolor: 'info.main', width: 32, height: 32 }}> <Avatar sx={{ bgcolor: 'info.main', width: 24, height: 24 }}>
<ImageIcon fontSize="small" /> <ImageIcon fontSize="small" />
</Avatar> </Avatar>
</Tooltip> </Tooltip>
@@ -179,39 +323,27 @@ function ChunkListResult(props: ChunkListResultProps) {
</Stack> </Stack>
</Box> </Box>
<Divider sx={{ mb: 2 }} />
{/* 内容区域 */} {/* 内容区域 */}
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography> </Typography>
<Paper <Paper
variant="outlined" variant="outlined"
sx={{ sx={{
p: 2, p: 1.5,
maxHeight: '200px', maxHeight: '150px',
overflow: 'auto', overflow: 'auto',
backgroundColor: 'grey.50', backgroundColor: 'grey.50',
borderRadius: 2, borderRadius: 1,
'&::-webkit-scrollbar': { fontSize: '0.875rem',
width: '6px', lineHeight: 1.5,
},
'&::-webkit-scrollbar-track': {
backgroundColor: 'grey.100',
borderRadius: '3px',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'grey.400',
borderRadius: '3px',
},
}} }}
> >
<Typography <Typography
variant="body2" variant="body2"
sx={{ sx={{
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
lineHeight: 1.6,
color: 'text.primary', color: 'text.primary',
}} }}
> >
@@ -222,17 +354,17 @@ function ChunkListResult(props: ChunkListResultProps) {
{/* 图片显示区域 */} {/* 图片显示区域 */}
{chunk.image_id && ( {chunk.image_id && (
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography> </Typography>
<Paper <Box
variant="outlined"
sx={{ sx={{
p: 2,
borderRadius: 2,
backgroundColor: 'background.paper',
textAlign: 'center', textAlign: 'center',
p: 1,
border: '1px solid',
borderColor: 'grey.300',
borderRadius: 1,
}} }}
> >
<Box <Box
@@ -241,33 +373,32 @@ function ChunkListResult(props: ChunkListResultProps) {
alt="Chunk相关图片" alt="Chunk相关图片"
sx={{ sx={{
maxWidth: '100%', maxWidth: '100%',
maxHeight: '300px', maxHeight: '200px',
borderRadius: 1, borderRadius: 1,
objectFit: 'contain', objectFit: 'contain',
boxShadow: 1,
}} }}
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.style.display = 'none'; target.style.display = 'none';
}} }}
/> />
</Paper> </Box>
</Box> </Box>
)} )}
{/* 关键词区域 */} {/* 关键词区域 */}
{((chunk.important_kwd ?? []).length > 0 || (chunk.question_kwd ?? []).length > 0 || (chunk.tag_kwd ?? []).length > 0) && ( {((chunk.important_kwd ?? []).length > 0 || (chunk.question_kwd ?? []).length > 0 || (chunk.tag_kwd ?? []).length > 0) && (
<Box sx={{ mb: 2 }}> <Box>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography> </Typography>
<Stack spacing={1}>
{chunk.important_kwd && chunk.important_kwd.length > 0 && ( {chunk.important_kwd && chunk.important_kwd.length > 0 && (
<Box sx={{ mb: 2 }}> <Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ mr: 1 }}>
:
</Typography> </Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{chunk.important_kwd.map((keyword, kwdIndex) => ( {chunk.important_kwd.map((keyword, kwdIndex) => (
<Chip <Chip
key={kwdIndex} key={kwdIndex}
@@ -275,19 +406,17 @@ function ChunkListResult(props: ChunkListResultProps) {
size="small" size="small"
variant="filled" variant="filled"
color="primary" color="primary"
sx={{ fontWeight: 'medium' }} sx={{ mr: 0.5, mb: 0.5, fontSize: '0.75rem' }}
/> />
))} ))}
</Box> </Box>
</Box>
)} )}
{chunk.question_kwd && chunk.question_kwd.length > 0 && ( {chunk.question_kwd && chunk.question_kwd.length > 0 && (
<Box sx={{ mb: 2 }}> <Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ mr: 1 }}>
:
</Typography> </Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{chunk.question_kwd.map((keyword, kwdIndex) => ( {chunk.question_kwd.map((keyword, kwdIndex) => (
<Chip <Chip
key={kwdIndex} key={kwdIndex}
@@ -295,19 +424,17 @@ function ChunkListResult(props: ChunkListResultProps) {
size="small" size="small"
variant="filled" variant="filled"
color="secondary" color="secondary"
sx={{ fontWeight: 'medium' }} sx={{ mr: 0.5, mb: 0.5, fontSize: '0.75rem' }}
/> />
))} ))}
</Box> </Box>
</Box>
)} )}
{chunk.tag_kwd && chunk.tag_kwd.length > 0 && ( {chunk.tag_kwd && chunk.tag_kwd.length > 0 && (
<Box sx={{ mb: 2 }}> <Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ mr: 1 }}>
:
</Typography> </Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{chunk.tag_kwd.map((keyword, kwdIndex) => ( {chunk.tag_kwd.map((keyword, kwdIndex) => (
<Chip <Chip
key={kwdIndex} key={kwdIndex}
@@ -315,25 +442,12 @@ function ChunkListResult(props: ChunkListResultProps) {
size="small" size="small"
variant="filled" variant="filled"
color="info" color="info"
sx={{ fontWeight: 'medium' }} sx={{ mr: 0.5, mb: 0.5, fontSize: '0.75rem' }}
/> />
))} ))}
</Box> </Box>
</Box>
)} )}
</Box> </Stack>
)}
{/* 位置信息 */}
{chunk.positions && chunk.positions.length > 0 && (
<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> </Box>
)} )}
</CardContent> </CardContent>
@@ -356,6 +470,31 @@ function ChunkListResult(props: ChunkListResultProps) {
</Box> </Box>
)} )}
</Paper> </Paper>
{/* 删除确认对话框 */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
>
<DialogTitle></DialogTitle>
<DialogContent>
<DialogContentText>
{selectedChunks.length} chunk吗
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>
</Button>
<Button
onClick={handleDeleteChunks}
color="error"
disabled={operationLoading}
>
{operationLoading ? '删除中...' : '确认删除'}
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { import {
Box, Box,
@@ -7,8 +7,6 @@ import {
CircularProgress, CircularProgress,
Alert, Alert,
Button, Button,
Breadcrumbs,
Link,
Card, Card,
CardContent, CardContent,
CardMedia, CardMedia,
@@ -20,6 +18,7 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import knowledgeService from '@/services/knowledge_service'; import knowledgeService from '@/services/knowledge_service';
import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge'; import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge';
import KnowledgeBreadcrumbs from '@/pages/knowledge/components/KnowledgeBreadcrumbs';
interface DocumentPreviewProps {} interface DocumentPreviewProps {}
@@ -35,6 +34,9 @@ function DocumentPreview(props: DocumentPreviewProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [fileLoading, setFileLoading] = useState(false); const [fileLoading, setFileLoading] = useState(false);
// 用于取消请求的AbortController
const abortControllerRef = useRef<AbortController | null>(null);
// 获取知识库和文档信息 // 获取知识库和文档信息
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -60,6 +62,9 @@ function DocumentPreview(props: DocumentPreviewProps) {
if (docResponse.data.data?.length > 0) { if (docResponse.data.data?.length > 0) {
setDocumentObj(docResponse.data.data[0]); setDocumentObj(docResponse.data.data[0]);
} }
// 自动开始预览文件
loadDocumentFile();
} catch (err) { } catch (err) {
console.error('获取数据失败:', err); console.error('获取数据失败:', err);
setError('获取数据失败,请稍后重试'); setError('获取数据失败,请稍后重试');
@@ -79,7 +84,19 @@ function DocumentPreview(props: DocumentPreviewProps) {
setFileLoading(true); setFileLoading(true);
setError(null); setError(null);
const fileResponse = await knowledgeService.getDocumentFile({ doc_id }); // 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 创建新的AbortController
abortControllerRef.current = new AbortController();
const fileResponse = await knowledgeService.getDocumentFile({
doc_id
}, {
signal: abortControllerRef.current.signal
});
if (fileResponse.data instanceof Blob) { if (fileResponse.data instanceof Blob) {
setDocumentFile(fileResponse.data); setDocumentFile(fileResponse.data);
@@ -88,20 +105,27 @@ function DocumentPreview(props: DocumentPreviewProps) {
} else { } else {
setError('文件格式不支持预览'); setError('文件格式不支持预览');
} }
} catch (err) { } catch (err: any) {
console.log('err', err);
if (err.name !== 'AbortError' && err.name !== 'CanceledError') {
console.error('获取文档文件失败:', err); console.error('获取文档文件失败:', err);
setError('获取文档文件失败,请稍后重试'); setError('获取文档文件失败,请稍后重试');
}
} finally { } finally {
setFileLoading(false); setFileLoading(false);
} }
}; };
// 清理文件URL // 清理fileUrl
useEffect(() => { useEffect(() => {
return () => { return () => {
if (fileUrl) { if (fileUrl) {
URL.revokeObjectURL(fileUrl); URL.revokeObjectURL(fileUrl);
} }
// 组件卸载时取消正在进行的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}; };
}, [fileUrl]); }, [fileUrl]);
@@ -119,6 +143,17 @@ function DocumentPreview(props: DocumentPreviewProps) {
// 返回上一页 // 返回上一页
const handleGoBack = () => { const handleGoBack = () => {
// 取消正在进行的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 清理文件URL
if (fileUrl) {
URL.revokeObjectURL(fileUrl);
setFileUrl('');
}
navigate(-1); navigate(-1);
}; };
@@ -204,33 +239,27 @@ function DocumentPreview(props: DocumentPreviewProps) {
return ( return (
<Box sx={{ p: 3 }}> <Box sx={{ p: 3 }}>
{/* 面包屑导航 */} {/* 面包屑导航 */}
<Breadcrumbs sx={{ mb: 2 }}> <KnowledgeBreadcrumbs
<Link kbItems={[
component="button" {
variant="body2" label: '知识库',
onClick={() => navigate('/knowledge')} path: '/knowledge'
sx={{ textDecoration: 'none' }} },
> {
label: kb?.name || '知识库详情',
</Link> path: `/knowledge/${kb_id}`
<Link }
component="button" ]}
variant="body2" extraItems={[
onClick={() => navigate(`/knowledge/${kb_id}`)} {
sx={{ textDecoration: 'none' }} label: documentObj?.name || '文档详情',
> path: `/chunk/parsed-result?kb_id=${kb_id}&doc_id=${doc_id}`
{kb?.name || '知识库详情'} },
</Link> {
<Link label: '文件预览'
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 sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
@@ -254,17 +283,6 @@ function DocumentPreview(props: DocumentPreviewProps) {
</Button> </Button>
{!fileUrl && (
<Button
startIcon={<VisibilityIcon />}
onClick={loadDocumentFile}
variant="contained"
disabled={fileLoading}
>
{fileLoading ? '加载中...' : '预览文件'}
</Button>
)}
{fileUrl && ( {fileUrl && (
<Button <Button
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}

View File

@@ -3,19 +3,20 @@ import { useSearchParams, useNavigate } from "react-router-dom";
import { import {
Box, Box,
Typography, Typography,
Breadcrumbs,
Link,
TextField, TextField,
InputAdornment, InputAdornment,
Paper, Paper,
Alert, Alert,
Button Button,
Card,
CardContent
} from "@mui/material"; } from "@mui/material";
import { Search as SearchIcon, Visibility as VisibilityIcon } from '@mui/icons-material'; import { Search as SearchIcon, Visibility as VisibilityIcon } from '@mui/icons-material';
import { useChunkList } from '@/hooks/chunk-hooks'; import { useChunkList } from '@/hooks/chunk-hooks';
import ChunkListResult from './components/ChunkListResult'; import ChunkListResult from './components/ChunkListResult';
import knowledgeService from '@/services/knowledge_service'; import knowledgeService from '@/services/knowledge_service';
import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge'; import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge';
import KnowledgeBreadcrumbs from '@/pages/knowledge/components/KnowledgeBreadcrumbs';
function ChunkParsedResult() { function ChunkParsedResult() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -53,6 +54,7 @@ function ChunkParsedResult() {
// 获取知识库信息 // 获取知识库信息
const kbResponse = await knowledgeService.getKnowledgeDetail({ kb_id }); const kbResponse = await knowledgeService.getKnowledgeDetail({ kb_id });
if (kbResponse.data.code === 0) { if (kbResponse.data.code === 0) {
console.log('KnowledgeBase:', kbResponse.data);
setKnowledgeBase(kbResponse.data.data); setKnowledgeBase(kbResponse.data.data);
} }
@@ -100,34 +102,23 @@ function ChunkParsedResult() {
return ( return (
<Box sx={{ p: 3 }}> <Box sx={{ p: 3 }}>
{/* 面包屑导航 */} {/* 面包屑导航 */}
<Box sx={{ mb: 3 }}> <KnowledgeBreadcrumbs
<Breadcrumbs> kbItems={[
<Link {
color="inherit" label: '知识库',
href="#" path: '/knowledge'
onClick={(e) => { },
e.preventDefault(); {
navigate('/knowledge'); label: knowledgeBase?.name || '知识库详情',
}} path: `/knowledge/${kb_id}`
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }} }
> ]}
extraItems={[
</Link> {
<Link label: document?.name || '文档详情'
color="inherit" },
href="#" ]}
onClick={(e) => { />
e.preventDefault();
navigate(`/knowledge/${kb_id}`);
}}
>
{knowledgeBase?.name || '知识库详情'}
</Link>
<Typography color="text.primary">
{document?.name || '文档Chunk详情'}
</Typography>
</Breadcrumbs>
</Box>
{/* 页面标题和文档信息 */} {/* 页面标题和文档信息 */}
<Paper sx={{ p: 3, mb: 3 }}> <Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
@@ -139,6 +130,16 @@ function ChunkParsedResult() {
"{document?.name}" chunk数据 "{document?.name}" chunk数据
</Typography> </Typography>
</Box> </Box>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="primary">
{total}
</Typography>
<Typography variant="body2" color="text.secondary">
Chunk数量
</Typography>
</CardContent>
</Card>
<Button <Button
variant="outlined" variant="outlined"
startIcon={<VisibilityIcon />} startIcon={<VisibilityIcon />}
@@ -169,6 +170,7 @@ function ChunkParsedResult() {
{/* Chunk列表结果 */} {/* Chunk列表结果 */}
<ChunkListResult <ChunkListResult
doc_id={doc_id}
chunks={chunks} chunks={chunks}
total={total} total={total}
loading={loading} loading={loading}
@@ -176,6 +178,7 @@ function ChunkParsedResult() {
page={currentPage} page={currentPage}
pageSize={pageSize} pageSize={pageSize}
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
onRefresh={refresh}
docName={document?.name} docName={document?.name}
/> />
</Box> </Box>

View File

@@ -1,88 +1,35 @@
import React from 'react'; import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import type { SxProps, Theme } from '@mui/material';
import { Breadcrumbs, Link, Typography } from '@mui/material'; import { BaseBreadcrumbs, type BreadcrumbItem } from '@/components/Breadcrumbs';
import type { IKnowledge } from '@/interfaces/database/knowledge';
interface KnowledgeBreadcrumbsProps { interface KnowledgeBreadcrumbsProps {
knowledge?: IKnowledge | null; sx?: SxProps<Theme>;
sx?: object; /** 知识库相关的面包屑项目,可以完全自定义 */
kbItems?: BreadcrumbItem[];
/** 额外的面包屑项目会添加到kbItems之后 */
extraItems?: BreadcrumbItem[];
} }
const KnowledgeBreadcrumbs: React.FC<KnowledgeBreadcrumbsProps> = ({ knowledge, sx }) => { const KnowledgeBreadcrumbs: React.FC<KnowledgeBreadcrumbsProps> = ({
const location = useLocation(); sx,
const navigate = useNavigate(); kbItems = [],
const { id } = useParams<{ id: string }>(); extraItems = []
}) => {
// 合并所有面包屑项目
const allItems = [...kbItems, ...extraItems];
// 解析当前路径 // 确保最后一个项目被标记为最后一项
const pathSegments = location.pathname.split('/').filter(Boolean); const breadcrumbItems = allItems.map((item, index) => ({
...item,
// 生成面包屑项 isLast: index === allItems.length - 1
const breadcrumbItems = []; }));
// 第一层:知识库列表
breadcrumbItems.push({
label: '知识库',
path: '/knowledge',
isLast: false
});
// 第二层知识库详情如果有id
if (id && knowledge) {
const isDetailPage = pathSegments.length === 2; // /knowledge/:id
breadcrumbItems.push({
label: knowledge.name,
path: `/knowledge/${id}`,
isLast: isDetailPage
});
// 第三层:设置或测试页面
if (pathSegments.length === 3) {
const lastSegment = pathSegments[2];
let label = '';
switch (lastSegment) {
case 'setting':
label = '设置';
break;
case 'testing':
label = '测试';
break;
default:
label = lastSegment;
}
breadcrumbItems.push({
label,
path: location.pathname,
isLast: true
});
}
}
return ( return (
<Breadcrumbs sx={{ mb: 2, ...sx }}> <BaseBreadcrumbs
{breadcrumbItems.map((item, index) => { items={breadcrumbItems}
if (item.isLast) { sx={sx}
return ( linkVariant="body1"
<Typography key={index} color="text.primary"> />
{item.label}
</Typography>
);
}
return (
<Link
key={index}
component="button"
variant="body1"
onClick={() => navigate(item.path)}
sx={{ textDecoration: 'none' }}
>
{item.label}
</Link>
);
})}
</Breadcrumbs>
); );
}; };

View File

@@ -275,7 +275,18 @@ function KnowledgeBaseDetail() {
return ( return (
<Box sx={{ p: 3 }}> <Box sx={{ p: 3 }}>
{/* 面包屑导航 */} {/* 面包屑导航 */}
<KnowledgeBreadcrumbs knowledge={knowledgeBase} /> <KnowledgeBreadcrumbs
kbItems={[
{
label: '知识库',
path: '/knowledge'
},
{
label: knowledgeBase?.name || '知识库详情',
path: `/knowledge/${id}`
}
]}
/>
{/* 知识库信息卡片 */} {/* 知识库信息卡片 */}
<KnowledgeInfoCard knowledgeBase={knowledgeBase} /> <KnowledgeInfoCard knowledgeBase={knowledgeBase} />

View File

@@ -157,7 +157,21 @@ function KnowledgeBaseSetting() {
return ( return (
<MainContainer> <MainContainer>
{/* 面包屑导航 */} {/* 面包屑导航 */}
<KnowledgeBreadcrumbs knowledge={knowledge} /> <KnowledgeBreadcrumbs
kbItems={[
{
label: '知识库',
path: '/knowledge'
},
{
label: knowledge?.name || '知识库详情',
path: `/knowledge/${id}`
},
{
label: '设置'
}
]}
/>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>

View File

@@ -236,7 +236,21 @@ function KnowledgeBaseTesting() {
<Container maxWidth="lg" sx={{ py: 4 }}> <Container maxWidth="lg" sx={{ py: 4 }}>
{/* 面包屑导航 */} {/* 面包屑导航 */}
<KnowledgeBreadcrumbs knowledge={knowledgeDetail} /> <KnowledgeBreadcrumbs
kbItems={[
{
label: '知识库',
path: '/knowledge'
},
{
label: knowledgeDetail?.name || '知识库详情',
path: `/knowledge/${id}`
},
{
label: '测试'
}
]}
/>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>

View File

@@ -1,5 +1,6 @@
import api from './api'; import api from './api';
import request, { post } from '@/utils/request'; import request, { post } from '@/utils/request';
import type { AxiosRequestConfig } from 'axios';
import type { import type {
IFetchKnowledgeListRequestBody, IFetchKnowledgeListRequestBody,
IFetchKnowledgeListRequestParams, IFetchKnowledgeListRequestParams,
@@ -137,9 +138,10 @@ const knowledgeService = {
}, },
// 获取文档文件 // 获取文档文件
getDocumentFile: (params: { doc_id: string }) => { getDocumentFile: (params: { doc_id: string }, config?: AxiosRequestConfig) => {
return request.get(`${api.get_document_file}/${params.doc_id}`, { return request.get(`${api.get_document_file}/${params.doc_id}`, {
responseType: 'blob' responseType: 'blob',
...config
}); });
}, },
@@ -185,8 +187,8 @@ const knowledgeService = {
return request.get(api.get_chunk, { params }); return request.get(api.get_chunk, { params });
}, },
// 切换分块状态 // 切换分块状态 available_int 是否启用0未启用1启用
switchChunk: (data: { chunk_ids: string[]; available_int: number }) => { switchChunk: (data: { chunk_ids: string[]; available_int: number, doc_id: string }) => {
return post(api.switch_chunk, data); return post(api.switch_chunk, data);
}, },

View File

@@ -136,13 +136,12 @@ request.interceptors.response.use(
} else if (data?.code !== 0) { } else if (data?.code !== 0) {
snackbar.error(data?.message); snackbar.error(data?.message);
} }
return response; return response;
}, },
(error) => { (error) => {
// 处理网络错误 // 处理网络错误
if (error.message === FAILED_TO_FETCH || !error.response) { if (error.message === FAILED_TO_FETCH || !error.response) {
notification.error(i18n.t('message.networkAnomaly'), i18n.t('message.networkAnomalyDescription')); // notification.error(i18n.t('message.networkAnomaly'), i18n.t('message.networkAnomalyDescription'));
} else if (error.response) { } else if (error.response) {
const { status, statusText } = error.response; const { status, statusText } = error.response;
const errorText = RetcodeMessage[status as ResultCode] || statusText; const errorText = RetcodeMessage[status as ResultCode] || statusText;