feat(knowledge): add local upload translation and sync tab with URL

This commit is contained in:
2025-11-18 17:32:09 +08:00
parent f5b51c8863
commit d84fd8934e
5 changed files with 261 additions and 27 deletions

View File

@@ -453,6 +453,7 @@ export default {
datasetLog: 'Dataset Log',
created: 'Created',
learnMore: 'Learn More',
localUpload: 'Local Upload',
general: 'General',
chunkMethodTab: 'Chunk Method',
testResults: 'Test Results',

View File

@@ -447,6 +447,7 @@ export default {
datasetLog: '知识库日志',
created: '创建于',
learnMore: '了解更多',
localUpload: '本地上传',
general: '通用',
chunkMethodTab: '切片方法',
testResults: '测试结果',

View File

@@ -38,10 +38,10 @@ const KnowledgeInfoCard: React.FC<KnowledgeInfoCardProps> = ({ knowledgeBase })
{knowledgeBase.description || t('knowledge.noDescription')}
</Typography>
<Stack direction="row" spacing={2} flexWrap="wrap">
<Chip label={t('knowledge.fileCount', { count: knowledgeBase.doc_num || 0 })} variant="outlined" />
<Chip label={t('knowledge.chunkCount', { count: knowledgeBase.chunk_num || 0 })} variant="outlined" />
<Chip label={t('knowledge.tokenCount', { count: knowledgeBase.token_num || 0 })} variant="outlined" />
<Chip label={t('knowledge.size', { size: formatFileSize(knowledgeBase.size || 0) })} variant="outlined" />
<Chip label={`${t('knowledge.fileCount')}: ${knowledgeBase.doc_num || 0}`} variant="outlined" />
<Chip label={`${t('knowledge.chunkCount')}: ${knowledgeBase.chunk_num || 0}`} variant="outlined" />
<Chip label={`${t('knowledge.tokenCount')}: ${knowledgeBase.token_num || 0}`} variant="outlined" />
<Chip label={`${t('knowledge.size')}: ${formatFileSize(knowledgeBase.size || 0)}`} variant="outlined" />
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Box,
@@ -42,6 +42,7 @@ import logger from '@/utils/logger';
function KnowledgeBaseDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
// 状态管理
@@ -102,6 +103,29 @@ function KnowledgeBaseDetail() {
console.log('showKnowledgeGraph:', showKnowledgeGraph, knowledgeGraph);
// 初次加载与 URL 变化时,从查询参数同步当前 Tab
useEffect(() => {
const params = new URLSearchParams(location.search);
const tab = params.get('tab') as 'documents' | 'testing' | 'overview' | 'graph' | null;
const validTabs: Array<'documents' | 'testing' | 'overview' | 'graph'> = ['documents', 'testing', 'overview', 'graph'];
if (tab && validTabs.includes(tab)) {
// 当 URL 指向 graph但图不可用则退回到 documents
const targetTab = tab === 'graph' && !showKnowledgeGraph ? 'documents' : tab;
if (targetTab !== currentTab) {
setCurrentTab(targetTab);
}
}
}, [location.search, showKnowledgeGraph]);
const handleTabChange = (_: any, newValue: 'documents' | 'testing' | 'overview' | 'graph') => {
// 若选择 graph 但不可用,直接忽略并返回
if (newValue === 'graph' && !showKnowledgeGraph) return;
setCurrentTab(newValue);
const params = new URLSearchParams(location.search);
params.set('tab', newValue);
navigate({ pathname: location.pathname, search: params.toString() }, { replace: false });
};
const refreshDetailData = async () => {
await fetchKnowledgeDetail();
await refreshFiles();
@@ -303,12 +327,12 @@ function KnowledgeBaseDetail() {
<Box sx={{ mt: 3 }}>
<Tabs
value={currentTab}
onChange={(event, newValue) => setCurrentTab(newValue)}
onChange={handleTabChange}
sx={{ borderBottom: 1, borderColor: 'divider' }}
>
<Tab value="documents" label={t('knowledgeDetails.files')} />
<Tab value="testing" label={t('knowledgeDetails.testing')} />
<Tab value="overview" label={t('knowledgeDetails.datasetLogs')} />
<Tab value="overview" label={t('knowledgeDetails.overview')} />
{showKnowledgeGraph && (
<Tab value="graph" label={t('knowledgeDetails.graph')} />
)}
@@ -540,8 +564,8 @@ function KnowledgeBaseDetail() {
</DialogContent>
<DialogActions sx={{ p: 3, pt: 0 }}>
<Button onClick={() => setProcessDetailsDialogOpen(false)} variant="contained">
{t('common.close')}
</Button>
{t('common.close')}
</Button>
</DialogActions>
</Dialog>
</Box>

View File

@@ -1,9 +1,10 @@
import React from 'react';
import { Box, Grid, Paper, Typography, IconButton, TextField, Tabs, Tab, Fab } from '@mui/material';
import { Box, Grid, Paper, Typography, IconButton, TextField, Tabs, Tab, Fab, Avatar, Chip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Card, CardContent, Divider } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import {
ArrowBack as ArrowBackIcon,
Search as SearchIcon,
Visibility as VisibilityIcon
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
@@ -13,6 +14,10 @@ import i18n from '@/locales';
import { enUS, zhCN } from '@mui/x-data-grid/locales';
import KnowledgeBreadcrumbs from './components/KnowledgeBreadcrumbs';
import { LanguageAbbreviation } from '@/constants/common';
import dayjs from 'dayjs';
import { RunningStatusMap, RunningStatus } from '@/constants/knowledge';
import knowledgeService from '@/services/knowledge_service';
import type { IFileLogItem } from '@/interfaces/database/knowledge';
const PROCESSING_TYPES = {
@@ -69,27 +74,94 @@ function KnowledgeLogsPage({ embedded = false, kbId: kbIdProp }: KnowledgeLogsPa
}, [currentPage, pageSize]);
const columnsFile: GridColDef[] = [
{ field: 'id', headerName: 'ID', width: 100 },
{ field: 'id', headerName: 'ID', width: 100, headerAlign: 'center', align: 'center' },
{
field: 'document_name', headerName: t('knowledgeDetails.fileName'), flex: 1, minWidth: 160, valueGetter: (params) => {
logger.info('params', params)
return ''
}
field: 'document_name',
headerName: t('knowledgeDetails.fileName'),
flex: 1,
minWidth: 160,
headerAlign: 'center',
},
{ field: 'source_from', headerName: t('knowledgeDetails.source'), flex: 1, minWidth: 140 },
{ field: 'pipeline_title', headerName: t('knowledgeDetails.ingestionPipeline'), flex: 1, minWidth: 180 },
{ field: 'create_date', headerName: t('knowledgeDetails.startDate') || 'Start Date', flex: 0.8, minWidth: 160, sortable: true },
{ field: 'task_type', headerName: t('knowledgeDetails.task') || 'Task', flex: 0.8, minWidth: 140 },
{ field: 'status', headerName: t('knowledgeDetails.status'), flex: 0.7, minWidth: 120 },
{ field: 'operations', headerName: t('knowledgeDetails.operations') || 'Operations', flex: 0.8, minWidth: 160, sortable: false, filterable: false, align: 'center', headerAlign: 'center' },
{
field: 'source_from',
headerName: t('knowledgeDetails.source'),
flex: 1,
minWidth: 140,
headerAlign: 'center',
align: 'center',
valueGetter: (value) => value || t('knowledgeDetails.localUpload'),
},
{
field: 'pipeline_title',
headerName: t('knowledgeDetails.dataPipeline'),
flex: 1,
minWidth: 180,
headerAlign: 'center',
align: 'center',
renderCell: (params) => (
<Box sx={{ height: '100%', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
{params.row.avatar && (
<Avatar src={params.row.avatar} alt={params.row.pipeline_title} sx={{ width: 18, height: 18 }} />
)}
<Typography variant="body2">
{params.row.pipeline_title === 'naive' ? t('knowledgeDetails.general') : params.row.pipeline_title}
</Typography>
</Box>
),
},
{
field: 'process_begin_at',
headerName: t('knowledgeDetails.startDate'),
flex: 0.8,
minWidth: 160,
sortable: true,
headerAlign: 'center',
align: 'center',
valueGetter: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
{ field: 'task_type', headerName: t('knowledgeDetails.task'), flex: 0.8, minWidth: 140, headerAlign: 'center', align: 'center' },
{
field: 'operation_status',
headerName: t('knowledgeDetails.status'),
flex: 0.7,
minWidth: 120,
headerAlign: 'center',
align: 'center',
renderCell: (params) => {
const status: RunningStatus = params.row.operation_status as RunningStatus;
const label = RunningStatusMap[status] || '';
const colorMap: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
['0']: 'default',
['1']: 'info',
['2']: 'warning',
['3']: 'success',
['4']: 'error',
};
const chipColor = colorMap[status] || 'default';
return <Chip label={label} color={chipColor} size="small" />;
},
},
{
field: 'actions', headerName: t('knowledgeDetails.operations'), type: 'actions',
flex: 0.8, minWidth: 160, sortable: false, filterable: false, align: 'center', headerAlign: 'center',
getActions: (params) => [
<IconButton
key="view"
color="primary"
onClick={() => handleViewClick(params.row)}
>
<VisibilityIcon />
</IconButton>,
],
}
];
const columnsDataset: GridColDef[] = [
{ field: 'id', headerName: 'ID', width: 100 },
{ field: 'create_date', headerName: t('knowledgeDetails.startDate') || 'Start Date', flex: 1, minWidth: 160, sortable: true },
{ field: 'task_type', headerName: 'Processing Type', flex: 1, minWidth: 180 },
{ field: 'status', headerName: t('knowledgeDetails.status'), flex: 0.8, minWidth: 120 },
{ field: 'operations', headerName: t('knowledgeDetails.operations') || 'Operations', flex: 0.8, minWidth: 160, sortable: false, filterable: false, align: 'center', headerAlign: 'center' },
{ field: 'id', headerName: 'ID', width: 100, headerAlign: 'center', align: 'center' },
{ field: 'create_date', headerName: t('knowledgeDetails.startDate'), flex: 1, minWidth: 160, sortable: true, headerAlign: 'center', align: 'center' },
{ field: 'task_type', headerName: t('knowledgeDetails.task'), flex: 1, minWidth: 180, headerAlign: 'center', align: 'center' },
{ field: 'status', headerName: t('knowledgeDetails.status'), flex: 0.8, minWidth: 120, headerAlign: 'center', align: 'center' },
{ field: 'operations', headerName: t('knowledgeDetails.operations'), flex: 0.8, minWidth: 160, sortable: false, filterable: false, align: 'center', headerAlign: 'center' },
];
const columns = React.useMemo(() => (activeTab === 'fileLogs' ?
@@ -106,6 +178,37 @@ function KnowledgeLogsPage({ embedded = false, kbId: kbIdProp }: KnowledgeLogsPa
[activeTab, fileLogs, datasetLogs]);
const navigate = useNavigate();
// 日志详情弹框状态
const [detailDialogOpen, setDetailDialogOpen] = React.useState(false);
const [selectedLog, setSelectedLog] = React.useState<IFileLogItem | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
const [detailError, setDetailError] = React.useState<string | null>(null);
const [detailData, setDetailData] = React.useState<any>(null);
async function handleViewClick(row: IFileLogItem) {
setSelectedLog(row);
setDetailDialogOpen(true);
setDetailLoading(true);
setDetailError(null);
try {
if (row?.task_id) {
const resp = await knowledgeService.getPipelineDetail({ task_id: row.task_id });
if (resp.data?.code === 0) {
setDetailData(resp.data.data || null);
} else {
setDetailError(resp.data?.message || '获取日志详情失败');
}
} else {
// 没有 task_id 时使用列表中的 progress_msg 作为详情
setDetailData({ message: row?.progress_msg || '' });
}
} catch (e: any) {
setDetailError(e?.response?.data?.message || e?.message || '获取日志详情失败');
} finally {
setDetailLoading(false);
}
}
const handleNavigateBack = () => {
navigate(`/knowledge/${kbId}`);
};
@@ -187,9 +290,114 @@ function KnowledgeLogsPage({ embedded = false, kbId: kbIdProp }: KnowledgeLogsPa
loading={loading}
disableColumnMenu
localeText={getDataGridLocale().components.MuiDataGrid.defaultProps.localeText}
sx={{
'& .MuiDataGrid-columnHeader': { justifyContent: 'center' },
'& .MuiDataGrid-cell': { justifyContent: 'center' },
}}
/>
</Paper>
{/* 日志详情弹框 */}
<Dialog open={detailDialogOpen} onClose={() => setDetailDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>{t('knowledgeDetails.viewProcessDetails')}</DialogTitle>
<DialogContent sx={{ pt: 1 }}>
{selectedLog && (
<>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary">{t('knowledgeDetails.file')}</Typography>
<Typography variant="body1" sx={{ mt: 0.5 }}>
{selectedLog.document_name || selectedLog.name}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary">{t('knowledgeDetails.task')}</Typography>
<Typography variant="body1" sx={{ mt: 0.5 }}>
{selectedLog.task_type || selectedLog.pipeline_title}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Typography variant="subtitle2" color="text.secondary">{t('knowledgeDetails.status')}</Typography>
<Box sx={{ mt: 0.5 }}>
{(() => {
const status: RunningStatus = (selectedLog.operation_status as RunningStatus) || (selectedLog.status as RunningStatus);
const label = RunningStatusMap[status] || '';
const colorMap: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
['0']: 'default',
['1']: 'info',
['2']: 'warning',
['3']: 'success',
['4']: 'error',
};
const chipColor = colorMap[status] || 'default';
return <Chip label={label} color={chipColor} size="small" />;
})()}
</Box>
</Grid>
</Grid>
<Divider sx={{ my: 2 }} />
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary">{t('knowledgeDetails.startDate')}</Typography>
<Typography variant="body1" sx={{ mt: 0.5 }}>
{dayjs(selectedLog.process_begin_at || selectedLog.create_date).format('YYYY-MM-DD HH:mm:ss')}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary">{t('knowledgeDetails.duration')}</Typography>
<Typography variant="body1" sx={{ mt: 0.5 }}>
{typeof selectedLog.process_duration === 'number'
? `${selectedLog.process_duration.toFixed(3)}s`
: `${selectedLog.process_duration || '-'}s`}
</Typography>
</Grid>
</Grid>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>{t('knowledgeDetails.details')}</Typography>
<Box
sx={{
p: 2,
bgcolor: 'background.default',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
maxHeight: 320,
overflow: 'auto',
}}
>
{detailLoading ? (
<Typography color="text.secondary">{t('common.loading')}</Typography>
) : detailError ? (
<Typography color="error.main">{detailError}</Typography>
) : (
<Typography
component="pre"
sx={{
fontFamily: 'monospace',
fontSize: '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
m: 0,
}}
>
{detailData?.message || selectedLog.progress_msg || ''}
</Typography>
)}
</Box>
</Box>
</>
)}
</DialogContent>
<DialogActions sx={{ p: 3, pt: 0 }}>
<Button onClick={() => setDetailDialogOpen(false)} variant="contained">
{t('common.close')}
</Button>
</DialogActions>
</Dialog>
{/* 返回按钮 */}
{!embedded && (
<Fab