实现权限管理页面与功能

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

37
src/actions/admin.ts Normal file
View File

@@ -0,0 +1,37 @@
"use server"
import type { PageRecord } from "@/lib/api"
import type { Admin } from "@/models/admin"
import { callByUser } from "./base"
export async function getPageAdmin(params: { page: number; size: number }) {
return callByUser<PageRecord<Admin>>("/api/admin/admin/page", params)
}
export async function createAdmin(data: {
username: string
password: string
name?: string
phone?: string
email?: string
status?: number
roles?: number[]
}) {
return callByUser<Admin>("/api/admin/admin/create", data)
}
export async function updateAdmin(data: {
id: number
password?: string
name?: string
phone?: string
email?: string
status?: number
roles?: number[]
}) {
return callByUser<Admin>("/api/admin/admin/update", data)
}
export async function deleteAdmin(id: number) {
return callByUser<Admin>("/api/admin/admin/remove", { id })
}

19
src/actions/permission.ts Normal file
View File

@@ -0,0 +1,19 @@
"use server"
import type { PageRecord } from "@/lib/api"
import type { Permission } from "@/models/permission"
import { callByUser } from "./base"
export async function getPagePermission(params: {
page: number
size: number
}) {
return callByUser<PageRecord<Permission>>(
"/api/admin/permission/page",
params,
)
}
export async function getAllPermissions() {
return callByUser<Permission[]>("/api/admin/permission/list", {})
}

38
src/actions/role.ts Normal file
View File

@@ -0,0 +1,38 @@
"use server"
import type { PageRecord } from "@/lib/api"
import type { Role } from "@/models/role"
import { callByUser } from "./base"
export async function getAllRoles() {
return callByUser<Role[]>("/api/admin/admin-role/list", {})
}
export async function getPageRole(params: { page: number; size: number }) {
return callByUser<PageRecord<Role>>("/api/admin/admin-role/page", params)
}
export async function createRole(data: {
name: string
description?: string
active?: boolean
sort?: number
permissions?: number[]
}) {
return callByUser<Role>("/api/admin/admin-role/create", data)
}
export async function updateRole(data: {
id: number
name?: string
description?: string
active?: boolean
sort?: number
permissions?: number[]
}) {
return callByUser<Role>("/api/admin/admin-role/update", data)
}
export async function deleteRole(id: number) {
return callByUser<Role>("/api/admin/admin-role/remove", { id })
}

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

View File

@@ -15,10 +15,10 @@ export function useDataTable<T>(
const [total, setTotal] = useState(0)
const refresh = useCallback(
async (page: number, size: number) => {
async (_page?: number, _size?: number) => {
setStatus("load")
try {
const resp = await fetch(page, size)
const resp = await fetch(_page ?? page, _size ?? size)
if (!resp.success) {
throw new Error("获取数据失败")
}
@@ -34,7 +34,7 @@ export function useDataTable<T>(
setStatus("fail")
}
},
[fetch, setStatus],
[fetch, page, size, setStatus],
)
const onPageChange = (page: number) => {

5
src/components/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import type { ReactNode } from "react"
export function Page(props: { children: ReactNode }) {
return <div className="flex flex-col">{props.children}</div>
}

View File

@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

28
src/models/admin.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { Role } from "./role"
// 管理员状态枚举
export enum AdminStatus {
Disabled = 0, // 禁用
Enabled = 1, // 正常
}
// 管理员
export type Admin = {
id: number
createdAt: Date
updatedAt: Date
username: string
password: string
name?: string
avatar?: string
phone?: string
email?: string
status: AdminStatus
lastLogin?: Date
lastLoginIp?: string
lastLoginUa?: string
roles: Role[]
}

12
src/models/permission.ts Normal file
View File

@@ -0,0 +1,12 @@
export type Permission = {
id: number
created_at: Date
updated_at: Date
expired_at: Date
parent_id: number
name: string
description: string
parent: number
children: Permission[]
}

14
src/models/role.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Permission } from "./permission"
export type Role = {
id: number
created_at: Date
updated_at: Date
name: string
description: string
active: boolean
sort: number
permissions: Permission[]
}