|
@@ -1,7 +1,7 @@
|
|
|
-import React, { useState } from 'react';
|
|
|
|
|
|
|
+import React, { useState, useMemo } from 'react';
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
import { format } from 'date-fns';
|
|
import { format } from 'date-fns';
|
|
|
-import { Plus, Search, Edit, Trash2 } from 'lucide-react';
|
|
|
|
|
|
|
+import { Plus, Search, Edit, Trash2, Filter, X } from 'lucide-react';
|
|
|
import { userClient } from '@/client/api';
|
|
import { userClient } from '@/client/api';
|
|
|
import type { InferRequestType, InferResponseType } from 'hono/client';
|
|
import type { InferRequestType, InferResponseType } from 'hono/client';
|
|
|
import { Button } from '@/client/components/ui/button';
|
|
import { Button } from '@/client/components/ui/button';
|
|
@@ -19,6 +19,10 @@ import { Skeleton } from '@/client/components/ui/skeleton';
|
|
|
import { Switch } from '@/client/components/ui/switch';
|
|
import { Switch } from '@/client/components/ui/switch';
|
|
|
import { DisabledStatus } from '@/share/types';
|
|
import { DisabledStatus } from '@/share/types';
|
|
|
import { CreateUserDto, UpdateUserDto } from '@/server/modules/users/user.schema';
|
|
import { CreateUserDto, UpdateUserDto } from '@/server/modules/users/user.schema';
|
|
|
|
|
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
|
|
|
|
|
+import { Popover, PopoverContent, PopoverTrigger } from '@/client/components/ui/popover';
|
|
|
|
|
+import { Calendar } from '@/client/components/ui/calendar';
|
|
|
|
|
+import { cn } from '@/client/lib/utils';
|
|
|
|
|
|
|
|
// 使用RPC方式提取类型
|
|
// 使用RPC方式提取类型
|
|
|
type CreateUserRequest = InferRequestType<typeof userClient.$post>['json'];
|
|
type CreateUserRequest = InferRequestType<typeof userClient.$post>['json'];
|
|
@@ -36,10 +40,16 @@ export const UsersPage = () => {
|
|
|
const [searchParams, setSearchParams] = useState({
|
|
const [searchParams, setSearchParams] = useState({
|
|
|
page: 1,
|
|
page: 1,
|
|
|
limit: 10,
|
|
limit: 10,
|
|
|
- search: ''
|
|
|
|
|
|
|
+ keyword: ''
|
|
|
});
|
|
});
|
|
|
|
|
+ const [filters, setFilters] = useState({
|
|
|
|
|
+ isDisabled: undefined as number | undefined,
|
|
|
|
|
+ roleIds: [] as number[],
|
|
|
|
|
+ createdAt: undefined as { gte?: string; lte?: string } | undefined
|
|
|
|
|
+ });
|
|
|
|
|
+ const [showFilters, setShowFilters] = useState(false);
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
- const [editingUser, setEditingUser] = useState<any>(null);
|
|
|
|
|
|
|
+ const [editingUser, setEditingUser] = useState<UserResponse | null>(null);
|
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
|
const [userToDelete, setUserToDelete] = useState<number | null>(null);
|
|
const [userToDelete, setUserToDelete] = useState<number | null>(null);
|
|
|
|
|
|
|
@@ -72,13 +82,28 @@ export const UsersPage = () => {
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
const { data: usersData, isLoading, refetch } = useQuery({
|
|
const { data: usersData, isLoading, refetch } = useQuery({
|
|
|
- queryKey: ['users', searchParams],
|
|
|
|
|
|
|
+ queryKey: ['users', searchParams, filters],
|
|
|
queryFn: async () => {
|
|
queryFn: async () => {
|
|
|
|
|
+ const filterParams: Record<string, unknown> = {};
|
|
|
|
|
+
|
|
|
|
|
+ if (filters.isDisabled !== undefined) {
|
|
|
|
|
+ filterParams.isDisabled = filters.isDisabled;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (filters.roleIds.length > 0) {
|
|
|
|
|
+ filterParams['roles.id'] = filters.roleIds;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (filters.createdAt) {
|
|
|
|
|
+ filterParams.createdAt = filters.createdAt;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const res = await userClient.$get({
|
|
const res = await userClient.$get({
|
|
|
query: {
|
|
query: {
|
|
|
page: searchParams.page,
|
|
page: searchParams.page,
|
|
|
pageSize: searchParams.limit,
|
|
pageSize: searchParams.limit,
|
|
|
- keyword: searchParams.search
|
|
|
|
|
|
|
+ keyword: searchParams.keyword,
|
|
|
|
|
+ filters: Object.keys(filterParams).length > 0 ? JSON.stringify(filterParams) : undefined
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
if (res.status !== 200) {
|
|
if (res.status !== 200) {
|
|
@@ -102,6 +127,30 @@ export const UsersPage = () => {
|
|
|
setSearchParams(prev => ({ ...prev, page, limit }));
|
|
setSearchParams(prev => ({ ...prev, page, limit }));
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ // 处理过滤条件变化
|
|
|
|
|
+ const handleFilterChange = (newFilters: Partial<typeof filters>) => {
|
|
|
|
|
+ setFilters(prev => ({ ...prev, ...newFilters }));
|
|
|
|
|
+ setSearchParams(prev => ({ ...prev, page: 1 }));
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 重置所有过滤条件
|
|
|
|
|
+ const resetFilters = () => {
|
|
|
|
|
+ setFilters({
|
|
|
|
|
+ isDisabled: undefined,
|
|
|
|
|
+ roleIds: [],
|
|
|
|
|
+ createdAt: undefined
|
|
|
|
|
+ });
|
|
|
|
|
+ setSearchParams(prev => ({ ...prev, page: 1 }));
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 检查是否有活跃的过滤条件
|
|
|
|
|
+ const hasActiveFilters = useMemo(() => {
|
|
|
|
|
+ return filters.isDisabled !== undefined ||
|
|
|
|
|
+ filters.roleIds.length > 0 ||
|
|
|
|
|
+ filters.createdAt !== undefined;
|
|
|
|
|
+ }, [filters]);
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
// 打开创建用户对话框
|
|
// 打开创建用户对话框
|
|
|
const handleCreateUser = () => {
|
|
const handleCreateUser = () => {
|
|
|
setEditingUser(null);
|
|
setEditingUser(null);
|
|
@@ -145,8 +194,7 @@ export const UsersPage = () => {
|
|
|
toast.success('用户创建成功');
|
|
toast.success('用户创建成功');
|
|
|
setIsModalOpen(false);
|
|
setIsModalOpen(false);
|
|
|
refetch();
|
|
refetch();
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('创建用户失败:', error);
|
|
|
|
|
|
|
+ } catch {
|
|
|
toast.error('创建失败,请重试');
|
|
toast.error('创建失败,请重试');
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
@@ -166,8 +214,7 @@ export const UsersPage = () => {
|
|
|
toast.success('用户更新成功');
|
|
toast.success('用户更新成功');
|
|
|
setIsModalOpen(false);
|
|
setIsModalOpen(false);
|
|
|
refetch();
|
|
refetch();
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('更新用户失败:', error);
|
|
|
|
|
|
|
+ } catch {
|
|
|
toast.error('更新失败,请重试');
|
|
toast.error('更新失败,请重试');
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
@@ -190,8 +237,7 @@ export const UsersPage = () => {
|
|
|
}
|
|
}
|
|
|
toast.success('用户删除成功');
|
|
toast.success('用户删除成功');
|
|
|
refetch();
|
|
refetch();
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('删除用户失败:', error);
|
|
|
|
|
|
|
+ } catch {
|
|
|
toast.error('删除失败,请重试');
|
|
toast.error('删除失败,请重试');
|
|
|
} finally {
|
|
} finally {
|
|
|
setDeleteDialogOpen(false);
|
|
setDeleteDialogOpen(false);
|
|
@@ -245,21 +291,192 @@ export const UsersPage = () => {
|
|
|
</CardDescription>
|
|
</CardDescription>
|
|
|
</CardHeader>
|
|
</CardHeader>
|
|
|
<CardContent>
|
|
<CardContent>
|
|
|
- <div className="mb-4">
|
|
|
|
|
|
|
+ <div className="mb-4 space-y-4">
|
|
|
<form onSubmit={handleSearch} className="flex gap-2">
|
|
<form onSubmit={handleSearch} className="flex gap-2">
|
|
|
<div className="relative flex-1 max-w-sm">
|
|
<div className="relative flex-1 max-w-sm">
|
|
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
|
<Input
|
|
<Input
|
|
|
placeholder="搜索用户名、昵称或邮箱..."
|
|
placeholder="搜索用户名、昵称或邮箱..."
|
|
|
- value={searchParams.search}
|
|
|
|
|
- onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
|
|
|
|
|
|
|
+ value={searchParams.keyword}
|
|
|
|
|
+ onChange={(e) => setSearchParams(prev => ({ ...prev, keyword: e.target.value }))}
|
|
|
className="pl-8"
|
|
className="pl-8"
|
|
|
/>
|
|
/>
|
|
|
</div>
|
|
</div>
|
|
|
<Button type="submit" variant="outline">
|
|
<Button type="submit" variant="outline">
|
|
|
搜索
|
|
搜索
|
|
|
</Button>
|
|
</Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ variant="outline"
|
|
|
|
|
+ onClick={() => setShowFilters(!showFilters)}
|
|
|
|
|
+ className="flex items-center gap-2"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Filter className="h-4 w-4" />
|
|
|
|
|
+ 高级筛选
|
|
|
|
|
+ {hasActiveFilters && (
|
|
|
|
|
+ <Badge variant="secondary" className="ml-1">
|
|
|
|
|
+ {Object.values(filters).filter(v =>
|
|
|
|
|
+ v !== undefined &&
|
|
|
|
|
+ (!Array.isArray(v) || v.length > 0)
|
|
|
|
|
+ ).length}
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ {hasActiveFilters && (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ onClick={resetFilters}
|
|
|
|
|
+ className="flex items-center gap-2"
|
|
|
|
|
+ >
|
|
|
|
|
+ <X className="h-4 w-4" />
|
|
|
|
|
+ 重置
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
</form>
|
|
</form>
|
|
|
|
|
+
|
|
|
|
|
+ {showFilters && (
|
|
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 border rounded-lg bg-muted/50">
|
|
|
|
|
+ {/* 状态筛选 */}
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ <label className="text-sm font-medium">用户状态</label>
|
|
|
|
|
+ <Select
|
|
|
|
|
+ value={filters.isDisabled?.toString() || ''}
|
|
|
|
|
+ onValueChange={(value) =>
|
|
|
|
|
+ handleFilterChange({
|
|
|
|
|
+ isDisabled: value === '' ? undefined : parseInt(value)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ <SelectTrigger>
|
|
|
|
|
+ <SelectValue placeholder="选择状态" />
|
|
|
|
|
+ </SelectTrigger>
|
|
|
|
|
+ <SelectContent>
|
|
|
|
|
+ <SelectItem value="">全部状态</SelectItem>
|
|
|
|
|
+ <SelectItem value="0">启用</SelectItem>
|
|
|
|
|
+ <SelectItem value="1">禁用</SelectItem>
|
|
|
|
|
+ </SelectContent>
|
|
|
|
|
+ </Select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 角色筛选 */}
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ <label className="text-sm font-medium">用户角色</label>
|
|
|
|
|
+ <Select
|
|
|
|
|
+ value=""
|
|
|
|
|
+ onValueChange={(value) => {
|
|
|
|
|
+ const roleId = parseInt(value);
|
|
|
|
|
+ if (!filters.roleIds.includes(roleId)) {
|
|
|
|
|
+ handleFilterChange({
|
|
|
|
|
+ roleIds: [...filters.roleIds, roleId]
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <SelectTrigger>
|
|
|
|
|
+ <SelectValue placeholder="选择角色" />
|
|
|
|
|
+ </SelectTrigger>
|
|
|
|
|
+ <SelectContent>
|
|
|
|
|
+ <SelectItem value="1">管理员</SelectItem>
|
|
|
|
|
+ <SelectItem value="2">普通用户</SelectItem>
|
|
|
|
|
+ </SelectContent>
|
|
|
|
|
+ </Select>
|
|
|
|
|
+ {filters.roleIds.length > 0 && (
|
|
|
|
|
+ <div className="flex flex-wrap gap-2 mt-2">
|
|
|
|
|
+ {filters.roleIds.map(roleId => (
|
|
|
|
|
+ <Badge
|
|
|
|
|
+ key={roleId}
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ className="flex items-center gap-1"
|
|
|
|
|
+ >
|
|
|
|
|
+ {roleId === 1 ? '管理员' : '普通用户'}
|
|
|
|
|
+ <X
|
|
|
|
|
+ className="h-3 w-3 cursor-pointer"
|
|
|
|
|
+ onClick={() => handleFilterChange({
|
|
|
|
|
+ roleIds: filters.roleIds.filter(id => id !== roleId)
|
|
|
|
|
+ })}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 创建时间筛选 */}
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ <label className="text-sm font-medium">创建时间</label>
|
|
|
|
|
+ <Popover>
|
|
|
|
|
+ <PopoverTrigger asChild>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="outline"
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ "w-full justify-start text-left font-normal",
|
|
|
|
|
+ !filters.createdAt && "text-muted-foreground"
|
|
|
|
|
+ )}
|
|
|
|
|
+ >
|
|
|
|
|
+ {filters.createdAt ?
|
|
|
|
|
+ `${filters.createdAt.gte || ''} 至 ${filters.createdAt.lte || ''}` :
|
|
|
|
|
+ '选择日期范围'
|
|
|
|
|
+ }
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </PopoverTrigger>
|
|
|
|
|
+ <PopoverContent className="w-auto p-0" align="start">
|
|
|
|
|
+ <Calendar
|
|
|
|
|
+ mode="range"
|
|
|
|
|
+ selected={{
|
|
|
|
|
+ from: filters.createdAt?.gte ? new Date(filters.createdAt.gte) : undefined,
|
|
|
|
|
+ to: filters.createdAt?.lte ? new Date(filters.createdAt.lte) : undefined
|
|
|
|
|
+ }}
|
|
|
|
|
+ onSelect={(range) => {
|
|
|
|
|
+ handleFilterChange({
|
|
|
|
|
+ createdAt: range?.from && range?.to ? {
|
|
|
|
|
+ gte: format(range.from, 'yyyy-MM-dd'),
|
|
|
|
|
+ lte: format(range.to, 'yyyy-MM-dd')
|
|
|
|
|
+ } : undefined
|
|
|
|
|
+ });
|
|
|
|
|
+ }}
|
|
|
|
|
+ initialFocus
|
|
|
|
|
+ />
|
|
|
|
|
+ </PopoverContent>
|
|
|
|
|
+ </Popover>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* 过滤条件标签 */}
|
|
|
|
|
+ {hasActiveFilters && (
|
|
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
|
|
+ {filters.isDisabled !== undefined && (
|
|
|
|
|
+ <Badge variant="secondary" className="flex items-center gap-1">
|
|
|
|
|
+ 状态: {filters.isDisabled === 0 ? '启用' : '禁用'}
|
|
|
|
|
+ <X
|
|
|
|
|
+ className="h-3 w-3 cursor-pointer"
|
|
|
|
|
+ onClick={() => handleFilterChange({ isDisabled: undefined })}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {filters.roleIds.map(roleId => (
|
|
|
|
|
+ <Badge key={roleId} variant="secondary" className="flex items-center gap-1">
|
|
|
|
|
+ 角色: {roleId === 1 ? '管理员' : '普通用户'}
|
|
|
|
|
+ <X
|
|
|
|
|
+ className="h-3 w-3 cursor-pointer"
|
|
|
|
|
+ onClick={() => handleFilterChange({
|
|
|
|
|
+ roleIds: filters.roleIds.filter(id => id !== roleId)
|
|
|
|
|
+ })}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ {filters.createdAt && (
|
|
|
|
|
+ <Badge variant="secondary" className="flex items-center gap-1">
|
|
|
|
|
+ 创建时间: {filters.createdAt.gte || ''} 至 {filters.createdAt.lte || ''}
|
|
|
|
|
+ <X
|
|
|
|
|
+ className="h-3 w-3 cursor-pointer"
|
|
|
|
|
+ onClick={() => handleFilterChange({ createdAt: undefined })}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div className="rounded-md border">
|
|
<div className="rounded-md border">
|
|
@@ -285,10 +502,10 @@ export const UsersPage = () => {
|
|
|
<TableCell>{user.name || '-'}</TableCell>
|
|
<TableCell>{user.name || '-'}</TableCell>
|
|
|
<TableCell>
|
|
<TableCell>
|
|
|
<Badge
|
|
<Badge
|
|
|
- variant={user.roles?.some((role: any) => role.name === 'admin') ? 'destructive' : 'default'}
|
|
|
|
|
|
|
+ variant={user.roles?.some((role) => role.name === 'admin') ? 'destructive' : 'default'}
|
|
|
className="capitalize"
|
|
className="capitalize"
|
|
|
>
|
|
>
|
|
|
- {user.roles?.some((role: any) => role.name === 'admin') ? '管理员' : '普通用户'}
|
|
|
|
|
|
|
+ {user.roles?.some((role) => role.name === 'admin') ? '管理员' : '普通用户'}
|
|
|
</Badge>
|
|
</Badge>
|
|
|
</TableCell>
|
|
</TableCell>
|
|
|
<TableCell>
|
|
<TableCell>
|