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

688 lines
26 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 {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { z } from "zod"
import {
IconShield,
IconPlus,
IconDotsVertical,
IconPencil,
IconTrash,
IconUsers,
IconKey,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
} from "@tabler/icons-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { toast } from "sonner"
const roleSchema = z.object({
id: z.string(),
name: z.string(),
code: z.string(),
description: z.string().optional(),
level: z.number(),
isActive: z.boolean(),
userCount: z.number(),
permissionCount: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
type Role = z.infer<typeof roleSchema>
const GET_ROLES = gql`
query GetRoles($pagination: PaginationInput) {
roles(pagination: $pagination) {
items {
id
name
code
description
level
isActive
userCount
permissionCount
createdAt
updatedAt
}
total
page
perPage
totalPages
}
}
`
const CREATE_ROLE = gql`
mutation CreateRole($input: CreateRoleInput!) {
createRole(input: $input) {
id
name
code
isActive
}
}
`
const UPDATE_ROLE = gql`
mutation UpdateRole($id: UUID!, $input: UpdateRoleInput!) {
updateRole(id: $id, input: $input) {
id
name
code
isActive
updatedAt
}
}
`
const DELETE_ROLE = gql`
mutation DeleteRole($id: UUID!) {
deleteRole(id: $id)
}
`
const columns: ColumnDef<Role>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "角色名称",
cell: ({ row }) => {
const role = row.original
return (
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
<IconShield className="h-4 w-4 text-primary" />
</div>
<div>
<div className="font-medium">{role.name}</div>
<div className="text-sm text-muted-foreground">{role.code}</div>
</div>
</div>
)
},
},
{
accessorKey: "description",
header: "描述",
cell: ({ row }) => {
const description = row.getValue("description") as string
return (
<div className="max-w-xs">
<span className="text-sm text-muted-foreground line-clamp-2">
{description || "无描述"}
</span>
</div>
)
},
},
{
accessorKey: "level",
header: "级别",
cell: ({ row }) => {
const level = row.getValue("level") as number
const getLevelColor = (level: number) => {
if (level >= 90) return "text-red-600"
if (level >= 70) return "text-orange-600"
if (level >= 50) return "text-yellow-600"
return "text-green-600"
}
return (
<span className={`font-medium ${getLevelColor(level)}`}>
{level}
</span>
)
},
},
{
accessorKey: "userCount",
header: ({ column }) => (
<div className="flex items-center gap-1">
<IconUsers className="h-4 w-4" />
</div>
),
cell: ({ row }) => {
const count = row.getValue("userCount") as number
return (
<div className="text-center">
<Badge variant="outline">{count}</Badge>
</div>
)
},
},
{
accessorKey: "permissionCount",
header: ({ column }) => (
<div className="flex items-center gap-1">
<IconKey className="h-4 w-4" />
</div>
),
cell: ({ row }) => {
const count = row.getValue("permissionCount") as number
return (
<div className="text-center">
<Badge variant="outline">{count}</Badge>
</div>
)
},
},
{
accessorKey: "isActive",
header: "状态",
cell: ({ row }) => {
const isActive = row.getValue("isActive") as boolean
return (
<Badge variant={isActive ? "default" : "secondary"}>
{isActive ? "启用" : "禁用"}
</Badge>
)
},
},
{
id: "actions",
cell: ({ row, table }) => {
const role = row.original
const updateRole = (table.options.meta as any)?.updateRole
const deleteRole = (table.options.meta as any)?.deleteRole
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<IconDotsVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem>
<IconPencil className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem>
<IconKey className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem>
<IconUsers className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
updateRole({
variables: {
id: role.id,
input: { isActive: !role.isActive }
}
})
}}
>
{role.isActive ? "禁用" : "启用"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onClick={() => {
if (confirm('确定要删除这个角色吗?此操作不可撤销。')) {
deleteRole({
variables: { id: role.id }
})
}
}}
>
<IconTrash className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
function CreateRoleDialog({ onSuccess }: { onSuccess: () => void }) {
const [open, setOpen] = React.useState(false)
const [loading, setLoading] = React.useState(false)
const [createRole] = useMutation(CREATE_ROLE)
const [formData, setFormData] = React.useState({
name: "",
code: "",
description: "",
level: 10,
roleType: "CUSTOM", // 默认为自定义角色
isActive: true,
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name || !formData.code) {
toast.error("请填写角色名称和代码")
return
}
setLoading(true)
try {
await createRole({
variables: { input: formData }
})
toast.success("角色创建成功")
setOpen(false)
setFormData({
name: "",
code: "",
description: "",
level: 10,
roleType: "CUSTOM",
isActive: true,
})
onSuccess()
} catch (error) {
toast.error("创建失败")
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<IconPlus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="管理员"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="code"> *</Label>
<Input
id="code"
value={formData.code}
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
placeholder="ADMIN"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="描述这个角色的职责和权限范围"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="roleType"></Label>
<Select
value={formData.roleType}
onValueChange={(value) => setFormData(prev => ({ ...prev, roleType: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SYSTEM"></SelectItem>
<SelectItem value="CUSTOM"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="level"></Label>
<Select
value={formData.level.toString()}
onValueChange={(value) => setFormData(prev => ({ ...prev, level: parseInt(value) }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 - </SelectItem>
<SelectItem value="30">30 - </SelectItem>
<SelectItem value="50">50 - </SelectItem>
<SelectItem value="70">70 - </SelectItem>
<SelectItem value="90">90 - </SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex items-center space-x-2">
<Checkbox
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isActive: !!checked }))}
/>
<Label htmlFor="isActive" className="text-sm font-normal">
</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="submit" disabled={loading}>
{loading ? "创建中..." : "创建角色"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function RoleTable() {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const { data, loading, error, refetch } = useQuery(GET_ROLES, {
variables: {
pagination: {
page: pagination.pageIndex + 1,
perPage: pagination.pageSize,
}
},
fetchPolicy: 'cache-and-network'
})
const [updateRole] = useMutation(UPDATE_ROLE, {
onCompleted: () => {
refetch()
toast.success("角色状态已更新")
}
})
const [deleteRole] = useMutation(DELETE_ROLE, {
onCompleted: () => {
refetch()
toast.success("角色已删除")
}
})
const roles = data?.roles?.items || []
const totalCount = data?.roles?.total || 0
const table = useReactTable({
data: roles,
columns,
pageCount: Math.ceil(totalCount / pagination.pageSize),
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
manualPagination: true,
manualSorting: true,
meta: {
updateRole,
deleteRole,
},
})
if (error) {
return <div className="text-red-500"></div>
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between py-4">
<div className="flex items-center space-x-2">
<Input
placeholder="搜索角色..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("name")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
</div>
<CreateRoleDialog onSuccess={refetch} />
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length}
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium"></p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
{table.getState().pagination.pageIndex + 1} {" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<IconChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<IconChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<IconChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<IconChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}