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 {
Box,
Paper,
@@ -15,16 +15,32 @@ import {
Avatar,
IconButton,
Tooltip,
Checkbox,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
DialogContentText,
Toolbar,
FormControlLabel,
} from '@mui/material';
import {
Image as ImageIcon,
TextSnippet as TextIcon,
Visibility as VisibilityIcon,
VisibilityOff as VisibilityOffIcon,
Delete as DeleteIcon,
ToggleOn as EnableIcon,
ToggleOff as DisableIcon,
SelectAll as SelectAllIcon,
Clear as ClearIcon,
} from '@mui/icons-material';
import type { IChunk } from '@/interfaces/database/knowledge';
import knowledgeService from '@/services/knowledge_service';
interface ChunkListResultProps {
doc_id: string;
chunks: IChunk[];
total: number;
loading: boolean;
@@ -32,11 +48,92 @@ interface ChunkListResultProps {
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onRefresh?: () => void;
docName?: string;
}
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) {
return (
@@ -73,50 +170,85 @@ function ChunkListResult(props: ChunkListResultProps) {
}
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 (
<Box>
{/* Chunk结果概览 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Chunk详情
</Typography>
{docName && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
: {docName}
</Typography>
)}
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6 }}>
<Card>
<CardContent>
<Typography variant="h4" color="primary">
{total}
</Typography>
<Typography variant="body2" color="text.secondary">
Chunk数量
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Card>
<CardContent>
<Typography variant="h4" color="secondary">
{chunks.filter(chunk => chunk.available_int === 1).length}
</Typography>
<Typography variant="body2" color="text.secondary">
Chunk
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* 批量操作工具栏 */}
<Paper sx={{ mb: 3 }}>
<Toolbar sx={{ px: 2, py: 1 }}>
<FormControlLabel
control={
<Checkbox
checked={selectAll}
indeterminate={selectedChunks.length > 0 && selectedChunks.length < chunks.length}
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>
)}
{selectedEnabledCount > 0 && (
<Button
startIcon={<DisableIcon />}
onClick={() => handleToggleChunks(false)}
disabled={operationLoading}
color="warning"
variant="outlined"
size="small"
>
({selectedEnabledCount})
</Button>
)}
<Button
startIcon={<DeleteIcon />}
onClick={() => setDeleteDialogOpen(true)}
disabled={operationLoading}
color="error"
variant="outlined"
size="small"
>
({selectedChunks.length})
</Button>
<Button
startIcon={<ClearIcon />}
onClick={handleClearSelection}
variant="outlined"
size="small"
>
</Button>
</Stack>
)}
</Toolbar>
</Paper>
{/* Chunk列表 */}
<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">
Chunk列表 ( {page} {totalPages} )
</Typography>
@@ -125,7 +257,7 @@ function ChunkListResult(props: ChunkListResultProps) {
</Typography>
</Box>
<Grid container spacing={3}>
<Grid container spacing={2}>
{chunks.map((chunk, index) => (
<Grid size={12} key={chunk.chunk_id}>
<Card
@@ -133,38 +265,50 @@ function ChunkListResult(props: ChunkListResultProps) {
sx={{
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: 3,
transform: 'translateY(-2px)',
boxShadow: 2,
},
border: chunk.available_int === 1 ? '2px solid' : '1px solid',
borderColor: chunk.available_int === 1 ? 'success.main' : 'grey.300',
border: selectedChunks.includes(chunk.chunk_id) ? '2px solid' : '1px solid',
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 }}>
<Checkbox
checked={selectedChunks.includes(chunk.chunk_id)}
onChange={(e) => handleSelectChunk(chunk.chunk_id, e.target.checked)}
sx={{ mr: 1 }}
/>
<Avatar
sx={{
bgcolor: chunk.available_int === 1 ? 'success.main' : 'grey.400',
mr: 2,
width: 40,
height: 40,
width: 32,
height: 32,
}}
>
<TextIcon />
<TextIcon fontSize="small" />
</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6" fontWeight="bold" color="text.primary">
<Typography variant="subtitle1" fontWeight="medium">
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 }}>
<Avatar sx={{ bgcolor: 'info.main', width: 24, height: 24 }}>
<ImageIcon fontSize="small" />
</Avatar>
</Tooltip>
@@ -179,39 +323,27 @@ function ChunkListResult(props: ChunkListResultProps) {
</Stack>
</Box>
<Divider sx={{ mb: 2 }} />
{/* 内容区域 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: '200px',
p: 1.5,
maxHeight: '150px',
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',
},
borderRadius: 1,
fontSize: '0.875rem',
lineHeight: 1.5,
}}
>
<Typography
variant="body2"
sx={{
whiteSpace: 'pre-wrap',
lineHeight: 1.6,
color: 'text.primary',
}}
>
@@ -222,17 +354,17 @@ function ChunkListResult(props: ChunkListResultProps) {
{/* 图片显示区域 */}
{chunk.image_id && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Paper
variant="outlined"
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: 'background.paper',
textAlign: 'center',
p: 1,
border: '1px solid',
borderColor: 'grey.300',
borderRadius: 1,
}}
>
<Box
@@ -241,33 +373,32 @@ function ChunkListResult(props: ChunkListResultProps) {
alt="Chunk相关图片"
sx={{
maxWidth: '100%',
maxHeight: '300px',
maxHeight: '200px',
borderRadius: 1,
objectFit: 'contain',
boxShadow: 1,
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
</Paper>
</Box>
</Box>
)}
{/* 关键词区域 */}
{((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 }}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
{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 }}>
<Stack spacing={1}>
{chunk.important_kwd && chunk.important_kwd.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mr: 1 }}>
:
</Typography>
{chunk.important_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
@@ -275,19 +406,17 @@ function ChunkListResult(props: ChunkListResultProps) {
size="small"
variant="filled"
color="primary"
sx={{ fontWeight: 'medium' }}
sx={{ mr: 0.5, mb: 0.5, fontSize: '0.75rem' }}
/>
))}
</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 && chunk.question_kwd.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mr: 1 }}>
:
</Typography>
{chunk.question_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
@@ -295,19 +424,17 @@ function ChunkListResult(props: ChunkListResultProps) {
size="small"
variant="filled"
color="secondary"
sx={{ fontWeight: 'medium' }}
sx={{ mr: 0.5, mb: 0.5, fontSize: '0.75rem' }}
/>
))}
</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 && chunk.tag_kwd.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mr: 1 }}>
:
</Typography>
{chunk.tag_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
@@ -315,25 +442,12 @@ function ChunkListResult(props: ChunkListResultProps) {
size="small"
variant="filled"
color="info"
sx={{ fontWeight: 'medium' }}
sx={{ mr: 0.5, mb: 0.5, fontSize: '0.75rem' }}
/>
))}
</Box>
</Box>
)}
</Box>
)}
{/* 位置信息 */}
{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' }}
/>
)}
</Stack>
</Box>
)}
</CardContent>
@@ -356,6 +470,31 @@ function ChunkListResult(props: ChunkListResultProps) {
</Box>
)}
</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>
);
}

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

View File

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

View File

@@ -1,88 +1,35 @@
import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { Breadcrumbs, Link, Typography } from '@mui/material';
import type { IKnowledge } from '@/interfaces/database/knowledge';
import type { SxProps, Theme } from '@mui/material';
import { BaseBreadcrumbs, type BreadcrumbItem } from '@/components/Breadcrumbs';
interface KnowledgeBreadcrumbsProps {
knowledge?: IKnowledge | null;
sx?: object;
sx?: SxProps<Theme>;
/** 知识库相关的面包屑项目,可以完全自定义 */
kbItems?: BreadcrumbItem[];
/** 额外的面包屑项目会添加到kbItems之后 */
extraItems?: BreadcrumbItem[];
}
const KnowledgeBreadcrumbs: React.FC<KnowledgeBreadcrumbsProps> = ({ knowledge, sx }) => {
const location = useLocation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
// 解析当前路径
const pathSegments = location.pathname.split('/').filter(Boolean);
const KnowledgeBreadcrumbs: React.FC<KnowledgeBreadcrumbsProps> = ({
sx,
kbItems = [],
extraItems = []
}) => {
// 合并所有面包屑项目
const allItems = [...kbItems, ...extraItems];
// 生成面包屑
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
});
}
}
// 确保最后一个项目被标记为最后一
const breadcrumbItems = allItems.map((item, index) => ({
...item,
isLast: index === allItems.length - 1
}));
return (
<Breadcrumbs sx={{ mb: 2, ...sx }}>
{breadcrumbItems.map((item, index) => {
if (item.isLast) {
return (
<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>
<BaseBreadcrumbs
items={breadcrumbItems}
sx={sx}
linkVariant="body1"
/>
);
};

View File

@@ -212,7 +212,7 @@ function KnowledgeBaseCreate() {
<Divider sx={{ my: 3 }} />
{/* 操作按钮 */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Box sx={{ display: 'flex', gap: 2 }}>
{activeStep === 0 && (
<Button

View File

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

View File

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

View File

@@ -236,7 +236,21 @@ function KnowledgeBaseTesting() {
<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 }}>
<Typography variant="h4" gutterBottom>

View File

@@ -1,5 +1,6 @@
import api from './api';
import request, { post } from '@/utils/request';
import type { AxiosRequestConfig } from 'axios';
import type {
IFetchKnowledgeListRequestBody,
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}`, {
responseType: 'blob'
responseType: 'blob',
...config
});
},
@@ -185,8 +187,8 @@ const knowledgeService = {
return request.get(api.get_chunk, { params });
},
// 切换分块状态
switchChunk: (data: { chunk_ids: string[]; available_int: number }) => {
// 切换分块状态 available_int 是否启用0未启用1启用
switchChunk: (data: { chunk_ids: string[]; available_int: number, doc_id: string }) => {
return post(api.switch_chunk, data);
},

View File

@@ -136,13 +136,12 @@ request.interceptors.response.use(
} else if (data?.code !== 0) {
snackbar.error(data?.message);
}
return response;
},
(error) => {
// 处理网络错误
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) {
const { status, statusText } = error.response;
const errorText = RetcodeMessage[status as ResultCode] || statusText;