实现权限管理页面与功能

This commit is contained in:
2026-03-18 17:13:31 +08:00
parent efe1568ab5
commit c4e1da8912
25 changed files with 2245 additions and 18 deletions

View File

@@ -0,0 +1,219 @@
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { getAllPermissions } from "@/actions/permission"
import { updateRole } from "@/actions/role"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import type { Permission } from "@/models/permission"
import type { Role } from "@/models/role"
function PermissionItem({
permission,
selected,
onToggle,
}: {
permission: Permission
selected: Set<number>
onToggle: (id: number, checked: boolean) => void
}) {
const hasChildren = permission.children && permission.children.length > 0
const allChildIds = (p: Permission): number[] => {
if (!p.children || p.children.length === 0) return [p.id]
return [p.id, ...p.children.flatMap(allChildIds)]
}
const childIds = hasChildren ? allChildIds(permission).slice(1) : []
const allChecked =
hasChildren && childIds.length > 0
? childIds.every(id => selected.has(id))
: selected.has(permission.id)
const someChecked =
hasChildren && childIds.length > 0
? childIds.some(id => selected.has(id)) && !allChecked
: false
const handleChange = (checked: boolean) => {
if (hasChildren) {
const ids = allChildIds(permission)
for (const id of ids) {
onToggle(id, checked)
}
} else {
onToggle(permission.id, checked)
}
}
const checkboxId = `perm-${permission.id}`
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 py-0.5">
<Checkbox
id={checkboxId}
checked={someChecked ? "indeterminate" : allChecked}
onCheckedChange={checked => handleChange(checked === true)}
/>
<label
htmlFor={checkboxId}
className="flex items-center gap-1.5 cursor-pointer select-none text-sm"
>
{permission.name}
{permission.description && (
<span className="text-xs text-muted-foreground">
{permission.description}
</span>
)}
</label>
</div>
{hasChildren && (
<div className="pl-6 flex flex-col gap-1 border-l ml-2">
{permission.children.map(child => (
<PermissionItem
key={child.id}
permission={child}
selected={selected}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
)
}
export function AssignPermissions(props: {
role: Role
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [permissions, setPermissions] = useState<Permission[]>([])
const [selected, setSelected] = useState<Set<number>>(new Set())
const fetchPermissions = useCallback(async () => {
setLoading(true)
try {
const resp = await getAllPermissions()
if (resp.success) {
setPermissions(resp.data ?? [])
} else {
toast.error(resp.message ?? "获取权限列表失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (open) {
fetchPermissions()
}
}, [open, fetchPermissions])
const handleToggle = (id: number, checked: boolean) => {
setSelected(prev => {
const next = new Set(prev)
if (checked) {
next.add(id)
} else {
next.delete(id)
}
return next
})
}
const handleSubmit = async () => {
setSubmitting(true)
try {
const resp = await updateRole({
id: props.role.id,
permissions: Array.from(selected),
})
if (resp.success) {
toast.success("权限分配成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message ?? "权限分配失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setSubmitting(false)
}
}
const handleOpenChange = (value: boolean) => {
if (value) {
const existingIds = new Set((props.role.permissions ?? []).map(p => p.id))
setSelected(existingIds)
} else {
setSelected(new Set())
setPermissions([])
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> · {props.role.name}</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto pr-1">
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
...
</div>
) : permissions.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="flex flex-col gap-2">
{permissions.map(permission => (
<PermissionItem
key={permission.id}
permission={permission}
selected={selected}
onToggle={handleToggle}
/>
))}
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button onClick={handleSubmit} disabled={submitting || loading}>
{submitting ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,123 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { createRole } from "@/actions/role"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
const schema = z.object({
name: z.string().min(1, "请输入角色名称"),
description: z.string().optional(),
})
export function CreateRole(props: { onSuccess?: () => void }) {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
name: "",
description: "",
},
})
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await createRole(data)
if (resp.success) {
form.reset()
toast.success("角色创建成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="role-create" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="role-create-name"></FieldLabel>
<Input
id="role-create-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="role-create-description">
</FieldLabel>
<Textarea
id="role-create-description"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="role-create">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,221 @@
"use client"
import { Suspense, useState } from "react"
import { toast } from "sonner"
import { deleteRole, getPageRole, updateRole } from "@/actions/role"
import { DataTable, useDataTable } from "@/components/data-table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card"
import type { Permission } from "@/models/permission"
import type { Role } from "@/models/role"
import { AssignPermissions } from "./assign-permissions"
import { CreateRole } from "./create"
import { UpdateRole } from "./update"
export default function RolesPage() {
const table = useDataTable((page, size) => getPageRole({ page, size }))
return (
<div className="flex flex-col gap-3">
{/* 操作栏 */}
<div className="flex justify-between items-stretch">
<div className="flex gap-3">
<CreateRole onSuccess={table.refresh} />
</div>
</div>
{/* 数据表 */}
<Suspense>
<DataTable
{...table}
columns={[
{ header: "名称", accessorKey: "name" },
{ header: "描述", accessorKey: "description" },
{
header: "状态",
accessorFn: row => (row.active ? "启用" : "停用"),
},
{
header: "权限",
cell: ({ row }) => (
<PermissionsCell permissions={row.original.permissions ?? []} />
),
},
{
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<UpdateRole role={row.original} onSuccess={table.refresh} />
<AssignPermissions
role={row.original}
onSuccess={table.refresh}
/>
<ToggleActiveButton
role={row.original}
onSuccess={table.refresh}
/>
<DeleteButton role={row.original} onSuccess={table.refresh} />
</div>
),
},
]}
/>
</Suspense>
</div>
)
}
function PermissionsCell({ permissions }: { permissions: Permission[] }) {
if (!permissions || permissions.length === 0) {
return <span className="text-muted-foreground text-xs"></span>
}
const preview = permissions.slice(0, 3)
const rest = permissions.length - preview.length
return (
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex flex-wrap gap-1 cursor-default max-w-52">
{preview.map(p => (
<Badge key={p.id} variant="secondary">
{p.name}
</Badge>
))}
{rest > 0 && <Badge variant="outline">+{rest}</Badge>}
</div>
</HoverCardTrigger>
<HoverCardContent className="w-72" align="start">
<p className="text-xs font-medium text-muted-foreground mb-2">
{permissions.length}
</p>
<div className="flex flex-wrap gap-1.5">
{permissions.map(p => (
<Badge key={p.id} variant="secondary">
{p.name}
</Badge>
))}
</div>
</HoverCardContent>
</HoverCard>
)
}
function ToggleActiveButton({
role,
onSuccess,
}: {
role: Role
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await updateRole({ id: role.id, active: !role.active })
if (resp.success) {
toast.success(role.active ? "已停用" : "已启用")
onSuccess?.()
} else {
toast.error(resp.message ?? "操作失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="secondary" disabled={loading}>
{role.active ? "停用" : "启用"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
{role.active ? "停用" : "启用"}
</AlertDialogTitle>
<AlertDialogDescription>
{role.active ? "停用" : "启用"}{role.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
function DeleteButton({
role,
onSuccess,
}: {
role: Role
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await deleteRole(role.id)
if (resp.success) {
toast.success("删除成功")
onSuccess?.()
} else {
toast.error(resp.message ?? "删除失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
} finally {
setLoading(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={loading}>
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{role.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,135 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { updateRole } from "@/actions/role"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import type { Role } from "@/models/role"
const schema = z.object({
name: z.string().min(1, "请输入角色名称"),
description: z.string().optional(),
})
export function UpdateRole(props: { role: Role; onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
name: props.role.name,
description: props.role.description ?? "",
},
})
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const resp = await updateRole({ id: props.role.id, ...data })
if (resp.success) {
toast.success("角色修改成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset({
name: props.role.name,
description: props.role.description ?? "",
})
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="role-update" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="role-update-name"></FieldLabel>
<Input
id="role-update-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="role-update-description">
</FieldLabel>
<Textarea
id="role-update-description"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="role-update">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}