You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.tsx 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. 'use client'
  2. import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  3. import { useTranslation } from 'react-i18next'
  4. import { useContext } from 'use-context-selector'
  5. import { RiDeleteBinLine, RiErrorWarningFill, RiUploadCloud2Line } from '@remixicon/react'
  6. import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
  7. import cn from '@/utils/classnames'
  8. import type { CustomFile as File, FileItem } from '@/models/datasets'
  9. import { ToastContext } from '@/app/components/base/toast'
  10. import SimplePieChart from '@/app/components/base/simple-pie-chart'
  11. import { upload } from '@/service/base'
  12. import I18n from '@/context/i18n'
  13. import { LanguagesSupported } from '@/i18n-config/language'
  14. import { IS_CE_EDITION } from '@/config'
  15. import { Theme } from '@/types/app'
  16. import useTheme from '@/hooks/use-theme'
  17. import { useFileUploadConfig } from '@/service/use-common'
  18. import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store'
  19. import produce from 'immer'
  20. const FILES_NUMBER_LIMIT = 20
  21. export type LocalFileProps = {
  22. allowedExtensions: string[]
  23. notSupportBatchUpload?: boolean
  24. }
  25. const LocalFile = ({
  26. allowedExtensions,
  27. notSupportBatchUpload,
  28. }: LocalFileProps) => {
  29. const { t } = useTranslation()
  30. const { notify } = useContext(ToastContext)
  31. const { locale } = useContext(I18n)
  32. const fileList = useDataSourceStoreWithSelector(state => state.localFileList)
  33. const dataSourceStore = useDataSourceStore()
  34. const [dragging, setDragging] = useState(false)
  35. const dropRef = useRef<HTMLDivElement>(null)
  36. const dragRef = useRef<HTMLDivElement>(null)
  37. const fileUploader = useRef<HTMLInputElement>(null)
  38. const fileListRef = useRef<FileItem[]>([])
  39. const hideUpload = notSupportBatchUpload && fileList.length > 0
  40. const { data: fileUploadConfigResponse } = useFileUploadConfig()
  41. const supportTypesShowNames = useMemo(() => {
  42. const extensionMap: { [key: string]: string } = {
  43. md: 'markdown',
  44. pptx: 'pptx',
  45. htm: 'html',
  46. xlsx: 'xlsx',
  47. docx: 'docx',
  48. }
  49. return allowedExtensions
  50. .map(item => extensionMap[item] || item) // map to standardized extension
  51. .map(item => item.toLowerCase()) // convert to lower case
  52. .filter((item, index, self) => self.indexOf(item) === index) // remove duplicates
  53. .map(item => item.toUpperCase()) // convert to upper case
  54. .join(locale !== LanguagesSupported[1] ? ', ' : '、 ')
  55. }, [locale, allowedExtensions])
  56. const ACCEPTS = allowedExtensions.map((ext: string) => `.${ext}`)
  57. const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
  58. file_size_limit: 15,
  59. batch_count_limit: 5,
  60. }, [fileUploadConfigResponse])
  61. const updateFile = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => {
  62. const { setLocalFileList } = dataSourceStore.getState()
  63. const newList = produce(list, (draft) => {
  64. const targetIndex = draft.findIndex(file => file.fileID === fileItem.fileID)
  65. draft[targetIndex] = {
  66. ...draft[targetIndex],
  67. progress,
  68. }
  69. })
  70. setLocalFileList(newList)
  71. }, [dataSourceStore])
  72. const updateFileList = useCallback((preparedFiles: FileItem[]) => {
  73. const { setLocalFileList } = dataSourceStore.getState()
  74. setLocalFileList(preparedFiles)
  75. }, [dataSourceStore])
  76. const handlePreview = useCallback((file: File) => {
  77. const { setCurrentLocalFile } = dataSourceStore.getState()
  78. if (file.id)
  79. setCurrentLocalFile(file)
  80. }, [dataSourceStore])
  81. // utils
  82. const getFileType = (currentFile: File) => {
  83. if (!currentFile)
  84. return ''
  85. const arr = currentFile.name.split('.')
  86. return arr[arr.length - 1]
  87. }
  88. const getFileSize = (size: number) => {
  89. if (size / 1024 < 10)
  90. return `${(size / 1024).toFixed(2)}KB`
  91. return `${(size / 1024 / 1024).toFixed(2)}MB`
  92. }
  93. const isValid = useCallback((file: File) => {
  94. const { size } = file
  95. const ext = `.${getFileType(file)}`
  96. const isValidType = ACCEPTS.includes(ext.toLowerCase())
  97. if (!isValidType)
  98. notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.typeError') })
  99. const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024
  100. if (!isValidSize)
  101. notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.size', { size: fileUploadConfig.file_size_limit }) })
  102. return isValidType && isValidSize
  103. }, [fileUploadConfig, notify, t, ACCEPTS])
  104. const fileUpload = useCallback(async (fileItem: FileItem): Promise<FileItem> => {
  105. const formData = new FormData()
  106. formData.append('file', fileItem.file)
  107. const onProgress = (e: ProgressEvent) => {
  108. if (e.lengthComputable) {
  109. const percent = Math.floor(e.loaded / e.total * 100)
  110. updateFile(fileItem, percent, fileListRef.current)
  111. }
  112. }
  113. return upload({
  114. xhr: new XMLHttpRequest(),
  115. data: formData,
  116. onprogress: onProgress,
  117. }, false, undefined, '?source=datasets')
  118. .then((res: File) => {
  119. const completeFile = {
  120. fileID: fileItem.fileID,
  121. file: res,
  122. progress: -1,
  123. }
  124. const index = fileListRef.current.findIndex(item => item.fileID === fileItem.fileID)
  125. fileListRef.current[index] = completeFile
  126. updateFile(completeFile, 100, fileListRef.current)
  127. return Promise.resolve({ ...completeFile })
  128. })
  129. .catch((e) => {
  130. notify({ type: 'error', message: e?.response?.code === 'forbidden' ? e?.response?.message : t('datasetCreation.stepOne.uploader.failed') })
  131. updateFile(fileItem, -2, fileListRef.current)
  132. return Promise.resolve({ ...fileItem })
  133. })
  134. .finally()
  135. }, [fileListRef, notify, updateFile, t])
  136. const uploadBatchFiles = useCallback((bFiles: FileItem[]) => {
  137. bFiles.forEach(bf => (bf.progress = 0))
  138. return Promise.all(bFiles.map(fileUpload))
  139. }, [fileUpload])
  140. const uploadMultipleFiles = useCallback(async (files: FileItem[]) => {
  141. const batchCountLimit = fileUploadConfig.batch_count_limit
  142. const length = files.length
  143. let start = 0
  144. let end = 0
  145. while (start < length) {
  146. if (start + batchCountLimit > length)
  147. end = length
  148. else
  149. end = start + batchCountLimit
  150. const bFiles = files.slice(start, end)
  151. await uploadBatchFiles(bFiles)
  152. start = end
  153. }
  154. }, [fileUploadConfig, uploadBatchFiles])
  155. const initialUpload = useCallback((files: File[]) => {
  156. if (!files.length)
  157. return false
  158. if (files.length + fileList.length > FILES_NUMBER_LIMIT && !IS_CE_EDITION) {
  159. notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.filesNumber', { filesNumber: FILES_NUMBER_LIMIT }) })
  160. return false
  161. }
  162. const preparedFiles = files.map((file, index) => ({
  163. fileID: `file${index}-${Date.now()}`,
  164. file,
  165. progress: -1,
  166. }))
  167. const newFiles = [...fileListRef.current, ...preparedFiles]
  168. updateFileList(newFiles)
  169. fileListRef.current = newFiles
  170. uploadMultipleFiles(preparedFiles)
  171. }, [updateFileList, uploadMultipleFiles, notify, t, fileList])
  172. const handleDragEnter = (e: DragEvent) => {
  173. e.preventDefault()
  174. e.stopPropagation()
  175. e.target !== dragRef.current && setDragging(true)
  176. }
  177. const handleDragOver = (e: DragEvent) => {
  178. e.preventDefault()
  179. e.stopPropagation()
  180. }
  181. const handleDragLeave = (e: DragEvent) => {
  182. e.preventDefault()
  183. e.stopPropagation()
  184. e.target === dragRef.current && setDragging(false)
  185. }
  186. const handleDrop = useCallback((e: DragEvent) => {
  187. e.preventDefault()
  188. e.stopPropagation()
  189. setDragging(false)
  190. if (!e.dataTransfer)
  191. return
  192. let files = [...e.dataTransfer.files] as File[]
  193. if (notSupportBatchUpload)
  194. files = files.slice(0, 1)
  195. const validFiles = files.filter(isValid)
  196. initialUpload(validFiles)
  197. }, [initialUpload, isValid, notSupportBatchUpload])
  198. const selectHandle = useCallback(() => {
  199. if (fileUploader.current)
  200. fileUploader.current.click()
  201. }, [])
  202. const removeFile = (fileID: string) => {
  203. if (fileUploader.current)
  204. fileUploader.current.value = ''
  205. fileListRef.current = fileListRef.current.filter(item => item.fileID !== fileID)
  206. updateFileList([...fileListRef.current])
  207. }
  208. const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  209. const files = [...(e.target.files ?? [])] as File[]
  210. initialUpload(files.filter(isValid))
  211. }, [isValid, initialUpload])
  212. const { theme } = useTheme()
  213. const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
  214. useEffect(() => {
  215. const dropElement = dropRef.current
  216. dropElement?.addEventListener('dragenter', handleDragEnter)
  217. dropElement?.addEventListener('dragover', handleDragOver)
  218. dropElement?.addEventListener('dragleave', handleDragLeave)
  219. dropElement?.addEventListener('drop', handleDrop)
  220. return () => {
  221. dropElement?.removeEventListener('dragenter', handleDragEnter)
  222. dropElement?.removeEventListener('dragover', handleDragOver)
  223. dropElement?.removeEventListener('dragleave', handleDragLeave)
  224. dropElement?.removeEventListener('drop', handleDrop)
  225. }
  226. }, [handleDrop])
  227. return (
  228. <div className='flex flex-col'>
  229. {!hideUpload && (
  230. <input
  231. ref={fileUploader}
  232. id='fileUploader'
  233. className='hidden'
  234. type='file'
  235. multiple={!notSupportBatchUpload}
  236. accept={ACCEPTS.join(',')}
  237. onChange={fileChangeHandle}
  238. />
  239. )}
  240. {!hideUpload && (
  241. <div
  242. ref={dropRef}
  243. className={cn(
  244. 'relative box-border flex min-h-20 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg px-4 py-3 text-xs leading-4 text-text-tertiary',
  245. dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
  246. )}>
  247. <div className='flex min-h-5 items-center justify-center text-sm leading-4 text-text-secondary'>
  248. <RiUploadCloud2Line className='mr-2 size-5' />
  249. <span>
  250. {t('datasetCreation.stepOne.uploader.button')}
  251. {allowedExtensions.length > 0 && (
  252. <label className='ml-1 cursor-pointer text-text-accent' onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.browse')}</label>
  253. )}
  254. </span>
  255. </div>
  256. <div>{t('datasetCreation.stepOne.uploader.tip', {
  257. size: fileUploadConfig.file_size_limit,
  258. supportTypes: supportTypesShowNames,
  259. batchCount: fileUploadConfig.batch_count_limit,
  260. })}</div>
  261. {dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
  262. </div>
  263. )}
  264. {fileList.length > 0 && (
  265. <div className='mt-1 flex flex-col gap-y-1'>
  266. {fileList.map((fileItem, index) => {
  267. const isUploading = fileItem.progress >= 0 && fileItem.progress < 100
  268. const isError = fileItem.progress === -2
  269. return (
  270. <div
  271. key={`${fileItem.fileID}-${index}`}
  272. onClick={handlePreview.bind(null, fileItem.file)}
  273. className={cn(
  274. 'flex h-12 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs shadow-shadow-shadow-4',
  275. isError && 'border-state-destructive-border bg-state-destructive-hover',
  276. )}
  277. >
  278. <div className='flex w-12 shrink-0 items-center justify-center'>
  279. <DocumentFileIcon
  280. size='lg'
  281. className='shrink-0'
  282. name={fileItem.file.name}
  283. extension={getFileType(fileItem.file)}
  284. />
  285. </div>
  286. <div className='flex shrink grow flex-col gap-0.5'>
  287. <div className='flex w-full'>
  288. <div className='w-0 grow truncate text-xs text-text-secondary'>{fileItem.file.name}</div>
  289. </div>
  290. <div className='w-full truncate text-2xs leading-3 text-text-tertiary'>
  291. <span className='uppercase'>{getFileType(fileItem.file)}</span>
  292. <span className='px-1 text-text-quaternary'>·</span>
  293. <span>{getFileSize(fileItem.file.size)}</span>
  294. </div>
  295. </div>
  296. <div className='flex w-16 shrink-0 items-center justify-end gap-1 pr-3'>
  297. {isUploading && (
  298. <SimplePieChart percentage={fileItem.progress} stroke={chartColor} fill={chartColor} animationDuration={0} />
  299. )}
  300. {
  301. isError && (
  302. <RiErrorWarningFill className='size-4 text-text-destructive' />
  303. )
  304. }
  305. <span className='flex h-6 w-6 cursor-pointer items-center justify-center' onClick={(e) => {
  306. e.stopPropagation()
  307. removeFile(fileItem.fileID)
  308. }}>
  309. <RiDeleteBinLine className='size-4 text-text-tertiary' />
  310. </span>
  311. </div>
  312. </div>
  313. )
  314. })}
  315. </div>
  316. )}
  317. </div>
  318. )
  319. }
  320. export default LocalFile