Files
ok_r/app/admin/users/page.tsx
2026-01-25 21:55:29 +08:00

252 lines
8.8 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { useUser } from '@/components/user-context';
import { ROLE_LABELS } from '@/lib/constants';
import type { User } from '@/types/database';
import { toast } from 'sonner';
import { getErrorMessage } from '@/lib/error-utils';
import { Plus, Pencil, Trash2, Users, Loader2, Crown, Briefcase, User as UserIcon } from 'lucide-react';
const userSchema = z.object({
name: z.string().min(1, '名称不能为空'),
role: z.enum(['owner', 'pm', 'member']),
});
type UserFormValues = z.infer<typeof userSchema>;
const roleIcons = {
owner: Crown,
pm: Briefcase,
member: UserIcon,
};
export default function AdminUsersPage() {
const { currentUser, users, refreshUsers } = useUser();
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues: {
name: '',
role: 'member',
},
});
const onSubmit = async (values: UserFormValues) => {
setIsSubmitting(true);
try {
const url = editingUser ? `/api/users/${editingUser.id}` : '/api/users';
const method = editingUser ? 'PATCH' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || '操作失败');
}
toast.success(editingUser ? '用户已更新' : '用户已创建');
setIsDialogOpen(false);
setEditingUser(null);
form.reset();
refreshUsers();
} catch (error) {
toast.error(error instanceof Error ? error.message : '操作失败');
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (user: User) => {
setEditingUser(user);
form.reset({
name: user.name,
role: user.role,
});
setIsDialogOpen(true);
};
const handleDelete = async (userId: string) => {
if (!confirm('确定要删除此用户吗?')) return;
try {
const res = await fetch(`/api/users/${userId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('删除失败');
toast.success('用户已删除');
refreshUsers();
} catch (error) {
toast.error(getErrorMessage(error));
}
};
const handleDialogClose = () => {
setIsDialogOpen(false);
setEditingUser(null);
form.reset();
};
if (currentUser?.role !== 'owner') {
return (
<div className="max-w-4xl mx-auto px-4 py-8 text-center">
<p className="text-muted-foreground">仅老板可访问此页面</p>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
<Users className="h-6 w-6 text-teal-600" />
用户管理
</h1>
<p className="text-muted-foreground mt-1">管理系统用户</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-teal-600 hover:bg-teal-700">
<Plus className="h-4 w-4 mr-2" />
新建用户
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingUser ? '编辑用户' : '新建用户'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>名称 *</FormLabel>
<FormControl>
<Input placeholder="输入用户名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>角色 *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(ROLE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 pt-4">
<Button type="submit" disabled={isSubmitting} className="bg-teal-600 hover:bg-teal-700">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editingUser ? '保存' : '创建'}
</Button>
<Button type="button" variant="outline" onClick={handleDialogClose}>
取消
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>用户</TableHead>
<TableHead>角色</TableHead>
<TableHead>创建时间</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(user => {
const RoleIcon = roleIcons[user.role];
return (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-slate-100">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="font-medium">{user.name}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<RoleIcon className="h-4 w-4 text-muted-foreground" />
{ROLE_LABELS[user.role]}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(user.created_at).toLocaleDateString('zh-CN')}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEdit(user)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(user.id)}
disabled={user.id === currentUser?.id}
>
<Trash2 className="h-4 w-4 text-rose-500" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}