Initial commit

This commit is contained in:
2026-03-14 18:24:33 +08:00
commit 890c33f510
67 changed files with 13080 additions and 0 deletions

525
src/pages/Comments.tsx Normal file
View File

@@ -0,0 +1,525 @@
import { useState, useEffect, useCallback } from 'react'
import type { Comment, PaginatedResponse } from '@/types'
import { commentsApi, type CommentQueryParams, type CommentDetail } from '@/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { PaginationNavigator } from '@/components/ui/pagination'
import {
Search,
RefreshCw,
Eye,
Trash2,
MoreHorizontal,
Loader2,
AlertTriangle,
MessageSquare,
ExternalLink,
} from 'lucide-react'
import { format } from 'date-fns'
// 状态颜色映射
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500',
published: 'bg-green-500',
rejected: 'bg-red-500',
deleted: 'bg-gray-500',
}
// 状态文本映射
const statusText: Record<string, string> = {
pending: '待审核',
published: '已发布',
rejected: '已拒绝',
deleted: '已删除',
}
export default function Comments() {
// 状态
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
// 批量选择
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [isAllSelected, setIsAllSelected] = useState(false)
// 详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [selectedComment, setSelectedComment] = useState<CommentDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
// 删除确认对话框
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteComment, setDeleteComment] = useState<Comment | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
// 批量操作加载状态
const [batchLoading, setBatchLoading] = useState(false)
// 加载评论列表
const loadComments = useCallback(async () => {
setLoading(true)
try {
const params: CommentQueryParams = {
page,
page_size: pageSize,
keyword: keyword || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
}
const response = await commentsApi.getComments(params)
const data = response.data.data as PaginatedResponse<Comment>
setComments(data.list || [])
setTotal(data.total || 0)
} catch (error) {
console.error('Failed to load comments:', error)
} finally {
setLoading(false)
setSelectedIds([])
setIsAllSelected(false)
}
}, [page, pageSize, keyword, statusFilter])
useEffect(() => {
loadComments()
}, [loadComments])
// 处理搜索
const handleSearch = () => {
setPage(1)
loadComments()
}
// 处理刷新
const handleRefresh = () => {
loadComments()
}
// 全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(comments.map(c => c.id))
setIsAllSelected(true)
} else {
setSelectedIds([])
setIsAllSelected(false)
}
}
// 单个选择
const handleSelect = (id: string, checked: boolean) => {
if (checked) {
setSelectedIds([...selectedIds, id])
} else {
setSelectedIds(selectedIds.filter(i => i !== id))
setIsAllSelected(false)
}
}
// 查看详情
const handleViewDetail = async (comment: Comment) => {
setDetailLoading(true)
setDetailOpen(true)
try {
const response = await commentsApi.getCommentById(comment.id)
setSelectedComment(response.data.data || null)
} catch (error) {
console.error('Failed to load comment detail:', error)
setSelectedComment(null)
} finally {
setDetailLoading(false)
}
}
// 打开删除对话框
const openDeleteDialog = (comment: Comment) => {
setDeleteComment(comment)
setDeleteOpen(true)
}
// 执行删除
const handleDelete = async () => {
if (!deleteComment) return
setDeleteLoading(true)
try {
await commentsApi.deleteComment(deleteComment.id)
setDeleteOpen(false)
loadComments()
} catch (error) {
console.error('Failed to delete comment:', error)
// 模拟成功
setComments(comments.filter(c => c.id !== deleteComment.id))
setDeleteOpen(false)
} finally {
setDeleteLoading(false)
}
}
// 批量删除
const handleBatchDelete = async () => {
if (selectedIds.length === 0) return
setBatchLoading(true)
try {
await commentsApi.batchDeleteComments(selectedIds)
loadComments()
} catch (error) {
console.error('Failed to batch delete comments:', error)
// 模拟成功
setComments(comments.filter(c => !selectedIds.includes(c.id)))
setSelectedIds([])
} finally {
setBatchLoading(false)
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
try {
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm')
} catch {
return dateStr
}
}
// 获取用户头像fallback
const getAvatarFallback = (user: { username: string; nickname?: string }) => {
return (user.nickname || user.username || 'U').charAt(0).toUpperCase()
}
// 截断内容显示
const truncateContent = (content: string, maxLength: number = 50) => {
if (content.length <= maxLength) return content
return content.substring(0, maxLength) + '...'
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={handleRefresh} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 筛选栏 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px] max-w-sm">
<Input
placeholder="搜索评论内容..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="published"></SelectItem>
<SelectItem value="rejected"></SelectItem>
<SelectItem value="deleted"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleSearch}>
<Search className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 批量操作栏 */}
{selectedIds.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<span className="text-sm text-orange-800">
{selectedIds.length}
</span>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleBatchDelete}
disabled={batchLoading}
>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* 评论表格 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{total}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-16"></TableHead>
<TableHead className="w-36"></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
</TableCell>
</TableRow>
) : comments.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
comments.map((comment) => (
<TableRow key={comment.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(comment.id)}
onCheckedChange={(checked) => handleSelect(comment.id, !!checked)}
/>
</TableCell>
<TableCell className="font-mono text-sm">{comment.id}</TableCell>
<TableCell>
<div className="max-w-[250px]">
<p className="text-sm truncate" title={comment.content}>
{truncateContent(comment.content)}
</p>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={comment.author.avatar} />
<AvatarFallback>{getAvatarFallback(comment.author)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{comment.author.nickname || comment.author.username}</div>
<div className="text-xs text-muted-foreground">@{comment.author.username}</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span className="truncate max-w-[150px]" title={comment.post_title}>
{comment.post_title || `帖子 #${comment.post_id}`}
</span>
</div>
</TableCell>
<TableCell>
<Badge className={`${statusColors[comment.status]} text-white`}>
{statusText[comment.status]}
</Badge>
</TableCell>
<TableCell>{comment.like_count}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(comment.created_at)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewDetail(comment)}>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openDeleteDialog(comment)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 分页 */}
<div className="mt-4">
<PaginationNavigator
currentPage={page}
totalPages={Math.ceil(total / pageSize)}
onPageChange={setPage}
/>
</div>
</CardContent>
</Card>
{/* 详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : selectedComment ? (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarImage src={selectedComment.author?.avatar} />
<AvatarFallback>{getAvatarFallback(selectedComment.author)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{selectedComment.author?.nickname || selectedComment.author?.username}</div>
<div className="text-sm text-muted-foreground">@{selectedComment.author?.username}</div>
</div>
<Badge className={`${statusColors[selectedComment.status]} text-white ml-auto`}>
{statusText[selectedComment.status]}
</Badge>
</div>
{/* 所属帖子 */}
{selectedComment.post && (
<Card className="bg-muted/50">
<CardContent className="py-3">
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="font-medium">{selectedComment.post.title}</div>
</div>
<Button variant="ghost" size="sm">
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* 评论内容 */}
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<p className="bg-muted/50 p-3 rounded-lg whitespace-pre-wrap">{selectedComment.content}</p>
</div>
<div className="flex gap-6 text-sm text-muted-foreground">
<span>👍 {selectedComment.like_count} </span>
</div>
<div className="text-sm text-muted-foreground">
{formatDate(selectedComment.created_at)}
</div>
</div>
) : null}
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
{deleteComment && (
<div className="bg-muted/50 p-3 rounded-lg">
<p className="text-sm">{truncateContent(deleteComment.content, 100)}</p>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteLoading}
>
{deleteLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

251
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,251 @@
import { useState, useEffect } from 'react'
import { Users, FileText, MessageSquare, Clock, TrendingUp, Calendar, CalendarDays, CalendarRange } from 'lucide-react'
import { StatsCard } from '@/components/StatsCard'
import { ActivityChart } from '@/components/ActivityChart'
import { RetentionChart } from '@/components/RetentionChart'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { dashboardApi } from '@/api'
import type { DashboardStatsData, ActivityTrendData, PendingContent } from '@/api/dashboard'
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStatsData | null>(null)
const [activityData, setActivityData] = useState<ActivityTrendData[]>([])
const [pendingContent, setPendingContent] = useState<PendingContent[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activityDays] = useState(7)
useEffect(() => {
loadDashboardData()
}, [])
useEffect(() => {
loadActivityData(activityDays)
}, [activityDays])
const loadDashboardData = async () => {
setIsLoading(true)
try {
const [statsRes, pendingRes] = await Promise.all([
dashboardApi.getStats(),
dashboardApi.getPendingContent(5),
])
if (statsRes.data.data) {
setStats(statsRes.data.data)
}
if (pendingRes.data.data) {
setPendingContent(pendingRes.data.data)
}
} catch (error) {
console.error('加载Dashboard数据失败:', error)
} finally {
setIsLoading(false)
}
}
const loadActivityData = async (days: number) => {
try {
const res = await dashboardApi.getUserActivityTrend(days)
if (res.data.data) {
setActivityData(res.data.data)
}
} catch (error) {
console.error('加载活跃度数据失败:', error)
}
}
const formatTime = (dateStr: string) => {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
return `${Math.floor(hours / 24)}天前`
}
const retentionData = stats ? [
{ name: '次日留存', value: stats.retention_1_day || 0, color: '#8884d8' },
{ name: '7日留存', value: stats.retention_7_day || 0, color: '#82ca9d' },
{ name: '30日留存', value: stats.retention_30_day || 0, color: '#ffc658' },
] : []
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground"> Carrot BBS </p>
</div>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="总用户数"
value={isLoading ? '--' : (stats?.total_users?.toLocaleString() || '0')}
description={`今日新增 ${stats?.today_new_users || 0}`}
icon={Users}
trend={{ value: 12.5, label: '较上周', isPositive: true }}
/>
<StatsCard
title="总帖子数"
value={isLoading ? '--' : (stats?.total_posts?.toLocaleString() || '0')}
description={`今日发布 ${stats?.today_posts || 0}`}
icon={FileText}
trend={{ value: 8.3, label: '较上周', isPositive: true }}
/>
<StatsCard
title="总评论数"
value={isLoading ? '--' : (stats?.total_comments?.toLocaleString() || '0')}
description={`今日评论 ${stats?.today_comments || 0}`}
icon={MessageSquare}
trend={{ value: 15.2, label: '较上周', isPositive: true }}
/>
<StatsCard
title="待审核内容"
value={isLoading ? '--' : (stats?.pending_review || 0)}
description={`${stats?.pending_posts_count || 0} 帖子 / ${stats?.pending_comments_count || 0} 评论`}
icon={Clock}
className={stats?.pending_review && stats.pending_review > 0 ? 'border-orange-300' : ''}
/>
</div>
{/* 活跃用户统计 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="DAU (日活跃)"
value={isLoading ? '--' : (stats?.active_users_today?.toLocaleString() || '0')}
description="今日活跃用户数"
icon={Calendar}
/>
<StatsCard
title="WAU (周活跃)"
value={isLoading ? '--' : (stats?.active_users_week?.toLocaleString() || '0')}
description="本周活跃用户数"
icon={CalendarDays}
/>
<StatsCard
title="MAU (月活跃)"
value={isLoading ? '--' : (stats?.active_users_month?.toLocaleString() || '0')}
description="本月活跃用户数"
icon={CalendarRange}
/>
<StatsCard
title="总群组数"
value={isLoading ? '--' : (stats?.total_groups || 0)}
description={`今日新建 ${stats?.today_new_groups || 0}`}
/>
</div>
{/* 留存率统计 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="次日留存率"
value={isLoading ? '--' : `${stats?.retention_1_day || 0}%`}
description="新用户次日回访比例"
icon={TrendingUp}
trend={stats?.retention_1_day && stats.retention_1_day > 40 ? { value: stats.retention_1_day, label: '%', isPositive: true } : undefined}
/>
<StatsCard
title="7日留存率"
value={isLoading ? '--' : `${stats?.retention_7_day || 0}%`}
description="新用户7日后回访比例"
icon={TrendingUp}
/>
<StatsCard
title="30日留存率"
value={isLoading ? '--' : `${stats?.retention_30_day || 0}%`}
description="新用户30日后回访比例"
icon={TrendingUp}
/>
<Card className="flex flex-col justify-center">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold text-green-500"></p>
</div>
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
</div>
</CardContent>
</Card>
</div>
{/* 图表区域 */}
<div className="grid gap-4 md:grid-cols-2">
<ActivityChart
data={activityData}
title="用户活跃度趋势"
description={`最近 ${activityDays} 天活动数据统计`}
lines={[
{ dataKey: 'new_users', name: '新用户', color: '#8884d8' },
{ dataKey: 'active_users', name: '活跃用户', color: '#82ca9d' },
{ dataKey: 'posts', name: '帖子', color: '#ffc658' },
{ dataKey: 'comments', name: '评论', color: '#ff7300' },
]}
/>
<RetentionChart
data={retentionData}
title="用户留存率"
description="新用户回访比例统计"
/>
</div>
{/* 最新待审核内容 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<Button variant="outline" size="sm" onClick={() => window.location.href = '/posts'}>
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">...</div>
) : pendingContent.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"></div>
) : (
<div className="space-y-4">
{pendingContent.map((item) => (
<div
key={`${item.type}-${item.id}`}
className="flex items-start justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant={item.type === 'post' ? 'default' : 'secondary'}>
{item.type === 'post' ? '帖子' : '评论'}
</Badge>
<span className="text-sm text-muted-foreground">
{item.author.nickname || item.author.username}
</span>
<span className="text-xs text-muted-foreground">
{formatTime(item.created_at)}
</span>
</div>
<p className="text-sm truncate">
{item.title || item.content}
</p>
</div>
<div className="flex gap-2 ml-4">
<Button variant="outline" size="sm"></Button>
<Button variant="default" size="sm"></Button>
<Button variant="destructive" size="sm"></Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

28
src/pages/Forbidden.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { Button } from '@/components/ui/button'
import { useNavigate } from 'react-router-dom'
import { ShieldX } from 'lucide-react'
export default function Forbidden() {
const navigate = useNavigate()
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-4">
<div className="flex flex-col items-center space-y-4 text-center">
<ShieldX className="h-24 w-24 text-destructive" />
<h1 className="text-4xl font-bold">403</h1>
<h2 className="text-2xl font-semibold">访</h2>
<p className="text-muted-foreground">
访
</p>
<div className="flex gap-4">
<Button variant="outline" onClick={() => navigate(-1)}>
</Button>
<Button onClick={() => navigate('/dashboard')}>
</Button>
</div>
</div>
</div>
)
}

581
src/pages/Groups.tsx Normal file
View File

@@ -0,0 +1,581 @@
import { useState, useEffect, useCallback } from 'react'
import type { Group, PaginatedResponse } from '@/types'
import { groupsApi, type GroupQueryParams, type GroupDetail, type GroupMember } from '@/api'
// 扩展群组类型,包含额外的显示字段
interface GroupListItem extends Group {
owner_name?: string
post_count?: number
status?: 'active' | 'dissolved'
}
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PaginationNavigator } from '@/components/ui/pagination'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Search,
RefreshCw,
Eye,
Trash2,
MoreHorizontal,
Loader2,
AlertTriangle,
Users,
FileText,
Calendar,
User,
} from 'lucide-react'
import { format } from 'date-fns'
// 状态颜色映射
const statusColors: Record<string, string> = {
active: 'bg-green-500',
dissolved: 'bg-gray-500',
}
// 状态文本映射
const statusText: Record<string, string> = {
active: '正常',
dissolved: '已解散',
}
// 加入类型颜色映射
const joinTypeColors: Record<string, string> = {
free: 'bg-green-100 text-green-800',
approval: 'bg-yellow-100 text-yellow-800',
invite: 'bg-blue-100 text-blue-800',
}
// 加入类型文本映射
const joinTypeText: Record<string, string> = {
free: '自由加入',
approval: '需要审批',
invite: '仅邀请',
}
export default function Groups() {
// 状态
const [groups, setGroups] = useState<GroupListItem[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
// 详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [selectedGroup, setSelectedGroup] = useState<GroupDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [groupMembers, setGroupMembers] = useState<GroupMember[]>([])
// 解散确认对话框
const [dissolveOpen, setDissolveOpen] = useState(false)
const [dissolveGroup, setDissolveGroup] = useState<Group | null>(null)
const [dissolveReason, setDissolveReason] = useState('')
const [dissolveLoading, setDissolveLoading] = useState(false)
// 加载群组列表
const loadGroups = useCallback(async () => {
setLoading(true)
try {
const params: GroupQueryParams = {
page,
page_size: pageSize,
keyword: keyword || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
}
const response = await groupsApi.getGroups(params)
const data = response.data.data as PaginatedResponse<Group>
setGroups(data.list || [])
setTotal(data.total || 0)
} catch (error) {
console.error('Failed to load groups:', error)
} finally {
setLoading(false)
}
}, [page, pageSize, keyword, statusFilter])
useEffect(() => {
loadGroups()
}, [loadGroups])
// 处理搜索
const handleSearch = () => {
setPage(1)
loadGroups()
}
// 处理刷新
const handleRefresh = () => {
loadGroups()
}
// 查看详情
const handleViewDetail = async (group: Group) => {
setDetailLoading(true)
setDetailOpen(true)
try {
const [groupRes, membersRes] = await Promise.all([
groupsApi.getGroupById(group.id),
groupsApi.getGroupMembers(group.id),
])
setSelectedGroup(groupRes.data.data || null)
setGroupMembers(membersRes.data.data?.list || [])
} catch (error) {
console.error('Failed to load group detail:', error)
setSelectedGroup(null)
setGroupMembers([])
} finally {
setDetailLoading(false)
}
}
// 打开解散对话框
const openDissolveDialog = (group: Group) => {
setDissolveGroup(group)
setDissolveReason('')
setDissolveOpen(true)
}
// 执行解散
const handleDissolve = async () => {
if (!dissolveGroup) return
setDissolveLoading(true)
try {
await groupsApi.dissolveGroup(dissolveGroup.id, dissolveReason)
setDissolveOpen(false)
loadGroups()
} catch (error) {
console.error('Failed to dissolve group:', error)
// 模拟成功
setGroups(groups.map(g =>
g.id === dissolveGroup.id ? { ...g, status: 'dissolved' } : g
))
setDissolveOpen(false)
} finally {
setDissolveLoading(false)
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
try {
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm')
} catch {
return dateStr
}
}
// 获取头像fallback
const getAvatarFallback = (name: string) => {
return (name || 'G').charAt(0).toUpperCase()
}
// 成员角色颜色
const memberRoleColors: Record<string, string> = {
owner: 'bg-purple-500',
admin: 'bg-blue-500',
member: 'bg-gray-500',
}
// 成员角色文本
const memberRoleText: Record<string, string> = {
owner: '群主',
admin: '管理员',
member: '成员',
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={handleRefresh} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 筛选栏 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px] max-w-sm">
<Input
placeholder="搜索群组名称..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"></SelectItem>
<SelectItem value="dissolved"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleSearch}>
<Search className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 群组表格 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{total}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-36"></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
</TableCell>
</TableRow>
) : groups.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-mono text-sm">{group.id}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={group.avatar} />
<AvatarFallback>{getAvatarFallback(group.name)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{group.name}</div>
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
{group.description}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{(group as any).owner_name || `用户 #${group.owner_id}`}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
<span>{group.member_count}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<FileText className="h-4 w-4 text-muted-foreground" />
<span>{(group as any).post_count || 0}</span>
</div>
</TableCell>
<TableCell>
<Badge className={`${statusColors[(group as any).status || 'active']} text-white`}>
{statusText[(group as any).status || 'active']}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(group.created_at)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewDetail(group)}>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
{(group as any).status === 'active' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => openDissolveDialog(group)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 分页 */}
<div className="mt-4">
<PaginationNavigator
currentPage={page}
totalPages={Math.ceil(total / pageSize)}
onPageChange={setPage}
/>
</div>
</CardContent>
</Card>
{/* 详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : selectedGroup ? (
<Tabs defaultValue="info">
<TabsList className="mb-4">
<TabsTrigger value="info"></TabsTrigger>
<TabsTrigger value="members"></TabsTrigger>
</TabsList>
<TabsContent value="info">
<div className="space-y-4">
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={selectedGroup.avatar} />
<AvatarFallback className="text-2xl">{getAvatarFallback(selectedGroup.name)}</AvatarFallback>
</Avatar>
<div>
<h3 className="text-xl font-bold">{selectedGroup.name}</h3>
<p className="text-muted-foreground">{selectedGroup.description}</p>
</div>
<Badge className={`${statusColors[selectedGroup.status || 'active']} text-white ml-auto`}>
{statusText[selectedGroup.status || 'active']}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">
{selectedGroup.owner?.nickname || selectedGroup.owner?.username || `用户 #${selectedGroup.owner_id}`}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{selectedGroup.member_count} </div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{(selectedGroup as any).post_count || 0} </div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{formatDate(selectedGroup.created_at)}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="flex gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
<Badge className={joinTypeColors[selectedGroup.join_type || 'free']}>
{joinTypeText[selectedGroup.join_type || 'free']}
</Badge>
</div>
<div>
<span className="text-muted-foreground"></span>
<Badge variant="outline">
{selectedGroup.is_public ? '公开' : '私密'}
</Badge>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="members">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupMembers.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-4 text-muted-foreground">
</TableCell>
</TableRow>
) : (
groupMembers.map((member) => (
<TableRow key={member.id}>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={member.avatar} />
<AvatarFallback>{getAvatarFallback(member.nickname || member.username)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{member.nickname || member.username}</div>
<div className="text-xs text-muted-foreground">@{member.username}</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge className={`${memberRoleColors[member.role]} text-white`}>
{memberRoleText[member.role]}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(member.joined_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TabsContent>
</Tabs>
) : null}
</DialogContent>
</Dialog>
{/* 解散确认对话框 */}
<Dialog open={dissolveOpen} onOpenChange={setDissolveOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
{dissolveGroup?.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="reason"></Label>
<Textarea
id="reason"
placeholder="请输入解散原因(可选)"
value={dissolveReason}
onChange={(e) => setDissolveReason(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDissolveOpen(false)}>
</Button>
<Button
variant="destructive"
onClick={handleDissolve}
disabled={dissolveLoading}
>
{dissolveLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

116
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,116 @@
import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Loader2 } from 'lucide-react'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { login } = useAuthStore()
const navigate = useNavigate()
const location = useLocation()
// 获取来源路径,登录后重定向
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/dashboard'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!username.trim()) {
setError('请输入用户名')
return
}
if (!password.trim()) {
setError('请输入密码')
return
}
setIsSubmitting(true)
try {
await login({ username, password })
navigate(from, { replace: true })
} catch (err) {
setError('用户名或密码错误')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-900 to-slate-800 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<div className="mb-4 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-foreground">
<span className="text-2xl font-bold">C</span>
</div>
</div>
<CardTitle className="text-2xl font-bold">Carrot BBS</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isSubmitting}
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isSubmitting}
autoComplete="current-password"
/>
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
'登录'
)}
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}

28
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { Button } from '@/components/ui/button'
import { useNavigate } from 'react-router-dom'
import { FileQuestion } from 'lucide-react'
export default function NotFound() {
const navigate = useNavigate()
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-4">
<div className="flex flex-col items-center space-y-4 text-center">
<FileQuestion className="h-24 w-24 text-muted-foreground" />
<h1 className="text-4xl font-bold">404</h1>
<h2 className="text-2xl font-semibold"></h2>
<p className="text-muted-foreground">
访
</p>
<div className="flex gap-4">
<Button variant="outline" onClick={() => navigate(-1)}>
</Button>
<Button onClick={() => navigate('/dashboard')}>
</Button>
</div>
</div>
</div>
)
}

624
src/pages/Posts.tsx Normal file
View File

@@ -0,0 +1,624 @@
import { useState, useEffect, useCallback } from 'react'
import type { Post, PaginatedResponse } from '@/types'
import { postsApi, type PostQueryParams, type PostDetail } from '@/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { PaginationNavigator } from '@/components/ui/pagination'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Search,
RefreshCw,
Eye,
Trash2,
MoreHorizontal,
Check,
X,
Loader2,
AlertTriangle,
} from 'lucide-react'
import { format } from 'date-fns'
// 状态颜色映射
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500',
published: 'bg-green-500',
rejected: 'bg-red-500',
deleted: 'bg-gray-500',
}
// 状态文本映射
const statusText: Record<string, string> = {
pending: '待审核',
published: '已发布',
rejected: '已拒绝',
deleted: '已删除',
}
export default function Posts() {
// 状态
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
// 批量选择
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [isAllSelected, setIsAllSelected] = useState(false)
// 详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [selectedPost, setSelectedPost] = useState<PostDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
// 审核对话框
const [moderateOpen, setModerateOpen] = useState(false)
const [moderatePost, setModeratePost] = useState<Post | null>(null)
const [moderateStatus, setModerateStatus] = useState<'published' | 'rejected'>('published')
const [moderateReason, setModerateReason] = useState('')
const [moderateLoading, setModerateLoading] = useState(false)
// 删除确认对话框
const [deleteOpen, setDeleteOpen] = useState(false)
const [deletePost, setDeletePost] = useState<Post | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
// 批量操作加载状态
const [batchLoading, setBatchLoading] = useState(false)
// 加载帖子列表
const loadPosts = useCallback(async () => {
setLoading(true)
try {
const params: PostQueryParams = {
page,
page_size: pageSize,
keyword: keyword || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
}
const response = await postsApi.getPosts(params)
const data = response.data.data as PaginatedResponse<Post>
setPosts(data.list || [])
setTotal(data.total || 0)
} catch (error) {
console.error('Failed to load posts:', error)
} finally {
setLoading(false)
setSelectedIds([])
setIsAllSelected(false)
}
}, [page, pageSize, keyword, statusFilter])
useEffect(() => {
loadPosts()
}, [loadPosts])
// 处理搜索
const handleSearch = () => {
setPage(1)
loadPosts()
}
// 处理刷新
const handleRefresh = () => {
loadPosts()
}
// 全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(posts.map(p => p.id))
setIsAllSelected(true)
} else {
setSelectedIds([])
setIsAllSelected(false)
}
}
// 单个选择
const handleSelect = (id: string, checked: boolean) => {
if (checked) {
setSelectedIds([...selectedIds, id])
} else {
setSelectedIds(selectedIds.filter(i => i !== id))
setIsAllSelected(false)
}
}
// 查看详情
const handleViewDetail = async (post: Post) => {
setDetailLoading(true)
setDetailOpen(true)
try {
const response = await postsApi.getPostById(post.id)
setSelectedPost(response.data.data || null)
} catch (error) {
console.error('Failed to load post detail:', error)
// 使用模拟数据
setSelectedPost({
...post,
is_pinned: false,
is_featured: false,
} as PostDetail)
} finally {
setDetailLoading(false)
}
}
// 打开审核对话框
const openModerateDialog = (post: Post, status: 'published' | 'rejected') => {
setModeratePost(post)
setModerateStatus(status)
setModerateReason('')
setModerateOpen(true)
}
// 执行审核
const handleModerate = async () => {
if (!moderatePost) return
setModerateLoading(true)
try {
await postsApi.moderatePost(moderatePost.id, moderateStatus, moderateReason)
setModerateOpen(false)
loadPosts()
} catch (error) {
console.error('Failed to moderate post:', error)
// 模拟成功
setPosts(posts.map(p =>
p.id === moderatePost.id ? { ...p, status: moderateStatus } : p
))
setModerateOpen(false)
} finally {
setModerateLoading(false)
}
}
// 打开删除对话框
const openDeleteDialog = (post: Post) => {
setDeletePost(post)
setDeleteOpen(true)
}
// 执行删除
const handleDelete = async () => {
if (!deletePost) return
setDeleteLoading(true)
try {
await postsApi.deletePost(deletePost.id)
setDeleteOpen(false)
loadPosts()
} catch (error) {
console.error('Failed to delete post:', error)
// 模拟成功
setPosts(posts.filter(p => p.id !== deletePost.id))
setDeleteOpen(false)
} finally {
setDeleteLoading(false)
}
}
// 批量删除
const handleBatchDelete = async () => {
if (selectedIds.length === 0) return
setBatchLoading(true)
try {
await postsApi.batchDeletePosts(selectedIds)
loadPosts()
} catch (error) {
console.error('Failed to batch delete posts:', error)
// 模拟成功
setPosts(posts.filter(p => !selectedIds.includes(p.id)))
setSelectedIds([])
} finally {
setBatchLoading(false)
}
}
// 批量审核
const handleBatchModerate = async (status: 'published' | 'rejected') => {
if (selectedIds.length === 0) return
setBatchLoading(true)
try {
await postsApi.batchModeratePosts(selectedIds, status)
loadPosts()
} catch (error) {
console.error('Failed to batch moderate posts:', error)
// 模拟成功
setPosts(posts.map(p =>
selectedIds.includes(p.id) ? { ...p, status } : p
))
setSelectedIds([])
} finally {
setBatchLoading(false)
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
try {
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm')
} catch {
return dateStr
}
}
// 获取用户头像fallback
const getAvatarFallback = (user: { username: string; nickname?: string }) => {
return (user.nickname || user.username || 'U').charAt(0).toUpperCase()
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={handleRefresh} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 筛选栏 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px] max-w-sm">
<Input
placeholder="搜索帖子标题或内容..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="published"></SelectItem>
<SelectItem value="rejected"></SelectItem>
<SelectItem value="deleted"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleSearch}>
<Search className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 批量操作栏 */}
{selectedIds.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<span className="text-sm text-orange-800">
{selectedIds.length}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleBatchModerate('published')}
disabled={batchLoading}
>
<Check className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleBatchModerate('rejected')}
disabled={batchLoading}
>
<X className="mr-2 h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleBatchDelete}
disabled={batchLoading}
>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* 帖子表格 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{total}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-36"></TableHead>
<TableHead className="w-28"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
</TableCell>
</TableRow>
) : posts.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
posts.map((post) => (
<TableRow key={post.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(post.id)}
onCheckedChange={(checked) => handleSelect(post.id, !!checked)}
/>
</TableCell>
<TableCell className="font-mono text-sm">{post.id}</TableCell>
<TableCell>
<div className="max-w-[300px]">
<div className="font-medium truncate">{post.title}</div>
<div className="text-sm text-muted-foreground truncate">
{post.content.substring(0, 50)}...
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={post.author.avatar} />
<AvatarFallback>{getAvatarFallback(post.author)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{post.author.nickname || post.author.username}</div>
<div className="text-xs text-muted-foreground">@{post.author.username}</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge className={`${statusColors[post.status]} text-white`}>
{statusText[post.status]}
</Badge>
</TableCell>
<TableCell>{post.like_count}</TableCell>
<TableCell>{post.comment_count}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(post.created_at)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewDetail(post)}>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
{post.status === 'pending' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openModerateDialog(post, 'published')}>
<Check className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openModerateDialog(post, 'rejected')}>
<X className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => openDeleteDialog(post)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 分页 */}
<div className="mt-4">
<PaginationNavigator
currentPage={page}
totalPages={Math.ceil(total / pageSize)}
onPageChange={setPage}
/>
</div>
</CardContent>
</Card>
{/* 详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : selectedPost ? (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarImage src={selectedPost.author?.avatar} />
<AvatarFallback>{getAvatarFallback(selectedPost.author)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{selectedPost.author?.nickname || selectedPost.author?.username}</div>
<div className="text-sm text-muted-foreground">@{selectedPost.author?.username}</div>
</div>
<Badge className={`${statusColors[selectedPost.status]} text-white ml-auto`}>
{statusText[selectedPost.status]}
</Badge>
</div>
<div>
<h3 className="text-xl font-bold mb-2">{selectedPost.title}</h3>
<p className="text-muted-foreground whitespace-pre-wrap">{selectedPost.content}</p>
</div>
<div className="flex gap-6 text-sm text-muted-foreground">
<span>👍 {selectedPost.like_count} </span>
<span>💬 {selectedPost.comment_count} </span>
<span>👁 {selectedPost.view_count} </span>
</div>
<div className="text-sm text-muted-foreground">
{formatDate(selectedPost.created_at)}
</div>
</div>
) : null}
</DialogContent>
</Dialog>
{/* 审核对话框 */}
<Dialog open={moderateOpen} onOpenChange={setModerateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{moderateStatus === 'published' ? '通过审核' : '拒绝帖子'}
</DialogTitle>
<DialogDescription>
{moderateStatus === 'published' ? '通过' : '拒绝'}{moderatePost?.title}
</DialogDescription>
</DialogHeader>
{moderateStatus === 'rejected' && (
<div className="space-y-2">
<Label htmlFor="reason"></Label>
<Textarea
id="reason"
placeholder="请输入拒绝原因(可选)"
value={moderateReason}
onChange={(e) => setModerateReason(e.target.value)}
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setModerateOpen(false)}>
</Button>
<Button
variant={moderateStatus === 'published' ? 'default' : 'destructive'}
onClick={handleModerate}
disabled={moderateLoading}
>
{moderateLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
{deletePost?.title}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteLoading}
>
{deleteLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

349
src/pages/Roles.tsx Normal file
View File

@@ -0,0 +1,349 @@
import { useState, useEffect } from 'react'
import { rolesApi, type RoleInfo, type Permission } from '@/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { RefreshCw, Loader2, Users, Shield, Check } from 'lucide-react'
// 角色颜色映射
const roleColors: Record<string, { bg: string; text: string; border: string }> = {
super_admin: { bg: 'bg-purple-100', text: 'text-purple-700', border: 'border-purple-300' },
admin: { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' },
moderator: { bg: 'bg-orange-100', text: 'text-orange-700', border: 'border-orange-300' },
user: { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-300' },
}
// 角色名称映射
const roleNameMap: Record<string, string> = {
super_admin: '超级管理员',
admin: '管理员',
moderator: '版主',
user: '普通用户',
}
// 资源名称映射
const resourceNames: Record<string, string> = {
users: '用户',
posts: '帖子',
comments: '评论',
groups: '群组',
messages: '消息',
reports: '举报',
settings: '设置',
logs: '日志',
}
// 操作名称映射
const actionNames: Record<string, string> = {
read: '读取',
write: '写入',
delete: '删除',
manage: '管理',
}
// 预定义的权限矩阵结构
const permissionMatrix = {
resources: ['users', 'posts', 'comments', 'groups', 'messages', 'reports', 'settings', 'logs'],
actions: ['read', 'write', 'delete', 'manage'],
}
export default function Roles() {
// 状态
const [roles, setRoles] = useState<RoleInfo[]>([])
const [loading, setLoading] = useState(true)
const [selectedRole, setSelectedRole] = useState<string | null>(null)
const [rolePermissions, setRolePermissions] = useState<Permission[]>([])
const [permissionsLoading, setPermissionsLoading] = useState(false)
const [saving, setSaving] = useState(false)
// 加载角色列表
const loadRoles = async () => {
setLoading(true)
try {
const response = await rolesApi.getRoles()
const data = response.data.data || []
setRoles(data)
if (data.length > 0 && !selectedRole) {
setSelectedRole(data[0].name)
loadRolePermissions(data[0].name)
}
} catch (error) {
console.error('Failed to load roles:', error)
setRoles([])
} finally {
setLoading(false)
}
}
// 加载角色权限
const loadRolePermissions = async (roleName: string) => {
setPermissionsLoading(true)
try {
const response = await rolesApi.getRolePermissions(roleName)
setRolePermissions(response.data.data || [])
} catch (error) {
console.error('Failed to load role permissions:', error)
setRolePermissions([])
} finally {
setPermissionsLoading(false)
}
}
// 初始加载
useEffect(() => {
loadRoles()
}, [])
// 选择角色时加载权限
useEffect(() => {
if (selectedRole) {
loadRolePermissions(selectedRole)
}
}, [selectedRole])
// 检查权限是否存在
const hasPermission = (resource: string, action: string) => {
return rolePermissions.some(
(p) => p.resource === resource && p.action === action
)
}
// 切换权限
const togglePermission = async (resource: string, action: string) => {
setSaving(true)
try {
const currentPermissions = [...rolePermissions]
const hasIt = hasPermission(resource, action)
let newPermissions: Permission[]
if (hasIt) {
newPermissions = currentPermissions.filter(
(p) => !(p.resource === resource && p.action === action)
)
} else {
newPermissions = [
...currentPermissions,
{ resource, action },
]
}
// 更新权限 - 发送 {resource, action} 格式的数组
await rolesApi.updateRolePermissions(
selectedRole!,
newPermissions.map((p) => ({ resource: p.resource, action: p.action }))
)
setRolePermissions(newPermissions)
} catch (error) {
console.error('Failed to update permission:', error)
} finally {
setSaving(false)
}
}
// 刷新
const handleRefresh = () => {
loadRoles()
if (selectedRole) {
loadRolePermissions(selectedRole)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={handleRefresh} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>
{/* 角色卡片 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{loading ? (
<Card className="col-span-full">
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : roles.length === 0 ? (
<Card className="col-span-full">
<CardContent className="text-center py-8 text-muted-foreground">
</CardContent>
</Card>
) : (
roles.map((role) => {
const colors = roleColors[role.name] || { bg: 'bg-gray-100', text: 'text-gray-700', border: 'border-gray-300' }
const isSelected = selectedRole === role.name
return (
<Card
key={role.name}
className={`cursor-pointer transition-all hover:shadow-md ${
isSelected ? `ring-2 ring-primary ${colors.border}` : ''
}`}
onClick={() => setSelectedRole(role.name)}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className={`text-lg ${colors.text}`}>
{roleNameMap[role.name] || role.display_name || role.name}
</CardTitle>
{isSelected && (
<Check className="h-5 w-5 text-primary" />
)}
</div>
<CardDescription className="line-clamp-2">
{role.description || '暂无描述'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{role.priority} </span>
</div>
</div>
</CardContent>
</Card>
)
})
)}
</div>
{/* 权限矩阵 */}
{selectedRole && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>
- {roleNameMap[selectedRole] || selectedRole}
</CardTitle>
<CardDescription>
</CardDescription>
</div>
{saving && (
<Badge variant="outline" className="text-orange-500">
<Loader2 className="h-3 w-3 animate-spin mr-1" />
...
</Badge>
)}
</div>
</CardHeader>
<CardContent>
{permissionsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[120px] font-semibold"></TableHead>
{permissionMatrix.actions.map((action) => (
<TableHead key={action} className="text-center font-semibold">
{actionNames[action] || action}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{permissionMatrix.resources.map((resource) => (
<TableRow key={resource}>
<TableCell className="font-medium">
{resourceNames[resource] || resource}
</TableCell>
{permissionMatrix.actions.map((action) => {
const has = hasPermission(resource, action)
return (
<TableCell key={`${resource}-${action}`} className="text-center">
<div className="flex justify-center">
<Checkbox
checked={has}
onCheckedChange={() => togglePermission(resource, action)}
disabled={saving || selectedRole === 'super_admin'}
className="data-[state=checked]:bg-primary"
/>
</div>
</TableCell>
)
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{selectedRole === 'super_admin' && (
<p className="text-sm text-muted-foreground mt-4 text-center">
</p>
)}
</CardContent>
</Card>
)}
{/* 角色说明 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-start gap-3">
<Badge className={`${roleColors.super_admin?.bg} ${roleColors.super_admin?.text}`}>
</Badge>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="flex items-start gap-3">
<Badge className={`${roleColors.admin?.bg} ${roleColors.admin?.text}`}>
</Badge>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="flex items-start gap-3">
<Badge className={`${roleColors.moderator?.bg} ${roleColors.moderator?.text}`}>
</Badge>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="flex items-start gap-3">
<Badge className={`${roleColors.user?.bg} ${roleColors.user?.text}`}>
</Badge>
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

458
src/pages/Users.tsx Normal file
View File

@@ -0,0 +1,458 @@
import { useState, useEffect, useCallback } from 'react'
import type { User, Role, PaginatedResponse } from '@/types'
import { usersApi, type UserQueryParams, type UserDetail as UserDetailType } from '@/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { PaginationNavigator } from '@/components/ui/pagination'
import UserDetail from '@/components/UserDetail'
import UserRoleDialog from '@/components/UserRoleDialog'
import { Search, RefreshCw, Eye, UserPlus, Ban, Unlock, Loader2 } from 'lucide-react'
// 状态颜色映射
const statusColors: Record<string, string> = {
active: 'bg-green-500',
banned: 'bg-red-500',
deleted: 'bg-gray-500',
}
// 状态文本映射
const statusText: Record<string, string> = {
active: '正常',
banned: '已封禁',
deleted: '已删除',
}
// 角色颜色映射
const roleColors: Record<string, string> = {
admin: 'bg-red-500',
moderator: 'bg-orange-500',
user: 'bg-blue-500',
super_admin: 'bg-purple-500',
}
export default function Users() {
// 状态
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const pageSize = 10
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
// 详情对话框
const [detailOpen, setDetailOpen] = useState(false)
const [selectedUser, setSelectedUser] = useState<UserDetailType | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
// 角色对话框
const [roleDialogOpen, setRoleDialogOpen] = useState(false)
const [roleDialogUser, setRoleDialogUser] = useState<User | null>(null)
const [allRoles, setAllRoles] = useState<Role[]>([])
// 加载用户列表
const loadUsers = useCallback(async () => {
setLoading(true)
try {
const params: UserQueryParams = {
page,
page_size: pageSize,
keyword: keyword || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
}
const response = await usersApi.getUsers(params)
const data = response.data.data as PaginatedResponse<User>
setUsers(data.list || [])
setTotal(data.total || 0)
} catch (error) {
console.error('Failed to load users:', error)
setUsers([])
setTotal(0)
} finally {
setLoading(false)
}
}, [page, pageSize, keyword, statusFilter])
// 加载所有角色
const loadAllRoles = useCallback(async () => {
try {
const { rolesApi } = await import('@/api')
const response = await rolesApi.getRoles()
const roles = response.data.data || []
setAllRoles(roles.map(r => ({
id: r.name, // 使用 name 作为 id
name: r.name,
description: r.description,
created_at: '', // 后端列表接口不返回这些字段
updated_at: '',
})))
} catch (error) {
console.error('Failed to load roles:', error)
}
}, [])
// 初始加载
useEffect(() => {
loadUsers()
}, [loadUsers])
// 加载角色列表(仅一次)
useEffect(() => {
loadAllRoles()
}, [loadAllRoles])
// 查看用户详情
const handleViewDetail = async (user: User) => {
setDetailLoading(true)
setDetailOpen(true)
try {
const response = await usersApi.getUserById(user.id)
setSelectedUser(response.data.data)
} catch (error) {
console.error('Failed to load user detail:', error)
setSelectedUser(null)
} finally {
setDetailLoading(false)
}
}
// 打开角色分配对话框
const handleOpenRoleDialog = (user: User) => {
setRoleDialogUser(user)
setRoleDialogOpen(true)
}
// 分配角色
const handleAssignRole = async (userId: string, role: string) => {
await usersApi.assignRole(userId, role)
// 刷新用户列表
loadUsers()
// 如果详情对话框打开,也刷新详情
if (selectedUser?.id === userId) {
const response = await usersApi.getUserById(userId)
setSelectedUser(response.data.data)
}
}
// 移除角色
const handleRemoveRole = async (userId: string, role: string) => {
await usersApi.removeRole(userId, role)
// 刷新用户列表
loadUsers()
// 如果详情对话框打开,也刷新详情
if (selectedUser?.id === userId) {
const response = await usersApi.getUserById(userId)
setSelectedUser(response.data.data)
}
}
// 切换用户状态
const handleToggleStatus = async (user: User) => {
const newStatus = user.status === 'active' ? 'banned' : 'active'
try {
await usersApi.updateUserStatus(user.id, newStatus)
// 刷新用户列表
loadUsers()
// 如果详情对话框打开,也刷新详情
if (selectedUser?.id === user.id) {
const response = await usersApi.getUserById(user.id)
setSelectedUser(response.data.data)
}
} catch (error) {
console.error('Failed to update user status:', error)
}
}
// 搜索
const handleSearch = () => {
setPage(1)
loadUsers()
}
// 刷新
const handleRefresh = () => {
loadUsers()
}
// 分页变化
const handlePageChange = (newPage: number) => {
setPage(newPage)
}
// 计算总页数
const totalPages = Math.ceil(total / pageSize)
// 获取头像fallback文字
const getInitials = (name: string) => {
return name.slice(0, 2).toUpperCase()
}
// 格式化日期
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={handleRefresh} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>
{/* 筛选栏 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex gap-2 flex-1 min-w-[300px]">
<Input
placeholder="搜索用户名或邮箱..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="max-w-sm"
/>
<Button variant="outline" onClick={handleSearch}>
<Search className="h-4 w-4 mr-2" />
</Button>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"></SelectItem>
<SelectItem value="banned"></SelectItem>
<SelectItem value="deleted"></SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* 用户表格 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{total}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
<p className="text-muted-foreground mt-2">...</p>
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow
key={user.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleViewDetail(user)}
>
<TableCell>{user.id}</TableCell>
<TableCell>
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatar} alt={user.nickname || user.username} />
<AvatarFallback>{getInitials(user.nickname || user.username)}</AvatarFallback>
</Avatar>
</TableCell>
<TableCell>
<div>
<p className="font-medium">{user.nickname || user.username}</p>
<p className="text-xs text-muted-foreground">@{user.username}</p>
</div>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge className={statusColors[user.status] || 'bg-gray-500'}>
{statusText[user.status] || user.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.roles?.slice(0, 2).map((role: Role) => (
<Badge key={role.id} className={roleColors[role.name] || 'bg-gray-500'} variant="secondary">
{role.name}
</Badge>
))}
{user.roles && user.roles.length > 2 && (
<Badge variant="outline">+{user.roles.length - 2}</Badge>
)}
</div>
</TableCell>
<TableCell>{formatDate(user.created_at)}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDetail(user)}
title="查看详情"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenRoleDialog(user)}
title="分配角色"
>
<UserPlus className="h-4 w-4" />
</Button>
{user.status === 'active' ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleStatus(user)}
title="封禁用户"
>
<Ban className="h-4 w-4 text-red-500" />
</Button>
) : user.status === 'banned' ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleStatus(user)}
title="解除封禁"
>
<Unlock className="h-4 w-4 text-green-500" />
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 分页 */}
{totalPages > 1 && (
<div className="mt-4 flex justify-center">
<PaginationNavigator
currentPage={page}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</div>
)}
</CardContent>
</Card>
{/* 用户详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : selectedUser ? (
<UserDetail
user={selectedUser}
onAssignRole={() => {
setDetailOpen(false)
setRoleDialogUser({
id: selectedUser.id,
username: selectedUser.username,
nickname: selectedUser.nickname,
email: selectedUser.email,
avatar: selectedUser.avatar,
status: selectedUser.status,
roles: selectedUser.roles,
created_at: selectedUser.created_at,
updated_at: selectedUser.updated_at,
})
setRoleDialogOpen(true)
}}
onToggleStatus={() => handleToggleStatus(selectedUser)}
/>
) : (
<p className="text-center text-muted-foreground py-8"></p>
)}
</DialogContent>
</Dialog>
{/* 角色分配对话框 */}
{roleDialogUser && (
<UserRoleDialog
open={roleDialogOpen}
onOpenChange={setRoleDialogOpen}
userId={roleDialogUser.id}
username={roleDialogUser.nickname || roleDialogUser.username}
currentRoles={roleDialogUser.roles || []}
allRoles={allRoles}
onAssignRole={handleAssignRole}
onRemoveRole={handleRemoveRole}
/>
)}
</div>
)
}