feat(web): add pagination to skill review list

- useReviewList now passes page/size to backend and returns paginated response
- reviews.tsx maintains per-tab page state with prev/next controls
- namespace-reviews.tsx adapted to new response shape
- Added i18n keys for pagination (en/zh)
This commit is contained in:
xiose 2026-03-19 17:15:27 +08:00
parent f50e5c9156
commit f6c72030b6
5 changed files with 76 additions and 19 deletions

View file

@ -1,22 +1,26 @@
import { useQuery } from '@tanstack/react-query'
import { reviewApi } from '@/api/client'
import type { ReviewTask } from '@/api/types'
/**
* Loads review tasks filtered by status and optional namespace scope.
* Returns paginated response with totalElements / totalPages.
*/
async function getReviewList(status: string, namespaceId?: number): Promise<ReviewTask[]> {
const page = await reviewApi.list({ status, namespaceId })
return page.items
async function getReviewList(status: string, namespaceId?: number, page = 0, size = 20) {
const response = await reviewApi.list({ status, namespaceId, page, size })
return {
...response,
totalElements: response.total,
totalPages: response.size > 0 ? Math.ceil(response.total / response.size) : 0,
}
}
/**
* Exposes the review list query used by dashboard moderation views.
*/
export function useReviewList(status: string, namespaceId?: number) {
export function useReviewList(status: string, namespaceId?: number, page = 0, size = 20) {
return useQuery({
queryKey: ['reviews', status, namespaceId],
queryFn: () => getReviewList(status, namespaceId),
queryKey: ['reviews', status, namespaceId, page, size],
queryFn: () => getReviewList(status, namespaceId, page, size),
enabled: namespaceId === undefined || namespaceId > 0,
})
}

View file

@ -414,7 +414,10 @@
"colSubmitter": "Submitted By",
"colSubmitTime": "Submitted At",
"colReviewer": "Reviewed By",
"colReviewTime": "Reviewed At"
"colReviewTime": "Reviewed At",
"pageSummary": "Total {{total}} records, page {{page}}",
"prevPage": "Previous",
"nextPage": "Next"
},
"profileReview": {
"queueTitle": "Profile Review Queue",

View file

@ -414,7 +414,10 @@
"colSubmitter": "提交者",
"colSubmitTime": "提交时间",
"colReviewer": "审核者",
"colReviewTime": "审核时间"
"colReviewTime": "审核时间",
"pageSummary": "共 {{total}} 条记录,第 {{page}} 页",
"prevPage": "上一页",
"nextPage": "下一页"
},
"profileReview": {
"queueTitle": "资料审核队列",

View file

@ -15,12 +15,13 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
const { data: rejected } = useReviewList('REJECTED', namespaceId)
const renderItems = (items?: typeof pending) => {
if (!items || items.length === 0) {
const list = items?.items
if (!list || list.length === 0) {
return <Card className="p-10 text-center text-muted-foreground">{t('nsReviews.empty')}</Card>
}
return (
<Card className="divide-y divide-border/40">
{items.map((review) => (
{list.map((review) => (
<div key={review.id} className="p-5">
<div className="flex items-center justify-between gap-4">
<div>

View file

@ -1,6 +1,8 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { FileCheck2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import {
@ -17,6 +19,9 @@ import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { ProfileReviewTable } from './profile-review-table'
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
const PAGE_SIZE = 20
/**
* Dashboard review queue page. Each tab materializes one review status because
* the moderation workflow treats pending, approved, and rejected queues as
@ -26,6 +31,11 @@ export function ReviewsPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const { hasRole } = useAuth()
const [pages, setPages] = useState<Record<ReviewStatus, number>>({
PENDING: 0,
APPROVED: 0,
REJECTED: 0,
})
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
@ -34,9 +44,9 @@ export function ReviewsPage() {
// Determine default top-level tab
const defaultType = isSkillAdmin ? 'skill' : 'profile'
const { data: pendingReviews, isLoading: isPendingLoading } = useReviewList('PENDING')
const { data: approvedReviews, isLoading: isApprovedLoading } = useReviewList('APPROVED')
const { data: rejectedReviews, isLoading: isRejectedLoading } = useReviewList('REJECTED')
const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE)
const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE)
const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE)
const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language)
@ -44,12 +54,46 @@ export function ReviewsPage() {
navigate({ to: `/dashboard/reviews/${reviewId}` })
}
function changePage(status: ReviewStatus, nextPage: number) {
setPages((current) => ({ ...current, [status]: nextPage }))
}
function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) {
if (totalPages <= 1) return null
const currentPage = pages[status]
return (
<div className="flex flex-col gap-3 border-t border-border/60 px-6 py-4 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
<p>{t('reviews.pageSummary', { total: totalElements, page: currentPage + 1 })}</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage === 0}
onClick={() => changePage(status, currentPage - 1)}
>
{t('reviews.prevPage')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages - 1}
onClick={() => changePage(status, currentPage + 1)}
>
{t('reviews.nextPage')}
</Button>
</div>
</div>
)
}
/**
* Keeps the table rendering logic in one local helper so the per-status tabs
* stay declarative while still allowing columns to differ by workflow state.
*/
const renderReviewTable = (reviews: typeof pendingReviews, isLoading: boolean, status: string) => {
if (isLoading) {
const renderReviewTable = (query: typeof pendingQuery, status: ReviewStatus) => {
if (query.isLoading) {
return (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
@ -58,6 +102,7 @@ export function ReviewsPage() {
</div>
)
}
const reviews = query.data?.items
if (!reviews || reviews.length === 0) {
return <div className="rounded-xl border border-dashed border-border/70 p-10 text-center text-muted-foreground">{t('reviews.empty')}</div>
}
@ -101,6 +146,7 @@ export function ReviewsPage() {
))}
</TableBody>
</Table>
{query.data && renderPagination(status, query.data.totalElements, query.data.totalPages)}
</div>
)
}
@ -144,13 +190,13 @@ export function ReviewsPage() {
</TabsTrigger>
</TabsList>
<TabsContent value="PENDING" className="mt-6">
{renderReviewTable(pendingReviews, isPendingLoading, 'PENDING')}
{renderReviewTable(pendingQuery, 'PENDING')}
</TabsContent>
<TabsContent value="APPROVED" className="mt-6">
{renderReviewTable(approvedReviews, isApprovedLoading, 'APPROVED')}
{renderReviewTable(approvedQuery, 'APPROVED')}
</TabsContent>
<TabsContent value="REJECTED" className="mt-6">
{renderReviewTable(rejectedReviews, isRejectedLoading, 'REJECTED')}
{renderReviewTable(rejectedQuery, 'REJECTED')}
</TabsContent>
</Tabs>
</CardContent>