Files
admin/src/pages/Groups.tsx
2026-03-14 18:24:33 +08:00

582 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}