Files
admin/src/pages/Groups.tsx

582 lines
21 KiB
TypeScript
Raw Normal View History

2026-03-14 18:24:33 +08:00
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>
)
}