Files
ok_r/app/admin/users/page.tsx
karlcarlo fc9cfda29e feat: 添加 API 客户端以处理 basePath 并更新所有 fetch 调用
移除 next.config.js 中的 standalone 输出配置,添加 lib/api-client.ts 封装 fetch 请求,
自动为所有 API 调用添加配置的 basePath。更新所有页面和组件中的 fetch 调用以使用新的 fetchApi 函数,
确保在部署到子路径时 API 请求能正确工作。同时调整 tsconfig.json 的格式并修复项目创建时的字段名。
2026-01-28 15:54:19 +08:00

346 lines
12 KiB
TypeScript
Raw Permalink 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.
'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';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { fetchApi } from '@/lib/api-client';
const userSchema = z.object({
name: z.string().min(1, '名称不能为空'),
role: z.enum(['owner', 'pm', 'member']),
username: z.string().min(1, '账号不能为空'),
password: z.string().optional(),
});
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 [deleteUserId, setDeleteUserId] = useState<string | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues: {
name: '',
role: 'member',
username: '',
password: '',
},
});
const onSubmit = async (values: UserFormValues) => {
setIsSubmitting(true);
try {
if (!editingUser && !values.password) {
throw new Error('请设置初始密码');
}
const url = editingUser ? `/api/users/${editingUser.id}` : '/api/users';
const method = editingUser ? 'PATCH' : 'POST';
const payload = {
name: values.name,
role: values.role,
username: values.username,
password: values.password ? values.password : undefined,
};
const res = await fetchApi(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
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,
username: user.username || '',
password: '',
});
setIsDialogOpen(true);
};
const requestDelete = (userId: string) => {
setDeleteUserId(userId);
setIsDeleteOpen(true);
};
const confirmDelete = async () => {
if (!deleteUserId) return;
setIsDeleting(true);
try {
const res = await fetchApi(`/api/users/${deleteUserId}`, { method: 'DELETE' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || '删除失败');
toast.success('用户已删除');
refreshUsers();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsDeleting(false);
setIsDeleteOpen(false);
setDeleteUserId(null);
}
};
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="username"
render={({ field }) => (
<FormItem>
<FormLabel>账号 *</FormLabel>
<FormControl>
<Input placeholder="输入登录账号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{editingUser ? '重置密码' : '密码 *'}</FormLabel>
<FormControl>
<Input type="password" placeholder={editingUser ? '留空则不修改' : '输入初始密码'} {...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>创建时间</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 className="text-muted-foreground">
{user.username || '-'}
</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={() => requestDelete(user.id)}
disabled={user.role === 'owner'}
>
<Trash2 className="h-4 w-4 text-rose-500" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除用户?</AlertDialogTitle>
<AlertDialogDescription>
删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={confirmDelete}
disabled={isDeleting}
>
{isDeleting ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}