feat(knowledgeBase): enhance file upload and dataset creation with abort support and improved UX

- Add AbortSignal support to uploadFile API for cancellable uploads
- Implement custom onRemove callback in UploadFiles component with confirmation dialog
- Add i18n translations for file removal confirmation and error messages
- Update supported file types documentation to include IMAGE and MEDIA formats
- Improve file removal UI with cursor pointer styling
- Refactor getModelList API to remove unused type parameter
- Add Form import and UploadFile type for better type safety in CreateDataset
- Enhance error handling and user feedback for file operations
This commit is contained in:
yujiangping
2025-12-29 19:13:03 +08:00
parent 6887d7dc74
commit 0b3fe0e799
9 changed files with 311 additions and 107 deletions

View File

@@ -4,7 +4,7 @@
* @Author: yujiangping
* @Date: 2025-11-10 18:52:55
* @LastEditors: yujiangping
* @LastEditTime: 2025-11-24 11:23:33
* @LastEditTime: 2025-12-29 16:09:13
*/
import { forwardRef, useImperativeHandle, useState } from 'react';
import type { RadioChangeEvent } from 'antd';
@@ -51,10 +51,10 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
// title: t('knowledgeBase.webLink'),
// description: t('knowledgeBase.readStaticWebPage')
// },
// {
// title: t('knowledgeBase.customText'),
// description: t('knowledgeBase.manuallyInputText')
// },
{
title: t('knowledgeBase.customText'),
description: t('knowledgeBase.manuallyInputText')
},
]
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
@@ -111,7 +111,7 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
return (
<RbModal
title={t('knowledgeBase.createA') + ' ' + t('knowledgeBase.text') + ' ' + t('knowledgeBase.dataset')}
title={t('knowledgeBase.createA') + ' ' + t('knowledgeBase.dataset')}
open={visible}
onCancel={handleClose}
okText={t('common.create')}
@@ -133,13 +133,13 @@ const CreateDatasetModal = forwardRef<CreateDatasetModalRef,CreateDatasetModalRe
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[1].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span>
</Flex>
</Radio>
</Radio> */}
<Radio value={2} style={getActiveRadioStyle(value === 2)} className='rb:w-full'>
<Flex gap="small" align='start' justify='start' vertical>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[2].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[2].description}</span>
<span className='rb:text-base rb:font-medium rb:text-gray-800'>{items[1].title}</span>
<span className='rb:text-xs rb:text-gray-500'>{items[1].description}</span>
</Flex>
</Radio> */}
</Radio>
</Radio.Group>
</div>
</div>

View File

@@ -1,14 +1,14 @@
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Form, Input, message } from 'antd';
import { Form, message } from 'antd';
import { useTranslation } from 'react-i18next';
import type { UploadFile } from 'antd';
import type { CreateSetModalRef, CreateSetMoealRefProps, UploadFileResponse } from '@/views/KnowledgeBase/types';
import type { CreateSetModalRef, CreateSetMoealRefProps } from '@/views/KnowledgeBase/types';
import type { UploadRequestOption } from 'rc-upload/lib/interface';
import RbModal from '@/components/RbModal';
import UploadFiles from '@/components/Upload/UploadFiles';
import { uploadFile } from '@/api/knowledgeBase';
import { uploadFile, deleteDocument } from '@/api/knowledgeBase';
interface ImageDatasetFormData {
name: string;
@@ -26,16 +26,26 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
const [loading, setLoading] = useState(false);
const [kbId, setKbId] = useState<string>('');
const [parentId, setParentId] = useState<string>('');
const [hasFiles, setHasFiles] = useState(false);
const uploadRef = useRef<{ fileList: UploadFile[]; clearFiles: () => void }>(null);
// 存储每个文件的 AbortController用于取消上传
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
// const fileIds = [];
const handleClose = () => {
// 取消所有正在进行的上传
abortControllersRef.current.forEach((controller) => {
controller.abort();
});
abortControllersRef.current.clear();
form.resetFields();
uploadRef.current?.clearFiles();
setLoading(false);
setVisible(false);
setKbId('');
setParentId('');
setHasFiles(false);
};
const handleOpen = (kb_id: string, parent_id: string) => {
@@ -43,6 +53,7 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
setParentId(parent_id);
form.resetFields();
uploadRef.current?.clearFiles();
setHasFiles(false);
setVisible(true);
};
@@ -120,21 +131,38 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
media.src = url;
});
};
// 删除已上传的文件
const handleDeleteFile = async (fileId: string) => {
try {
await deleteDocument(fileId);
console.log(`${t('common.deleteSuccess')}`);
} catch (error) {
messageApi.error(`${t('common.deleteFailed')}`);
}
};
// 上传文件
const handleUpload = async (options: UploadRequestOption) => {
const { file, onSuccess, onError, onProgress, filename = 'file' } = options;
// 创建 AbortController 用于取消上传
const abortController = new AbortController();
const fileUid = (file as any).uid;
abortControllersRef.current.set(fileUid, abortController);
// 获取文件扩展名
const fileExtension = (file as File).name.split('.').pop()?.toLowerCase();
const mediaExtensions = ['mp3', 'mp4', 'mov', 'wav'];
// 如果是媒体文件,进行大小和时长检查
if (fileExtension && mediaExtensions.includes(fileExtension)) {
const fileSizeInMB = (file as File).size / (1024 * 1024);
const fileSizeInMB = (file as File).size / (50 * 1024);
// 检查文件大小(256MB限制
if (fileSizeInMB > 256) {
// 检查文件大小(50MB限制
if (fileSizeInMB > 50) {
messageApi.error(`${t('knowledgeBase.sizeLimitError')}${fileSizeInMB.toFixed(2)}MB`);
onError?.(new Error(`${t('knowledgeBase.fileSizeExceeds')}`));
abortControllersRef.current.delete(fileUid);
return;
}
@@ -144,11 +172,13 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
if (duration > 150) {
messageApi.error(`${t('knowledgeBase.fileDurationLimitError')}${Math.round(duration)}`);
onError?.(new Error(`${t('knowledgeBase.fileDurationExceeds')}`));
abortControllersRef.current.delete(fileUid);
return;
}
} catch (error) {
messageApi.error(`${t('knowledgeBase.unableReadFile')}`);
onError?.(error as Error);
abortControllersRef.current.delete(fileUid);
return;
}
}
@@ -162,36 +192,53 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
formData.append('parent_id', parentId);
}
uploadFile(formData, {
kb_id: kbId,
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) {
// 上传成功
// fileIds.push(res.id)
}
})
.catch((error) => {
onError?.(error as Error);
try {
const res = await uploadFile(formData, {
kb_id: kbId,
parent_id: parentId,
signal: abortController.signal,
onUploadProgress: (event) => {
if (!event.total) return;
const percent = Math.round((event.loaded / event.total) * 100);
onProgress?.({ percent }, file);
},
});
// 上传成功,移除 AbortController
abortControllersRef.current.delete(fileUid);
onSuccess?.(res, new XMLHttpRequest());
if (res?.id) {
// 上传成功
// fileIds.push(res.id)
}
} catch (error: any) {
// 移除 AbortController
abortControllersRef.current.delete(fileUid);
// 如果是用户主动取消,不显示错误信息
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
console.log('上传已取消:', (file as File).name);
return;
}
onError?.(error as Error);
}
};
return (
<>
{contextHolder}
<RbModal
title={`${t('knowledgeBase.createA')} ${t('knowledgeBase.imageDataSet')}`}
title={`${t('knowledgeBase.createA')} ${t('knowledgeBase.mediaDataSet')}`}
open={visible}
onCancel={handleClose}
okText={t('common.create')}
onOk={handleSave}
confirmLoading={loading}
maskClosable={false}
okButtonProps={{
disabled: loading || !hasFiles
}}
>
<Form form={form} layout="vertical">
{/* <Form.Item
@@ -206,11 +253,31 @@ const CreateImageDataset = forwardRef<CreateSetModalRef, CreateSetMoealRefProps>
<UploadFiles
ref={uploadRef}
isCanDrag={true}
fileSize={100}
fileSize={50}
multiple={true}
maxCount={99}
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp3', 'mp4', 'mov', 'wav']}
customRequest={handleUpload}
customRequest={handleUpload}
onChange={(fileList) => {
// 实时更新文件状态
setHasFiles(fileList.length > 0);
}}
onRemove={async (file) => {
// 如果文件正在上传,取消上传
const fileUid = file.uid;
const abortController = abortControllersRef.current.get(fileUid);
if (abortController) {
abortController.abort();
abortControllersRef.current.delete(fileUid);
}
// 如果文件已经上传成功,删除服务器上的文件
if (file.response?.id) {
await handleDeleteFile(file.response.id);
}
return true; // 允许移除文件
}}
/>
</Form.Item>
</Form>

View File

@@ -7,6 +7,9 @@ import RbModal from '@/components/RbModal'
const { TextArea } = Input;
const { confirm } = Modal
// 全局模型数据常量
let models: any = null;
const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
refreshTable
}, ref) => {
@@ -46,20 +49,26 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
};
const fetchModelLists = async (types: string[]) => {
// 如果 types 中包含 'llm',也需要获取 'chat' 的数据
const typesToFetch = types.includes('llm') ? [...types, 'chat'] : types;
const entries = await Promise.all(typesToFetch.map(async (tp) => {
// 如果还没有获取过全部模型数据,则获取一次
if (!models) {
try {
const res = await getModelList(tp === 'image2text' ? 'chat' : tp, { page: 1, pagesize: 100 });
const options = (res?.items || []).map((m: any) => ({ label: m.name, value: m.id }));
return [tp, options] as [string, { label: string; value: string }[]];
} catch {
return [tp, []] as [string, { label: string; value: string }[]];
models = await getModelList({ page: 1, pagesize: 100 });
} catch (error) {
console.error('Failed to fetch models:', error);
models = { items: [] };
}
}));
}
// 从全部模型数据中过滤出需要的类型
const typesToFetch = types.includes('llm') ? [...types, 'chat'] : types;
const next: Record<string, { label: string; value: string }[]> = {};
entries.forEach(([k, v]) => { next[k] = v; });
typesToFetch.forEach((tp) => {
const targetType = tp === 'image2text' ? 'chat' : tp;
const filteredModels = (models?.items || []).filter((m: any) => m.type === targetType);
next[tp] = filteredModels.map((m: any) => ({ label: m.name, value: m.id }));
});
setModelOptionsByType(next);
};
@@ -157,7 +166,7 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
console.log('Validation failed:', err)
});
}
const handleChange = (value: string, tp: string) => {
const handleChange = (_value: string, tp: string) => {
// 只在编辑模式且类型为 embedding 时触发提示
if (datasets?.id && tp.toLowerCase() === 'embedding') {
const fieldKey = typeToFieldKey(tp);