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:
85
src/components/Breadcrumbs/BaseBreadcrumbs.tsx
Normal file
85
src/components/Breadcrumbs/BaseBreadcrumbs.tsx
Normal 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;
|
||||
2
src/components/Breadcrumbs/index.ts
Normal file
2
src/components/Breadcrumbs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as BaseBreadcrumbs } from './BaseBreadcrumbs';
|
||||
export type { BreadcrumbItem, BaseBreadcrumbsProps } from './BaseBreadcrumbs';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user