Files
OpenFlare/frontend/app/(main)/admin/users/page.tsx
T
2026-07-13 15:10:28 +08:00

286 lines
11 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Eye, Layers, Pencil, Plus, UserRound, UserX } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { AdminUser } from '@/lib/services/admin';
import { formatDateTime } from '@/lib/utils';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { ErrorInline } from '@/components/layout/error';
import { useAdminUsers } from '@/contexts/admin-users-context';
import { CreateUserModal } from './components/create-user-modal';
import { UserFilterBar } from './components/user-filter-bar';
import { UserDetailSheet } from './components/user-detail-sheet';
import { EditUserModal } from './components/edit-user-modal';
export default function UsersPage() {
const { users, loading, error, fetchUsers, getUserDetail, updateUserStatus } =
useAdminUsers();
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editTarget, setEditTarget] = useState<AdminUser | null>(null);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleStatusToggle = async (user: AdminUser) => {
await updateUserStatus(user);
if (selectedUser?.id === user.id) {
setSelectedUser((prev) =>
prev ? { ...prev, is_active: !prev.is_active } : null,
);
}
};
const handleShowDetail = async (user: AdminUser) => {
setSelectedUser(user);
setDetailOpen(true);
setDetailLoading(true);
try {
const detail = await getUserDetail(user.id);
setSelectedUser(detail);
} catch {
setSelectedUser(user);
} finally {
setDetailLoading(false);
}
};
return (
<div className='py-6 space-y-4'>
{/* 顶部标题栏 */}
<div className='flex items-center justify-between pb-2'>
<div className='flex items-center gap-2'>
<UserRound className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>用户管理</h1>
</div>
</div>
<Button
variant='secondary'
size='sm'
className='h-7 text-xs'
onClick={() => setCreateModalOpen(true)}
>
<Plus className='size-3.5 mr-1' />
新增用户
</Button>
</div>
{/* 筛选与分页栏 (已拆分为独立自治组件) */}
<UserFilterBar />
{error ? (
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
error={error}
onRetry={() => fetchUsers(true)}
className='justify-center'
/>
</div>
) : loading && users.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description='加载用户列表中...' />
) : users.length === 0 ? (
<EmptyStateWithBorder icon={UserX} description='暂无用户数据' />
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<TooltipProvider delayDuration={0}>
<Table className='w-full caption-bottom text-sm min-w-full'>
<TableHeader className='sticky top-0 z-20 bg-background'>
<TableRow className='border-b border-dashed hover:bg-transparent'>
<TableHead className='w-[90px] whitespace-nowrap py-2 h-8'>
ID
</TableHead>
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
用户
</TableHead>
<TableHead className='whitespace-nowrap min-w-[160px] py-2 h-8 pl-4'>
邮箱
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次登陆
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
注册时间
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次更新
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow
key={user.id}
className='border-dashed hover:bg-muted/30 cursor-pointer group'
onClick={() => handleShowDetail(user)}
>
<TableCell className='font-mono text-[11px] text-muted-foreground py-1'>
{user.id}
</TableCell>
<TableCell className='py-1'>
<div className='flex items-center gap-2'>
<Avatar className='h-7 w-7 rounded-sm border'>
<AvatarImage src={user.avatar_url} />
<AvatarFallback className='rounded-sm text-[10px]'>
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className='flex flex-col gap-0'>
<div className='flex items-center gap-1.5'>
<span
className='font-medium text-[11px] leading-tight max-w-[100px] truncate'
title={user.nickname}
>
{user.nickname}
</span>
{user.is_admin && (
<Badge
variant='secondary'
className='text-[9px] h-3.5 px-0.5 rounded-[2px] font-normal leading-none tracking-tighter'
>
ADM
</Badge>
)}
</div>
<div className='flex items-center gap-1.5'>
<span className='text-[10px] text-muted-foreground font-mono leading-tight'>
@{user.username}
</span>
</div>
</div>
</div>
</TableCell>
<TableCell className='text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4'>
{user.email || '-'}
</TableCell>
<TableCell className='text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4'>
{formatDateTime(user.last_login_at)}
</TableCell>
<TableCell className='text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1'>
{formatDateTime(user.created_at)}
</TableCell>
<TableCell className='text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1'>
{formatDateTime(user.updated_at)}
</TableCell>
<TableCell
className='sticky right-0 text-center bg-background z-10 py-1'
onClick={(e) => e.stopPropagation()}
>
<div className='flex items-center justify-center gap-0.5'>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={user.is_active}
onCheckedChange={() => handleStatusToggle(user)}
disabled={user.is_admin}
className='scale-75'
/>
</div>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
{user.is_admin
? '管理员账户'
: user.is_active
? '禁用账户'
: '启用账户'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => handleShowDetail(user)}
>
<Eye className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看详情
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => setEditTarget(user)}
>
<Pencil className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
编辑用户
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TooltipProvider>
</div>
)}
{/* 用户详情侧拉抽屉 (已拆分为独立子组件) */}
<UserDetailSheet
selectedUser={selectedUser}
isOpen={detailOpen}
onOpenChange={setDetailOpen}
detailLoading={detailLoading}
onStatusToggle={handleStatusToggle}
/>
{/* 编辑用户弹窗 */}
<EditUserModal
user={editTarget}
isOpen={!!editTarget}
onClose={() => setEditTarget(null)}
/>
{/* 新建用户模态弹窗 */}
<CreateUserModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
/>
</div>
);
}