feat: Add base project structure with API and web components
This commit is contained in:
596
web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx
Normal file
596
web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
import { useMemo,useRef, useState, useEffect } from 'react';
|
||||
import { Button, Flex, Radio, Steps, Modal, Input, Spin,message} from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import Table, { type TableRef } from '@/components/Table'
|
||||
import type { AnyObject } from 'antd/es/_util/type';
|
||||
import type { UploadFileResponse,KnowledgeBaseDocumentData } from '../types';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import UploadFiles from '@/components/Upload/UploadFiles';
|
||||
import type { UploadRequestOption } from 'rc-upload/lib/interface';
|
||||
import { uploadFile, getDocumentList, previewDocumentChunk, parseDocument, updateDocument, deleteDocument } from '../service';
|
||||
import exitIcon from '@/assets/images/knowledgeBase/exit.png';
|
||||
import { NoData } from '../components/noData';
|
||||
import noDataIcon from '@/assets/images/knowledgeBase/noData.png';
|
||||
import SliderInput from '@/components/SliderInput';
|
||||
import DelimiterSelector from '../components/DelimiterSelector';
|
||||
const { confirm } = Modal
|
||||
const { TextArea } = Input;
|
||||
import styles from '../index.module.css';
|
||||
const style: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
};
|
||||
const radioWrapperBaseStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
columnGap: 14, // 点与文字更宽的间距
|
||||
width: '100%',
|
||||
border: '1px solid #E5E5E5',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
};
|
||||
const getActiveRadioStyle = (active: boolean): React.CSSProperties => ({
|
||||
...radioWrapperBaseStyle,
|
||||
border: active ? '1px solid #1677ff' : radioWrapperBaseStyle.border,
|
||||
});
|
||||
|
||||
|
||||
type SourceType = 'local' | 'link' | 'text';
|
||||
type ProcessingMethod = 'directBlock' | 'qaExtract';
|
||||
type ParameterSettings = 'defaultSettings' | 'customSettings';
|
||||
const stepKeys = ['selectFile', 'parameterSettings', 'dataPreview', 'confirmUpload'] as const;
|
||||
type StepKey = typeof stepKeys[number];
|
||||
|
||||
const stepIndexMap: Record<StepKey, number> = {
|
||||
selectFile: 0,
|
||||
parameterSettings: 1,
|
||||
dataPreview: 2,
|
||||
confirmUpload: 3,
|
||||
};
|
||||
|
||||
interface CreateDatasetLocationState {
|
||||
source?: SourceType;
|
||||
knowledgeBaseId?: string;
|
||||
parentId?: string;
|
||||
startStep?: StepKey;
|
||||
fileId?: string;
|
||||
fileIds?: string[];
|
||||
}
|
||||
|
||||
const CreateDataset = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { knowledgeBaseId: routeKnowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
|
||||
const location = useLocation();
|
||||
const locationState = (location.state ?? {}) as CreateDatasetLocationState;
|
||||
const source = (locationState.source ?? 'local') as SourceType;
|
||||
const knowledgeBaseId = locationState.knowledgeBaseId || routeKnowledgeBaseId;
|
||||
const parentId = locationState.parentId;
|
||||
const initialStepKey = locationState.startStep ?? 'selectFile';
|
||||
const initialFileIds = locationState.fileIds ?? (locationState.fileId ? [locationState.fileId] : []);
|
||||
const [current, setCurrent] = useState<number>(stepIndexMap[initialStepKey]);
|
||||
const tableRef = useRef<TableRef>(null);
|
||||
const [data, setData] = useState<KnowledgeBaseDocumentData[]>([]);
|
||||
const [chunkData, setChunkData] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [rechunkFileIds, setRechunkFileIds] = useState<string[]>(initialFileIds);
|
||||
const [curSelectedFileId, setCurSelectedFileId] = useState<number>(-1);
|
||||
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
|
||||
const [pollingLoading, setPollingLoading] = useState<boolean>(false);
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [delimiter, setDelimiter] = useState<string | undefined>(undefined);
|
||||
const [blockSize, setBlockSize] = useState<number>(130);
|
||||
const [processingMethod, setProcessingMethod] = useState<ProcessingMethod>('directBlock');
|
||||
const [parameterSettings, setParameterSettings] = useState<ParameterSettings>('defaultSettings');
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const fileType = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'md', 'htm', 'html', 'json', 'ppt', 'pptx', 'txt','png','jpg']
|
||||
const steps = useMemo(
|
||||
() => [
|
||||
{ title: t('knowledgeBase.selectFile') },
|
||||
{ title: t('knowledgeBase.parameterSettings') },
|
||||
// { title: t('knowledgeBase.dataPreview') }, // 暂时隐藏第三步
|
||||
{ title: t('knowledgeBase.confirmUpload') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleNext = () => {
|
||||
// 暂时隐藏第三步:调整步骤索引(0->1->2 对应 选择文件->参数设置->确认上传)
|
||||
let nextStep = current + 1;
|
||||
|
||||
if(nextStep === 1) {
|
||||
// 检查是否有文件已上传
|
||||
if (rechunkFileIds.length === 0) {
|
||||
// 如果没有文件,提示用户先上传文件
|
||||
Modal.warning({
|
||||
title: t('common.warning') || '提示',
|
||||
content: t('knowledgeBase.pleaseUploadFileFirst') || '请先上传文件',
|
||||
});
|
||||
return; // 不进入下一步
|
||||
}
|
||||
}
|
||||
|
||||
// 从参数设置进入确认上传时的处理
|
||||
if(current === 1 && nextStep === 2) {
|
||||
// handlePreview(data[0],0)
|
||||
if(parameterSettings === 'customSettings'){
|
||||
rechunkFileIds.map((id) => {
|
||||
const params = {
|
||||
parser_config: {
|
||||
layout_recognize:'DeepDOC',
|
||||
delimiter: delimiter,
|
||||
chunk_token_num: blockSize,
|
||||
}
|
||||
}
|
||||
updateDocument(id, params)
|
||||
})
|
||||
}
|
||||
|
||||
// 立即执行一次,加载文档列表用于预览(不自动返回)
|
||||
pollDocumentStatus(false);
|
||||
}
|
||||
|
||||
// 限制最大步骤为 2(确认上传)
|
||||
setCurrent(Math.min(nextStep, 2));
|
||||
};
|
||||
const handlePrev = () => setCurrent((c) => Math.max(c - 1, 0));
|
||||
|
||||
// 开始上传:触发文档解析并启动轮询
|
||||
const handleStartUpload = () => {
|
||||
if (rechunkFileIds.length === 0) {
|
||||
Modal.warning({
|
||||
title: t('common.warning') || '提示',
|
||||
content: t('knowledgeBase.pleaseUploadFileFirst') || '请先上传文件',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示确认弹框
|
||||
confirm({
|
||||
title: t('knowledgeBase.startUploadConfirmTitle') || '开始处理文档',
|
||||
content: t('knowledgeBase.startUploadConfirmContent') || '文档处理将在后台进行,您可以选择立即返回列表页或停留在此页面查看处理进度。',
|
||||
okText: t('knowledgeBase.returnToList') || '返回列表页',
|
||||
cancelText: t('knowledgeBase.stayOnPage') || '停留在此页',
|
||||
onOk: () => {
|
||||
// 用户选择返回列表页
|
||||
startProcessing(true);
|
||||
},
|
||||
onCancel: () => {
|
||||
// 用户选择停留在当前页
|
||||
startProcessing(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 实际开始处理的函数
|
||||
const startProcessing = (autoReturnToList: boolean) => {
|
||||
// 触发文档解析
|
||||
rechunkFileIds.map((id) => {
|
||||
parseDocument(id);
|
||||
});
|
||||
|
||||
// 开启 loading
|
||||
setPollingLoading(true);
|
||||
|
||||
if (autoReturnToList) {
|
||||
// 用户选择立即返回,直接跳转
|
||||
console.log('用户选择立即返回列表页');
|
||||
handleBack();
|
||||
} else {
|
||||
// 用户选择停留,启动轮询查看进度
|
||||
console.log('用户选择停留查看进度');
|
||||
|
||||
// 立即执行一次轮询(启用自动返回)
|
||||
pollDocumentStatus(true);
|
||||
|
||||
// 然后每3秒执行一次(启用自动返回)
|
||||
pollingTimerRef.current = setInterval(() => {
|
||||
pollDocumentStatus(true);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
const handleDelete = (record: AnyObject) => {
|
||||
confirm({
|
||||
title: t('common.deleteWarning'),
|
||||
content: t('common.deleteWarningContent', { content: record.name }),
|
||||
onOk: async () => {
|
||||
// TODO: 实现删除逻辑
|
||||
const response = await deleteDocument(record.id);
|
||||
|
||||
// 删除成功,刷新列表
|
||||
// messageApi.success(t('common.deleteSuccess'));
|
||||
messageApi.success(t('common.deleteSuccess'));
|
||||
tableRef.current?.loadData();
|
||||
|
||||
},
|
||||
onCancel: () => {
|
||||
console.log('取消删除');
|
||||
},
|
||||
});
|
||||
}
|
||||
// 表格列配置
|
||||
const columns: ColumnsType = [
|
||||
{
|
||||
title: t('knowledgeBase.name'),
|
||||
dataIndex: 'file_name',
|
||||
key: 'file_name'
|
||||
},
|
||||
|
||||
{
|
||||
title: t('knowledgeBase.status'),
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
render: (value: number) => {
|
||||
return (
|
||||
<span className="rb:text-xs rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded rb:items-center rb:text-[#212332] rb:py-1 rb:px-2">
|
||||
<span className="rb:inline-block rb:w-[5px] rb:h-[5px] rb:mr-2 rb:rounded-full" style={{ backgroundColor: value === 1 ? '#369F21' : '#FF8A4C' }}></span>
|
||||
<span>{value === 1 ? 'Completed' : 'Processing'}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('common.operation'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Button type='text' danger onClick={() => handleDelete(record)}>{t('common.delete')}</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
// 上传文件
|
||||
const handleUpload = (options: UploadRequestOption) => {
|
||||
const { file, onSuccess, onError, onProgress, filename = 'file' } = options;
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append(filename, file as File);
|
||||
if (knowledgeBaseId) {
|
||||
formData.append('kb_id', knowledgeBaseId);
|
||||
}
|
||||
if (parentId) {
|
||||
formData.append('parent_id', parentId);
|
||||
}
|
||||
|
||||
uploadFile(formData, {
|
||||
kb_id: knowledgeBaseId,
|
||||
parent_id: parentId,
|
||||
onUploadProgress: (event) => {
|
||||
if (!event.total) return;
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress?.({ percent }, file);
|
||||
},
|
||||
})
|
||||
.then((res: UploadFileResponse) => {
|
||||
onSuccess?.(res, new XMLHttpRequest());
|
||||
if (res?.id) {
|
||||
setRechunkFileIds((prev) => {
|
||||
if (prev.includes(res.id)) return prev;
|
||||
const next = [...prev, res.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
onError?.(error as Error);
|
||||
});
|
||||
};
|
||||
// 点击文件 预览分块
|
||||
const handlePreview = async(item: KnowledgeBaseDocumentData, index: number) => {
|
||||
setCurSelectedFileId(index);
|
||||
setPreviewLoading(true);
|
||||
try{
|
||||
const res = await previewDocumentChunk(knowledgeBaseId ?? '', item.id ?? '');
|
||||
setChunkData(res.items || []);
|
||||
setTotal(res.page.total || 0);
|
||||
console.log('res', res);
|
||||
}catch(error) {
|
||||
console.log('error', error);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询检查文档处理状态
|
||||
// autoReturn: 是否在所有文档完成时自动返回列表页
|
||||
const pollDocumentStatus = (autoReturn: boolean = false) => {
|
||||
if (!knowledgeBaseId || !parentId || rechunkFileIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷新 Table 组件的数据(仅在 confirmUpload 步骤)
|
||||
if (current === 2) {
|
||||
tableRef.current?.loadData();
|
||||
}
|
||||
|
||||
// 同时获取文档列表检查是否全部完成
|
||||
getDocumentList({
|
||||
kb_id: knowledgeBaseId,
|
||||
parent_id: parentId,
|
||||
document_ids: rechunkFileIds.join(','),
|
||||
})
|
||||
.then((res: any) => {
|
||||
const documents = res.items || [];
|
||||
setData(documents);
|
||||
|
||||
// 检查是否所有文档的 progress 都为 1
|
||||
const allCompleted = documents.every((doc: KnowledgeBaseDocumentData) => doc.progress === 1);
|
||||
|
||||
console.log('轮询状态:', documents.map((d: KnowledgeBaseDocumentData) => ({ name: d.file_name, progress: d.progress })));
|
||||
|
||||
// 只有在 autoReturn 为 true 且所有文档完成时才自动返回
|
||||
if (allCompleted && autoReturn) {
|
||||
// 所有文档处理完成,清除定时器和 loading
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
setPollingLoading(false);
|
||||
|
||||
// 延迟 2 秒后跳转,让用户看到完成状态
|
||||
console.log('所有文档处理完成,2秒后返回列表页');
|
||||
setTimeout(() => {
|
||||
handleBack();
|
||||
}, 2000);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('轮询文档状态失败:', error);
|
||||
setPollingLoading(false);
|
||||
});
|
||||
};
|
||||
const handleBack = () => {
|
||||
if (knowledgeBaseId) {
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/private`, {
|
||||
state: {
|
||||
refresh: true,
|
||||
timestamp: Date.now(), // 添加时间戳确保每次都是新的 state
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.warn('缺少路由参数,无法返回');
|
||||
}
|
||||
};
|
||||
const handleChange = (value: number | null) =>{
|
||||
if (value !== null) {
|
||||
setBlockSize(value);
|
||||
}
|
||||
}
|
||||
|
||||
// 当从其他页面跳转过来且带有 fileIds 时,加载对应的文档数据
|
||||
useEffect(() => {
|
||||
if (initialFileIds.length > 0 && initialStepKey !== 'selectFile' && knowledgeBaseId && parentId) {
|
||||
// 加载文档列表数据
|
||||
getDocumentList({
|
||||
kb_id: knowledgeBaseId,
|
||||
parent_id: parentId,
|
||||
document_ids: initialFileIds.join(','),
|
||||
})
|
||||
.then((res: any) => {
|
||||
const documents = res.items || [];
|
||||
setData(documents);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('加载文档列表失败:', error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 清理函数:组件卸载时清除定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
setPollingLoading(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
|
||||
<div className='rb:p-6 rb:pt-2 rb:h-full'>
|
||||
{/* <Typography.Title level={4} className='rb:!m-0 rb:!mb-4'>
|
||||
{t('knowledgeBase.createA') + ' ' + t('knowledgeBase.dataset')}
|
||||
</Typography.Title> */}
|
||||
<div className='rb:flex rb:items-center rb:gap-2 rb:mb-4 rb:cursor-pointer' onClick={handleBack}>
|
||||
<img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' />
|
||||
<span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span>
|
||||
</div>
|
||||
<div className='rb:px-24 rb:py-5 rb:bg-[#FBFDFF] rb:rounded-lg rb:border rb:border-[#DFE4ED]'>
|
||||
<Steps current={current} items={steps} />
|
||||
</div>
|
||||
|
||||
|
||||
{current === 0 && (
|
||||
<div className='rb:flex rb:w-full rb:mt-10'>
|
||||
{source && source === 'local' && (
|
||||
<UploadFiles isCanDrag={true} fileSize={50} multiple={true} maxCount={99} fileType={fileType} customRequest={handleUpload} />
|
||||
)}
|
||||
{source && source === 'link' && (
|
||||
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
|
||||
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.webLink')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} />
|
||||
<div className='rb:text-sm rb:text-gray-500 rb:mt-3 rb:max-w-[558px]'>
|
||||
{t('knowledgeBase.webLinkDesc')}
|
||||
</div>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
|
||||
{t('knowledgeBase.selectorTutorial')}
|
||||
</div>
|
||||
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
|
||||
</div>
|
||||
)}
|
||||
{source && source === 'text' && (
|
||||
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
|
||||
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.customText')}
|
||||
</div>
|
||||
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
|
||||
{t('knowledgeBase.customContent')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current === 1 && (
|
||||
<div className='rb:flex rb:flex-col rb:mt-10 rb:px-40'>
|
||||
{rechunkFileIds.length > 0 && (
|
||||
<div className='rb:bg-[#F0F3F8] rb:border rb:border-[#DFE4ED] rb:rounded rb:px-3 rb:py-2 rb:mb-4 rb:text-xs rb:text-gray-600 rb:flex rb:flex-wrap rb:gap-2'>
|
||||
<span className='rb:text-gray-700 rb:font-medium'>{t('knowledgeBase.rechunking')}:</span>
|
||||
{rechunkFileIds.map((id) => (
|
||||
<span key={id} className='rb:px-2 rb:py-0.5 rb:bg-white rb:border rb:border-[#DFE4ED] rb:rounded'>{id}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className='rb:text-base rb:font-medium rb:text-gray-800'>
|
||||
{t('knowledgeBase.dataProcessingSettings')}
|
||||
</div>
|
||||
<div className='rb:font-medium rb:text-gray-500 rb:mt-4 rb:mb-3'>
|
||||
{t('knowledgeBase.processingMethod')}
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={processingMethod}
|
||||
onChange={(e) => setProcessingMethod(e.target.value)}
|
||||
style={style}
|
||||
>
|
||||
<Radio value='directBlock' style={getActiveRadioStyle(processingMethod === 'directBlock')}>
|
||||
<Flex gap='small' vertical>
|
||||
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
|
||||
{t('knowledgeBase.directBlock')}
|
||||
</span>
|
||||
</Flex>
|
||||
</Radio>
|
||||
<Radio value='qaExtract' style={getActiveRadioStyle(processingMethod === 'qaExtract')}>
|
||||
<Flex gap='small' vertical>
|
||||
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
|
||||
{t('knowledgeBase.qaExtract')}
|
||||
</span>
|
||||
</Flex>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
<div className='rb:font-medium rb:text-gray-500 rb:mt-4 rb:mb-3'>
|
||||
{t('knowledgeBase.parameterSettings')}
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={parameterSettings}
|
||||
onChange={(e) => setParameterSettings(e.target.value)}
|
||||
style={style}
|
||||
>
|
||||
<Radio value='defaultSettings' style={getActiveRadioStyle(parameterSettings === 'defaultSettings')}>
|
||||
<Flex gap='small' vertical>
|
||||
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
|
||||
{t('knowledgeBase.default')}
|
||||
</span>
|
||||
<span className='rb:text-3 rb:text-gray-500'>{t('knowledgeBase.defaultSettings')}</span>
|
||||
</Flex>
|
||||
</Radio>
|
||||
<Radio value='customSettings' style={getActiveRadioStyle(parameterSettings === 'customSettings')}>
|
||||
<Flex gap='small' vertical>
|
||||
<span className='rb:text-base rb:font-medium rb:text-gray-800'>
|
||||
{t('knowledgeBase.customize')}
|
||||
</span>
|
||||
<span className='rb:text-3 rb:text-gray-500'>{t('knowledgeBase.customSettings')}</span>
|
||||
</Flex>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
{parameterSettings === 'customSettings' && (
|
||||
<div className='rb:flex rb:flex-col rb:mt-5'>
|
||||
<div className='rb:w-full rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.delimiter')}
|
||||
</div>
|
||||
<DelimiterSelector value={delimiter} onChange={setDelimiter} className='rb:mb-5'/>
|
||||
<SliderInput label={t('knowledgeBase.suggestedBlockSize')} max={1024} min={1} step={1} value={blockSize} onChange={handleChange} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 暂时隐藏第三步:数据预览 */}
|
||||
{/* {current === stepIndexMap.dataPreview && (
|
||||
<div className='rb:grid rb:grid-cols-2 rb:rounded-xl rb:border rb:border-[#DFE4ED] rb:h-[calc(100%-160px)] rb:bg-[#FBFDFF] rb:mt-4'>
|
||||
<div className='rb:border-r rb:h-full rb:overflow-hidden rb:border-[#DFE4ED]'>
|
||||
<div className='rb:h-11 rb:w-full rb:text-sm rb:font-medium rb:text-gray-800 rb:px-4 rb:py-3 rb:border-b rb:border-[#DFE4ED]'>
|
||||
{t('knowledgeBase.fileList')}
|
||||
</div>
|
||||
<div className='rb:flex rb:flex-col rb:h-[calc(100%-44px)] rb:overflow-y-auto'>
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className={`rb:h-11 rb:w-full rb:text-sm rb:text-gray-800 rb:px-4 rb:py-3 rb:hover:text-[#155EEF] rb:cursor-pointer ${curSelectedFileId === index ? styles.textBg + ' ' + styles.active : ''}`}
|
||||
onClick={() => handlePreview(item, index)}>
|
||||
{item.file_name}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className='rb:h-full rb:overflow-hidden'>
|
||||
<div className='rb:flex rb:items-center rb:justify-between rb:h-11 rb:w-full rb:text-sm rb:font-medium rb:text-gray-800 rb:px-4 rb:py-3 rb:border-b rb:border-[#DFE4ED]'>
|
||||
{t('knowledgeBase.dataPreview')}
|
||||
<span className='rb:text-sm rb:text-gray-500'>{t('knowledgeBase.maxPreviewChunks', {count: total, max: chunkData.length})}</span>
|
||||
</div>
|
||||
<Spin spinning={previewLoading}>
|
||||
<div className='rb:flex rb:flex-col rb:h-[calc(100%-44px)] rb:overflow-y-auto'>
|
||||
{chunkData.length > 0 ? (
|
||||
chunkData.map((item, index) => (
|
||||
<div key={index} className='rb:text-sm rb:text-gray-800 rb:px-4 rb:py-3'
|
||||
dangerouslySetInnerHTML={{ __html: item.page_content }}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoData title={t('knowledgeBase.noChunksToPreview')}
|
||||
subTitle={t('knowledgeBase.clickToPreview')}
|
||||
image={noDataIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{current === 2 && (
|
||||
<Spin spinning={pollingLoading} tip={t('knowledgeBase.processingDocuments') || '正在处理文档...'}>
|
||||
<div className='rb:text-sm rb:text-gray-500 rb:mt-4 rb:h-[calc(100%-160px)] rb:overflow-y-auto'>
|
||||
<Table
|
||||
ref={tableRef}
|
||||
apiUrl={`/documents/${knowledgeBaseId}/${parentId}/documents`}
|
||||
apiParams={{
|
||||
document_ids: rechunkFileIds.join(','),
|
||||
}}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
)}
|
||||
|
||||
<div className={`rb:flex rb:gap-3 rb:mt-6 ${current === 1 || (source == 'link' && current === 0) || (source == 'text' && current === 0) ? 'rb:pl-40 rb:mt-10' : ''}`}>
|
||||
{current !== 0 && (
|
||||
<Button onClick={handlePrev} disabled={current === 0 || pollingLoading}>
|
||||
{t('common.previous') || 'Prev'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={current === 2 ? handleStartUpload : handleNext}
|
||||
disabled={pollingLoading}
|
||||
loading={pollingLoading}
|
||||
>
|
||||
{current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>);
|
||||
};
|
||||
|
||||
export default CreateDataset;
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* @Description: 文档详情
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-15 16:13:47
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-11-29 19:46:46
|
||||
*/
|
||||
import { useEffect, useState, useRef, type FC } from 'react';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Spin, message, Switch } from 'antd';
|
||||
import { getDocumentDetail, getDocumentChunkList, downloadFile, updateDocument, updateDocumentChunk, createDocumentChunk } from '../service';
|
||||
import type { KnowledgeBaseDocumentData, RecallTestData } from '../types';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import InfoPanel, { type InfoItem } from '../components/InfoPanel';
|
||||
import RecallTestResult from '../components/RecallTestResult';
|
||||
import SearchInput from '@/components/SearchInput';
|
||||
import DocumentPreview from '@/components/DocumentPreview';
|
||||
import InsertModal, { type InsertModalRef } from '../components/InsertModal';
|
||||
import exitIcon from '@/assets/images/knowledgeBase/exit.png';
|
||||
const imagePath = 'https://devapi.mem.redbearai.com'
|
||||
const DocumentDetails: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
|
||||
const location = useLocation();
|
||||
const { documentId, parentId: locationParentId } = location.state as { documentId: string; parentId?: string };
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [document, setDocument] = useState<KnowledgeBaseDocumentData | null>(null);
|
||||
const [chunkList, setChunkList] = useState<RecallTestData[]>([]);
|
||||
const [infoItems, setInfoItems] = useState<InfoItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [chunkLoading, setChunkLoading] = useState(false);
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [fileUrl, setFileUrl] = useState('');
|
||||
const insertModalRef = useRef<InsertModalRef>(null);
|
||||
const isManualRefreshRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (documentId) {
|
||||
fetchDocumentDetail();
|
||||
}
|
||||
}, [documentId]);
|
||||
|
||||
// 当文档加载完成且 progress === 1 时,加载分块列表
|
||||
useEffect(() => {
|
||||
if (document && document.progress === 1 && !isManualRefreshRef.current) {
|
||||
ChunkList();
|
||||
}
|
||||
// 重置标志
|
||||
isManualRefreshRef.current = false;
|
||||
}, [document]);
|
||||
|
||||
// 监听 keywords 变化,重新搜索
|
||||
useEffect(() => {
|
||||
if (documentId && keywords && document?.progress === 1) {
|
||||
setPage(1); // 重置页码
|
||||
setChunkList([]); // 清空列表
|
||||
ChunkList(1, false); // 重新加载第一页
|
||||
}
|
||||
}, [keywords]);
|
||||
|
||||
|
||||
|
||||
const formatDocumentInfo = (doc: KnowledgeBaseDocumentData): InfoItem[] => {
|
||||
return [
|
||||
{
|
||||
key: 'file_name',
|
||||
label: t('knowledgeBase.fileName') || '文件名',
|
||||
value: doc.file_name ?? '-',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('knowledgeBase.status') || '进度',
|
||||
value: doc.progress === 1 ? t('knowledgeBase.progressComplete') : t('knowledgeBase.progressing') ?? '-',
|
||||
},
|
||||
{
|
||||
key: 'chunk_num',
|
||||
label: t('knowledgeBase.chunk_num') || '分块数量',
|
||||
value: doc.chunk_num ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'parser_id',
|
||||
label: t('knowledgeBase.processingMode') || '处理模式',
|
||||
value: doc.parser_id ?? '-',
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: t('knowledgeBase.created_at') || '创建时间',
|
||||
value: formatDateTime(doc.created_at, 'YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
label: t('knowledgeBase.updated_at') || '更新时间',
|
||||
value: formatDateTime(doc.updated_at, 'YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
|
||||
};
|
||||
|
||||
const fetchDocumentDetail = async () => {
|
||||
if (!documentId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getDocumentDetail(documentId);
|
||||
setDocument(response);
|
||||
setInfoItems(formatDocumentInfo(response));
|
||||
const url = `${imagePath}/api/files/${response.file_id}`
|
||||
setFileUrl(url);
|
||||
// ChunkList 会在 useEffect 中根据 document.progress 自动调用
|
||||
} catch (error) {
|
||||
console.error('获取文档详情失败:', error);
|
||||
message.error(t('common.loadFailed') || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const ChunkList = async (pageNum: number = 1, append: boolean = false, force: boolean = false) => {
|
||||
if (!documentId) return;
|
||||
|
||||
// 如果不是强制刷新,且正在加载中,则跳过
|
||||
if (!force && chunkLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有当文档处理完成时才获取分块列表
|
||||
if (document && document.progress !== 1) {
|
||||
return;
|
||||
}
|
||||
setChunkLoading(true);
|
||||
try {
|
||||
const response = await getDocumentChunkList({
|
||||
kb_id: knowledgeBaseId,
|
||||
document_id: documentId,
|
||||
keywords: keywords || undefined,
|
||||
page: pageNum,
|
||||
pagesize: 20,
|
||||
_t: force ? Date.now() : undefined, // 强制刷新时添加时间戳破坏缓存
|
||||
});
|
||||
|
||||
// 转换数据格式以匹配 RecallTestData
|
||||
const formattedChunks: RecallTestData[] = response.items.map((item: any) => ({
|
||||
page_content: item.page_content || item.content || '',
|
||||
vector: null,
|
||||
metadata: {
|
||||
doc_id: item.metadata.doc_id || '',
|
||||
file_id: item.metadata.file_id || document?.file_id || '',
|
||||
file_name: item.metadata.file_name || document?.file_name || '',
|
||||
file_created_at: item.metadata.file_created_at || item.metadata.created_at || '',
|
||||
document_id: item.metadata.document_id || documentId || '',
|
||||
knowledge_id: item.metadata.knowledge_id || knowledgeBaseId || '',
|
||||
sort_id: item.metadata.sort_id || item.id || 0,
|
||||
score: item.metadata.score || null, // chunk 列表没有相似度分数
|
||||
status: item.metadata.status,
|
||||
},
|
||||
children: null,
|
||||
}));
|
||||
|
||||
if (append) {
|
||||
setChunkList(prev => [...prev, ...formattedChunks]);
|
||||
} else {
|
||||
setChunkList(formattedChunks);
|
||||
}
|
||||
|
||||
setHasMore(response.page?.has_next ?? false);
|
||||
} catch (error) {
|
||||
console.error('获取文档详情失败:', error);
|
||||
message.error(t('common.loadFailed') || '加载失败');
|
||||
} finally {
|
||||
setChunkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreChunks = () => {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
ChunkList(nextPage, true);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (knowledgeBaseId) {
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/private`);
|
||||
}
|
||||
};
|
||||
const handleSearch = (value?: string) => {
|
||||
setKeywords(value || '');
|
||||
};
|
||||
const handleInsert = () => {
|
||||
if (!documentId) {
|
||||
message.error(t('knowledgeBase.documentIdRequired') || '文档ID不能为空');
|
||||
return;
|
||||
}
|
||||
insertModalRef.current?.handleOpen(documentId);
|
||||
};
|
||||
|
||||
// 处理插入/编辑内容
|
||||
const handleInsertContent = async (_docId: string, content: string, chunkId?: string): Promise<boolean> => {
|
||||
try {
|
||||
if (chunkId) {
|
||||
// 编辑模式:更新现有块
|
||||
const response = await updateDocumentChunk(knowledgeBaseId || '', documentId, chunkId, { content });
|
||||
|
||||
// 直接更新前端列表,不等待后端缓存刷新
|
||||
setChunkList(prev => prev.map(item =>
|
||||
item.metadata?.doc_id === chunkId
|
||||
? { ...item, page_content: response.page_content || content }
|
||||
: item
|
||||
));
|
||||
|
||||
// 编辑模式返回特殊标记,告诉 InsertModal 不要调用 onSuccess
|
||||
return true;
|
||||
} else {
|
||||
// 插入模式:创建新块
|
||||
await createDocumentChunk(knowledgeBaseId || '', documentId, { content });
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理点击文本块
|
||||
const handleChunkClick = (item: RecallTestData, index: number) => {
|
||||
if (!documentId) return;
|
||||
const chunkId = String(item.metadata?.doc_id || index);
|
||||
insertModalRef.current?.handleOpen(documentId, item.page_content, chunkId);
|
||||
};
|
||||
|
||||
// 插入成功后的回调(仅用于插入新块,编辑操作已在 handleInsertContent 中同步更新)
|
||||
const handleInsertSuccess = () => {
|
||||
// 设置手动刷新标志,防止 useEffect 重复调用
|
||||
isManualRefreshRef.current = true;
|
||||
|
||||
// 重置页码
|
||||
setPage(1);
|
||||
|
||||
// 等待后端处理完成,然后重新加载数据(仅用于插入新块的情况)
|
||||
setTimeout(() => {
|
||||
ChunkList(1, false, true).then(() => {
|
||||
return fetchDocumentDetail();
|
||||
}).catch(err => {
|
||||
console.error('刷新失败:', err);
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
const handleAdjustmentParameter = () =>{
|
||||
if (!knowledgeBaseId || !document) return;
|
||||
const targetFileId = document.id;
|
||||
// 优先使用从 location 传递的 parentId,其次使用 document.parent_id,最后使用 knowledgeBaseId
|
||||
const parentId = locationParentId ?? document.parent_id ?? document.kb_id ?? knowledgeBaseId;
|
||||
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`, {
|
||||
state: {
|
||||
source: 'local',
|
||||
knowledgeBaseId,
|
||||
parentId,
|
||||
startStep: 'parameterSettings',
|
||||
fileId: targetFileId,
|
||||
},
|
||||
});
|
||||
}
|
||||
const handleDownload = () => {
|
||||
if (!document) return;
|
||||
downloadFile(document.file_id || '', document.file_name)
|
||||
};
|
||||
const onChange = (checked: boolean) => {
|
||||
updateDocument(documentId, {
|
||||
status: checked ? 1 : 0,
|
||||
});
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rb:flex rb:items-center rb:justify-center rb:h-full">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (document?.progress !== 1) {
|
||||
return (
|
||||
<div className="rb:flex rb:flex-col rb:h-full rb:p-4">
|
||||
<div className='rb:flex rb:items-center rb:gap-2 rb:mb-4 rb:cursor-pointer' onClick={handleBack}>
|
||||
<img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' />
|
||||
<span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span>
|
||||
</div>
|
||||
{/* 文档预览 */}
|
||||
{fileUrl && (
|
||||
<div className='rb:flex-1 rb:border rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:p-4 rb:overflow-hidden'>
|
||||
<h3 className="rb:text-sm rb:font-medium rb:mb-3">
|
||||
{t('knowledgeBase.documentPreview') || '文档预览'}
|
||||
</h3>
|
||||
<DocumentPreview
|
||||
fileUrl={fileUrl}
|
||||
fileName={document?.file_name}
|
||||
fileExt={document?.file_ext}
|
||||
height="calc(100% - 40px)"
|
||||
mode="google"
|
||||
showModeSwitch={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (<>
|
||||
<div className="rb:flex rb:flex-col rb:h-full rb:p-4">
|
||||
{/* 头部 */}
|
||||
<div className="rb:flex rb:flex-col rb:text-left rb:mb-6">
|
||||
<div className='rb:flex rb:items-center rb:justify-between'>
|
||||
<div className='rb:flex rb:items-center rb:gap-2 rb:mb-4 rb:cursor-pointer' onClick={handleBack}>
|
||||
<img src={exitIcon} alt='exit' className='rb:w-4 rb:h-4' />
|
||||
<span className='rb:text-gray-500 rb:text-sm'>{t('common.exit')}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:gap-4">
|
||||
|
||||
<div className="rb:flex rb:gap-2 rb:items-center rb:text-xl rb:font-semibold rb:text-gray-800 ">
|
||||
{document.file_name || t('knowledgeBase.documentDetails') || '文档详情'}
|
||||
<Switch checkedChildren={t('common.enable')} unCheckedChildren={t('common.disable')} defaultChecked={document.status === 1} onChange={onChange}/>
|
||||
</div>
|
||||
<div className='rb:flex rb:gap-3 rb:items-center'>
|
||||
<SearchInput
|
||||
placeholder={t('knowledgeBase.search')}
|
||||
onSearch={handleSearch}
|
||||
defaultValue={keywords}
|
||||
/>
|
||||
<Button type='primary' onClick={handleAdjustmentParameter}>{t('knowledgeBase.adjustmentParameter') || '调整参数'}</Button>
|
||||
<Button type="primary" onClick={handleInsert}>{t('knowledgeBase.insert') || '插入'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="rb:flex rb:h-full rb:gap-4 rb:flex-1 rb:overflow-hidden">
|
||||
{/* 左侧:文档信息 */}
|
||||
<div className='rb:w-80 rb:h-full rb:flex rb:flex-col rb:gap-4 rb:overflow-hidden'>
|
||||
<div className='rb:border rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:p-4'>
|
||||
<InfoPanel
|
||||
title={t('knowledgeBase.documentInfo') || '文档信息'}
|
||||
items={infoItems}
|
||||
/>
|
||||
<Button type='primary' onClick={handleDownload} className="rb:mt-4 rb:w-full">
|
||||
{t('knowledgeBase.downloadOriginal')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:分块列表 */}
|
||||
<div
|
||||
id="chunkScrollableDiv"
|
||||
className="rb:flex-1 rb:bg-white rb:rounded-lg rb:border rb:border-gray-200 rb:p-6 rb:overflow-y-auto"
|
||||
>
|
||||
<h2 className="rb:text-lg rb:font-medium rb:mb-4">
|
||||
{t('knowledgeBase.chunkList') || '分块列表'}
|
||||
</h2>
|
||||
<RecallTestResult
|
||||
data={chunkList}
|
||||
showEmpty={false}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMoreChunks}
|
||||
loading={chunkLoading}
|
||||
scrollableTarget="chunkScrollableDiv"
|
||||
editable={true}
|
||||
onItemClick={handleChunkClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 插入内容弹窗 */}
|
||||
<InsertModal
|
||||
ref={insertModalRef}
|
||||
onInsert={handleInsertContent}
|
||||
onSuccess={handleInsertSuccess}
|
||||
/>
|
||||
</div>
|
||||
</>);
|
||||
};
|
||||
|
||||
export default DocumentDetails;
|
||||
|
||||
113
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.css
Normal file
113
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* 去掉所有默认白色背景 */
|
||||
.customTree,
|
||||
.customTree *,
|
||||
.customTree .ant-tree,
|
||||
.customTree .ant-tree-list,
|
||||
.customTree .ant-tree-list-holder,
|
||||
.customTree .ant-tree-list-holder-inner,
|
||||
.customTree .ant-tree-list-holder-inner > div,
|
||||
.customTree .ant-tree-list-holder-inner > div > div {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
|
||||
/* 节点内容区域 - 默认透明 */
|
||||
.customTree .ant-tree-node-content-wrapper {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
height: 40px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
padding: 0 8px !important;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease !important;
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper:before,
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper:hover:before {
|
||||
background: #FFFFFF !important;
|
||||
}
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper,
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-node-content-wrapper:hover{
|
||||
color: #000 !important;
|
||||
}
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-switcher,
|
||||
.customTree.ant-tree.ant-tree-directory .ant-tree-treenode-selected .ant-tree-draggable-icon{
|
||||
color: #000;
|
||||
}
|
||||
.customTree .ant-tree-switcher {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 24px !important;
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
.customTree .ant-tree-switcher::before{
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Switcher 图标样式 - 收起状态(默认向右) */
|
||||
.customTree .ant-tree-switcher .ant-tree-switcher-icon,
|
||||
.customTree .ant-tree-switcher img {
|
||||
transition: transform 0.3s ease !important;
|
||||
transform: rotate(0deg) !important;
|
||||
}
|
||||
|
||||
/* Switcher 图标样式 - 展开状态(向下旋转90度) */
|
||||
.customTree .ant-tree-switcher_open .ant-tree-switcher-icon,
|
||||
.customTree .ant-tree-switcher_open img,
|
||||
.customTree .ant-tree-switcher.ant-tree-switcher_open img {
|
||||
transform: rotate(90deg) !important;
|
||||
}
|
||||
|
||||
/* 如果使用 ant-tree-switcher_close 类 */
|
||||
.customTree .ant-tree-switcher_close .ant-tree-switcher-icon,
|
||||
.customTree .ant-tree-switcher_close img {
|
||||
transform: rotate(0deg) !important;
|
||||
}
|
||||
|
||||
.customTree .ant-tree-node-content-wrapper .ant-tree-iconEle {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
margin-right: 4px !important;
|
||||
line-height: 1 !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.customTree .ant-tree-node-content-wrapper .ant-tree-iconEle img {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
display: block !important;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.customTree .ant-tree-title {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
flex: 1 !important;
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.customTree .ant-tree-child-tree {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.customTree .ant-tree-node-content-wrapper .ant-tree-iconEle + .ant-tree-title {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
526
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.tsx
Normal file
526
web/src/views/KnowledgeBase/[knowledgeBaseId]/Private.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
|
||||
import { useEffect, useState, useRef, type FC } from 'react';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Switch, Button, Dropdown, Space, Modal, message } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import Table, { type TableRef } from '@/components/Table'
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { AnyObject } from 'antd/es/_util/type';
|
||||
import { MoreOutlined } from '@ant-design/icons';
|
||||
import folderIcon from '@/assets/images/knowledgeBase/folder.png';
|
||||
import textIcon from '@/assets/images/knowledgeBase/text.png';
|
||||
import imageIcon from '@/assets/images/knowledgeBase/image.png';
|
||||
import blankIcon from '@/assets/images/knowledgeBase/blankDocument.png';
|
||||
import templateIcon from '@/assets/images/knowledgeBase/template.png';
|
||||
import backupIcon from '@/assets/images/knowledgeBase/backup.png';
|
||||
import editIcon from '@/assets/images/knowledgeBase/edit.png';
|
||||
import { getKnowledgeBaseDetail, deleteDocument, downloadFile, updateKnowledgeBase } from '../service';
|
||||
import type {
|
||||
CreateModalRef,
|
||||
KnowledgeBaseListItem,
|
||||
RecallTestDrawerRef,
|
||||
CreateFolderModalRef,
|
||||
CreateImageModalRef,
|
||||
ShareModalRef,
|
||||
CreateDatasetModalRef,FolderFormData,
|
||||
KnowledgeBaseDocumentData
|
||||
} from '../types';
|
||||
import RecallTestDrawer from '../components/RecallTestDrawer';
|
||||
import CreateFolderModal from '../components/CreateFolderModal';
|
||||
import CreateModal from '../components/CreateModal';
|
||||
import ShareModal from '../components/ShareModal';
|
||||
import CreateDatasetModal from '../components/CreateDatasetModal';
|
||||
import CreateImageDataset from '../components/CreateImageDataset';
|
||||
import FolderTree, { type TreeNodeData } from '../components/FolderTree';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import './Private.css'
|
||||
const { confirm } = Modal
|
||||
// 树节点数据类型
|
||||
|
||||
const Private: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { knowledgeBaseId } = useParams<{ knowledgeBaseId: string }>();
|
||||
const [parentId, setParentId] = useState<string | undefined>(knowledgeBaseId);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const tableRef = useRef<TableRef>(null);
|
||||
const [tableApi, setTableApi] = useState<string | undefined>(undefined);
|
||||
const recallTestDrawerRef = useRef<RecallTestDrawerRef>(null);
|
||||
const createFolderModalRef = useRef<CreateFolderModalRef>(null);
|
||||
const createImageDataset = useRef<CreateImageModalRef>(null)
|
||||
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBaseListItem | null>(null);
|
||||
const [folder, setFolder] = useState<FolderFormData | null>({
|
||||
kb_id:knowledgeBaseId ?? '',
|
||||
parent_id:parentId ?? ''
|
||||
});
|
||||
const [keywords, setKeywords] = useState<string>('');
|
||||
const [query, setQuery] = useState<Record<string, unknown>>({
|
||||
orderby: 'created_at',
|
||||
desc: true,
|
||||
});
|
||||
const modalRef = useRef<CreateModalRef>(null)
|
||||
const shareModalRef = useRef<ShareModalRef>(null);
|
||||
const datasetModalRef = useRef<CreateDatasetModalRef>(null);
|
||||
const [folderTreeRefreshKey, setFolderTreeRefreshKey] = useState(0);
|
||||
useEffect(() => {
|
||||
if (knowledgeBaseId) {
|
||||
let url = `/documents/${knowledgeBaseId}/${parentId}/documents`;
|
||||
setTableApi(url);
|
||||
fetchKnowledgeBaseDetail(knowledgeBaseId);
|
||||
}
|
||||
}, [knowledgeBaseId]);
|
||||
|
||||
// 监听 tableApi 变化,自动刷新表格数据
|
||||
useEffect(() => {
|
||||
if (tableApi) {
|
||||
tableRef.current?.loadData();
|
||||
}
|
||||
}, [tableApi]);
|
||||
|
||||
// 监听 location state 变化,如果有 refresh 标志则刷新列表
|
||||
useEffect(() => {
|
||||
const state = location.state as { refresh?: boolean; timestamp?: number } | null;
|
||||
if (state?.refresh) {
|
||||
tableRef.current?.loadData();
|
||||
// 清除 state,避免重复刷新
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
const fetchKnowledgeBaseDetail = async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getKnowledgeBaseDetail(id);
|
||||
setKnowledgeBase(res.data || res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理树节点选择
|
||||
const onSelect = (selectedKeys: React.Key[], info: any) => {
|
||||
if (!selectedKeys.length) return;
|
||||
if (!folder) return;
|
||||
const node = info.node as TreeNodeData;
|
||||
const f = {
|
||||
...folder,
|
||||
parent_id: String(selectedKeys[0]),
|
||||
}
|
||||
let url = `/documents/${knowledgeBaseId}/${String(selectedKeys[0])}/documents`;
|
||||
setTableApi(url);
|
||||
setParentId(String(selectedKeys[0]))
|
||||
setFolder(f)
|
||||
// 根据节点类型执行不同操作
|
||||
if (node.type === 'folder') {
|
||||
|
||||
// 文件夹:展开/收起
|
||||
} else if (node.type === 'text' || node.type === 'image' || node.type === 'dataset') {
|
||||
// 文件:打开详情
|
||||
}
|
||||
};
|
||||
|
||||
// 处理树节点展开
|
||||
const onExpand = (expandedKeys: React.Key[], info: any) => {
|
||||
};
|
||||
// create / import list
|
||||
const createItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
icon: <img src={folderIcon} alt="dataset" style={{ width: 16, height: 16 }} />,
|
||||
label: t('knowledgeBase.folder'),
|
||||
onClick: () => {
|
||||
let f: FolderFormData | null = null;
|
||||
f = {
|
||||
kb_id: knowledgeBase?.id ?? '',
|
||||
parent_id:folder?.parent_id ?? knowledgeBase?.id ?? '',
|
||||
}
|
||||
// setFolder(f);
|
||||
|
||||
createFolderModalRef?.current?.handleOpen(f as FolderFormData);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
icon: <img src={textIcon} alt="text" style={{ width: 16, height: 16 }} />,
|
||||
label: (<span>{t('knowledgeBase.text')} {t('knowledgeBase.dataset')}</span>),
|
||||
onClick: () => {
|
||||
datasetModalRef?.current?.handleOpen(knowledgeBase?.id,folder?.parent_id ?? knowledgeBase?.id ?? '');
|
||||
},
|
||||
},
|
||||
// 暂时未实现
|
||||
// {
|
||||
// key: '3',
|
||||
// icon: <img src={imageIcon} alt="image" style={{ width: 16, height: 16 }} />,
|
||||
// label: t('knowledgeBase.imageDataSet'),
|
||||
// onClick: () => {
|
||||
// createImageDataset?.current?.handleOpen(knowledgeBaseId || '', parentId || '')
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// key: '4',
|
||||
// icon: <img src={blankIcon} alt="blank" style={{ width: 16, height: 16 }} />,
|
||||
// label: t('knowledgeBase.blankDataset'),
|
||||
// onClick: () => {
|
||||
// handleCreate('folder'); // 传入 type: 'folder'
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// key: '5',
|
||||
// type: 'divider',
|
||||
// },
|
||||
// {
|
||||
// key: '6',
|
||||
// icon: <img src={templateIcon} alt="import" style={{ width: 16, height: 16 }} />,
|
||||
// label: t('knowledgeBase.importTemplate'),
|
||||
// onClick: () => {
|
||||
// handleCreate('folder'); // 传入 type: 'folder'
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// key: '7',
|
||||
// icon: <img src={backupIcon} alt="import" style={{ width: 16, height: 16 }} />,
|
||||
// label: t('knowledgeBase.importBackup'),
|
||||
// onClick: () => {
|
||||
// handleCreate('folder'); // 传入 type: 'folder'
|
||||
// },
|
||||
// },
|
||||
|
||||
];
|
||||
//
|
||||
const handleCreate = (type: string) => {
|
||||
console.log('create', type);
|
||||
}
|
||||
// 处理开关
|
||||
const onChange = (checked: boolean) => {
|
||||
updateKnowledgeBase(knowledgeBaseId || '', {
|
||||
status: checked ? 1 : 0,
|
||||
});
|
||||
console.log(`switch to ${checked}`);
|
||||
};
|
||||
// 处理搜索
|
||||
const handleSearch = (value?: string) => {
|
||||
setQuery({ ...query, keywords: value })
|
||||
}
|
||||
|
||||
// 处理分享
|
||||
const handleShare = () => {
|
||||
shareModalRef?.current?.handleOpen(knowledgeBaseId,knowledgeBase);
|
||||
}
|
||||
// 处理分享回调,接收选中的数据
|
||||
const handleShareCallback = (selectedData: { checkedItems: any[], selectedItem: any | null }) => {
|
||||
console.log('选中的数据:', selectedData);
|
||||
// checkedItems: 所有 checked 为 true 的数据
|
||||
// selectedItem: 当前选中的项(curIndex 对应的数据)
|
||||
// 在这里处理分享逻辑
|
||||
}
|
||||
const handleCreateDatasetCallback = (payload: { value: number; title: string; description: string }) => {
|
||||
console.log('创建数据集:', payload);
|
||||
}
|
||||
// 处理设置
|
||||
const handleSetting = () => {
|
||||
modalRef?.current?.handleOpen(knowledgeBase, '');
|
||||
}
|
||||
// 处理召回测试
|
||||
const handleRecallTest = () => {
|
||||
recallTestDrawerRef?.current?.handleOpen(knowledgeBaseId);
|
||||
}
|
||||
|
||||
// new / import
|
||||
const handelCreateOrImport = () => {
|
||||
|
||||
}
|
||||
// 生成下拉菜单项(根据当前 row)
|
||||
const getOptMenuItems = (row: KnowledgeBaseListItem): MenuProps['items'] => [
|
||||
{
|
||||
key: '1',
|
||||
label: t('knowledgeBase.rechunking'),
|
||||
onClick: () => {
|
||||
handleRechunking(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('knowledgeBase.download'),
|
||||
onClick: () => {
|
||||
handleDownload(row);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('knowledgeBase.delete'),
|
||||
onClick: () => {
|
||||
handleDelete(row);
|
||||
},
|
||||
}
|
||||
];
|
||||
const handleRechunking = (item: KnowledgeBaseListItem) => {
|
||||
if (!knowledgeBaseId) return;
|
||||
const document = item as unknown as KnowledgeBaseDocumentData;
|
||||
const targetFileId = document?.id || document?.file_id;
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/create-dataset`, {
|
||||
state: {
|
||||
source: 'local',
|
||||
knowledgeBaseId,
|
||||
parentId: parentId ?? knowledgeBaseId,
|
||||
startStep: 'parameterSettings',
|
||||
fileId: targetFileId,
|
||||
},
|
||||
});
|
||||
}
|
||||
const handleDownload = (item: KnowledgeBaseListItem) => {
|
||||
const document = item as unknown as KnowledgeBaseDocumentData;
|
||||
const targetFileId = document?.file_id ?? '';
|
||||
const fileName = document?.file_name ?? '';
|
||||
downloadFile(targetFileId, fileName);
|
||||
}
|
||||
const handleDelete = (item: any) => {
|
||||
confirm({
|
||||
title: t('common.deleteWarning'),
|
||||
content: t('common.deleteWarningContent', { content: item.file_name }),
|
||||
onOk: () => {
|
||||
deleteDocument(item.id)
|
||||
.then(() => {
|
||||
messageApi.success(t('common.deleteSuccess'));
|
||||
// 刷新表格数据
|
||||
tableRef.current?.loadData();
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.log('删除失败', err);
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
console.log('取消删除');
|
||||
},
|
||||
});
|
||||
}
|
||||
// 表格列配置
|
||||
const columns: ColumnsType = [
|
||||
{
|
||||
title: t('knowledgeBase.name'),
|
||||
dataIndex: 'file_name',
|
||||
key: 'file_name',
|
||||
render: (text: string, record: AnyObject) => {
|
||||
const document = record as KnowledgeBaseDocumentData;
|
||||
return (
|
||||
<span
|
||||
className="rb:text-blue-600 rb:cursor-pointer rb:hover:underline"
|
||||
onClick={() => {
|
||||
if (knowledgeBaseId && document.id) {
|
||||
navigate(`/knowledge-base/${knowledgeBaseId}/DocumentDetails`,{
|
||||
state: {
|
||||
documentId: document.id,
|
||||
parentId: parentId ?? knowledgeBaseId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('knowledgeBase.processingMode'),
|
||||
dataIndex: 'parser_id',
|
||||
key: 'parser_id',
|
||||
},
|
||||
{
|
||||
title: t('knowledgeBase.dataSize'),
|
||||
dataIndex: 'file_size',
|
||||
key: 'file_size',
|
||||
},
|
||||
{
|
||||
title: t('knowledgeBase.createUpdateTime'),
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render:(value:string) => {
|
||||
return(
|
||||
<span>{formatDateTime(value,'YYYY-MM-DD HH:mm:ss')}</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('knowledgeBase.status'),
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
render: (value: string | number, record: AnyObject) => {
|
||||
|
||||
return (
|
||||
<span className="rb:text-xs rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded rb:items-center rb:text-[#212332] rb:py-1 rb:px-2">
|
||||
<span
|
||||
className="rb:inline-block rb:w-[5px] rb:h-[5px] rb:mr-2 rb:rounded-full"
|
||||
style={{ backgroundColor: value === 1 ? '#369F21' : value === 0 ? '#FF0000' : '#FF8A4C' }}
|
||||
></span>
|
||||
<span>{value === 1 ? t('knowledgeBase.completed') : value === 0 ? t('knowledgeBase.pending') : t('knowledgeBase.processing')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('common.operation'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Space size="middle">
|
||||
<Dropdown
|
||||
menu={{ items: getOptMenuItems(record as KnowledgeBaseListItem) }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<MoreOutlined className='rb:text-base rb:font-semibold'/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
// 刷新列表数据
|
||||
if (loading) {
|
||||
return <div>加载中...</div>;
|
||||
}
|
||||
|
||||
if (!knowledgeBase) {
|
||||
return <div>知识库不存在</div>;
|
||||
}
|
||||
const refreshDirectoryTree = async () => {
|
||||
// 先刷新知识库详情,确保数据是最新的
|
||||
await fetchKnowledgeBaseDetail(knowledgeBase.id);
|
||||
// 添加短暂延迟,确保后端数据已经完全更新
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
// 然后刷新文件夹树
|
||||
setFolderTreeRefreshKey((prev) => prev + 1);
|
||||
if (!folder) {
|
||||
setFolder({
|
||||
kb_id: knowledgeBaseId ?? '',
|
||||
parent_id: parentId ?? knowledgeBaseId ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
const handleRootTreeLoad = (nodes: TreeNodeData[] | null) => {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
setFolder(null);
|
||||
} else {
|
||||
// 如果有节点且 folder 为 null,重新设置 folder
|
||||
if (!folder) {
|
||||
setFolder({
|
||||
kb_id: knowledgeBaseId ?? '',
|
||||
parent_id: parentId ?? knowledgeBaseId ?? ''
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleEditFolder = () => {
|
||||
const f = {
|
||||
id:knowledgeBase.id,
|
||||
parent_id:knowledgeBase.parent_id,
|
||||
kb_id:knowledgeBase.id,
|
||||
folder_name:knowledgeBase.name
|
||||
}
|
||||
// setFolder(f)
|
||||
createFolderModalRef?.current?.handleOpen(f,'edit');
|
||||
}
|
||||
|
||||
const handleRefreshTable = () => {
|
||||
// 刷新表格数据
|
||||
tableRef.current?.loadData();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<div className="rb:flex rb:h-full rb:gap-4">
|
||||
{folder && (
|
||||
<div className="rb:w-80 rb:flex-shrink-0 rb:h-[calc(100%+40px)] rb:mt-[-16px] rb:border-r rb:border-[#EAECEE] rb:p-4 rb:bg-transparent">
|
||||
<FolderTree
|
||||
multiple
|
||||
className="customTree"
|
||||
style={{ background: 'transparent' }}
|
||||
onSelect={onSelect}
|
||||
onExpand={onExpand}
|
||||
knowledgeBaseId={knowledgeBaseId ?? ''}
|
||||
refreshKey={folderTreeRefreshKey}
|
||||
onRootLoad={handleRootTreeLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='rb:flex-1 rb:min-w-0'>
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:mb-4">
|
||||
|
||||
<div className="rb:flex-col">
|
||||
<div className="rb:flex rb:items-center rb:gap-3">
|
||||
<h1 className="rb:text-xl rb:font-medium rb:text-gray-800">{knowledgeBase.name}</h1>
|
||||
<div className="rb:flex rb:items-center rb:border rb:border-[rgba(33, 35, 50, 0.17)] rb:text-gray-500 rb:cursor-pointer rb:px-1 rb:py-0.5 rb:rounded"
|
||||
onClick={handleEditFolder}
|
||||
>
|
||||
<img src={editIcon} alt="edit" className="rb:w-[14px] rb:h-[14px" />
|
||||
<span className='rb:text-[12px]'>{t('knowledgeBase.edit')} {t('knowledgeBase.name')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='rb:flex rb:items-center rb:gap-6 rb:text-gray-500 rb:mt-2 rb:text-xs'>
|
||||
<span className='rb:text-[12px]'>{t('knowledgeBase.created')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.created_at) || '-'}</span>
|
||||
<span className='rb:text-[12px]'>{t('knowledgeBase.updated')} {t('knowledgeBase.time')}: {formatDateTime(knowledgeBase.updated_at) || '-'}</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className='rb:flex'> */}
|
||||
<Switch checkedChildren={t('common.enable')} unCheckedChildren={t('common.disable')} defaultChecked={knowledgeBase.status === 1} onChange={onChange}/>
|
||||
{/* </div> */}
|
||||
</div>
|
||||
<div className='rb:flex rb:items-center rb:justify-between rb:mb-4'>
|
||||
<SearchInput placeholder={t('knowledgeBase.search')} onSearch={handleSearch} />
|
||||
<div className='rb:flex-1 rb:flex rb:items-center rb:justify-end rb:gap-2.5'>
|
||||
<Button onClick={handleShare}>{t('knowledgeBase.share')}</Button>
|
||||
<Button onClick={handleRecallTest}>{t('knowledgeBase.recallTest')}</Button>
|
||||
<Button onClick={handleSetting}>{t('knowledgeBase.knowledgeBase')} {t('knowledgeBase.setting')}</Button>
|
||||
<Dropdown menu={{ items: createItems }} trigger={['click']}>
|
||||
<Button type="primary" onClick={handelCreateOrImport} >+ {t('knowledgeBase.createImport')}</Button>
|
||||
</Dropdown>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="rb:rounded rb:max-h-[calc(100%-100px)] rb:overflow-y-auto">
|
||||
<Table
|
||||
ref={tableRef}
|
||||
apiUrl={tableApi}
|
||||
apiParams={query as Record<string, unknown>}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
scrollX={1500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RecallTestDrawer
|
||||
ref={recallTestDrawerRef}
|
||||
/>
|
||||
<CreateFolderModal
|
||||
ref={createFolderModalRef}
|
||||
refreshTable={refreshDirectoryTree}
|
||||
/>
|
||||
<CreateModal
|
||||
ref={modalRef}
|
||||
refreshTable={handleRefreshTable}
|
||||
/>
|
||||
<ShareModal
|
||||
ref={shareModalRef}
|
||||
handleShare={handleShareCallback}
|
||||
/>
|
||||
<CreateDatasetModal
|
||||
ref={datasetModalRef}
|
||||
handleCreateDataset={handleCreateDatasetCallback}
|
||||
/>
|
||||
<CreateImageDataset
|
||||
ref={createImageDataset}
|
||||
refreshTable={refreshDirectoryTree}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Private;
|
||||
|
||||
158
web/src/views/KnowledgeBase/[knowledgeBaseId]/Share.tsx
Normal file
158
web/src/views/KnowledgeBase/[knowledgeBaseId]/Share.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState, useRef, type FC } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { KnowledgeBaseListItem, RecallTestDrawerRef } from '../types';
|
||||
import RecallTest from '../components/RecallTest';
|
||||
import InfoPanel, { type InfoItem } from '../components/InfoPanel';
|
||||
import shareUserIcon from '@/assets/images/knowledgeBase/share-user.png';
|
||||
import timestampIcon from '@/assets/images/knowledgeBase/timestamp.png';
|
||||
//
|
||||
import kbNameIcon from '@/assets/images/knowledgeBase/kb-name.png';
|
||||
import kbDataIcon from '@/assets/images/knowledgeBase/kb-data.png';
|
||||
import kbSizeIcon from '@/assets/images/knowledgeBase/kb-size.png';
|
||||
import kbModelIcon from '@/assets/images/knowledgeBase/kb-model.png';
|
||||
|
||||
import kbHistoryIcon from '@/assets/images/knowledgeBase/kb-history.png';
|
||||
import { getKnowledgeBaseDetail } from '../service';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
|
||||
const Share: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ knowledgeBaseId: string }>();
|
||||
const knowledgeBaseId = params.knowledgeBaseId;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [knowledgeBase, setKnowledgeBase] = useState<KnowledgeBaseListItem | null>(null);
|
||||
const recallTestRef = useRef<RecallTestDrawerRef>(null);
|
||||
const [infoItems, setInfoItems] = useState<InfoItem[]>([]);
|
||||
useEffect(() => {
|
||||
console.log('Share.tsx - useParams result:', params);
|
||||
console.log('Share.tsx - knowledgeBaseId:', knowledgeBaseId);
|
||||
console.log('Share.tsx - typeof knowledgeBaseId:', typeof knowledgeBaseId);
|
||||
|
||||
if (knowledgeBaseId) {
|
||||
fetchKnowledgeBaseDetail(knowledgeBaseId);
|
||||
// 打开召回测试组件
|
||||
setTimeout(() => {
|
||||
console.log('Share.tsx - calling handleOpen with:', knowledgeBaseId);
|
||||
recallTestRef.current?.handleOpen(knowledgeBaseId);
|
||||
}, 100);
|
||||
} else {
|
||||
console.warn('Share.tsx - knowledgeBaseId is undefined or empty');
|
||||
}
|
||||
}, [knowledgeBaseId]);
|
||||
const formatInfoItems = (data: KnowledgeBaseListItem): InfoItem[] => {
|
||||
const items: InfoItem[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('knowledgeBase.knowledgeBase') + ' ' + t('knowledgeBase.name'),
|
||||
value: data.name ?? '-',
|
||||
icon: kbNameIcon,
|
||||
},
|
||||
{
|
||||
key: 'doc_num',
|
||||
label: t('knowledgeBase.doc_num'),
|
||||
value: data.doc_num ?? 0,
|
||||
icon: kbDataIcon,
|
||||
},
|
||||
{
|
||||
key: 'chunk_num',
|
||||
label: t('knowledgeBase.chunk_num'),
|
||||
value: data.chunk_num ?? 0,
|
||||
icon: kbSizeIcon,
|
||||
},
|
||||
{
|
||||
key: 'embedding_id',
|
||||
label: t('knowledgeBase.embedding_id') + ' ' + 'model',
|
||||
value: data.embedding?.name ?? '-',
|
||||
icon: kbModelIcon,
|
||||
},
|
||||
{
|
||||
key: 'llm_id',
|
||||
label: t('knowledgeBase.llm_id') + ' ' + 'model',
|
||||
value: data.llm?.name ?? '-',
|
||||
icon: kbModelIcon,
|
||||
},
|
||||
{
|
||||
key: 'image2text_id',
|
||||
label: t('knowledgeBase.image2text_id') + ' ' + 'model',
|
||||
value: data.image2text?.name ?? '-',
|
||||
icon: kbModelIcon,
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
label: t('knowledgeBase.last_at'),
|
||||
value: formatDateTime(data.updated_at, 'YYYY-MM-DD HH:mm:ss'),
|
||||
icon: kbHistoryIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return items.filter((item) => {
|
||||
return item.value !== null && item.value !== undefined && item.value !== '';
|
||||
});
|
||||
}
|
||||
const fetchKnowledgeBaseDetail = (id: string) => {
|
||||
setLoading(true);
|
||||
getKnowledgeBaseDetail(id)
|
||||
.then((res: any) => {
|
||||
const data = res.data || res;
|
||||
setKnowledgeBase(data);
|
||||
setInfoItems(formatInfoItems(data));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
// const handleBack = () => {
|
||||
// navigate('/knowledge-base');
|
||||
// };
|
||||
|
||||
if (loading) {
|
||||
return <div>加载中...</div>;
|
||||
}
|
||||
|
||||
if (!knowledgeBase) {
|
||||
return <div>知识库不存在</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rb:flex rb:flex-col rb:h-full rb:max-h-full rb:overflow-hidden">
|
||||
|
||||
<div className="rb:flex rb:w-full rb:items-center rb:mb-2 rb:gap-2">
|
||||
<h1 className="rb:text-xl rb:font-bold">{knowledgeBase.name}
|
||||
<span className='rb:text-gray-500 rb:text-sm rb:ml-2 rb:font-normal'>(ID: {knowledgeBase.id})</span></h1>
|
||||
|
||||
{/* <p className="rb:text-gray-600 rb:mt-2">{knowledgeBase.description || t('knowledgeBase.noDescription')}</p> */}
|
||||
<span className='rb:text-gray-800 rb:text-xs rb:border rb:border-[#369F21] rb:bg-[rgba(54,159,33,0.2)] rb:px-1 rb:py-[2px] rb:rounded'>{knowledgeBase.permission_id}</span>
|
||||
</div>
|
||||
<div className="rb:flex rb:w-full rb:items-center rb:mb-5 rb:gap-2">
|
||||
<img src={shareUserIcon} className='rb:size-4 rb:ml-2' />
|
||||
<span className='rb:text-gray-500 rb:text-xs'>{knowledgeBase.created_by}</span>
|
||||
<img src={timestampIcon} className='rb:size-4 rb:ml-2' />
|
||||
<span className='rb:text-gray-500 rb:text-xs'>{formatDateTime(knowledgeBase.created_at)}</span>
|
||||
</div>
|
||||
<div className="rb:flex rb:flex-1 rb:gap-4 rb:min-h-0">
|
||||
<div className="rb:flex-1 rb:p-4 rb:border rb:flex rb:flex-col rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:overflow-hidden">
|
||||
<div className='rb:flex rb:flex-col rb:txt-left rb:mb-5 rb:gap-2 rb:flex-shrink-0'>
|
||||
<h1 className="rb:text-lg rb:font-bold">{t('knowledgeBase.knowledgeBase')} {t('knowledgeBase.recallTest')}</h1>
|
||||
<span className='rb:text-gray-500 rb:text-xs'>{t('knowledgeBase.recallTestDescription')}</span>
|
||||
</div>
|
||||
<div className='rb:flex-1 rb:min-h-0'>
|
||||
<RecallTest ref={recallTestRef} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='rb:w-80 rb:border rb:overflow-y-auto rb:border-[#DFE4ED] rb:bg-white rb:rounded-xl rb:p-4'>
|
||||
<InfoPanel
|
||||
title={t('knowledgeBase.knowledgeBaseInfo')}
|
||||
items={infoItems}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Share;
|
||||
|
||||
Reference in New Issue
Block a user