Pārlūkot izejas kodu

Feat: new pagination (#11170)

KVOJJJin 4 mēneši atpakaļ
vecāks
revīzija
18d3ffc194

+ 9 - 33
web/app/components/app/annotation/index.tsx

@@ -2,19 +2,17 @@
 import type { FC } from 'react'
 import React, { useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Pagination } from 'react-headless-pagination'
 import { useDebounce } from 'ahooks'
-import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'
 import Toast from '../../base/toast'
 import Filter from './filter'
 import type { QueryParam } from './filter'
 import List from './list'
 import EmptyElement from './empty-element'
 import HeaderOpts from './header-opts'
-import s from './style.module.css'
 import { AnnotationEnableStatus, type AnnotationItem, type AnnotationItemBasic, JobStatus } from './type'
 import ViewAnnotationModal from './view-annotation-modal'
 import cn from '@/utils/classnames'
+import Pagination from '@/app/components/base/pagination'
 import Switch from '@/app/components/base/switch'
 import { addAnnotation, delAnnotation, fetchAnnotationConfig as doFetchAnnotationConfig, editAnnotation, fetchAnnotationList, queryAnnotationJobStatus, updateAnnotationScore, updateAnnotationStatus } from '@/service/annotation'
 import Loading from '@/app/components/base/loading'
@@ -69,9 +67,10 @@ const Annotation: FC<Props> = ({
   const [queryParams, setQueryParams] = useState<QueryParam>({})
   const [currPage, setCurrPage] = React.useState<number>(0)
   const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
+  const [limit, setLimit] = React.useState<number>(APP_PAGE_LIMIT)
   const query = {
     page: currPage + 1,
-    limit: APP_PAGE_LIMIT,
+    limit,
     keyword: debouncedQueryParams.keyword || '',
   }
 
@@ -228,35 +227,12 @@ const Annotation: FC<Props> = ({
         {/* Show Pagination only if the total is more than the limit */}
         {(total && total > APP_PAGE_LIMIT)
           ? <Pagination
-            className="flex items-center w-full h-10 text-sm select-none mt-8"
-            currentPage={currPage}
-            edgePageCount={2}
-            middlePagesSiblingCount={1}
-            setCurrentPage={setCurrPage}
-            totalPages={Math.ceil(total / APP_PAGE_LIMIT)}
-            truncableClassName="w-8 px-0.5 text-center"
-            truncableText="..."
-          >
-            <Pagination.PrevButton
-              disabled={currPage === 0}
-              className={`flex items-center mr-2 text-gray-500  focus:outline-none ${currPage === 0 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
-              <ArrowLeftIcon className="mr-3 h-3 w-3" />
-              {t('appLog.table.pagination.previous')}
-            </Pagination.PrevButton>
-            <div className={`flex items-center justify-center flex-grow ${s.pagination}`}>
-              <Pagination.PageButton
-                activeClassName="bg-primary-50 dark:bg-opacity-0 text-primary-600 dark:text-white"
-                className="flex items-center justify-center h-8 w-8 rounded-full cursor-pointer"
-                inactiveClassName="text-gray-500"
-              />
-            </div>
-            <Pagination.NextButton
-              disabled={currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1}
-              className={`flex items-center mr-2 text-gray-500 focus:outline-none ${currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
-              {t('appLog.table.pagination.next')}
-              <ArrowRightIcon className="ml-3 h-3 w-3" />
-            </Pagination.NextButton>
-          </Pagination>
+            current={currPage}
+            onChange={setCurrPage}
+            total={total}
+            limit={limit}
+            onLimitChange={setLimit}
+          />
           : null}
 
         {isShowViewModal && (

+ 0 - 3
web/app/components/app/annotation/style.module.css

@@ -1,3 +0,0 @@
-.pagination li {
-  list-style: none;
-}

+ 5 - 31
web/app/components/app/annotation/view-annotation-modal/index.tsx

@@ -2,13 +2,12 @@
 import type { FC } from 'react'
 import React, { useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Pagination } from 'react-headless-pagination'
-import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'
 import EditItem, { EditItemType } from '../edit-annotation-modal/edit-item'
 import type { AnnotationItem, HitHistoryItem } from '../type'
 import s from './style.module.css'
 import HitHistoryNoData from './hit-history-no-data'
 import cn from '@/utils/classnames'
+import Pagination from '@/app/components/base/pagination'
 import Drawer from '@/app/components/base/drawer-plus'
 import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
 import Confirm from '@/app/components/base/confirm'
@@ -150,35 +149,10 @@ const ViewAnnotationModal: FC<Props> = ({
         </table>
         {(total && total > APP_PAGE_LIMIT)
           ? <Pagination
-            className="flex items-center w-full h-10 text-sm select-none mt-8"
-            currentPage={currPage}
-            edgePageCount={2}
-            middlePagesSiblingCount={1}
-            setCurrentPage={setCurrPage}
-            totalPages={Math.ceil(total / APP_PAGE_LIMIT)}
-            truncatableClassName="w-8 px-0.5 text-center"
-            truncatableText="..."
-          >
-            <Pagination.PrevButton
-              disabled={currPage === 0}
-              className={`flex items-center mr-2 text-gray-500  focus:outline-none ${currPage === 0 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
-              <ArrowLeftIcon className="mr-3 h-3 w-3" />
-              {t('appLog.table.pagination.previous')}
-            </Pagination.PrevButton>
-            <div className={`flex items-center justify-center flex-grow ${s.pagination}`}>
-              <Pagination.PageButton
-                activeClassName="bg-primary-50 dark:bg-opacity-0 text-primary-600 dark:text-white"
-                className="flex items-center justify-center h-8 w-8 rounded-full cursor-pointer"
-                inactiveClassName="text-gray-500"
-              />
-            </div>
-            <Pagination.NextButton
-              disabled={currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1}
-              className={`flex items-center mr-2 text-gray-500 focus:outline-none ${currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
-              {t('appLog.table.pagination.next')}
-              <ArrowRightIcon className="ml-3 h-3 w-3" />
-            </Pagination.NextButton>
-          </Pagination>
+            current={currPage}
+            onChange={setCurrPage}
+            total={total}
+          />
           : null}
       </div>
 

+ 1 - 1
web/app/components/app/log-annotation/index.tsx

@@ -52,7 +52,7 @@ const LogAnnotation: FC<Props> = ({
           options={options}
         />
       )}
-      <div className={cn('grow', appDetail.mode !== 'workflow' && 'mt-3')}>
+      <div className={cn('grow h-0', appDetail.mode !== 'workflow' && 'mt-3')}>
         {pageType === PageType.log && appDetail.mode !== 'workflow' && (<Log appDetail={appDetail} />)}
         {pageType === PageType.annotation && (<Annotation appDetail={appDetail} />)}
         {pageType === PageType.log && appDetail.mode === 'workflow' && (<WorkflowLog appDetail={appDetail} />)}

+ 13 - 37
web/app/components/app/log/index.tsx

@@ -2,17 +2,15 @@
 import type { FC, SVGProps } from 'react'
 import React, { useState } from 'react'
 import useSWR from 'swr'
+import Link from 'next/link'
 import { usePathname } from 'next/navigation'
-import { Pagination } from 'react-headless-pagination'
 import { useDebounce } from 'ahooks'
 import { omit } from 'lodash-es'
 import dayjs from 'dayjs'
-import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'
 import { Trans, useTranslation } from 'react-i18next'
-import Link from 'next/link'
 import List from './list'
 import Filter, { TIME_PERIOD_MAPPING } from './filter'
-import s from './style.module.css'
+import Pagination from '@/app/components/base/pagination'
 import Loading from '@/app/components/base/loading'
 import { fetchChatConversations, fetchCompletionConversations } from '@/service/log'
 import { APP_PAGE_LIMIT } from '@/config'
@@ -60,6 +58,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
     sort_by: '-created_at',
   })
   const [currPage, setCurrPage] = React.useState<number>(0)
+  const [limit, setLimit] = React.useState<number>(APP_PAGE_LIMIT)
   const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
 
   // Get the app type first
@@ -67,7 +66,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
 
   const query = {
     page: currPage + 1,
-    limit: APP_PAGE_LIMIT,
+    limit,
     ...((debouncedQueryParams.period !== '9')
       ? {
         start: dayjs().subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period].value, 'day').startOf('day').format('YYYY-MM-DD HH:mm'),
@@ -102,9 +101,9 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
   const total = isChatMode ? chatConversations?.total : completionConversations?.total
 
   return (
-    <div className='flex flex-col h-full'>
-      <p className='text-text-tertiary system-sm-regular'>{t('appLog.description')}</p>
-      <div className='flex flex-col py-4 flex-1'>
+    <div className='grow flex flex-col h-full'>
+      <p className='shrink-0 text-text-tertiary system-sm-regular'>{t('appLog.description')}</p>
+      <div className='grow max-h-[calc(100%-16px)] flex flex-col py-4 flex-1'>
         <Filter isChatMode={isChatMode} appId={appDetail.id} queryParams={queryParams} setQueryParams={setQueryParams} />
         {total === undefined
           ? <Loading type='app' />
@@ -115,35 +114,12 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
         {/* Show Pagination only if the total is more than the limit */}
         {(total && total > APP_PAGE_LIMIT)
           ? <Pagination
-            className="flex items-center w-full h-10 text-sm select-none mt-8"
-            currentPage={currPage}
-            edgePageCount={2}
-            middlePagesSiblingCount={1}
-            setCurrentPage={setCurrPage}
-            totalPages={Math.ceil(total / APP_PAGE_LIMIT)}
-            truncableClassName="w-8 px-0.5 text-center"
-            truncableText="..."
-          >
-            <Pagination.PrevButton
-              disabled={currPage === 0}
-              className={`flex items-center mr-2 text-gray-500  focus:outline-none ${currPage === 0 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
-              <ArrowLeftIcon className="mr-3 h-3 w-3" />
-              {t('appLog.table.pagination.previous')}
-            </Pagination.PrevButton>
-            <div className={`flex items-center justify-center flex-grow ${s.pagination}`}>
-              <Pagination.PageButton
-                activeClassName="bg-primary-50 dark:bg-opacity-0 text-primary-600 dark:text-white"
-                className="flex items-center justify-center h-8 w-8 rounded-full cursor-pointer"
-                inactiveClassName="text-gray-500"
-              />
-            </div>
-            <Pagination.NextButton
-              disabled={currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1}
-              className={`flex items-center mr-2 text-gray-500 focus:outline-none ${currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
-              {t('appLog.table.pagination.next')}
-              <ArrowRightIcon className="ml-3 h-3 w-3" />
-            </Pagination.NextButton>
-          </Pagination>
+            current={currPage}
+            onChange={setCurrPage}
+            total={total}
+            limit={limit}
+            onLimitChange={setLimit}
+          />
           : null}
       </div>
     </div>

+ 0 - 3
web/app/components/app/log/style.module.css

@@ -1,3 +0,0 @@
-.pagination li {
-  list-style: none;
-}

+ 9 - 33
web/app/components/app/workflow-log/index.tsx

@@ -3,14 +3,12 @@ import type { FC, SVGProps } from 'react'
 import React, { useState } from 'react'
 import useSWR from 'swr'
 import { usePathname } from 'next/navigation'
-import { Pagination } from 'react-headless-pagination'
 import { useDebounce } from 'ahooks'
-import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'
 import { Trans, useTranslation } from 'react-i18next'
 import Link from 'next/link'
 import List from './list'
 import Filter from './filter'
-import s from './style.module.css'
+import Pagination from '@/app/components/base/pagination'
 import Loading from '@/app/components/base/loading'
 import { fetchWorkflowLogs } from '@/service/log'
 import { APP_PAGE_LIMIT } from '@/config'
@@ -53,10 +51,11 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
   const [queryParams, setQueryParams] = useState<QueryParam>({ status: 'all' })
   const [currPage, setCurrPage] = React.useState<number>(0)
   const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
+  const [limit, setLimit] = React.useState<number>(APP_PAGE_LIMIT)
 
   const query = {
     page: currPage + 1,
-    limit: APP_PAGE_LIMIT,
+    limit,
     ...(debouncedQueryParams.status !== 'all' ? { status: debouncedQueryParams.status } : {}),
     ...(debouncedQueryParams.keyword ? { keyword: debouncedQueryParams.keyword } : {}),
   }
@@ -89,35 +88,12 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
         {/* Show Pagination only if the total is more than the limit */}
         {(total && total > APP_PAGE_LIMIT)
           ? <Pagination
-            className="flex items-center w-full h-10 text-sm select-none mt-8"
-            currentPage={currPage}
-            edgePageCount={2}
-            middlePagesSiblingCount={1}
-            setCurrentPage={setCurrPage}
-            totalPages={Math.ceil(total / APP_PAGE_LIMIT)}
-            truncableClassName="w-8 px-0.5 text-center"
-            truncableText="..."
-          >
-            <Pagination.PrevButton
-              disabled={currPage === 0}
-              className={`flex items-center mr-2 text-gray-500  focus:outline-none ${currPage === 0 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
-              <ArrowLeftIcon className="mr-3 h-3 w-3" />
-              {t('appLog.table.pagination.previous')}
-            </Pagination.PrevButton>
-            <div className={`flex items-center justify-center flex-grow ${s.pagination}`}>
-              <Pagination.PageButton
-                activeClassName="bg-primary-50 dark:bg-opacity-0 text-primary-600 dark:text-white"
-                className="flex items-center justify-center h-8 w-8 rounded-full cursor-pointer"
-                inactiveClassName="text-gray-500"
-              />
-            </div>
-            <Pagination.NextButton
-              disabled={currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1}
-              className={`flex items-center mr-2 text-gray-500 focus:outline-none ${currPage === Math.ceil(total / APP_PAGE_LIMIT) - 1 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600 dark:hover:text-gray-200'}`} >
-              {t('appLog.table.pagination.next')}
-              <ArrowRightIcon className="ml-3 h-3 w-3" />
-            </Pagination.NextButton>
-          </Pagination>
+            current={currPage}
+            onChange={setCurrPage}
+            total={total}
+            limit={limit}
+            onLimitChange={setLimit}
+          />
           : null}
       </div>
     </div>

+ 1 - 2
web/app/components/app/workflow-log/list.tsx

@@ -2,9 +2,7 @@
 import type { FC } from 'react'
 import React, { useState } from 'react'
 import { useTranslation } from 'react-i18next'
-// import s from './style.module.css'
 import DetailPanel from './detail'
-import cn from '@/utils/classnames'
 import type { WorkflowAppLogDetail, WorkflowLogsResponse } from '@/models/log'
 import type { App } from '@/types/app'
 import Loading from '@/app/components/base/loading'
@@ -12,6 +10,7 @@ import Drawer from '@/app/components/base/drawer'
 import Indicator from '@/app/components/header/indicator'
 import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
 import useTimestamp from '@/hooks/use-timestamp'
+import cn from '@/utils/classnames'
 
 type ILogs = {
   logs?: WorkflowLogsResponse

+ 0 - 3
web/app/components/app/workflow-log/style.module.css

@@ -1,3 +0,0 @@
-.pagination li {
-  list-style: none;
-}

+ 95 - 0
web/app/components/base/pagination/hook.ts

@@ -0,0 +1,95 @@
+import React, { useCallback } from 'react'
+import type { IPaginationProps, IUsePagination } from './type'
+
+const usePagination = ({
+  currentPage,
+  setCurrentPage,
+  truncableText = '...',
+  truncableClassName = '',
+  totalPages,
+  edgePageCount,
+  middlePagesSiblingCount,
+}: IPaginationProps): IUsePagination => {
+  const pages = Array(totalPages)
+    .fill(0)
+    .map((_, i) => i + 1)
+
+  const hasPreviousPage = currentPage > 1
+  const hasNextPage = currentPage < totalPages
+
+  const isReachedToFirst = currentPage <= middlePagesSiblingCount
+  const isReachedToLast = currentPage + middlePagesSiblingCount >= totalPages
+
+  const middlePages = React.useMemo(() => {
+    const middlePageCount = middlePagesSiblingCount * 2 + 1
+    if (isReachedToFirst)
+      return pages.slice(0, middlePageCount)
+
+    if (isReachedToLast)
+      return pages.slice(-middlePageCount)
+
+    return pages.slice(
+      currentPage - middlePagesSiblingCount,
+      currentPage + middlePagesSiblingCount + 1,
+    )
+  }, [currentPage, isReachedToFirst, isReachedToLast, middlePagesSiblingCount, pages])
+
+  const getAllPreviousPages = useCallback(() => {
+    return pages.slice(0, middlePages[0] - 1)
+  }, [middlePages, pages])
+
+  const previousPages = React.useMemo(() => {
+    if (isReachedToFirst || getAllPreviousPages().length < 1)
+      return []
+
+    return pages
+      .slice(0, edgePageCount)
+      .filter(p => !middlePages.includes(p))
+  }, [edgePageCount, getAllPreviousPages, isReachedToFirst, middlePages, pages])
+
+  const getAllNextPages = React.useMemo(() => {
+    return pages.slice(
+      middlePages[middlePages.length - 1],
+      pages[pages.length],
+    )
+  }, [pages, middlePages])
+
+  const nextPages = React.useMemo(() => {
+    if (isReachedToLast)
+      return []
+
+    if (getAllNextPages.length < 1)
+      return []
+
+    return pages
+      .slice(pages.length - edgePageCount, pages.length)
+      .filter(p => !middlePages.includes(p))
+  }, [edgePageCount, getAllNextPages.length, isReachedToLast, middlePages, pages])
+
+  const isPreviousTruncable = React.useMemo(() => {
+    // Is truncable if first value of middlePage is larger than last value of previousPages
+    return middlePages[0] > previousPages[previousPages.length - 1] + 1
+  }, [previousPages, middlePages])
+
+  const isNextTruncable = React.useMemo(() => {
+    // Is truncable if last value of middlePage is larger than first value of previousPages
+    return middlePages[middlePages.length - 1] + 1 < nextPages[0]
+  }, [nextPages, middlePages])
+
+  return {
+    currentPage,
+    setCurrentPage,
+    truncableText,
+    truncableClassName,
+    pages,
+    hasPreviousPage,
+    hasNextPage,
+    previousPages,
+    isPreviousTruncable,
+    middlePages,
+    isNextTruncable,
+    nextPages,
+  }
+}
+
+export default usePagination

+ 138 - 23
web/app/components/base/pagination/index.tsx

@@ -1,50 +1,165 @@
 import type { FC } from 'react'
 import React from 'react'
-import { Pagination } from 'react-headless-pagination'
-import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'
 import { useTranslation } from 'react-i18next'
-import s from './style.module.css'
+import { RiArrowLeftLine, RiArrowRightLine } from '@remixicon/react'
+import { useDebounceFn } from 'ahooks'
+import { Pagination } from './pagination'
+import Button from '@/app/components/base/button'
+import Input from '@/app/components/base/input'
+import cn from '@/utils/classnames'
 
 type Props = {
+  className?: string
   current: number
   onChange: (cur: number) => void
   total: number
   limit?: number
+  onLimitChange?: (limit: number) => void
 }
 
-const CustomizedPagination: FC<Props> = ({ current, onChange, total, limit = 10 }) => {
+const CustomizedPagination: FC<Props> = ({
+  className,
+  current,
+  onChange,
+  total,
+  limit = 10,
+  onLimitChange,
+}) => {
   const { t } = useTranslation()
   const totalPages = Math.ceil(total / limit)
+  const inputRef = React.useRef<HTMLDivElement>(null)
+  const [showInput, setShowInput] = React.useState(false)
+  const [inputValue, setInputValue] = React.useState<string | number>(current + 1)
+  const [showPerPageTip, setShowPerPageTip] = React.useState(false)
+
+  const { run: handlePaging } = useDebounceFn((value: string) => {
+    if (parseInt(value) > totalPages) {
+      setInputValue(totalPages)
+      onChange(totalPages - 1)
+      setShowInput(false)
+      return
+    }
+    if (parseInt(value) < 1) {
+      setInputValue(1)
+      onChange(0)
+      setShowInput(false)
+      return
+    }
+    onChange(parseInt(value) - 1)
+    setInputValue(parseInt(value))
+    setShowInput(false)
+  }, { wait: 500 })
+
+  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const value = e.target.value
+    if (!value)
+      return setInputValue('')
+    if (isNaN(parseInt(value)))
+      return setInputValue('')
+    setInputValue(parseInt(value))
+    handlePaging(value)
+  }
+
   return (
     <Pagination
-      className="flex items-center w-full h-10 text-sm select-none mt-8"
+      className={cn('flex items-center w-full px-6 py-3 select-none', className)}
       currentPage={current}
       edgePageCount={2}
       middlePagesSiblingCount={1}
       setCurrentPage={onChange}
       totalPages={totalPages}
-      truncatableClassName="w-8 px-0.5 text-center"
-      truncatableText="..."
+      truncableClassName='flex items-center justify-center w-8 px-1 py-2 system-sm-medium text-text-tertiary'
+      truncableText='...'
     >
-      <Pagination.PrevButton
-        disabled={current === 0}
-        className={`flex items-center mr-2 text-gray-500  focus:outline-none ${current === 0 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600'}`} >
-        <ArrowLeftIcon className="mr-3 h-3 w-3" />
-        {t('appLog.table.pagination.previous')}
-      </Pagination.PrevButton>
-      <div className={`flex items-center justify-center flex-grow ${s.pagination}`}>
+      <div className='flex items-center gap-0.5 p-0.5 rounded-[10px] bg-background-section-burn'>
+        <Pagination.PrevButton
+          as={<div></div>}
+          disabled={current === 0}
+        >
+          <Button
+            variant='secondary'
+            className='w-7 h-7 px-1.5'
+            disabled={current === 0}
+          >
+            <RiArrowLeftLine className='h-4 w-4' />
+          </Button>
+        </Pagination.PrevButton>
+        {!showInput && (
+          <div
+            ref={inputRef}
+            className='flex items-center gap-0.5 px-2 py-1.5 rounded-lg hover:bg-state-base-hover-alt hover:cursor-text'
+            onClick={() => setShowInput(true)}
+          >
+            <div className='system-xs-medium text-text-secondary'>{current + 1}</div>
+            <div className='system-xs-medium text-text-quaternary'>/</div>
+            <div className='system-xs-medium text-text-secondary'>{totalPages}</div>
+          </div>
+        )}
+        {showInput && (
+          <Input
+            styleCss={{
+              height: '28px',
+              width: `${inputRef.current?.clientWidth}px`,
+            }}
+            placeholder=''
+            autoFocus
+            value={inputValue}
+            onChange={handleInputChange}
+            onBlur={() => setShowInput(false)}
+          />
+        )}
+        <Pagination.NextButton
+          as={<div></div>}
+          disabled={current === totalPages - 1}
+        >
+          <Button
+            variant='secondary'
+            className='w-7 h-7 px-1.5'
+            disabled={current === totalPages - 1}
+          >
+            <RiArrowRightLine className='h-4 w-4' />
+          </Button>
+        </Pagination.NextButton>
+      </div>
+      <div className={cn('grow flex items-center justify-center gap-1 list-none')}>
         <Pagination.PageButton
-          activeClassName="bg-primary-50 text-primary-600"
-          className="flex items-center justify-center h-8 w-8 rounded-lg cursor-pointer"
-          inactiveClassName="text-gray-500"
+          className='flex items-center justify-center min-w-8 px-1 py-2 rounded-lg system-sm-medium cursor-pointer hover:bg-components-button-ghost-bg-hover'
+          activeClassName='bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover'
+          inactiveClassName='text-text-tertiary'
         />
       </div>
-      <Pagination.NextButton
-        disabled={current === totalPages - 1}
-        className={`flex items-center mr-2 text-gray-500 focus:outline-none ${current === totalPages - 1 ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:text-gray-600'}`} >
-        {t('appLog.table.pagination.next')}
-        <ArrowRightIcon className="ml-3 h-3 w-3" />
-      </Pagination.NextButton>
+      {onLimitChange && (
+        <div className='shrink-0 flex items-center gap-2'>
+          <div className='shrink-0 w-[51px] text-end text-text-tertiary system-2xs-regular-uppercase'>{showPerPageTip ? t('common.pagination.perPage') : ''}</div>
+          <div
+            className='flex items-center gap-[1px] p-0.5 rounded-[10px] bg-components-segmented-control-bg-normal'
+            onMouseEnter={() => setShowPerPageTip(true)}
+            onMouseLeave={() => setShowPerPageTip(false)}
+          >
+            <div
+              className={cn(
+                'px-2.5 py-1.5 rounded-lg border-[0.5px] border-transparent system-sm-medium text-text-tertiary cursor-pointer hover:bg-state-base-hover hover:text-text-secondary',
+                limit === 10 && 'shadow-xs border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary hover:bg-components-segmented-control-item-active-bg',
+              )}
+              onClick={() => onLimitChange?.(10)}
+            >10</div>
+            <div
+              className={cn(
+                'px-2.5 py-1.5 rounded-lg border-[0.5px] border-transparent system-sm-medium text-text-tertiary cursor-pointer hover:bg-state-base-hover hover:text-text-secondary',
+                limit === 25 && 'shadow-xs border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary hover:bg-components-segmented-control-item-active-bg',
+              )}
+              onClick={() => onLimitChange?.(25)}
+            >25</div>
+            <div
+              className={cn(
+                'px-2.5 py-1.5 rounded-lg border-[0.5px] border-transparent system-sm-medium text-text-tertiary cursor-pointer hover:bg-state-base-hover hover:text-text-secondary',
+                limit === 50 && 'shadow-xs border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary hover:bg-components-segmented-control-item-active-bg',
+              )}
+              onClick={() => onLimitChange?.(50)}
+            >50</div>
+          </div>
+        </div>
+      )}
     </Pagination>
   )
 }

+ 189 - 0
web/app/components/base/pagination/pagination.tsx

@@ -0,0 +1,189 @@
+import React from 'react'
+import clsx from 'clsx'
+import usePagination from './hook'
+import type {
+  ButtonProps,
+  IPagination,
+  IPaginationProps,
+  PageButtonProps,
+} from './type'
+
+const defaultState: IPagination = {
+  currentPage: 0,
+  setCurrentPage: () => {},
+  truncableText: '...',
+  truncableClassName: '',
+  pages: [],
+  hasPreviousPage: false,
+  hasNextPage: false,
+  previousPages: [],
+  isPreviousTruncable: false,
+  middlePages: [],
+  isNextTruncable: false,
+  nextPages: [],
+}
+
+const PaginationContext: React.Context<IPagination> = React.createContext<IPagination>(defaultState)
+
+export const PrevButton = ({
+  className,
+  children,
+  dataTestId,
+  as = <button />,
+  ...buttonProps
+}: ButtonProps) => {
+  const pagination = React.useContext(PaginationContext)
+  const previous = () => {
+    if (pagination.currentPage + 1 > 1)
+      pagination.setCurrentPage(pagination.currentPage - 1)
+  }
+
+  const disabled = pagination.currentPage === 0
+
+  return (
+    <as.type
+      {...buttonProps}
+      {...as.props}
+      className={clsx(className, as.props.className)}
+      onClick={() => previous()}
+      tabIndex={disabled ? '-1' : 0}
+      disabled={disabled}
+      data-testid={dataTestId}
+      onKeyPress={(event: React.KeyboardEvent) => {
+        event.preventDefault()
+        if (event.key === 'Enter' && !disabled)
+          previous()
+      }}
+    >
+      {as.props.children ?? children}
+    </as.type>
+  )
+}
+
+export const NextButton = ({
+  className,
+  children,
+  dataTestId,
+  as = <button />,
+  ...buttonProps
+}: ButtonProps) => {
+  const pagination = React.useContext(PaginationContext)
+  const next = () => {
+    if (pagination.currentPage + 1 < pagination.pages.length)
+      pagination.setCurrentPage(pagination.currentPage + 1)
+  }
+
+  const disabled = pagination.currentPage === pagination.pages.length - 1
+
+  return (
+    <as.type
+      {...buttonProps}
+      {...as.props}
+      className={clsx(className, as.props.className)}
+      onClick={() => next()}
+      tabIndex={disabled ? '-1' : 0}
+      disabled={disabled}
+      data-testid={dataTestId}
+      onKeyPress={(event: React.KeyboardEvent) => {
+        event.preventDefault()
+        if (event.key === 'Enter' && !disabled)
+          next()
+      }}
+    >
+      {as.props.children ?? children}
+    </as.type>
+  )
+}
+
+type ITruncableElementProps = {
+  prev?: boolean
+}
+
+const TruncableElement = ({ prev }: ITruncableElementProps) => {
+  const pagination: IPagination = React.useContext(PaginationContext)
+
+  const {
+    isPreviousTruncable,
+    isNextTruncable,
+    truncableText,
+    truncableClassName,
+  } = pagination
+
+  return ((isPreviousTruncable && prev === true) || (isNextTruncable && !prev))
+    ? (
+      <li className={truncableClassName || undefined}>{truncableText}</li>
+    )
+    : null
+}
+
+export const PageButton = ({
+  as = <a />,
+  className,
+  dataTestIdActive,
+  dataTestIdInactive,
+  activeClassName,
+  inactiveClassName,
+  renderExtraProps,
+}: PageButtonProps) => {
+  const pagination: IPagination = React.useContext(PaginationContext)
+
+  const renderPageButton = (page: number) => (
+    <li key={page}>
+      <as.type
+        data-testid={
+          clsx({
+            [`${dataTestIdActive}`]:
+              dataTestIdActive && pagination.currentPage + 1 === page,
+            [`${dataTestIdInactive}-${page}`]:
+              dataTestIdActive && pagination.currentPage + 1 !== page,
+          }) || undefined
+        }
+        tabIndex={0}
+        onKeyPress={(event: React.KeyboardEvent) => {
+          if (event.key === 'Enter')
+            pagination.setCurrentPage(page - 1)
+        }}
+        onClick={() => pagination.setCurrentPage(page - 1)}
+        className={clsx(
+          className,
+          pagination.currentPage + 1 === page
+            ? activeClassName
+            : inactiveClassName,
+        )}
+        {...as.props}
+        {...(renderExtraProps ? renderExtraProps(page) : {})}
+      >
+        {page}
+      </as.type>
+    </li>
+  )
+
+  return (
+    <>
+      {pagination.previousPages.map(renderPageButton)}
+      <TruncableElement prev />
+      {pagination.middlePages.map(renderPageButton)}
+      <TruncableElement />
+      {pagination.nextPages.map(renderPageButton)}
+    </>
+  )
+}
+
+export const Pagination = ({
+  dataTestId,
+  ...paginationProps
+}: IPaginationProps & { dataTestId?: string }) => {
+  const pagination = usePagination(paginationProps)
+
+  return (
+    <PaginationContext.Provider value={pagination}>
+      <div className={paginationProps.className} data-testid={dataTestId}>
+        {paginationProps.children}
+      </div>
+    </PaginationContext.Provider>
+  )
+}
+
+Pagination.PrevButton = PrevButton
+Pagination.NextButton = NextButton
+Pagination.PageButton = PageButton

+ 0 - 3
web/app/components/base/pagination/style.module.css

@@ -1,3 +0,0 @@
-.pagination li {
-  list-style: none;
-}

+ 58 - 0
web/app/components/base/pagination/type.ts

@@ -0,0 +1,58 @@
+import type { ButtonHTMLAttributes } from 'react'
+
+type IBasePaginationProps = {
+  currentPage: number
+  setCurrentPage: (page: number) => void
+  truncableText?: string
+  truncableClassName?: string
+}
+
+type IPaginationProps = IBasePaginationProps & {
+  totalPages: number
+  edgePageCount: number
+  middlePagesSiblingCount: number
+  className?: string
+  children?: React.ReactNode
+}
+
+type IUsePagination = IBasePaginationProps & {
+  pages: number[]
+  hasPreviousPage: boolean
+  hasNextPage: boolean
+  previousPages: number[]
+  isPreviousTruncable: boolean
+  middlePages: number[]
+  isNextTruncable: boolean
+  nextPages: number[]
+}
+
+type IPagination = IUsePagination & {
+  setCurrentPage: (page: number) => void
+}
+
+type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
+  as?: React.ReactElement
+  children?: string | React.ReactNode
+  className?: string
+  dataTestId?: string
+}
+
+type PageButtonProps = ButtonProps & {
+  /**
+   * Provide a custom ReactElement (e.g. Next/Link)
+   */
+  as?: React.ReactElement
+  activeClassName?: string
+  inactiveClassName?: string
+  dataTestIdActive?: string
+  dataTestIdInactive?: string
+  renderExtraProps?: (pageNum: number) => {}
+}
+
+export type {
+  IPaginationProps,
+  IUsePagination,
+  IPagination,
+  ButtonProps,
+  PageButtonProps,
+}

+ 3 - 0
web/i18n/en-US/common.ts

@@ -595,6 +595,9 @@ const translation = {
     expiring: 'Expiring in one day',
     expiring_plural: 'Expiring in {{count}} days',
   },
+  pagination: {
+    perPage: 'Items per page',
+  },
 }
 
 export default translation

+ 3 - 0
web/i18n/zh-Hans/common.ts

@@ -595,6 +595,9 @@ const translation = {
     expiring: '许可证还有 1 天到期',
     expiring_plural: '许可证还有 {{count}} 天到期',
   },
+  pagination: {
+    perPage: '每页显示',
+  },
 }
 
 export default translation

+ 0 - 1
web/package.json

@@ -77,7 +77,6 @@
     "react-dom": "~18.2.0",
     "react-easy-crop": "^5.0.8",
     "react-error-boundary": "^4.0.2",
-    "react-headless-pagination": "^1.1.4",
     "react-hook-form": "^7.51.4",
     "react-i18next": "^12.2.0",
     "react-infinite-scroll-component": "^6.1.0",

+ 0 - 7
web/yarn.lock

@@ -11665,13 +11665,6 @@ react-error-boundary@^4.0.2:
   dependencies:
     "@babel/runtime" "^7.12.5"
 
-react-headless-pagination@^1.1.4:
-  version "1.1.4"
-  resolved "https://registry.npmjs.org/react-headless-pagination/-/react-headless-pagination-1.1.4.tgz"
-  integrity sha512-Z5d55g3gM2BQMvHJUGm1jbbQ5Bgtq54kNlI5ca1NTwdVR8ZNunN0EdOtNKNobsFRKuZGkQ24VTIu6ulNq190Iw==
-  dependencies:
-    classnames "2.3.1"
-
 react-hook-form@^7.51.4:
   version "7.51.4"
   resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz"