mosaicmap/app/admin/permissions/user-role-management.tsx
2025-08-17 20:28:13 +08:00

586 lines
24 KiB
TypeScript
Raw 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 * 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>
)
}