Переглянути джерело

feat: support batch upload files (#419)

Joel 1 рік тому
батько
коміт
d637a147ee

+ 3 - 3
web/app/components/datasets/create/file-preview/index.tsx

@@ -9,7 +9,6 @@ import { fetchFilePreview } from '@/service/common'
 
 
 type IProps = {
 type IProps = {
   file?: File
   file?: File
-  notionPage?: any
   hidePreview: () => void
   hidePreview: () => void
 }
 }
 
 
@@ -33,14 +32,15 @@ const FilePreview = ({
   const getFileName = (currentFile?: File) => {
   const getFileName = (currentFile?: File) => {
     if (!currentFile)
     if (!currentFile)
       return ''
       return ''
-
     const arr = currentFile.name.split('.')
     const arr = currentFile.name.split('.')
     return arr.slice(0, -1).join()
     return arr.slice(0, -1).join()
   }
   }
 
 
   useEffect(() => {
   useEffect(() => {
-    if (file)
+    if (file) {
+      setLoading(true)
       getPreviewContent(file.id)
       getPreviewContent(file.id)
+    }
   }, [file])
   }, [file])
 
 
   return (
   return (

+ 46 - 60
web/app/components/datasets/create/file-uploader/index.module.css

@@ -1,5 +1,5 @@
 .fileUploader {
 .fileUploader {
-  @apply mb-9;
+  @apply mb-6;
 }
 }
 .fileUploader .title {
 .fileUploader .title {
   @apply mb-2;
   @apply mb-2;
@@ -9,14 +9,14 @@
   color: #344054;
   color: #344054;
 }
 }
 .fileUploader .tip {
 .fileUploader .tip {
-  @apply mt-2;
   font-weight: 400;
   font-weight: 400;
   font-size: 12px;
   font-size: 12px;
-  line-height: 26px;
+  line-height: 18px;
   color: #667085;
   color: #667085;
 }
 }
 .uploader {
 .uploader {
-  @apply relative box-border flex justify-center items-center;
+  @apply relative box-border flex justify-center items-center mb-2;
+  flex-direction: column;
   max-width: 640px;
   max-width: 640px;
   height: 80px;
   height: 80px;
   background: #F9FAFB;
   background: #F9FAFB;
@@ -38,7 +38,7 @@
   width: 100%;
   width: 100%;
   height: 100%;
   height: 100%;
 }
 }
-.uploader::before {
+.uploader .uploadIcon {
   content: '';
   content: '';
   display: block;
   display: block;
   margin-right: 8px;
   margin-right: 8px;
@@ -51,16 +51,20 @@
   @apply pl-1 cursor-pointer;
   @apply pl-1 cursor-pointer;
   color: #155eef;
   color: #155eef;
 }
 }
-
+.fileList {
+  @apply space-y-2;
+}
 .file {
 .file {
-  @apply box-border relative flex items-center;
-  padding: 21px 24px 21px 64px;
+  @apply box-border relative flex items-center justify-between;
+  padding: 8px 12px 8px 8px;
   max-width: 640px;
   max-width: 640px;
-  height: 80px;
-  background: #F9FAFB;
-  border: 1px solid #F2F4F7;
-  border-radius: 12px;
+  height: 40px;
+  background: #ffffff;
+  border:  0.5px solid #EAECF0;
+  box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
+  border-radius: 8px;
   overflow: hidden;
   overflow: hidden;
+  cursor: pointer;
 }
 }
 .progressbar {
 .progressbar {
   position: absolute;
   position: absolute;
@@ -69,36 +73,27 @@
   height: 100%;
   height: 100%;
   background-color: #F2F4F7;
   background-color: #F2F4F7;
 }
 }
-.file:hover {
-  background: #F5F8FF;
-  border: 1px solid #D1E0FF;
-}
-.file:hover .actionWrapper .buttonWrapper {
-  display: flex;
-  align-items: center;
-}
-.file:hover .actionWrapper .divider {
-  display: block;
-}
+
 .file.uploading,
 .file.uploading,
 .file.uploading:hover {
 .file.uploading:hover {
   background: #FCFCFD;
   background: #FCFCFD;
-  border: 1px solid #EAECF0;
+  border: 0.5px solid #EAECF0;
 }
 }
-.file.uploading:hover .actionWrapper .percent {
-  padding: 8px;
+.file.active {
+  background: #F5F8FF;
+  border: 1px solid #D1E0FF;
+  box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
 }
 }
-.file.uploading:hover .actionWrapper .buttonWrapper {
-  display: flex;
-  align-items: center;
+.file:hover {
+  background: #F5F8FF;
+  border: 1px solid #D1E0FF;
+  box-shadow: 0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06);
 }
 }
+
 .fileIcon {
 .fileIcon {
-  @apply w-8 h-8 bg-center bg-no-repeat;
-  position: absolute;
-  top: 24px;
-  left: 24px;
+  @apply shrink-0 w-6 h-6 mr-2 bg-center bg-no-repeat;
   background-image: url(../assets/unknow.svg);
   background-image: url(../assets/unknow.svg);
-  background-size: 32px;
+  background-size: 24px;
 }
 }
 .fileIcon.csv {
 .fileIcon.csv {
   background-image: url(../assets/csv.svg);
   background-image: url(../assets/csv.svg);
@@ -126,7 +121,7 @@
   background-image: url(../assets/json.svg);
   background-image: url(../assets/json.svg);
 }
 }
 .fileInfo {
 .fileInfo {
-  @apply grow;
+  @apply grow flex items-center;
   z-index: 1;
   z-index: 1;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
@@ -134,46 +129,37 @@
 }
 }
 .filename {
 .filename {
   font-weight: 500;
   font-weight: 500;
-  font-size: 14px;
-  line-height: 20px;
-}
-.name {
+  font-size: 13px;
+  line-height: 18px;
   color: #1D2939;
   color: #1D2939;
-  line-height: 20px;
-}
-.extension {
-  color: #667085;
-  line-height: 20px;
 }
 }
-.fileExtraInfo {
-  color: #667085;
+
+.size {
+  @apply ml-3;
+  font-weight: 400;
   font-size: 12px;
   font-size: 12px;
   line-height: 18px;
   line-height: 18px;
+  color: #667085;
 }
 }
 .actionWrapper {
 .actionWrapper {
   @apply flex items-center shrink-0;
   @apply flex items-center shrink-0;
   z-index: 1;
   z-index: 1;
 }
 }
 .actionWrapper .percent {
 .actionWrapper .percent {
-  font-size: 16px;
-  line-height: 24px;
+  font-weight: 400;
+  font-size: 13px;
+  line-height: 18px;
   color: #344054;
   color: #344054;
 }
 }
-.actionWrapper .divider {
-  display: none;
-  margin: 0 8px;
-  width: 1px;
-  height: 16px;
-  background: #FEE4E2;
-}
+
 .actionWrapper .remove {
 .actionWrapper .remove {
-  width: 32px;
-  height: 32px;
+  display: none;
+  width: 24px;
+  height: 24px;
   background: center no-repeat url(../assets/trash.svg);
   background: center no-repeat url(../assets/trash.svg);
   background-size: 16px;
   background-size: 16px;
   cursor: pointer;
   cursor: pointer;
 }
 }
-.actionWrapper .buttonWrapper {
-  @apply flex items-center;
-  display: none;
+.file:hover .actionWrapper .remove {
+  display: block;
 }
 }

+ 155 - 128
web/app/components/datasets/create/file-uploader/index.tsx

@@ -1,19 +1,21 @@
 'use client'
 'use client'
-import React, { useCallback, useEffect, useRef, useState } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { useContext } from 'use-context-selector'
 import { useContext } from 'use-context-selector'
 import cn from 'classnames'
 import cn from 'classnames'
 import s from './index.module.css'
 import s from './index.module.css'
 import type { File as FileEntity } from '@/models/datasets'
 import type { File as FileEntity } from '@/models/datasets'
 import { ToastContext } from '@/app/components/base/toast'
 import { ToastContext } from '@/app/components/base/toast'
-import Button from '@/app/components/base/button'
 
 
 import { upload } from '@/service/base'
 import { upload } from '@/service/base'
 
 
 type IFileUploaderProps = {
 type IFileUploaderProps = {
-  file?: FileEntity
+  fileList: any[]
   titleClassName?: string
   titleClassName?: string
-  onFileUpdate: (file?: FileEntity) => void
+  prepareFileList: (files: any[]) => void
+  onFileUpdate: (fileItem: any, progress: number, list: any[]) => void
+  onFileListUpdate?: (files: any) => void
+  onPreview: (file: FileEntity) => void
 }
 }
 
 
 const ACCEPTS = [
 const ACCEPTS = [
@@ -28,19 +30,25 @@ const ACCEPTS = [
   '.csv',
   '.csv',
 ]
 ]
 
 
-const MAX_SIZE = 15 * 1024 * 1024
+const MAX_SIZE = 10 * 1024 * 1024
+const BATCH_COUNT = 5
 
 
-const FileUploader = ({ file, onFileUpdate, titleClassName }: IFileUploaderProps) => {
+const FileUploader = ({
+  fileList,
+  titleClassName,
+  prepareFileList,
+  onFileUpdate,
+  onFileListUpdate,
+  onPreview,
+}: IFileUploaderProps) => {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const { notify } = useContext(ToastContext)
   const { notify } = useContext(ToastContext)
   const [dragging, setDragging] = useState(false)
   const [dragging, setDragging] = useState(false)
   const dropRef = useRef<HTMLDivElement>(null)
   const dropRef = useRef<HTMLDivElement>(null)
   const dragRef = useRef<HTMLDivElement>(null)
   const dragRef = useRef<HTMLDivElement>(null)
   const fileUploader = useRef<HTMLInputElement>(null)
   const fileUploader = useRef<HTMLInputElement>(null)
-  const uploadPromise = useRef<any>(null)
-  const [currentFile, setCurrentFile] = useState<File>()
-  const [uploading, setUploading] = useState(false)
-  const [percent, setPercent] = useState(0)
+
+  const fileListRef = useRef<any>([])
 
 
   // utils
   // utils
   const getFileType = (currentFile: File) => {
   const getFileType = (currentFile: File) => {
@@ -50,10 +58,7 @@ const FileUploader = ({ file, onFileUpdate, titleClassName }: IFileUploaderProps
     const arr = currentFile.name.split('.')
     const arr = currentFile.name.split('.')
     return arr[arr.length - 1]
     return arr[arr.length - 1]
   }
   }
-  const getFileName = (name: string) => {
-    const arr = name.split('.')
-    return arr.slice(0, -1).join()
-  }
+
   const getFileSize = (size: number) => {
   const getFileSize = (size: number) => {
     if (size / 1024 < 10)
     if (size / 1024 < 10)
       return `${(size / 1024).toFixed(2)}KB`
       return `${(size / 1024).toFixed(2)}KB`
@@ -74,52 +79,76 @@ const FileUploader = ({ file, onFileUpdate, titleClassName }: IFileUploaderProps
 
 
     return isValidType && isValidSize
     return isValidType && isValidSize
   }
   }
-  const onProgress = useCallback((e: ProgressEvent) => {
-    if (e.lengthComputable) {
-      const percent = Math.floor(e.loaded / e.total * 100)
-      setPercent(percent)
-    }
-  }, [setPercent])
-  const abort = () => {
-    const currentXHR = uploadPromise.current
-    currentXHR.abort()
-  }
-  const fileUpload = async (file?: File) => {
-    if (!file)
-      return
-
-    if (!isValid(file))
-      return
 
 
-    setCurrentFile(file)
-    setUploading(true)
+  const fileUpload = async (fileItem: any) => {
     const formData = new FormData()
     const formData = new FormData()
-    formData.append('file', file)
-    // store for abort
-    const currentXHR = new XMLHttpRequest()
-    uploadPromise.current = currentXHR
-    try {
-      const result = await upload({
-        xhr: currentXHR,
-        data: formData,
-        onprogress: onProgress,
-      }) as FileEntity
-      onFileUpdate(result)
-      setUploading(false)
+    formData.append('file', fileItem.file)
+    const onProgress = (e: ProgressEvent) => {
+      if (e.lengthComputable) {
+        const percent = Math.floor(e.loaded / e.total * 100)
+        onFileUpdate(fileItem, percent, fileListRef.current)
+      }
     }
     }
-    catch (xhr: any) {
-      setUploading(false)
-      // abort handle
-      if (xhr.readyState === 0 && xhr.status === 0) {
-        if (fileUploader.current)
-          fileUploader.current.value = ''
 
 
-        setCurrentFile(undefined)
-        return
-      }
-      notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.failed') })
+    return upload({
+      xhr: new XMLHttpRequest(),
+      data: formData,
+      onprogress: onProgress,
+    })
+      .then((res: FileEntity) => {
+        const fileListCopy = fileListRef.current
+
+        const completeFile = {
+          fileID: fileItem.fileID,
+          file: res,
+        }
+        const index = fileListCopy.findIndex((item: any) => item.fileID === fileItem.fileID)
+        fileListCopy[index] = completeFile
+        onFileUpdate(completeFile, 100, fileListCopy)
+        return Promise.resolve({ ...completeFile })
+      })
+      .catch(() => {
+        notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.failed') })
+        onFileUpdate(fileItem, -2, fileListCopy)
+        return Promise.resolve({ ...fileItem })
+      })
+      .finally()
+  }
+  const uploadBatchFiles = (bFiles: any) => {
+    bFiles.forEach((bf: any) => (bf.progress = 0))
+    return Promise.all(bFiles.map((bFile: any) => fileUpload(bFile)))
+  }
+  const uploadMultipleFiles = async (files: any) => {
+    const length = files.length
+    let start = 0
+    let end = 0
+
+    while (start < length) {
+      if (start + BATCH_COUNT > length)
+        end = length
+      else
+        end = start + BATCH_COUNT
+      const bFiles = files.slice(start, end)
+      await uploadBatchFiles(bFiles)
+      start = end
     }
     }
   }
   }
+  const initialUpload = (files: any) => {
+    if (!files.length)
+      return false
+    const preparedFiles = files.map((file: any, index: number) => {
+      const fileItem = {
+        fileID: `file${index}-${Date.now()}`,
+        file,
+        progress: -1,
+      }
+      return fileItem
+    })
+    const newFiles = [...fileListRef.current, ...preparedFiles]
+    prepareFileList(newFiles)
+    fileListRef.current = newFiles
+    uploadMultipleFiles(preparedFiles)
+  }
   const handleDragEnter = (e: DragEvent) => {
   const handleDragEnter = (e: DragEvent) => {
     e.preventDefault()
     e.preventDefault()
     e.stopPropagation()
     e.stopPropagation()
@@ -134,6 +163,7 @@ const FileUploader = ({ file, onFileUpdate, titleClassName }: IFileUploaderProps
     e.stopPropagation()
     e.stopPropagation()
     e.target === dragRef.current && setDragging(false)
     e.target === dragRef.current && setDragging(false)
   }
   }
+
   const handleDrop = (e: DragEvent) => {
   const handleDrop = (e: DragEvent) => {
     e.preventDefault()
     e.preventDefault()
     e.stopPropagation()
     e.stopPropagation()
@@ -142,29 +172,26 @@ const FileUploader = ({ file, onFileUpdate, titleClassName }: IFileUploaderProps
       return
       return
 
 
     const files = [...e.dataTransfer.files]
     const files = [...e.dataTransfer.files]
-    if (files.length > 1) {
-      notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.count') })
-      return
-    }
-    onFileUpdate()
-    fileUpload(files[0])
+    const validFiles = files.filter(file => isValid(file))
+    // fileUpload(files[0])
+    initialUpload(validFiles)
   }
   }
 
 
   const selectHandle = () => {
   const selectHandle = () => {
     if (fileUploader.current)
     if (fileUploader.current)
       fileUploader.current.click()
       fileUploader.current.click()
   }
   }
-  const removeFile = () => {
+
+  const removeFile = (fileID: string) => {
     if (fileUploader.current)
     if (fileUploader.current)
       fileUploader.current.value = ''
       fileUploader.current.value = ''
 
 
-    setCurrentFile(undefined)
-    onFileUpdate()
+    fileListRef.current = fileListRef.current.filter((item: any) => item.fileID !== fileID)
+    onFileListUpdate?.([...fileListRef.current])
   }
   }
   const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
   const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
-    const currentFile = e.target.files?.[0]
-    onFileUpdate()
-    fileUpload(currentFile)
+    const files = [...(e.target.files ?? [])].filter(file => isValid(file))
+    initialUpload(files)
   }
   }
 
 
   useEffect(() => {
   useEffect(() => {
@@ -184,83 +211,83 @@ const FileUploader = ({ file, onFileUpdate, titleClassName }: IFileUploaderProps
     <div className={s.fileUploader}>
     <div className={s.fileUploader}>
       <input
       <input
         ref={fileUploader}
         ref={fileUploader}
+        id="fileUploader"
         style={{ display: 'none' }}
         style={{ display: 'none' }}
         type="file"
         type="file"
-        id="fileUploader"
+        multiple
         accept={ACCEPTS.join(',')}
         accept={ACCEPTS.join(',')}
         onChange={fileChangeHandle}
         onChange={fileChangeHandle}
       />
       />
       <div className={cn(s.title, titleClassName)}>{t('datasetCreation.stepOne.uploader.title')}</div>
       <div className={cn(s.title, titleClassName)}>{t('datasetCreation.stepOne.uploader.title')}</div>
-      <div ref={dropRef}>
-        {!currentFile && !file && (
-          <div className={cn(s.uploader, dragging && s.dragging)}>
-            <span>{t('datasetCreation.stepOne.uploader.button')}</span>
-            <label className={s.browse} onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.browse')}</label>
-            {dragging && <div ref={dragRef} className={s.draggingCover}/>}
-          </div>
-        )}
+      <div ref={dropRef} className={cn(s.uploader, dragging && s.dragging)}>
+        <div className='flex justify-center items-center h-6 mb-2'>
+          <span className={s.uploadIcon}/>
+          <span>{t('datasetCreation.stepOne.uploader.button')}</span>
+          <label className={s.browse} onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.browse')}</label>
+        </div>
+        <div className={s.tip}>{t('datasetCreation.stepOne.uploader.tip')}</div>
+        {dragging && <div ref={dragRef} className={s.draggingCover}/>}
       </div>
       </div>
-      {currentFile && (
-        <div className={cn(s.file, uploading && s.uploading)}>
-          {uploading && (
-            <div className={s.progressbar} style={{ width: `${percent}%` }}/>
-          )}
-          <div className={cn(s.fileIcon, s[getFileType(currentFile)])}/>
-          <div className={s.fileInfo}>
-            <div className={s.filename}>
-              <span className={s.name}>{getFileName(currentFile.name)}</span>
-              <span className={s.extension}>{`.${getFileType(currentFile)}`}</span>
+      <div className={s.fileList}>
+        {fileList.map((fileItem, index) => (
+          <div
+            key={`${fileItem.fileID}-${index}`}
+            onClick={() => fileItem.file?.id && onPreview(fileItem.file)}
+            className={cn(
+              s.file,
+              fileItem.progress < 100 && s.uploading,
+              // s.active,
+            )}
+          >
+            {fileItem.progress < 100 && (
+              <div className={s.progressbar} style={{ width: `${fileItem.progress}%` }}/>
+            )}
+            <div className={s.fileInfo}>
+              <div className={cn(s.fileIcon, s[getFileType(fileItem.file)])}/>
+              <div className={s.filename}>{fileItem.file.name}</div>
+              <div className={s.size}>{getFileSize(fileItem.file.size)}</div>
             </div>
             </div>
-            <div className={s.fileExtraInfo}>
-              <span className={s.size}>{getFileSize(currentFile.size)}</span>
-              <span className={s.error}></span>
+            <div className={s.actionWrapper}>
+              {(fileItem.progress < 100 && fileItem.progress >= 0) && (
+                <div className={s.percent}>{`${fileItem.progress}%`}</div>
+              )}
+              {fileItem.progress === 100 && (
+                <div className={s.remove} onClick={(e) => {
+                  e.stopPropagation()
+                  removeFile(fileItem.fileID)
+                }}/>
+              )}
             </div>
             </div>
           </div>
           </div>
-          <div className={s.actionWrapper}>
-            {uploading && (
-              <>
-                <div className={s.percent}>{`${percent}%`}</div>
-                <div className={s.divider}/>
-                <div className={s.buttonWrapper}>
-                  <Button className={cn(s.button, 'ml-2 !h-8 bg-white')} onClick={abort}>{t('datasetCreation.stepOne.uploader.cancel')}</Button>
-                </div>
-              </>
+        ))}
+        {/* {currentFile && (
+          <div
+            // onClick={() => onPreview(currentFile)}
+            className={cn(
+              s.file,
+              uploading && s.uploading,
+              // s.active,
             )}
             )}
-            {!uploading && (
-              <>
-                <div className={s.buttonWrapper}>
-                  <Button className={cn(s.button, 'ml-2 !h-8 bg-white')} onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button>
-                  <div className={s.divider}/>
-                  <div className={s.remove} onClick={removeFile}/>
-                </div>
-              </>
+          >
+            {uploading && (
+              <div className={s.progressbar} style={{ width: `${percent}%` }}/>
             )}
             )}
-          </div>
-        </div>
-      )}
-      {!currentFile && file && (
-        <div className={cn(s.file)}>
-          <div className={cn(s.fileIcon, s[file.extension])}/>
-          <div className={s.fileInfo}>
-            <div className={s.filename}>
-              <span className={s.name}>{getFileName(file.name)}</span>
-              <span className={s.extension}>{`.${file.extension}`}</span>
-            </div>
-            <div className={s.fileExtraInfo}>
-              <span className={s.size}>{getFileSize(file.size)}</span>
-              <span className={s.error}></span>
+            <div className={s.fileInfo}>
+              <div className={cn(s.fileIcon, s[getFileType(currentFile)])}/>
+              <div className={s.filename}>{currentFile.name}</div>
+              <div className={s.size}>{getFileSize(currentFile.size)}</div>
             </div>
             </div>
-          </div>
-          <div className={s.actionWrapper}>
-            <div className={s.buttonWrapper}>
-              <Button className={cn(s.button, 'ml-2 !h-8 bg-white')} onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button>
-              <div className={s.divider}/>
-              <div className={s.remove} onClick={removeFile}/>
+            <div className={s.actionWrapper}>
+              {uploading && (
+                <div className={s.percent}>{`${percent}%`}</div>
+              )}
+              {!uploading && (
+                <div className={s.remove} onClick={() => removeFile(index)}/>
+              )}
             </div>
             </div>
           </div>
           </div>
-        </div>
-      )}
-      <div className={s.tip}>{t('datasetCreation.stepOne.uploader.tip')}</div>
+        )} */}
+      </div>
     </div>
     </div>
   )
   )
 }
 }

+ 27 - 6
web/app/components/datasets/create/index.tsx

@@ -8,7 +8,7 @@ import StepOne from './step-one'
 import StepTwo from './step-two'
 import StepTwo from './step-two'
 import StepThree from './step-three'
 import StepThree from './step-three'
 import { DataSourceType } from '@/models/datasets'
 import { DataSourceType } from '@/models/datasets'
-import type { DataSet, File, createDocumentResponse } from '@/models/datasets'
+import type { DataSet, createDocumentResponse } from '@/models/datasets'
 import { fetchDataSource, fetchTenantInfo } from '@/service/common'
 import { fetchDataSource, fetchTenantInfo } from '@/service/common'
 import { fetchDataDetail } from '@/service/datasets'
 import { fetchDataDetail } from '@/service/datasets'
 import type { DataSourceNotionPage } from '@/models/common'
 import type { DataSourceNotionPage } from '@/models/common'
@@ -30,7 +30,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
   const [dataSourceType, setDataSourceType] = useState<DataSourceType>(DataSourceType.FILE)
   const [dataSourceType, setDataSourceType] = useState<DataSourceType>(DataSourceType.FILE)
   const [step, setStep] = useState(1)
   const [step, setStep] = useState(1)
   const [indexingTypeCache, setIndexTypeCache] = useState('')
   const [indexingTypeCache, setIndexTypeCache] = useState('')
-  const [file, setFile] = useState<File | undefined>()
+  const [fileList, setFiles] = useState<any[]>([])
   const [result, setResult] = useState<createDocumentResponse | undefined>()
   const [result, setResult] = useState<createDocumentResponse | undefined>()
   const [hasError, setHasError] = useState(false)
   const [hasError, setHasError] = useState(false)
 
 
@@ -39,8 +39,28 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
     setNotionPages(value)
     setNotionPages(value)
   }
   }
 
 
-  const updateFile = (file?: File) => {
-    setFile(file)
+  const updateFileList = (preparedFiles: any) => {
+    setFiles(preparedFiles)
+  }
+
+  const updateFile = (fileItem: any, progress: number, list: any[]) => {
+    const targetIndex = list.findIndex((file: any) => file.fileID === fileItem.fileID)
+    list[targetIndex] = {
+      ...list[targetIndex],
+      progress,
+    }
+    setFiles([...list])
+    // use follow code would cause dirty list update problem
+    // const newList = list.map((file) => {
+    //   if (file.fileID === fileItem.fileID) {
+    //     return {
+    //       ...fileItem,
+    //       progress,
+    //     }
+    //   }
+    //   return file
+    // })
+    // setFiles(newList)
   }
   }
   const updateIndexingTypeCache = (type: string) => {
   const updateIndexingTypeCache = (type: string) => {
     setIndexTypeCache(type)
     setIndexTypeCache(type)
@@ -104,8 +124,9 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
           dataSourceType={dataSourceType}
           dataSourceType={dataSourceType}
           dataSourceTypeDisable={!!detail?.data_source_type}
           dataSourceTypeDisable={!!detail?.data_source_type}
           changeType={setDataSourceType}
           changeType={setDataSourceType}
-          file={file}
+          files={fileList}
           updateFile={updateFile}
           updateFile={updateFile}
+          updateFileList={updateFileList}
           notionPages={notionPages}
           notionPages={notionPages}
           updateNotionPages={updateNotionPages}
           updateNotionPages={updateNotionPages}
           onStepChange={nextStep}
           onStepChange={nextStep}
@@ -116,7 +137,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
           indexingType={detail?.indexing_technique || ''}
           indexingType={detail?.indexing_technique || ''}
           datasetId={datasetId}
           datasetId={datasetId}
           dataSourceType={dataSourceType}
           dataSourceType={dataSourceType}
-          file={file}
+          files={fileList.map(file => file.file)}
           notionPages={notionPages}
           notionPages={notionPages}
           onStepChange={changeStep}
           onStepChange={changeStep}
           updateIndexingTypeCache={updateIndexingTypeCache}
           updateIndexingTypeCache={updateIndexingTypeCache}

+ 2 - 0
web/app/components/datasets/create/step-one/index.module.css

@@ -10,7 +10,9 @@
 }
 }
 
 
 .form {
 .form {
+  position: relative;
   padding: 12px 64px;
   padding: 12px 64px;
+  background-color: #fff;
 }
 }
 
 
 .dataSourceTypeList {
 .dataSourceTypeList {

+ 35 - 13
web/app/components/datasets/create/step-one/index.tsx

@@ -1,5 +1,5 @@
 'use client'
 'use client'
-import React, { useState } from 'react'
+import React, { useMemo, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import cn from 'classnames'
 import cn from 'classnames'
 import FilePreview from '../file-preview'
 import FilePreview from '../file-preview'
@@ -20,8 +20,9 @@ type IStepOneProps = {
   dataSourceTypeDisable: Boolean
   dataSourceTypeDisable: Boolean
   hasConnection: boolean
   hasConnection: boolean
   onSetting: () => void
   onSetting: () => void
-  file?: File
-  updateFile: (file?: File) => void
+  files: any[]
+  updateFileList: (files: any[]) => void
+  updateFile: (fileItem: any, progress: number, list: any[]) => void
   notionPages?: any[]
   notionPages?: any[]
   updateNotionPages: (value: any[]) => void
   updateNotionPages: (value: any[]) => void
   onStepChange: () => void
   onStepChange: () => void
@@ -54,23 +55,28 @@ const StepOne = ({
   hasConnection,
   hasConnection,
   onSetting,
   onSetting,
   onStepChange,
   onStepChange,
-  file,
+  files,
+  updateFileList,
   updateFile,
   updateFile,
   notionPages = [],
   notionPages = [],
   updateNotionPages,
   updateNotionPages,
 }: IStepOneProps) => {
 }: IStepOneProps) => {
   const { dataset } = useDatasetDetailContext()
   const { dataset } = useDatasetDetailContext()
   const [showModal, setShowModal] = useState(false)
   const [showModal, setShowModal] = useState(false)
-  const [showFilePreview, setShowFilePreview] = useState(true)
+  const [currentFile, setCurrentFile] = useState<File | undefined>()
   const [currentNotionPage, setCurrentNotionPage] = useState<Page | undefined>()
   const [currentNotionPage, setCurrentNotionPage] = useState<Page | undefined>()
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 
-  const hidePreview = () => setShowFilePreview(false)
-
   const modalShowHandle = () => setShowModal(true)
   const modalShowHandle = () => setShowModal(true)
-
   const modalCloseHandle = () => setShowModal(false)
   const modalCloseHandle = () => setShowModal(false)
 
 
+  const updateCurrentFile = (file: File) => {
+    setCurrentFile(file)
+  }
+  const hideFilePreview = () => {
+    setCurrentNotionPage(undefined)
+  }
+
   const updateCurrentPage = (page: Page) => {
   const updateCurrentPage = (page: Page) => {
     setCurrentNotionPage(page)
     setCurrentNotionPage(page)
   }
   }
@@ -81,6 +87,13 @@ const StepOne = ({
 
 
   const shouldShowDataSourceTypeList = !datasetId || (datasetId && !dataset?.data_source_type)
   const shouldShowDataSourceTypeList = !datasetId || (datasetId && !dataset?.data_source_type)
 
 
+  const nextDisabled = useMemo(() => {
+    if (!files.length)
+      return true
+    if (files.some(file => !file.file.id))
+      return true
+    return false
+  }, [files])
   return (
   return (
     <div className='flex w-full h-full'>
     <div className='flex w-full h-full'>
       <div className='grow overflow-y-auto relative'>
       <div className='grow overflow-y-auto relative'>
@@ -103,7 +116,8 @@ const StepOne = ({
                     if (dataSourceTypeDisable)
                     if (dataSourceTypeDisable)
                       return
                       return
                     changeType(DataSourceType.FILE)
                     changeType(DataSourceType.FILE)
-                    hidePreview()
+                    hideFilePreview()
+                    hideNotionPagePreview()
                   }}
                   }}
                 >
                 >
                   <span className={cn(s.datasetIcon)} />
                   <span className={cn(s.datasetIcon)} />
@@ -119,7 +133,8 @@ const StepOne = ({
                     if (dataSourceTypeDisable)
                     if (dataSourceTypeDisable)
                       return
                       return
                     changeType(DataSourceType.NOTION)
                     changeType(DataSourceType.NOTION)
-                    hidePreview()
+                    hideFilePreview()
+                    hideNotionPagePreview()
                   }}
                   }}
                 >
                 >
                   <span className={cn(s.datasetIcon, s.notion)} />
                   <span className={cn(s.datasetIcon, s.notion)} />
@@ -138,8 +153,15 @@ const StepOne = ({
           }
           }
           {dataSourceType === DataSourceType.FILE && (
           {dataSourceType === DataSourceType.FILE && (
             <>
             <>
-              <FileUploader onFileUpdate={updateFile} file={file} titleClassName={(!shouldShowDataSourceTypeList) ? 'mt-[30px] !mb-[44px] !text-lg !font-semibold !text-gray-900' : undefined} />
-              <Button disabled={!file} className={s.submitButton} type='primary' onClick={onStepChange}>{t('datasetCreation.stepOne.button')}</Button>
+              <FileUploader
+                fileList={files}
+                titleClassName={!shouldShowDataSourceTypeList ? 'mt-[30px] !mb-[44px] !text-lg !font-semibold !text-gray-900' : undefined}
+                prepareFileList={updateFileList}
+                onFileListUpdate={updateFileList}
+                onFileUpdate={updateFile}
+                onPreview={updateCurrentFile}
+              />
+              <Button disabled={nextDisabled} className={s.submitButton} type='primary' onClick={onStepChange}>{t('datasetCreation.stepOne.button')}</Button>
             </>
             </>
           )}
           )}
           {dataSourceType === DataSourceType.NOTION && (
           {dataSourceType === DataSourceType.NOTION && (
@@ -164,7 +186,7 @@ const StepOne = ({
         </div>
         </div>
         <EmptyDatasetCreationModal show={showModal} onHide={modalCloseHandle} />
         <EmptyDatasetCreationModal show={showModal} onHide={modalCloseHandle} />
       </div>
       </div>
-      {file && showFilePreview && <FilePreview file={file} hidePreview={hidePreview} />}
+      {currentFile && <FilePreview file={currentFile} hidePreview={hideFilePreview} />}
       {currentNotionPage && <NotionPagePreview currentPage={currentNotionPage} hidePreview={hideNotionPagePreview} />}
       {currentNotionPage && <NotionPagePreview currentPage={currentNotionPage} hidePreview={hideNotionPagePreview} />}
     </div>
     </div>
   )
   )

+ 13 - 9
web/app/components/datasets/create/step-two/index.tsx

@@ -36,7 +36,7 @@ type StepTwoProps = {
   datasetId?: string
   datasetId?: string
   indexingType?: string
   indexingType?: string
   dataSourceType: DataSourceType
   dataSourceType: DataSourceType
-  file?: File
+  files: File[]
   notionPages?: Page[]
   notionPages?: Page[]
   onStepChange?: (delta: number) => void
   onStepChange?: (delta: number) => void
   updateIndexingTypeCache?: (type: string) => void
   updateIndexingTypeCache?: (type: string) => void
@@ -62,7 +62,7 @@ const StepTwo = ({
   datasetId,
   datasetId,
   indexingType,
   indexingType,
   dataSourceType,
   dataSourceType,
-  file,
+  files,
   notionPages = [],
   notionPages = [],
   onStepChange,
   onStepChange,
   updateIndexingTypeCache,
   updateIndexingTypeCache,
@@ -212,8 +212,7 @@ const StepTwo = ({
         info_list: {
         info_list: {
           data_source_type: dataSourceType,
           data_source_type: dataSourceType,
           file_info_list: {
           file_info_list: {
-            // TODO multi files
-            file_ids: [file?.id || ''],
+            file_ids: files.map(file => file.id),
           },
           },
         },
         },
         indexing_technique: getIndexing_technique(),
         indexing_technique: getIndexing_technique(),
@@ -254,8 +253,7 @@ const StepTwo = ({
       } as CreateDocumentReq
       } as CreateDocumentReq
       if (dataSourceType === DataSourceType.FILE) {
       if (dataSourceType === DataSourceType.FILE) {
         params.data_source.info_list.file_info_list = {
         params.data_source.info_list.file_info_list = {
-          // TODO multi files
-          file_ids: [file?.id || ''],
+          file_ids: files.map(file => file.id),
         }
         }
       }
       }
       if (dataSourceType === DataSourceType.NOTION)
       if (dataSourceType === DataSourceType.NOTION)
@@ -529,15 +527,21 @@ const StepTwo = ({
                 <Link className='text-[#155EEF]' href={`/datasets/${datasetId}/settings`}>{t('datasetCreation.stepTwo.datasetSettingLink')}</Link>
                 <Link className='text-[#155EEF]' href={`/datasets/${datasetId}/settings`}>{t('datasetCreation.stepTwo.datasetSettingLink')}</Link>
               </div>
               </div>
             )}
             )}
-            {/* TODO multi files */}
             <div className={s.source}>
             <div className={s.source}>
               <div className={s.sourceContent}>
               <div className={s.sourceContent}>
                 {dataSourceType === DataSourceType.FILE && (
                 {dataSourceType === DataSourceType.FILE && (
                   <>
                   <>
                     <div className='mb-2 text-xs font-medium text-gray-500'>{t('datasetCreation.stepTwo.fileSource')}</div>
                     <div className='mb-2 text-xs font-medium text-gray-500'>{t('datasetCreation.stepTwo.fileSource')}</div>
                     <div className='flex items-center text-sm leading-6 font-medium text-gray-800'>
                     <div className='flex items-center text-sm leading-6 font-medium text-gray-800'>
-                      <span className={cn(s.fileIcon, file && s[file.extension])} />
-                      {getFileName(file?.name || '')}
+                      <span className={cn(s.fileIcon, files.length && s[files[0].extension])} />
+                      {getFileName(files[0].name || '')}
+                      {files.length > 1 && (
+                        <span className={s.sourceCount}>
+                          <span>{t('datasetCreation.stepTwo.other')}</span>
+                          <span>{files.length - 1}</span>
+                          <span>{t('datasetCreation.stepTwo.fileUnit')}</span>
+                        </span>
+                      )}
                     </div>
                     </div>
                   </>
                   </>
                 )}
                 )}

+ 1 - 1
web/app/components/datasets/documents/detail/settings/index.tsx

@@ -85,7 +85,7 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
             indexingType={indexingTechnique || ''}
             indexingType={indexingTechnique || ''}
             isSetting
             isSetting
             documentDetail={documentDetail}
             documentDetail={documentDetail}
-            file={documentDetail.data_source_info.upload_file}
+            files={[documentDetail.data_source_info.upload_file]}
             onSave={saveHandler}
             onSave={saveHandler}
             onCancel={cancelHandler}
             onCancel={cancelHandler}
           />
           />

+ 2 - 2
web/i18n/lang/dataset-creation.en.ts

@@ -23,10 +23,10 @@ const translation = {
       title: 'Upload text file',
       title: 'Upload text file',
       button: 'Drag and drop file, or',
       button: 'Drag and drop file, or',
       browse: 'Browse',
       browse: 'Browse',
-      tip: 'Supports txt, html, markdown, xlsx, and pdf.',
+      tip: 'Supports txt, html, markdown, xlsx, and pdf. Max 10MB each.',
       validation: {
       validation: {
         typeError: 'File type not supported',
         typeError: 'File type not supported',
-        size: 'File too large. Maximum is 15MB',
+        size: 'File too large. Maximum is 10MB',
         count: 'Multiple files not supported',
         count: 'Multiple files not supported',
       },
       },
       cancel: 'Cancel',
       cancel: 'Cancel',

+ 2 - 2
web/i18n/lang/dataset-creation.zh.ts

@@ -23,10 +23,10 @@ const translation = {
       title: '上传文本文件',
       title: '上传文本文件',
       button: '拖拽文件至此,或者',
       button: '拖拽文件至此,或者',
       browse: '选择文件',
       browse: '选择文件',
-      tip: '已支持 TXT, HTML, Markdown, PDF, XLSX',
+      tip: '已支持 TXT、 HTML、 Markdown、 PDF、 XLSX,每个文件不超过 10 MB。',
       validation: {
       validation: {
         typeError: '文件类型不支持',
         typeError: '文件类型不支持',
-        size: '文件太大了,不能超过 15MB',
+        size: '文件太大了,不能超过 10MB',
         count: '暂不支持多个文件',
         count: '暂不支持多个文件',
       },
       },
       cancel: '取消',
       cancel: '取消',