实现权限管理页面与功能

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,159 @@
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { updateAdmin } from "@/actions/admin"
import { getAllRoles } 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 { Admin } from "@/models/admin"
import type { Role } from "@/models/role"
export function AssignRoles(props: { admin: Admin; onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [roles, setRoles] = useState<Role[]>([])
const [selected, setSelected] = useState<Set<number>>(new Set())
const fetchRoles = useCallback(async () => {
setLoading(true)
try {
const resp = await getAllRoles()
if (resp.success) {
setRoles(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) {
fetchRoles()
}
}, [open, fetchRoles])
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 updateAdmin({
id: props.admin.id,
roles: 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.admin.roles ?? []).map(r => r.id))
setSelected(existingIds)
} else {
setSelected(new Set())
setRoles([])
}
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.admin.username}</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>
) : roles.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="flex flex-col gap-2">
{roles.map(role => {
const checkboxId = `assign-role-${role.id}`
const isChecked = selected.has(role.id)
return (
<div key={role.id} className="flex items-center gap-2 py-0.5">
<Checkbox
id={checkboxId}
checked={isChecked}
onCheckedChange={checked =>
handleToggle(role.id, checked === true)
}
/>
<label
htmlFor={checkboxId}
className="flex items-center gap-1.5 cursor-pointer select-none text-sm"
>
{role.name}
{role.description && (
<span className="text-xs text-muted-foreground">
{role.description}
</span>
)}
</label>
</div>
)
})}
</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,237 @@
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 { createAdmin } from "@/actions/admin"
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { AdminStatus } from "@/models/admin"
const schema = z.object({
username: z.string().min(1, "请输入用户名"),
password: z.string().min(6, "密码至少 6 位"),
name: z.string().optional(),
phone: z.string().optional(),
email: z.string().email("请输入有效的邮箱地址").optional().or(z.literal("")),
status: z.nativeEnum(AdminStatus),
})
type FormValues = z.infer<typeof schema>
export function CreateAdmin(props: { onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
username: "",
password: "",
name: "",
phone: "",
email: "",
status: AdminStatus.Enabled,
},
})
const onSubmit = async (data: FormValues) => {
try {
const resp = await createAdmin({
username: data.username,
password: data.password,
name: data.name || undefined,
phone: data.phone || undefined,
email: data.email || undefined,
status: data.status,
})
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 handleOpenChange = (value: boolean) => {
if (!value) {
form.reset()
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="admin-create" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={form.control}
name="username"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-create-username">
</FieldLabel>
<Input
id="admin-create-username"
autoComplete="off"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-create-password"></FieldLabel>
<Input
id="admin-create-password"
type="password"
autoComplete="new-password"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-create-name"></FieldLabel>
<Input
id="admin-create-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="phone"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-create-phone"></FieldLabel>
<Input
id="admin-create-phone"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-create-email"></FieldLabel>
<Input
id="admin-create-email"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="status"
render={({ field }) => (
<Field>
<FieldLabel></FieldLabel>
<Select
value={String(field.value)}
onValueChange={value => field.onChange(Number(value))}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(AdminStatus.Enabled)}>
</SelectItem>
<SelectItem value={String(AdminStatus.Disabled)}>
</SelectItem>
</SelectContent>
</Select>
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="admin-create">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,244 @@
"use client"
import { Suspense, useState } from "react"
import { toast } from "sonner"
import { deleteAdmin, getPageAdmin, updateAdmin } from "@/actions/admin"
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 Admin, AdminStatus } from "@/models/admin"
import type { Role } from "@/models/role"
import { AssignRoles } from "./assign-roles"
import { CreateAdmin } from "./create"
import { UpdateAdmin } from "./update"
export default function AdminPage() {
const table = useDataTable((page, size) => getPageAdmin({ page, size }))
return (
<div className="flex flex-col gap-3">
{/* 操作栏 */}
<div className="flex justify-between items-stretch">
<div className="flex gap-3">
<CreateAdmin onSuccess={table.refresh} />
</div>
</div>
{/* 数据表 */}
<Suspense>
<DataTable
{...table}
columns={[
{ header: "用户名", accessorKey: "username" },
{
header: "姓名",
accessorFn: row => row.name ?? "-",
},
{
header: "手机号",
accessorFn: row => row.phone ?? "-",
},
{
header: "邮箱",
accessorFn: row => row.email ?? "-",
},
{
header: "状态",
cell: ({ row }) => (
<Badge
variant={
row.original.status === AdminStatus.Enabled
? "default"
: "secondary"
}
>
{row.original.status === AdminStatus.Enabled
? "启用"
: "禁用"}
</Badge>
),
},
{
header: "角色",
cell: ({ row }) => <RolesCell roles={row.original.roles ?? []} />,
},
{
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<UpdateAdmin admin={row.original} onSuccess={table.refresh} />
<AssignRoles admin={row.original} onSuccess={table.refresh} />
<ToggleStatusButton
admin={row.original}
onSuccess={table.refresh}
/>
<DeleteButton
admin={row.original}
onSuccess={table.refresh}
/>
</div>
),
},
]}
/>
</Suspense>
</div>
)
}
function RolesCell({ roles }: { roles: Role[] }) {
if (!roles || roles.length === 0) {
return <span className="text-muted-foreground text-xs"></span>
}
const preview = roles.slice(0, 3)
const rest = roles.length - preview.length
return (
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex flex-wrap gap-1 cursor-default max-w-52">
{preview.map(r => (
<Badge key={r.id} variant="secondary">
{r.name}
</Badge>
))}
{rest > 0 && <Badge variant="outline">+{rest}</Badge>}
</div>
</HoverCardTrigger>
<HoverCardContent className="w-64" align="start">
<p className="text-xs font-medium text-muted-foreground mb-2">
{roles.length}
</p>
<div className="flex flex-wrap gap-1.5">
{roles.map(r => (
<Badge key={r.id} variant="secondary">
{r.name}
</Badge>
))}
</div>
</HoverCardContent>
</HoverCard>
)
}
function ToggleStatusButton({
admin,
onSuccess,
}: {
admin: Admin
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const isEnabled = admin.status === AdminStatus.Enabled
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await updateAdmin({
id: admin.id,
status: isEnabled ? AdminStatus.Disabled : AdminStatus.Enabled,
})
if (resp.success) {
toast.success(isEnabled ? "已禁用" : "已启用")
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}>
{isEnabled ? "禁用" : "启用"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>{isEnabled ? "禁用" : "启用"}</AlertDialogTitle>
<AlertDialogDescription>
{isEnabled ? "禁用" : "启用"}{admin.username}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
function DeleteButton({
admin,
onSuccess,
}: {
admin: Admin
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await deleteAdmin(admin.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>
{admin.username}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,229 @@
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 { updateAdmin } from "@/actions/admin"
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { type Admin, AdminStatus } from "@/models/admin"
const schema = z.object({
password: z.string().min(6, "密码至少6位").optional().or(z.literal("")),
name: z.string().optional(),
phone: z.string().optional(),
email: z.string().email("请输入有效的邮箱地址").optional().or(z.literal("")),
status: z.nativeEnum(AdminStatus),
})
type FormValues = z.infer<typeof schema>
export function UpdateAdmin(props: { admin: Admin; onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
password: "",
name: props.admin.name ?? "",
phone: props.admin.phone ?? "",
email: props.admin.email ?? "",
status: props.admin.status,
},
})
const onSubmit = async (data: FormValues) => {
try {
const resp = await updateAdmin({
id: props.admin.id,
password: data.password || undefined,
name: data.name || undefined,
phone: data.phone || undefined,
email: data.email || undefined,
status: data.status,
})
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({
password: "",
name: props.admin.name ?? "",
phone: props.admin.phone ?? "",
email: props.admin.email ?? "",
status: props.admin.status,
})
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="admin-update" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
{/* 用户名只读,不可修改 */}
<Field>
<FieldLabel></FieldLabel>
<Input value={props.admin.username} disabled />
</Field>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-update-password">
</FieldLabel>
<Input
id="admin-update-password"
type="password"
placeholder="不修改请留空"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-update-name"></FieldLabel>
<Input
id="admin-update-name"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="phone"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-update-phone"></FieldLabel>
<Input
id="admin-update-phone"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="admin-update-email"></FieldLabel>
<Input
id="admin-update-email"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="status"
render={({ field }) => (
<Field>
<FieldLabel></FieldLabel>
<Select
value={String(field.value)}
onValueChange={value => field.onChange(Number(value))}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(AdminStatus.Enabled)}>
</SelectItem>
<SelectItem value={String(AdminStatus.Disabled)}>
</SelectItem>
</SelectContent>
</Select>
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="admin-update">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,26 +1,24 @@
import {ReactNode} from 'react'
import Appbar from '@/app/(root)/appbar'
import Navigation from '@/app/(root)/navigation'
import type { ReactNode } from "react"
import Appbar from "@/app/(root)/appbar"
import Navigation from "@/app/(root)/navigation"
export type RootLayoutProps = {
children: ReactNode
}
export default function RootLayout({children}: RootLayoutProps) {
export default function RootLayout({ children }: RootLayoutProps) {
return (
<div className="flex h-screen bg-gray-100">
{/* 侧边栏 */}
<Navigation/>
<Navigation />
{/* 主内容区 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 顶部导航栏 */}
<Appbar/>
<Appbar />
{/* 内容区域 */}
<main className="flex-1 overflow-auto p-6">
{children}
</main>
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
)

View File

@@ -7,15 +7,11 @@ import {
ClipboardList,
Code,
ComputerIcon,
Database,
DollarSign,
FileText,
Globe,
Home,
KeyRound,
type LucideIcon,
Package,
Server,
Settings,
Shield,
Users,
} from "lucide-react"
@@ -206,7 +202,9 @@ export default function Navigation() {
{/* 系统 */}
<NavGroup title="系统">
{/*<NavItem href="/settings" icon={Settings} label="系统设置" />*/}
<NavItem href="/security" icon={Shield} label="管理员" />
<NavItem href="/admin" icon={Shield} label="管理员" />
<NavItem href="/roles" icon={KeyRound} label="角色列表" />
<NavItem href="/permissions" icon={Shield} label="权限列表" />
{/*<NavItem href="/logs" icon={FileText} label="系统日志" />*/}
</NavGroup>
</nav>

View File

@@ -0,0 +1,19 @@
"use client"
import { Suspense } from "react"
import { getPagePermission } from "@/actions/permission"
import { DataTable, useDataTable } from "@/components/data-table"
export default function PermissionsPage() {
const table = useDataTable((page, size) => getPagePermission({ page, size }))
return (
<Suspense>
<DataTable
{...table}
columns={[
{ header: "编码", accessorKey: "name" },
{ header: "描述", accessorKey: "description" },
]}
/>
</Suspense>
)
}

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>
)
}