完善权限树的展示与选择

This commit is contained in:
2026-03-28 17:07:34 +08:00
parent 1e6b307586
commit 2c7970796f
5 changed files with 209 additions and 97 deletions

View File

@@ -1,19 +1,136 @@
"use client" "use client"
import { Suspense } from "react" import {
import { getPagePermission } from "@/actions/permission" flexRender,
import { DataTable, useDataTable } from "@/components/data-table" getCoreRowModel,
getExpandedRowModel,
type Row,
useReactTable,
} from "@tanstack/react-table"
import { Suspense, useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { getAllPermissions } from "@/actions/permission"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { cn } from "@/lib/utils"
type Node = {
id: number
name: string
description: string
children: Node[]
}
export default function PermissionsPage() { export default function PermissionsPage() {
const table = useDataTable((page, size) => getPagePermission({ page, size }))
return ( return (
<Suspense> <Suspense>
<DataTable <PermissionTable />
{...table}
columns={[
{ header: "编码", accessorKey: "name" },
{ header: "描述", accessorKey: "description" },
]}
/>
</Suspense> </Suspense>
) )
} }
function PermissionTable() {
const [data, setData] = useState<Node[]>([])
const table = useReactTable({
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
data,
columns: [
{ header: "编码", accessorKey: "name" },
{ header: "描述", accessorKey: "description" },
],
getSubRows: row => row.children,
})
const refresh = useCallback(async () => {
try {
const resp = await getAllPermissions()
if (!resp.success) {
throw new Error(resp.message)
}
const map = new Map<number, Node>()
resp.data.forEach(permission => {
map.set(permission.id, {
id: permission.id,
name: permission.name,
description: permission.description,
children: [],
})
})
const roots: Node[] = []
resp.data.forEach(permission => {
const node = map.get(permission.id)
if (!node) {
throw new Error(`找不到权限节点: ${permission.name}`)
}
if (!permission.parent_id) {
roots.push(node)
return
}
const parent = map.get(permission.parent_id)
if (!parent) {
throw new Error(`找不到父权限节点: ${permission.name}`)
}
parent.children.push(node)
})
setData(roots)
console.log(roots)
} catch (e) {
toast.error(e instanceof Error ? e.message : "获取权限列表失败")
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
return (
<div className="bg-background rounded-lg">
<Table>
<TableHeader>
<TableRow className="h-10">
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{table.getRowModel().rows.map(row => (
<PermisionRow key={row.id} row={row} />
))}
</TableBody>
</Table>
</div>
)
}
function PermisionRow(props: { row: Row<Node> }) {
const row = props.row
return (
<>
<TableRow key={row.id} className="h-10">
<TableCell className="flex">
<div style={{ width: row.depth * 16 }}></div>
<span className={cn(row.subRows.length ? "font-bold" : "text-sm")}>
{row.original.name}
</span>
</TableCell>
<TableCell>{row.original.description}</TableCell>
</TableRow>
{row.subRows.map(subRow => (
<PermisionRow key={subRow.id} row={subRow} />
))}
</>
)
}

View File

@@ -13,81 +13,58 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import type { Permission } from "@/models/permission" import { cn } from "@/lib/utils"
import type { Role } from "@/models/role" import type { Role } from "@/models/role"
function PermissionItem({ type TreeNode = {
permission, id: number
name: string
description: string
children: TreeNode[]
}
function PermissionRow({
node,
depth,
selected, selected,
onToggle, onToggle,
}: { }: {
permission: Permission node: TreeNode
depth: number
selected: Set<number> selected: Set<number>
onToggle: (id: number, checked: boolean) => void onToggle: (id: number, checked: boolean) => void
}) { }) {
const hasChildren = permission.children && permission.children.length > 0 const hasChildren = node.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 ( return (
<div className="flex flex-col gap-1"> <>
<div className="flex items-center gap-2 py-0.5"> <div className={cn("flex items-center gap-2 h-8", hasChildren && "mt-2")}>
<div style={{ width: depth * 16 }} />
<Checkbox <Checkbox
id={checkboxId} id={`perm-${node.id}`}
checked={someChecked ? "indeterminate" : allChecked} checked={selected.has(node.id)}
onCheckedChange={checked => handleChange(checked === true)} onCheckedChange={checked => onToggle(node.id, checked === true)}
/> />
<label <label
htmlFor={checkboxId} htmlFor={`perm-${node.id}`}
className="flex items-center gap-1.5 cursor-pointer select-none text-sm" className={cn(
> "cursor-pointer select-none text-sm size-full flex items-center",
{permission.name} hasChildren && "font-bold",
{permission.description && (
<span className="text-xs text-muted-foreground">
{permission.description}
</span>
)} )}
>
{node.description}
</label> </label>
</div> </div>
{hasChildren && ( {node.children.map(child => (
<div className="pl-6 flex flex-col gap-1 border-l ml-2"> <PermissionRow
{permission.children.map(child => ( key={child.id}
<PermissionItem node={child}
key={child.id} depth={depth + 1}
permission={child} selected={selected}
selected={selected} onToggle={onToggle}
onToggle={onToggle} />
/> ))}
))} </>
</div>
)}
</div>
) )
} }
@@ -98,33 +75,51 @@ export function AssignPermissions(props: {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [permissions, setPermissions] = useState<Permission[]>([]) const [nodes, setNodes] = useState<TreeNode[]>([])
const [selected, setSelected] = useState<Set<number>>(new Set()) const [selected, setSelected] = useState<Set<number>>(new Set())
const fetchPermissions = useCallback(async () => { const fetchPermissions = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const resp = await getAllPermissions() const resp = await getAllPermissions()
if (resp.success) { if (!resp.success) throw new Error(resp.message)
setPermissions(resp.data ?? [])
} else { const data = resp.data ?? []
toast.error(resp.message ?? "获取权限列表失败")
} const map = new Map<number, TreeNode>()
data.forEach(p => {
map.set(p.id, {
id: p.id,
name: p.name,
description: p.description,
children: [],
})
})
const roots: TreeNode[] = []
data.forEach(p => {
const node = map.get(p.id)
if (!node) return
if (!p.parent_id) {
roots.push(node)
} else {
map.get(p.parent_id)?.children.push(node)
}
})
setNodes(roots)
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : error toast.error(error instanceof Error ? error.message : "获取权限列表失败")
toast.error(`接口请求错误: ${message}`)
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
if (open) { if (open) fetchPermissions()
fetchPermissions()
}
}, [open, fetchPermissions]) }, [open, fetchPermissions])
const handleToggle = (id: number, checked: boolean) => { const handleToggle = useCallback((id: number, checked: boolean) => {
setSelected(prev => { setSelected(prev => {
const next = new Set(prev) const next = new Set(prev)
if (checked) { if (checked) {
@@ -134,7 +129,7 @@ export function AssignPermissions(props: {
} }
return next return next
}) })
} }, [])
const handleSubmit = async () => { const handleSubmit = async () => {
setSubmitting(true) setSubmitting(true)
@@ -151,8 +146,7 @@ export function AssignPermissions(props: {
toast.error(resp.message ?? "权限分配失败") toast.error(resp.message ?? "权限分配失败")
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : error toast.error(error instanceof Error ? error.message : "接口请求错误")
toast.error(`接口请求错误: ${message}`)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -160,11 +154,10 @@ export function AssignPermissions(props: {
const handleOpenChange = (value: boolean) => { const handleOpenChange = (value: boolean) => {
if (value) { if (value) {
const existingIds = new Set((props.role.permissions ?? []).map(p => p.id)) setSelected(new Set((props.role.permissions ?? []).map(p => p.id)))
setSelected(existingIds)
} else { } else {
setSelected(new Set()) setSelected(new Set())
setPermissions([]) setNodes([])
} }
setOpen(value) setOpen(value)
} }
@@ -187,16 +180,17 @@ export function AssignPermissions(props: {
<div className="py-8 text-center text-sm text-muted-foreground"> <div className="py-8 text-center text-sm text-muted-foreground">
... ...
</div> </div>
) : permissions.length === 0 ? ( ) : nodes.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground"> <div className="py-8 text-center text-sm text-muted-foreground">
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col">
{permissions.map(permission => ( {nodes.map(node => (
<PermissionItem <PermissionRow
key={permission.id} key={node.id}
permission={permission} node={node}
depth={0}
selected={selected} selected={selected}
onToggle={handleToggle} onToggle={handleToggle}
/> />

View File

@@ -94,7 +94,7 @@ function PermissionsCell({ permissions }: { permissions: Permission[] }) {
<div className="flex flex-wrap gap-1 cursor-default max-w-52"> <div className="flex flex-wrap gap-1 cursor-default max-w-52">
{preview.map(p => ( {preview.map(p => (
<Badge key={p.id} variant="secondary"> <Badge key={p.id} variant="secondary">
{p.name} {p.description}
</Badge> </Badge>
))} ))}
{rest > 0 && <Badge variant="outline">+{rest}</Badge>} {rest > 0 && <Badge variant="outline">+{rest}</Badge>}
@@ -107,7 +107,7 @@ function PermissionsCell({ permissions }: { permissions: Permission[] }) {
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{permissions.map(p => ( {permissions.map(p => (
<Badge key={p.id} variant="secondary"> <Badge key={p.id} variant="secondary">
{p.name} {p.description}
</Badge> </Badge>
))} ))}
</div> </div>

View File

@@ -3,6 +3,7 @@ import {
type ColumnDef, type ColumnDef,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getExpandedRowModel,
useReactTable, useReactTable,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Loader } from "lucide-react" import { Loader } from "lucide-react"

View File

@@ -4,7 +4,7 @@ export type Permission = {
updated_at: Date updated_at: Date
expired_at: Date expired_at: Date
parent_id: number parent_id?: number
name: string name: string
description: string description: string
parent: number parent: number