feat: add admin dashboard, audit logs, token expiry check and test suite
Frontend Features: - Add ProtectedRoute component with token expiry validation - Create AdminDashboardPage with system statistics and user management - Create AuditLogsPage with filtering and pagination - Add admin-only navigation (Shield icon) for ymirliu@panjit.com.tw - Add admin API methods to apiV2 service - Add admin type definitions (SystemStats, AuditLog, etc.) Token Management: - Auto-redirect to login on token expiry - Check authentication on route change - Show loading state during auth check - Admin privilege verification Backend Testing: - Add pytest configuration (pytest.ini) - Create test fixtures (conftest.py) - Add unit tests for auth, tasks, and admin endpoints - Add integration tests for complete workflows - Test user isolation and admin access control Documentation: - Add TESTING.md with comprehensive testing guide - Include test running instructions - Document fixtures and best practices Routes: - /admin - Admin dashboard (admin only) - /admin/audit-logs - Audit logs viewer (admin only) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
306
frontend/src/pages/AdminDashboardPage.tsx
Normal file
306
frontend/src/pages/AdminDashboardPage.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Admin Dashboard Page
|
||||
* System statistics and user management for administrators
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type { SystemStats, UserWithStats, TopUser } from '@/types/apiV2'
|
||||
import {
|
||||
Users,
|
||||
ClipboardList,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const [stats, setStats] = useState<SystemStats | null>(null)
|
||||
const [users, setUsers] = useState<UserWithStats[]>([])
|
||||
const [topUsers, setTopUsers] = useState<TopUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Fetch all data
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const [statsData, usersData, topUsersData] = await Promise.all([
|
||||
apiClientV2.getSystemStats(),
|
||||
apiClientV2.listUsers({ page: 1, page_size: 10 }),
|
||||
apiClientV2.getTopUsers({ metric: 'tasks', limit: 5 }),
|
||||
])
|
||||
|
||||
setStats(statsData)
|
||||
setUsers(usersData.users)
|
||||
setTopUsers(topUsersData)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch admin data:', err)
|
||||
setError(err.response?.data?.detail || '載入管理員資料失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-TW')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto mb-4" />
|
||||
<p className="text-gray-600">載入管理員儀表板...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-red-600 font-semibold">載入失敗</p>
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-8 h-8 text-blue-600" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">管理員儀表板</h1>
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">系統統計與用戶管理</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => navigate('/admin/audit-logs')} variant="outline">
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
審計日誌
|
||||
</Button>
|
||||
<Button onClick={fetchData} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Statistics */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
總用戶數
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total_users}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
活躍: {stats.active_users}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
總任務數
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total_tasks}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
待處理
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{stats.task_stats.pending}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4" />
|
||||
處理中
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{stats.task_stats.processing}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
已完成
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{stats.task_stats.completed}
|
||||
</div>
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
失敗: {stats.task_stats.failed}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Users */}
|
||||
{topUsers.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
活躍用戶排行
|
||||
</CardTitle>
|
||||
<CardDescription>任務數量最多的用戶</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>顯示名稱</TableHead>
|
||||
<TableHead className="text-right">總任務</TableHead>
|
||||
<TableHead className="text-right">已完成</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topUsers.map((user, index) => (
|
||||
<TableRow key={user.user_id}>
|
||||
<TableCell className="font-medium">
|
||||
<Badge variant={index === 0 ? 'default' : 'secondary'}>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.display_name || '-'}</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
{user.task_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-green-600">
|
||||
{user.completed_tasks}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent Users */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
最近用戶
|
||||
</CardTitle>
|
||||
<CardDescription>最新註冊的用戶列表</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{users.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暫無用戶</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>顯示名稱</TableHead>
|
||||
<TableHead>註冊時間</TableHead>
|
||||
<TableHead>最後登入</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead className="text-right">任務數</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.email}</TableCell>
|
||||
<TableCell>{user.display_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{formatDate(user.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{formatDate(user.last_login)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.is_active ? 'default' : 'secondary'}>
|
||||
{user.is_active ? '活躍' : '停用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div>
|
||||
<div className="font-semibold">{user.task_count}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
完成: {user.completed_tasks} | 失敗: {user.failed_tasks}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
324
frontend/src/pages/AuditLogsPage.tsx
Normal file
324
frontend/src/pages/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Audit Logs Page
|
||||
* View and filter system audit logs (admin only)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type { AuditLog } from '@/types/apiV2'
|
||||
import {
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
ChevronLeft,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select } from '@/components/ui/select'
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Filters
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [successFilter, setSuccessFilter] = useState<string>('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(50)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
|
||||
// Fetch logs
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const params: any = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
params.category = categoryFilter
|
||||
}
|
||||
|
||||
if (successFilter !== 'all') {
|
||||
params.success = successFilter === 'true'
|
||||
}
|
||||
|
||||
const response = await apiClientV2.getAuditLogs(params)
|
||||
|
||||
setLogs(response.logs)
|
||||
setTotal(response.total)
|
||||
setHasMore(response.has_more)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch audit logs:', err)
|
||||
setError(err.response?.data?.detail || '載入審計日誌失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
}, [categoryFilter, successFilter, page])
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
const handleFilterChange = () => {
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-TW')
|
||||
}
|
||||
|
||||
// Get category badge
|
||||
const getCategoryBadge = (category: string) => {
|
||||
const variants: Record<string, { variant: any; label: string }> = {
|
||||
auth: { variant: 'default', label: '認證' },
|
||||
task: { variant: 'secondary', label: '任務' },
|
||||
file: { variant: 'secondary', label: '檔案' },
|
||||
admin: { variant: 'destructive', label: '管理' },
|
||||
system: { variant: 'secondary', label: '系統' },
|
||||
}
|
||||
|
||||
const config = variants[category] || { variant: 'secondary', label: category }
|
||||
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/admin')}
|
||||
className="mr-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
返回
|
||||
</Button>
|
||||
<Shield className="w-8 h-8 text-blue-600" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">審計日誌</h1>
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">系統操作記錄與審計追蹤</p>
|
||||
</div>
|
||||
<Button onClick={() => fetchLogs()} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Filter className="w-5 h-5" />
|
||||
篩選條件
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">類別</label>
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => {
|
||||
setCategoryFilter(e.target.value)
|
||||
handleFilterChange()
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'auth', label: '認證' },
|
||||
{ value: 'task', label: '任務' },
|
||||
{ value: 'file', label: '檔案' },
|
||||
{ value: 'admin', label: '管理' },
|
||||
{ value: 'system', label: '系統' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">狀態</label>
|
||||
<Select
|
||||
value={successFilter}
|
||||
onChange={(e) => {
|
||||
setSuccessFilter(e.target.value)
|
||||
handleFilterChange()
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'true', label: '成功' },
|
||||
{ value: 'false', label: '失敗' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(categoryFilter !== 'all' || successFilter !== 'all') && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCategoryFilter('all')
|
||||
setSuccessFilter('all')
|
||||
handleFilterChange()
|
||||
}}
|
||||
>
|
||||
清除篩選
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit Logs List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">審計日誌記錄</CardTitle>
|
||||
<CardDescription>
|
||||
共 {total} 筆記錄 {hasMore && `(顯示第 ${page} 頁)`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暫無審計日誌</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>時間</TableHead>
|
||||
<TableHead>用戶</TableHead>
|
||||
<TableHead>類別</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
<TableHead>資源</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead>錯誤訊息</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{formatDate(log.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{log.user_email}</div>
|
||||
<div className="text-xs text-gray-500">ID: {log.user_id}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getCategoryBadge(log.category)}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{log.action}</TableCell>
|
||||
<TableCell>
|
||||
{log.resource_type ? (
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-700">{log.resource_type}</div>
|
||||
{log.resource_id && (
|
||||
<div className="text-xs text-gray-500 font-mono">
|
||||
{log.resource_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.success ? (
|
||||
<Badge variant="default" className="flex items-center gap-1 w-fit">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
成功
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="flex items-center gap-1 w-fit">
|
||||
<XCircle className="w-3 h-3" />
|
||||
失敗
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.error_message ? (
|
||||
<span className="text-sm text-red-600">{log.error_message}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
顯示 {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} / 共{' '}
|
||||
{total} 筆
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
上一頁
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasMore}
|
||||
>
|
||||
下一頁
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user