您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

csv-uploader.tsx 8.1KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  4. import {
  5. RiDeleteBinLine,
  6. } from '@remixicon/react'
  7. import { useTranslation } from 'react-i18next'
  8. import { useContext } from 'use-context-selector'
  9. import cn from '@/utils/classnames'
  10. import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
  11. import { ToastContext } from '@/app/components/base/toast'
  12. import Button from '@/app/components/base/button'
  13. import type { FileItem } from '@/models/datasets'
  14. import { upload } from '@/service/base'
  15. import useSWR from 'swr'
  16. import { fetchFileUploadConfig } from '@/service/common'
  17. import SimplePieChart from '@/app/components/base/simple-pie-chart'
  18. import { Theme } from '@/types/app'
  19. import useTheme from '@/hooks/use-theme'
  20. export type Props = {
  21. file: FileItem | undefined
  22. updateFile: (file?: FileItem) => void
  23. }
  24. const CSVUploader: FC<Props> = ({
  25. file,
  26. updateFile,
  27. }) => {
  28. const { t } = useTranslation()
  29. const { notify } = useContext(ToastContext)
  30. const [dragging, setDragging] = useState(false)
  31. const dropRef = useRef<HTMLDivElement>(null)
  32. const dragRef = useRef<HTMLDivElement>(null)
  33. const fileUploader = useRef<HTMLInputElement>(null)
  34. const { data: fileUploadConfigResponse } = useSWR({ url: '/files/upload' }, fetchFileUploadConfig)
  35. const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
  36. file_size_limit: 15,
  37. }, [fileUploadConfigResponse])
  38. const fileUpload = useCallback(async (fileItem: FileItem): Promise<FileItem> => {
  39. fileItem.progress = 0
  40. const formData = new FormData()
  41. formData.append('file', fileItem.file)
  42. const onProgress = (e: ProgressEvent) => {
  43. if (e.lengthComputable) {
  44. const progress = Math.floor(e.loaded / e.total * 100)
  45. updateFile({
  46. ...fileItem,
  47. progress,
  48. })
  49. }
  50. }
  51. return upload({
  52. xhr: new XMLHttpRequest(),
  53. data: formData,
  54. onprogress: onProgress,
  55. }, false, undefined, '?source=datasets')
  56. .then((res: File) => {
  57. const completeFile = {
  58. fileID: fileItem.fileID,
  59. file: res,
  60. progress: 100,
  61. }
  62. updateFile(completeFile)
  63. return Promise.resolve({ ...completeFile })
  64. })
  65. .catch((e) => {
  66. notify({ type: 'error', message: e?.response?.code === 'forbidden' ? e?.response?.message : t('datasetCreation.stepOne.uploader.failed') })
  67. const errorFile = {
  68. ...fileItem,
  69. progress: -2,
  70. }
  71. updateFile(errorFile)
  72. return Promise.resolve({ ...errorFile })
  73. })
  74. .finally()
  75. }, [notify, t, updateFile])
  76. const uploadFile = useCallback(async (fileItem: FileItem) => {
  77. await fileUpload(fileItem)
  78. }, [fileUpload])
  79. const initialUpload = useCallback((file?: File) => {
  80. if (!file)
  81. return false
  82. const newFile: FileItem = {
  83. fileID: `file0-${Date.now()}`,
  84. file,
  85. progress: -1,
  86. }
  87. updateFile(newFile)
  88. uploadFile(newFile)
  89. }, [updateFile, uploadFile])
  90. const handleDragEnter = (e: DragEvent) => {
  91. e.preventDefault()
  92. e.stopPropagation()
  93. e.target !== dragRef.current && setDragging(true)
  94. }
  95. const handleDragOver = (e: DragEvent) => {
  96. e.preventDefault()
  97. e.stopPropagation()
  98. }
  99. const handleDragLeave = (e: DragEvent) => {
  100. e.preventDefault()
  101. e.stopPropagation()
  102. e.target === dragRef.current && setDragging(false)
  103. }
  104. const handleDrop = (e: DragEvent) => {
  105. e.preventDefault()
  106. e.stopPropagation()
  107. setDragging(false)
  108. if (!e.dataTransfer)
  109. return
  110. const files = [...e.dataTransfer.files]
  111. if (files.length > 1) {
  112. notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.count') })
  113. return
  114. }
  115. initialUpload(files[0])
  116. }
  117. const selectHandle = () => {
  118. if (fileUploader.current)
  119. fileUploader.current.click()
  120. }
  121. const removeFile = () => {
  122. if (fileUploader.current)
  123. fileUploader.current.value = ''
  124. updateFile()
  125. }
  126. const getFileType = (currentFile: File) => {
  127. if (!currentFile)
  128. return ''
  129. const arr = currentFile.name.split('.')
  130. return arr[arr.length - 1]
  131. }
  132. const isValid = useCallback((file?: File) => {
  133. if (!file)
  134. return false
  135. const { size } = file
  136. const ext = `.${getFileType(file)}`
  137. const isValidType = ext.toLowerCase() === '.csv'
  138. if (!isValidType)
  139. notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.typeError') })
  140. const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024
  141. if (!isValidSize)
  142. notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.size', { size: fileUploadConfig.file_size_limit }) })
  143. return isValidType && isValidSize
  144. }, [fileUploadConfig, notify, t])
  145. const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
  146. const currentFile = e.target.files?.[0]
  147. if (!isValid(currentFile))
  148. return
  149. initialUpload(currentFile)
  150. }
  151. const { theme } = useTheme()
  152. const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
  153. useEffect(() => {
  154. dropRef.current?.addEventListener('dragenter', handleDragEnter)
  155. dropRef.current?.addEventListener('dragover', handleDragOver)
  156. dropRef.current?.addEventListener('dragleave', handleDragLeave)
  157. dropRef.current?.addEventListener('drop', handleDrop)
  158. return () => {
  159. dropRef.current?.removeEventListener('dragenter', handleDragEnter)
  160. dropRef.current?.removeEventListener('dragover', handleDragOver)
  161. dropRef.current?.removeEventListener('dragleave', handleDragLeave)
  162. dropRef.current?.removeEventListener('drop', handleDrop)
  163. }
  164. }, [])
  165. return (
  166. <div className='mt-6'>
  167. <input
  168. ref={fileUploader}
  169. style={{ display: 'none' }}
  170. type="file"
  171. id="fileUploader"
  172. accept='.csv'
  173. onChange={fileChangeHandle}
  174. />
  175. <div ref={dropRef}>
  176. {!file && (
  177. <div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur text-sm font-normal', dragging && 'border border-divider-subtle bg-components-panel-on-panel-item-bg-hover')}>
  178. <div className='flex w-full items-center justify-center space-x-2'>
  179. <CSVIcon className="shrink-0" />
  180. <div className='text-text-secondary'>
  181. {t('datasetDocuments.list.batchModal.csvUploadTitle')}
  182. <span className='cursor-pointer text-text-accent' onClick={selectHandle}>{t('datasetDocuments.list.batchModal.browse')}</span>
  183. </div>
  184. </div>
  185. {dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
  186. </div>
  187. )}
  188. {file && (
  189. <div className={cn('group flex h-20 items-center rounded-xl border border-components-panel-border bg-components-panel-bg-blur px-6 text-sm font-normal', 'hover:border-divider-subtle hover:bg-components-panel-on-panel-item-bg-hover')}>
  190. <CSVIcon className="shrink-0" />
  191. <div className='ml-2 flex w-0 grow'>
  192. <span className='max-w-[calc(100%_-_30px)] overflow-hidden text-ellipsis whitespace-nowrap text-text-primary'>{file.file.name.replace(/.csv$/, '')}</span>
  193. <span className='shrink-0 text-text-secondary'>.csv</span>
  194. </div>
  195. <div className='hidden items-center group-hover:flex'>
  196. {(file.progress < 100 && file.progress >= 0) && (
  197. <>
  198. <SimplePieChart percentage={file.progress} stroke={chartColor} fill={chartColor} animationDuration={0}/>
  199. <div className='mx-2 h-4 w-px bg-text-secondary'/>
  200. </>
  201. )}
  202. <Button onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button>
  203. <div className='mx-2 h-4 w-px bg-text-secondary' />
  204. <div className='cursor-pointer p-2' onClick={removeFile}>
  205. <RiDeleteBinLine className='h-4 w-4 text-text-secondary' />
  206. </div>
  207. </div>
  208. </div>
  209. )}
  210. </div>
  211. </div>
  212. )
  213. }
  214. export default React.memo(CSVUploader)