Initial commit
This commit is contained in:
525
src/pages/Comments.tsx
Normal file
525
src/pages/Comments.tsx
Normal 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
251
src/pages/Dashboard.tsx
Normal 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
28
src/pages/Forbidden.tsx
Normal 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
581
src/pages/Groups.tsx
Normal 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
116
src/pages/Login.tsx
Normal 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
28
src/pages/NotFound.tsx
Normal 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
624
src/pages/Posts.tsx
Normal 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
349
src/pages/Roles.tsx
Normal 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
458
src/pages/Users.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user