586 lines
24 KiB
TypeScript
586 lines
24 KiB
TypeScript
"use client"
|
||
|
||
import * as React from "react"
|
||
import { useQuery, useMutation, gql } from '@apollo/client';
|
||
import {
|
||
IconUsers,
|
||
IconShield,
|
||
IconPlus,
|
||
IconMinus,
|
||
IconUserCheck,
|
||
IconChevronDown,
|
||
} from "@tabler/icons-react"
|
||
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Checkbox } from "@/components/ui/checkbox"
|
||
import {
|
||
Command,
|
||
CommandEmpty,
|
||
CommandGroup,
|
||
CommandInput,
|
||
CommandItem,
|
||
CommandList,
|
||
} from "@/components/ui/command"
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Label } from "@/components/ui/label"
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover"
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select"
|
||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||
import { toast } from "sonner"
|
||
|
||
const GET_USERS_WITH_GROUPS = gql`
|
||
query GetUsersWithGroups($offset: Int, $limit: Int, $sortBy: String, $sortOrder: String, $filter: String) {
|
||
usersWithGroups(offset: $offset, limit: $limit, sortBy: $sortBy, sortOrder: $sortOrder, filter: $filter) {
|
||
user {
|
||
id
|
||
username
|
||
email
|
||
isActivate
|
||
createdAt
|
||
updatedAt
|
||
}
|
||
groups
|
||
}
|
||
}
|
||
`
|
||
|
||
const GET_ALL_ROLES = gql`
|
||
query GetAllRoles {
|
||
roles(pagination: { page: 1, perPage: 100 }) {
|
||
items {
|
||
id
|
||
name
|
||
code
|
||
level
|
||
isActive
|
||
}
|
||
}
|
||
}
|
||
`
|
||
|
||
const ASSIGN_ROLE_TO_USER = gql`
|
||
mutation AssignRoleToUser($userId: UUID!, $roleName: String!) {
|
||
assignRoleToUser(userId: $userId, roleName: $roleName)
|
||
}
|
||
`
|
||
|
||
const REMOVE_ROLE_FROM_USER = gql`
|
||
mutation RemoveRoleFromUser($userId: UUID!, $roleName: String!) {
|
||
removeRoleFromUser(userId: $userId, roleName: $roleName)
|
||
}
|
||
`
|
||
|
||
const BATCH_ASSIGN_ROLES_TO_USER = gql`
|
||
mutation BatchAssignRolesToUser($userId: UUID!, $roleIds: [UUID!]!) {
|
||
batchAssignRolesToUser(userId: $userId, roleIds: $roleIds)
|
||
}
|
||
`
|
||
|
||
interface UserWithGroups {
|
||
user: User
|
||
groups: string[]
|
||
}
|
||
|
||
interface User {
|
||
id: string
|
||
username: string
|
||
email: string
|
||
isActivate: boolean
|
||
createdAt: string
|
||
updatedAt: string
|
||
groups: string[]
|
||
}
|
||
|
||
interface Role {
|
||
id: string
|
||
name: string
|
||
code: string
|
||
level: number
|
||
isActive: boolean
|
||
}
|
||
|
||
function UserRoleCard({ user, allRoles, onRoleChange }: {
|
||
user: User
|
||
allRoles: Role[]
|
||
onRoleChange: () => void
|
||
}) {
|
||
const [assignRole] = useMutation(ASSIGN_ROLE_TO_USER)
|
||
const [removeRole] = useMutation(REMOVE_ROLE_FROM_USER)
|
||
const [open, setOpen] = React.useState(false)
|
||
|
||
const availableRoles = allRoles.filter(role =>
|
||
role.isActive && !user.groups.some(group => group.toLowerCase() === role.code.toLowerCase())
|
||
)
|
||
|
||
|
||
const handleAssignRole = async (roleId: string) => {
|
||
const role = allRoles.find((r: Role) => r.id === roleId)
|
||
if (!role) return
|
||
|
||
try {
|
||
await assignRole({
|
||
variables: { userId: user.id, roleName: role.code }
|
||
})
|
||
toast.success("角色分配成功")
|
||
onRoleChange()
|
||
} catch (error) {
|
||
toast.error("角色分配失败")
|
||
}
|
||
}
|
||
|
||
const handleRemoveRole = async (roleName: string) => {
|
||
const role = allRoles.find((r: Role) => r.code === roleName)
|
||
if (!role) return
|
||
|
||
try {
|
||
await removeRole({
|
||
variables: { userId: user.id, roleName: role.code }
|
||
})
|
||
toast.success("角色移除成功")
|
||
onRoleChange()
|
||
} catch (error) {
|
||
toast.error("角色移除失败")
|
||
}
|
||
}
|
||
|
||
const getInitials = (username: string) => {
|
||
return username.slice(0, 2).toUpperCase()
|
||
}
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center gap-3">
|
||
<Avatar className="h-12 w-12">
|
||
<AvatarFallback>{getInitials(user.username)}</AvatarFallback>
|
||
</Avatar>
|
||
<div className="flex-1">
|
||
<CardTitle className="text-base">{user.username}</CardTitle>
|
||
<CardDescription>{user.email}</CardDescription>
|
||
</div>
|
||
<Badge variant={user.isActivate ? "default" : "secondary"}>
|
||
{user.isActivate ? "活跃" : "非活跃"}
|
||
</Badge>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-sm font-medium">已分配角色</Label>
|
||
<Popover open={open} onOpenChange={setOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button variant="outline" size="sm" className="h-8">
|
||
<IconPlus className="mr-1 h-3 w-3" />
|
||
分配角色
|
||
<IconChevronDown className="ml-1 h-3 w-3" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-64 p-0" align="end">
|
||
<div className="w-64">
|
||
{availableRoles.length === 0 ? (
|
||
<div className="p-4 text-sm text-muted-foreground text-center">
|
||
没有可分配的角色
|
||
</div>
|
||
) : (
|
||
<div className="p-1">
|
||
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
|
||
可分配的角色
|
||
</div>
|
||
{availableRoles.map((role) => (
|
||
<button
|
||
key={role.id}
|
||
className="w-full flex items-center gap-2 px-2 py-2 text-left hover:bg-accent hover:text-accent-foreground rounded-sm"
|
||
onClick={() => {
|
||
handleAssignRole(role.id)
|
||
setOpen(false)
|
||
}}
|
||
>
|
||
<IconShield className="h-4 w-4" />
|
||
<div className="flex-1">
|
||
<div className="font-medium text-sm">{role.name}</div>
|
||
<div className="text-xs text-muted-foreground">{role.code}</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{user.groups.length === 0 ? (
|
||
<div className="text-sm text-muted-foreground py-2 text-center">
|
||
未分配任何角色
|
||
</div>
|
||
) : (
|
||
user.groups.map((groupCode) => {
|
||
const role = allRoles.find((r: Role) => r.code.toLowerCase() === groupCode.toLowerCase())
|
||
return (
|
||
<div
|
||
key={groupCode}
|
||
className="flex items-center justify-between p-2 border rounded-lg bg-muted/30"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<IconShield className="h-4 w-4 text-primary" />
|
||
<div>
|
||
<div className="font-medium text-sm">
|
||
{role?.name || groupCode}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">{groupCode}</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="outline" className="text-xs">
|
||
{role ? `Level ${role.level}` : "角色"}
|
||
</Badge>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-600"
|
||
onClick={() => handleRemoveRole(groupCode)}
|
||
>
|
||
<IconMinus className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function BatchAssignDialog({ selectedUsers, allRoles, onSuccess }: {
|
||
selectedUsers: User[]
|
||
allRoles: Role[]
|
||
onSuccess: () => void
|
||
}) {
|
||
const [open, setOpen] = React.useState(false)
|
||
const [selectedRole, setSelectedRole] = React.useState("")
|
||
const [loading, setLoading] = React.useState(false)
|
||
const [batchAssignRoles] = useMutation(BATCH_ASSIGN_ROLES_TO_USER)
|
||
|
||
const handleBatchAssign = async () => {
|
||
if (!selectedRole || selectedUsers.length === 0) {
|
||
toast.error("请选择角色和用户")
|
||
return
|
||
}
|
||
|
||
const role = allRoles.find((r: Role) => r.id === selectedRole)
|
||
if (!role) return
|
||
|
||
setLoading(true)
|
||
try {
|
||
await Promise.all(
|
||
selectedUsers.map(user =>
|
||
batchAssignRoles({
|
||
variables: { userId: user.id, roleIds: [role.id] }
|
||
})
|
||
)
|
||
)
|
||
toast.success(`成功为 ${selectedUsers.length} 个用户分配角色`)
|
||
setOpen(false)
|
||
setSelectedRole("")
|
||
onSuccess()
|
||
} catch (error) {
|
||
toast.error("批量分配失败")
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogTrigger asChild>
|
||
<Button variant="outline" size="sm" disabled={selectedUsers.length === 0}>
|
||
<IconUserCheck className="mr-2 h-4 w-4" />
|
||
批量分配角色 ({selectedUsers.length})
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>批量分配角色</DialogTitle>
|
||
<DialogDescription>
|
||
为选中的 {selectedUsers.length} 个用户分配角色
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>选择角色</Label>
|
||
<Select value={selectedRole} onValueChange={setSelectedRole}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择要分配的角色" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{allRoles.filter(role => role.isActive).map((role) => (
|
||
<SelectItem key={role.id} value={role.id}>
|
||
<div className="flex items-center gap-2">
|
||
<IconShield className="h-4 w-4" />
|
||
{role.name} ({role.code})
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>选中的用户</Label>
|
||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||
{selectedUsers.map((user) => (
|
||
<div key={user.id} className="flex items-center gap-2 text-sm">
|
||
<Avatar className="h-6 w-6">
|
||
<AvatarFallback className="text-xs">
|
||
{user.username.slice(0, 2).toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
{user.username} ({user.email})
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleBatchAssign} disabled={loading || !selectedRole}>
|
||
{loading ? "分配中..." : "确认分配"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
export function UserRoleManagement() {
|
||
const [searchTerm, setSearchTerm] = React.useState("")
|
||
const [roleFilter, setRoleFilter] = React.useState("")
|
||
const [selectedUsers, setSelectedUsers] = React.useState<User[]>([])
|
||
const [pagination, setPagination] = React.useState({
|
||
offset: 0,
|
||
limit: 12,
|
||
})
|
||
|
||
const { data: usersData, loading: usersLoading, refetch: refetchUsers } = useQuery(GET_USERS_WITH_GROUPS, {
|
||
variables: {
|
||
offset: pagination.offset,
|
||
limit: pagination.limit,
|
||
sortBy: "created_at",
|
||
sortOrder: "DESC",
|
||
filter: searchTerm || undefined,
|
||
},
|
||
fetchPolicy: 'cache-and-network'
|
||
})
|
||
|
||
// 当搜索词或角色筛选改变时,重新获取数据
|
||
React.useEffect(() => {
|
||
refetchUsers()
|
||
}, [searchTerm, refetchUsers])
|
||
|
||
const { data: rolesData } = useQuery(GET_ALL_ROLES, {
|
||
fetchPolicy: 'cache-and-network'
|
||
})
|
||
|
||
const rawUsersWithGroups = usersData?.usersWithGroups || []
|
||
const users: User[] = rawUsersWithGroups.map((userWithGroups: UserWithGroups) => ({
|
||
...userWithGroups.user,
|
||
groups: userWithGroups.groups
|
||
}))
|
||
const allRoles = rolesData?.roles?.items || []
|
||
const totalUsers = users.length
|
||
|
||
const handleUserSelect = (user: User, checked: boolean) => {
|
||
if (checked) {
|
||
setSelectedUsers(prev => [...prev, user])
|
||
} else {
|
||
setSelectedUsers(prev => prev.filter(u => u.id !== user.id))
|
||
}
|
||
}
|
||
|
||
const handleSelectAll = (checked: boolean) => {
|
||
if (checked) {
|
||
setSelectedUsers(filteredUsers)
|
||
} else {
|
||
setSelectedUsers([])
|
||
}
|
||
}
|
||
|
||
// 按角色筛选用户
|
||
const filteredUsers = users.filter(user => {
|
||
if (!roleFilter) return true
|
||
const selectedRole = allRoles.find((r: Role) => r.id === roleFilter)
|
||
return selectedRole ? user.groups.some(group => group.toLowerCase() === selectedRole.code.toLowerCase()) : true
|
||
})
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>用户角色管理</CardTitle>
|
||
<CardDescription>
|
||
为用户分配和管理角色,支持单个和批量操作
|
||
{roleFilter && ` • 筛选角色: ${allRoles.find((r: Role) => r.id === roleFilter)?.name}`}
|
||
{searchTerm && ` • 搜索: "${searchTerm}"`}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex flex-col gap-4 mb-6">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<Input
|
||
placeholder="搜索用户..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="max-w-sm"
|
||
/>
|
||
</div>
|
||
<Select value={roleFilter || "all"} onValueChange={(value) => setRoleFilter(value === "all" ? "" : value)}>
|
||
<SelectTrigger className="w-[180px]">
|
||
<SelectValue placeholder="筛选角色" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部角色</SelectItem>
|
||
{allRoles.map((role: Role) => (
|
||
<SelectItem key={role.id} value={role.id}>
|
||
{role.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{(searchTerm || roleFilter) && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setSearchTerm("")
|
||
setRoleFilter("")
|
||
}}
|
||
>
|
||
清除筛选
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox
|
||
checked={selectedUsers.length === filteredUsers.length && filteredUsers.length > 0}
|
||
onCheckedChange={(checked) => handleSelectAll(!!checked)}
|
||
/>
|
||
<Label className="text-sm">
|
||
全选 ({selectedUsers.length}/{filteredUsers.length})
|
||
</Label>
|
||
</div>
|
||
<BatchAssignDialog
|
||
selectedUsers={selectedUsers}
|
||
allRoles={allRoles}
|
||
onSuccess={() => {
|
||
refetchUsers()
|
||
setSelectedUsers([])
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{usersLoading ? (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{[...Array(6)].map((_, i) => (
|
||
<Card key={i} className="animate-pulse">
|
||
<CardHeader>
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-12 w-12 rounded-full bg-muted" />
|
||
<div className="space-y-2 flex-1">
|
||
<div className="h-4 bg-muted rounded w-2/3" />
|
||
<div className="h-3 bg-muted rounded w-1/2" />
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
<div className="h-3 bg-muted rounded w-1/3" />
|
||
<div className="h-8 bg-muted rounded" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
) : filteredUsers.length === 0 ? (
|
||
<div className="text-center py-8">
|
||
<IconUsers className="mx-auto h-12 w-12 text-muted-foreground" />
|
||
<h3 className="mt-2 text-sm font-semibold text-gray-900">暂无用户</h3>
|
||
<p className="mt-1 text-sm text-gray-500">没有找到匹配的用户。</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{filteredUsers.map((user) => (
|
||
<div key={user.id} className="relative">
|
||
<div className="absolute top-3 left-3 z-10">
|
||
<Checkbox
|
||
checked={selectedUsers.some(u => u.id === user.id)}
|
||
onCheckedChange={(checked) => handleUserSelect(user, !!checked)}
|
||
/>
|
||
</div>
|
||
<UserRoleCard
|
||
user={user}
|
||
allRoles={allRoles}
|
||
onRoleChange={refetchUsers}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{totalUsers >= pagination.limit && (
|
||
<div className="flex items-center justify-center gap-2 mt-6">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setPagination(prev => ({ ...prev, offset: Math.max(0, prev.offset - prev.limit) }))}
|
||
disabled={pagination.offset === 0}
|
||
>
|
||
上一页
|
||
</Button>
|
||
<span className="text-sm text-muted-foreground">
|
||
显示 {pagination.offset + 1} - {pagination.offset + users.length} 项
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setPagination(prev => ({ ...prev, offset: prev.offset + prev.limit }))}
|
||
disabled={users.length < pagination.limit}
|
||
>
|
||
下一页
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
} |