list.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { useState, useEffect } from 'react'
  4. import { TrashIcon, ArrowDownIcon } from '@heroicons/react/24/outline'
  5. import { ExclamationCircleIcon } from '@heroicons/react/24/solid'
  6. import dayjs from 'dayjs'
  7. import { pick } from 'lodash-es'
  8. import { useContext } from 'use-context-selector'
  9. import { useRouter } from 'next/navigation'
  10. import { useTranslation } from 'react-i18next'
  11. import Switch from '@/app/components/base/switch'
  12. import Divider from '@/app/components/base/divider'
  13. import Popover from '@/app/components/base/popover'
  14. import Modal from '@/app/components/base/modal'
  15. import Button from '@/app/components/base/button'
  16. import Tooltip from '@/app/components/base/tooltip'
  17. import { ToastContext } from '@/app/components/base/toast'
  18. import type { IndicatorProps } from '@/app/components/header/indicator'
  19. import Indicator from '@/app/components/header/indicator'
  20. import { asyncRunSafe } from '@/utils'
  21. import { formatNumber } from '@/utils/format'
  22. import { archiveDocument, deleteDocument, disableDocument, enableDocument } from '@/service/datasets'
  23. import type { DocumentListResponse, DocumentDisplayStatus } from '@/models/datasets'
  24. import type { CommonResponse } from '@/models/common'
  25. import cn from 'classnames'
  26. import s from './style.module.css'
  27. export const SettingsIcon: FC<{ className?: string }> = ({ className }) => {
  28. return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
  29. <path d="M2 5.33325L10 5.33325M10 5.33325C10 6.43782 10.8954 7.33325 12 7.33325C13.1046 7.33325 14 6.43782 14 5.33325C14 4.22868 13.1046 3.33325 12 3.33325C10.8954 3.33325 10 4.22868 10 5.33325ZM6 10.6666L14 10.6666M6 10.6666C6 11.7712 5.10457 12.6666 4 12.6666C2.89543 12.6666 2 11.7712 2 10.6666C2 9.56202 2.89543 8.66659 4 8.66659C5.10457 8.66659 6 9.56202 6 10.6666Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
  30. </svg>
  31. }
  32. export const FilePlusIcon: FC<{ className?: string }> = ({ className }) => {
  33. return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
  34. <path d="M13.3332 6.99992V4.53325C13.3332 3.41315 13.3332 2.85309 13.1152 2.42527C12.9234 2.04895 12.6175 1.74299 12.2412 1.55124C11.8133 1.33325 11.2533 1.33325 10.1332 1.33325H5.8665C4.7464 1.33325 4.18635 1.33325 3.75852 1.55124C3.3822 1.74299 3.07624 2.04895 2.88449 2.42527C2.6665 2.85309 2.6665 3.41315 2.6665 4.53325V11.4666C2.6665 12.5867 2.6665 13.1467 2.88449 13.5746C3.07624 13.9509 3.3822 14.2569 3.75852 14.4486C4.18635 14.6666 4.7464 14.6666 5.8665 14.6666H7.99984M9.33317 7.33325H5.33317M6.6665 9.99992H5.33317M10.6665 4.66659H5.33317M11.9998 13.9999V9.99992M9.99984 11.9999H13.9998" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
  35. </svg>
  36. }
  37. export const ArchiveIcon: FC<{ className?: string }> = ({ className }) => {
  38. return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
  39. <path d="M2.66683 5.33106C2.55749 5.32824 2.47809 5.32191 2.40671 5.30771C1.87779 5.2025 1.46432 4.78904 1.35912 4.26012C1.3335 4.13132 1.3335 3.97644 1.3335 3.66667C1.3335 3.3569 1.3335 3.20201 1.35912 3.07321C1.46432 2.54429 1.87779 2.13083 2.40671 2.02562C2.53551 2 2.69039 2 3.00016 2H13.0002C13.3099 2 13.4648 2 13.5936 2.02562C14.1225 2.13083 14.536 2.54429 14.6412 3.07321C14.6668 3.20201 14.6668 3.3569 14.6668 3.66667C14.6668 3.97644 14.6668 4.13132 14.6412 4.26012C14.536 4.78904 14.1225 5.2025 13.5936 5.30771C13.5222 5.32191 13.4428 5.32824 13.3335 5.33106M6.66683 8.66667H9.3335M2.66683 5.33333H13.3335V10.8C13.3335 11.9201 13.3335 12.4802 13.1155 12.908C12.9238 13.2843 12.6178 13.5903 12.2415 13.782C11.8137 14 11.2536 14 10.1335 14H5.86683C4.74672 14 4.18667 14 3.75885 13.782C3.38252 13.5903 3.07656 13.2843 2.88482 12.908C2.66683 12.4802 2.66683 11.9201 2.66683 10.8V5.33333Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
  40. </svg>
  41. }
  42. export const useIndexStatus = () => {
  43. const { t } = useTranslation()
  44. return {
  45. queuing: { color: 'orange', text: t('datasetDocuments.list.status.queuing') }, // waiting
  46. indexing: { color: 'blue', text: t('datasetDocuments.list.status.indexing') }, // indexing splitting parsing cleaning
  47. paused: { color: 'orange', text: t('datasetDocuments.list.status.parsed') }, // paused
  48. error: { color: 'red', text: t('datasetDocuments.list.status.error') }, // error
  49. available: { color: 'green', text: t('datasetDocuments.list.status.available') }, // completed,archived = false,enabled = true
  50. enabled: { color: 'green', text: t('datasetDocuments.list.status.enabled') }, // completed,archived = false,enabled = true
  51. disabled: { color: 'gray', text: t('datasetDocuments.list.status.disabled') }, // completed,archived = false,enabled = false
  52. archived: { color: 'gray', text: t('datasetDocuments.list.status.archived') }, // completed,archived = true
  53. }
  54. }
  55. // status item for list
  56. export const StatusItem: FC<{
  57. status: DocumentDisplayStatus;
  58. reverse?: boolean;
  59. scene?: 'list' | 'detail'
  60. textCls?: string
  61. }> = ({ status, reverse = false, scene = 'list', textCls = '' }) => {
  62. const DOC_INDEX_STATUS_MAP = useIndexStatus();
  63. const localStatus = status.toLowerCase() as keyof typeof DOC_INDEX_STATUS_MAP
  64. return <div className={
  65. cn('flex items-center',
  66. reverse ? 'flex-row-reverse' : '',
  67. scene === 'detail' ? s.statusItemDetail : '')
  68. }>
  69. <Indicator color={DOC_INDEX_STATUS_MAP[localStatus]?.color as IndicatorProps['color']} className={reverse ? 'ml-2' : 'mr-2'} />
  70. <span className={cn('text-gray-700 text-sm', textCls)}>{DOC_INDEX_STATUS_MAP[localStatus]?.text}</span>
  71. </div>
  72. }
  73. type OperationName = 'delete' | 'archive' | 'enable' | 'disable'
  74. // operation action for list and detail
  75. export const OperationAction: FC<{
  76. detail: {
  77. enabled: boolean;
  78. archived: boolean;
  79. id: string
  80. }
  81. datasetId: string;
  82. onUpdate: () => void
  83. scene?: 'list' | 'detail'
  84. className?: string
  85. }> = ({ datasetId, detail, onUpdate, scene = 'list', className = '' }) => {
  86. const { id, enabled = false, archived = false } = detail || {}
  87. const [showModal, setShowModal] = useState(false)
  88. const { notify } = useContext(ToastContext)
  89. const { t } = useTranslation()
  90. const isListScene = scene === 'list';
  91. const onOperate = async (operationName: OperationName) => {
  92. let opApi = deleteDocument
  93. switch (operationName) {
  94. case 'archive':
  95. opApi = archiveDocument
  96. break
  97. case 'enable':
  98. opApi = enableDocument
  99. break
  100. case 'disable':
  101. opApi = disableDocument
  102. break
  103. default:
  104. opApi = deleteDocument
  105. break
  106. }
  107. const [e] = await asyncRunSafe<CommonResponse>(opApi({ datasetId, documentId: id }) as Promise<CommonResponse>)
  108. if (!e)
  109. notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
  110. else
  111. notify({ type: 'error', message: t('common.actionMsg.modificationFailed') })
  112. onUpdate()
  113. }
  114. return <div
  115. className='flex items-center'
  116. onClick={(e) => e.stopPropagation()}
  117. >
  118. {isListScene && <>
  119. {archived ?
  120. <Tooltip selector={`list-switch-${id}`} content={t('datasetDocuments.list.action.enableWarning') as string} className='!font-semibold'>
  121. <div>
  122. <Switch defaultValue={false} onChange={() => { }} disabled={true} size='md' />
  123. </div>
  124. </Tooltip> :
  125. <Switch defaultValue={enabled} onChange={v => onOperate(v ? 'enable' : 'disable')} size='md' />
  126. }
  127. <Divider className='!ml-4 !mr-2 !h-3' type='vertical' />
  128. </>}
  129. <Popover
  130. htmlContent={
  131. <div className='w-full py-1'>
  132. {!isListScene && <>
  133. <div className='flex justify-between items-center mx-4 pt-2'>
  134. <span className={cn(s.actionName, 'font-medium')}>
  135. {!archived && enabled ? t('datasetDocuments.list.index.enable') : t('datasetDocuments.list.index.disable')}
  136. </span>
  137. <Tooltip
  138. selector={`detail-switch-${id}`}
  139. content={t('datasetDocuments.list.action.enableWarning') as string}
  140. className='!font-semibold'
  141. disabled={!archived}
  142. >
  143. <div>
  144. <Switch
  145. defaultValue={archived ? false : enabled}
  146. onChange={v => !archived && onOperate(v ? 'enable' : 'disable')}
  147. disabled={archived}
  148. size='md'
  149. />
  150. </div>
  151. </Tooltip>
  152. </div>
  153. <div className='mx-4 pb-1 pt-0.5 text-xs text-gray-500'>
  154. {!archived && enabled ? t('datasetDocuments.list.index.enableTip') : t('datasetDocuments.list.index.disableTip')}
  155. </div>
  156. <Divider />
  157. </>}
  158. {/* <div className={s.actionItem}>
  159. <SettingsIcon />
  160. <span className={s.actionName}>{t('datasetDocuments.list.action.settings')}</span>
  161. </div>
  162. <div className={s.actionItem} onClick={() => router.push(`/datasets/${datasetId}/documents/create`)}>
  163. <FilePlusIcon />
  164. <span className={s.actionName}>{t('datasetDocuments.list.action.uploadFile')}</span>
  165. </div>
  166. <Divider className='my-1' /> */}
  167. {!archived && <div className={s.actionItem} onClick={() => onOperate('archive')}>
  168. <ArchiveIcon />
  169. <span className={s.actionName}>{t('datasetDocuments.list.action.archive')}</span>
  170. </div>}
  171. <div className={cn(s.actionItem, s.deleteActionItem, 'group')} onClick={() => setShowModal(true)}>
  172. <TrashIcon className={'w-4 h-4 stroke-current text-gray-500 stroke-2 group-hover:text-red-500'} />
  173. <span className={cn(s.actionName, 'group-hover:text-red-500')}>{t('datasetDocuments.list.action.delete')}</span>
  174. </div>
  175. </div>
  176. }
  177. trigger='click'
  178. position='br'
  179. btnElement={<div className={cn(s.actionIcon, s.commonIcon)} />}
  180. btnClassName={(open) => cn(isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail, open ? '!bg-gray-100 !shadow-none' : '!bg-transparent')}
  181. className={`!w-[200px] h-fit !z-20 ${className}`}
  182. />
  183. {showModal && <Modal isShow={showModal} onClose={() => setShowModal(false)} className={s.delModal} closable>
  184. <div>
  185. <div className={s.warningWrapper}>
  186. <ExclamationCircleIcon className={s.warningIcon} />
  187. </div>
  188. <div className='text-xl font-semibold text-gray-900 mb-1'>{t('datasetDocuments.list.delete.title')}</div>
  189. <div className='text-sm text-gray-500 mb-10'>{t('datasetDocuments.list.delete.content')}</div>
  190. <div className='flex gap-2 justify-end'>
  191. <Button onClick={() => setShowModal(false)}>{t('common.operation.cancel')}</Button>
  192. <Button
  193. type='warning'
  194. onClick={() => onOperate('delete')}
  195. className='border-red-700 border-[0.5px]'
  196. >
  197. {t('common.operation.sure')}
  198. </Button>
  199. </div>
  200. </div>
  201. </Modal>}
  202. </div>
  203. }
  204. export const renderTdValue = (value: string | number | null, isEmptyStyle = false) => {
  205. return (
  206. <div className={cn(isEmptyStyle ? 'text-gray-400' : 'text-gray-700', s.tdValue)}>
  207. {value ?? '-'}
  208. </div>
  209. )
  210. }
  211. const renderCount = (count: number | undefined) => {
  212. if (!count) {
  213. return renderTdValue(0, true)
  214. }
  215. if (count < 1000) {
  216. return count;
  217. }
  218. return `${formatNumber((count / 1000).toFixed(1))}k`
  219. }
  220. type IDocumentListProps = {
  221. documents: DocumentListResponse['data']
  222. datasetId: string
  223. onUpdate: () => void
  224. }
  225. /**
  226. * Document list component including basic information
  227. */
  228. const DocumentList: FC<IDocumentListProps> = ({ documents = [], datasetId, onUpdate }) => {
  229. const { t } = useTranslation()
  230. const router = useRouter()
  231. const [localDocs, setLocalDocs] = useState<DocumentListResponse['data']>(documents);
  232. const [enableSort, setEnableSort] = useState(false);
  233. useEffect(() => {
  234. setLocalDocs(documents)
  235. }, [documents])
  236. const onClickSort = () => {
  237. setEnableSort(!enableSort);
  238. if (!enableSort) {
  239. const sortedDocs = [...localDocs].sort((a, b) => dayjs(a.created_at).isBefore(dayjs(b.created_at)) ? -1 : 1);
  240. setLocalDocs(sortedDocs);
  241. } else {
  242. setLocalDocs(documents);
  243. }
  244. }
  245. return (
  246. <>
  247. <table className={`w-full border-collapse border-0 text-sm mt-3 ${s.documentTable}`}>
  248. <thead className="h-8 leading-8 border-b border-gray-200 text-gray-500 font-medium text-xs uppercase">
  249. <tr>
  250. <td className='w-12'>#</td>
  251. <td>{t('datasetDocuments.list.table.header.fileName')}</td>
  252. <td className='w-24'>{t('datasetDocuments.list.table.header.words')}</td>
  253. <td className='w-24'>{t('datasetDocuments.list.table.header.hitCount')}</td>
  254. <td className='w-44'>
  255. <div className='flex justify-between items-center'>
  256. {t('datasetDocuments.list.table.header.uploadTime')}
  257. <ArrowDownIcon className={cn('h-3 w-3 stroke-current stroke-2 cursor-pointer', enableSort ? 'text-gray-500' : 'text-gray-300')} onClick={onClickSort} />
  258. </div>
  259. </td>
  260. <td className='w-40'>{t('datasetDocuments.list.table.header.status')}</td>
  261. <td className='w-20'>{t('datasetDocuments.list.table.header.action')}</td>
  262. </tr>
  263. </thead>
  264. <tbody className="text-gray-700">
  265. {localDocs.map((doc) => {
  266. const suffix = doc.name.split('.').pop() || 'txt'
  267. return <tr
  268. key={doc.id}
  269. className={'border-b border-gray-200 h-8 hover:bg-gray-50 cursor-pointer'}
  270. onClick={() => {
  271. router.push(`datasets/${datasetId}/documents/${doc.id}`)
  272. }}>
  273. <td className='text-left align-middle text-gray-500 text-xs'>{doc.position}</td>
  274. <td className={s.tdValue}>
  275. <div className={cn(s[`${doc?.data_source_info?.upload_file?.extension ?? suffix}Icon`], s.commonIcon, 'mr-1.5')}></div>
  276. <span>{doc?.name?.replace(/\.[^/.]+$/, "")}<span className='text-gray-500'>.{suffix}</span></span>
  277. </td>
  278. <td>{renderCount(doc.word_count)}</td>
  279. <td>{renderCount(doc.hit_count)}</td>
  280. <td className='text-gray-500 text-[13px]'>
  281. {dayjs.unix(doc.created_at).format(t('datasetHitTesting.dateTimeFormat') as string)}
  282. </td>
  283. <td>
  284. <StatusItem status={doc.display_status} />
  285. </td>
  286. <td>
  287. <OperationAction
  288. datasetId={datasetId}
  289. detail={pick(doc, ['enabled', 'archived', 'id'])}
  290. onUpdate={onUpdate}
  291. />
  292. </td>
  293. </tr>
  294. })}
  295. </tbody>
  296. </table>
  297. </>
  298. )
  299. }
  300. export default DocumentList