+
+
{children}
diff --git a/app/admin/nav-main-client.tsx b/app/admin/nav-main-client.tsx
index 4cc77e6..8326d2d 100644
--- a/app/admin/nav-main-client.tsx
+++ b/app/admin/nav-main-client.tsx
@@ -7,11 +7,13 @@ import {
IconCamera,
IconFileDescription,
IconFileAi,
+ IconFileText,
IconSettings,
IconHelp,
IconSearch,
IconDatabase,
- IconReport
+ IconReport,
+ IconShield
} from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import Link from "next/link"
@@ -26,9 +28,11 @@ const iconMap = {
dashboard: IconDashboard,
chartBar: IconChartBar,
users: IconUsers,
+ shield: IconShield,
camera: IconCamera,
fileDescription: IconFileDescription,
fileAi: IconFileAi,
+ fileText: IconFileText,
settings: IconSettings,
help: IconHelp,
search: IconSearch,
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index 17827ed..a29ae1e 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -4,6 +4,8 @@ import { redirect } from "next/navigation"
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useUser } from "../user-context";
+import { Metadata, ResolvingMetadata } from "next";
+
export default function Dashboard() {
diff --git a/app/admin/permissions/page.tsx b/app/admin/permissions/page.tsx
new file mode 100644
index 0000000..51749bf
--- /dev/null
+++ b/app/admin/permissions/page.tsx
@@ -0,0 +1,123 @@
+"use client"
+
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { IconShield, IconUsers, IconKey, IconSettings } from "@tabler/icons-react"
+import { SiteHeader } from "../site-header"
+import { RoleTable } from "./role-table"
+import { PermissionTable } from "./permission-table"
+import { UserRoleManagement } from "./user-role-management"
+import { RolePermissionManagement } from "./role-permission-management"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+export default function PermissionsPage() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ 权限管理中心
+
+
+ 管理用户角色和权限,确保系统安全
+
+
+
+ {/* 统计卡片 */}
+
+
+
+ 总用户数
+
+
+
+ 1,234
+ +20% 较上月
+
+
+
+
+ 活跃角色
+
+
+
+ 8
+ 5个系统角色
+
+
+
+
+ 权限总数
+
+
+
+ 45
+ 12个模块
+
+
+
+
+ 访问日志
+
+
+
+ 2,847
+ 今日访问次数
+
+
+
+
+ {/* 主要功能标签页 */}
+
+
+
+
+
+ 角色管理
+
+
+
+ 权限管理
+
+
+
+ 用户角色
+
+
+
+ 角色权限
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/app/admin/permissions/permission-table.tsx b/app/admin/permissions/permission-table.tsx
new file mode 100644
index 0000000..6c8ffee
--- /dev/null
+++ b/app/admin/permissions/permission-table.tsx
@@ -0,0 +1,773 @@
+"use client"
+
+import * as React from "react"
+import { useQuery, useMutation, gql } from '@apollo/client';
+import {
+ ColumnDef,
+ ColumnFiltersState,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ SortingState,
+ useReactTable,
+ VisibilityState,
+} from "@tanstack/react-table"
+import { z } from "zod"
+import {
+ IconKey,
+ IconPlus,
+ IconDotsVertical,
+ IconPencil,
+ IconTrash,
+ IconShield,
+ IconChevronLeft,
+ IconChevronRight,
+ IconChevronsLeft,
+ IconChevronsRight,
+} from "@tabler/icons-react"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { toast } from "sonner"
+
+const permissionSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ code: z.string(),
+ description: z.string().optional(),
+ module: z.string(),
+ action: z.string(),
+ resource: z.string(),
+ level: z.number(),
+ isActive: z.boolean(),
+ roleCount: z.number(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+})
+
+type Permission = z.infer
+
+const GET_PERMISSIONS = gql`
+ query GetPermissions($pagination: PaginationInput) {
+ permissions(pagination: $pagination) {
+ items {
+ id
+ name
+ code
+ description
+ module
+ action
+ resource
+ level
+ isActive
+ roleCount
+ createdAt
+ updatedAt
+ }
+ total
+ page
+ perPage
+ totalPages
+ }
+ }
+`
+
+const CREATE_PERMISSION = gql`
+ mutation CreatePermission($input: CreatePermissionInput!) {
+ createPermission(input: $input) {
+ id
+ name
+ code
+ module
+ action
+ resource
+ isActive
+ }
+ }
+`
+
+const UPDATE_PERMISSION = gql`
+ mutation UpdatePermission($id: UUID!, $input: UpdatePermissionInput!) {
+ updatePermission(id: $id, input: $input) {
+ id
+ name
+ code
+ isActive
+ updatedAt
+ }
+ }
+`
+
+const DELETE_PERMISSION = gql`
+ mutation DeletePermission($id: UUID!) {
+ deletePermission(id: $id)
+ }
+`
+
+const getActionColor = (action: string) => {
+ switch (action.toLowerCase()) {
+ case 'create':
+ return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
+ case 'read':
+ return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'
+ case 'update':
+ return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300'
+ case 'delete':
+ return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
+ default:
+ return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
+ }
+}
+
+const columns: ColumnDef[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "name",
+ header: "权限名称",
+ cell: ({ row }) => {
+ const permission = row.original
+ return (
+
+
+
+
+
+
{permission.name}
+
{permission.code}
+
+
+ )
+ },
+ },
+ {
+ accessorKey: "description",
+ header: "描述",
+ cell: ({ row }) => {
+ const description = row.getValue("description") as string
+ return (
+
+
+ {description || "无描述"}
+
+
+ )
+ },
+ },
+ {
+ accessorKey: "module",
+ header: "模块",
+ cell: ({ row }) => {
+ const module = row.getValue("module") as string
+ return (
+
+ {module}
+
+ )
+ },
+ },
+ {
+ accessorKey: "action",
+ header: "操作",
+ cell: ({ row }) => {
+ const action = row.getValue("action") as string
+ return (
+
+ {action}
+
+ )
+ },
+ },
+ {
+ accessorKey: "resource",
+ header: "资源",
+ cell: ({ row }) => {
+ const resource = row.getValue("resource") as string
+ return (
+
+ {resource}
+
+ )
+ },
+ },
+ {
+ accessorKey: "level",
+ header: "级别",
+ cell: ({ row }) => {
+ const level = row.getValue("level") as number
+ const getLevelColor = (level: number) => {
+ if (level >= 90) return "text-red-600"
+ if (level >= 70) return "text-orange-600"
+ if (level >= 50) return "text-yellow-600"
+ return "text-green-600"
+ }
+ return (
+
+ {level}
+
+ )
+ },
+ },
+ {
+ accessorKey: "roleCount",
+ header: "角色数",
+ cell: ({ row }) => {
+ const count = row.getValue("roleCount") as number
+ return (
+
+ {count}
+
+ )
+ },
+ },
+ {
+ accessorKey: "isActive",
+ header: "状态",
+ cell: ({ row }) => {
+ const isActive = row.getValue("isActive") as boolean
+ return (
+
+ {isActive ? "启用" : "禁用"}
+
+ )
+ },
+ },
+ {
+ id: "actions",
+ cell: ({ row, table }) => {
+ const permission = row.original
+ const updatePermission = (table.options.meta as any)?.updatePermission
+ const deletePermission = (table.options.meta as any)?.deletePermission
+
+ return (
+
+
+
+
+
+
+
+ 编辑权限
+
+
+
+ 角色分配
+
+ {
+ updatePermission({
+ variables: {
+ id: permission.id,
+ input: { isActive: !permission.isActive }
+ }
+ })
+ }}
+ >
+ {permission.isActive ? "禁用" : "启用"}
+
+
+ {
+ if (confirm('确定要删除这个权限吗?此操作不可撤销。')) {
+ deletePermission({
+ variables: { id: permission.id }
+ })
+ }
+ }}
+ >
+
+ 删除权限
+
+
+
+ )
+ },
+ },
+]
+
+function CreatePermissionDialog({ onSuccess }: { onSuccess: () => void }) {
+ const [open, setOpen] = React.useState(false)
+ const [loading, setLoading] = React.useState(false)
+ const [createPermission] = useMutation(CREATE_PERMISSION)
+
+ const [formData, setFormData] = React.useState({
+ name: "",
+ code: "",
+ description: "",
+ module: "",
+ action: "",
+ resource: "",
+ level: 50,
+ isActive: true,
+ })
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!formData.name || !formData.code || !formData.module || !formData.action || !formData.resource) {
+ toast.error("请填写所有必需字段")
+ return
+ }
+
+ setLoading(true)
+ try {
+ await createPermission({
+ variables: { input: formData }
+ })
+ toast.success("权限创建成功")
+ setOpen(false)
+ setFormData({
+ name: "",
+ code: "",
+ description: "",
+ module: "",
+ action: "",
+ resource: "",
+ level: 50,
+ isActive: true,
+ })
+ onSuccess()
+ } catch (error) {
+ toast.error("创建失败")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+export function PermissionTable() {
+ const [sorting, setSorting] = React.useState([])
+ const [columnFilters, setColumnFilters] = React.useState([])
+ const [columnVisibility, setColumnVisibility] = React.useState({})
+ const [rowSelection, setRowSelection] = React.useState({})
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+
+ const { data, loading, error, refetch } = useQuery(GET_PERMISSIONS, {
+ variables: {
+ pagination: {
+ page: pagination.pageIndex + 1,
+ perPage: pagination.pageSize,
+ }
+ },
+ fetchPolicy: 'cache-and-network'
+ })
+
+ const [updatePermission] = useMutation(UPDATE_PERMISSION, {
+ onCompleted: () => {
+ refetch()
+ toast.success("权限状态已更新")
+ }
+ })
+
+ const [deletePermission] = useMutation(DELETE_PERMISSION, {
+ onCompleted: () => {
+ refetch()
+ toast.success("权限已删除")
+ }
+ })
+
+ const permissions = data?.permissions?.items || []
+ const totalCount = data?.permissions?.total || 0
+
+ const table = useReactTable({
+ data: permissions,
+ columns,
+ pageCount: Math.ceil(totalCount / pagination.pageSize),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ pagination,
+ },
+ enableRowSelection: true,
+ onRowSelectionChange: setRowSelection,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onPaginationChange: setPagination,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ manualPagination: true,
+ manualSorting: true,
+ meta: {
+ updatePermission,
+ deletePermission,
+ },
+ })
+
+ if (error) {
+ return 加载权限数据失败
+ }
+
+ return (
+
+
+
+ 权限管理
+
+ 管理系统中的所有权限,包括模块、操作、资源等
+
+
+
+
+
+
+ table.getColumn("name")?.setFilterValue(event.target.value)
+ }
+ className="max-w-sm"
+ />
+
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ ))}
+
+ ))}
+
+
+ {loading ? (
+
+
+ 加载中...
+
+
+ ) : table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ 暂无权限数据
+
+
+ )}
+
+
+
+
+
+
+ 已选择 {table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length} 行
+
+
+
+
每页显示
+
+
+
+ 第 {table.getState().pagination.pageIndex + 1} 页,共{" "}
+ {table.getPageCount()} 页
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/admin/permissions/role-permission-management.tsx b/app/admin/permissions/role-permission-management.tsx
new file mode 100644
index 0000000..9efb1f9
--- /dev/null
+++ b/app/admin/permissions/role-permission-management.tsx
@@ -0,0 +1,571 @@
+"use client"
+
+import * as React from "react"
+import { useQuery, useMutation, gql } from '@apollo/client';
+import {
+ IconShield,
+ IconKey,
+ IconCheck,
+ IconX,
+ IconSettings,
+} from "@tabler/icons-react"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { toast } from "sonner"
+
+const GET_ROLES_WITH_PERMISSIONS = gql`
+ query GetRolesWithPermissions {
+ roles(pagination: { page: 1, perPage: 100 }) {
+ items {
+ id
+ name
+ code
+ level
+ isActive
+ permissions {
+ id
+ name
+ code
+ module
+ action
+ resource
+ }
+ }
+ }
+ }
+`
+
+const GET_ALL_PERMISSIONS = gql`
+ query GetAllPermissions {
+ permissions(pagination: { page: 1, perPage: 500 }) {
+ items {
+ id
+ name
+ code
+ description
+ module
+ action
+ resource
+ level
+ isActive
+ }
+ }
+ }
+`
+
+const ADD_POLICY = gql`
+ mutation AddPolicy($roleName: String!, $resource: String!, $action: String!) {
+ addPolicy(roleName: $roleName, resource: $resource, action: $action)
+ }
+`
+
+const REMOVE_POLICY = gql`
+ mutation RemovePolicy($roleName: String!, $resource: String!, $action: String!) {
+ removePolicy(roleName: $roleName, resource: $resource, action: $action)
+ }
+`
+
+
+interface Permission {
+ id: string
+ name: string
+ code: string
+ description?: string
+ module: string
+ action: string
+ resource: string
+ level: number
+ isActive: boolean
+}
+
+interface Role {
+ id: string
+ name: string
+ code: string
+ level: number
+ isActive: boolean
+ permissions: Permission[]
+}
+
+const getActionColor = (action: string) => {
+ switch (action.toLowerCase()) {
+ case 'create':
+ return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
+ case 'read':
+ return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'
+ case 'update':
+ return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300'
+ case 'delete':
+ return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
+ default:
+ return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
+ }
+}
+
+function PermissionMatrix({ roles, permissions, onPermissionChange }: {
+ roles: Role[]
+ permissions: Permission[]
+ onPermissionChange: () => void
+}) {
+ const [addPolicy] = useMutation(ADD_POLICY)
+ const [removePolicy] = useMutation(REMOVE_POLICY)
+ const [selectedModule, setSelectedModule] = React.useState("")
+
+ const modules = Array.from(new Set(permissions.map(p => p.module)))
+ const filteredPermissions = selectedModule
+ ? permissions.filter(p => p.module === selectedModule)
+ : permissions
+
+ const handlePermissionToggle = async (roleId: string, permissionId: string, hasPermission: boolean) => {
+ const role = roles.find(r => r.id === roleId)
+ const permission = permissions.find(p => p.id === permissionId)
+
+ if (!role || !permission) return
+
+ try {
+ if (hasPermission) {
+ await removePolicy({
+ variables: {
+ roleName: role.code,
+ resource: permission.resource,
+ action: permission.action
+ }
+ })
+ toast.success("权限移除成功")
+ } else {
+ await addPolicy({
+ variables: {
+ roleName: role.code,
+ resource: permission.resource,
+ action: permission.action
+ }
+ })
+ toast.success("权限分配成功")
+ }
+ onPermissionChange()
+ } catch (error) {
+ toast.error("操作失败")
+ }
+ }
+
+ const hasPermission = (role: Role, permissionId: string) => {
+ return role.permissions.some(p => p.id === permissionId)
+ }
+
+ return (
+
+
+ 权限矩阵
+
+ 查看和管理角色与权限的对应关系
+
+
+
+
+
+
+
+ 共 {filteredPermissions.length} 个权限
+
+
+
+
+
+
+
+ 权限
+ {roles.filter(r => r.isActive).map((role) => (
+
+
+
{role.name}
+
+ Level {role.level}
+
+
+
+ ))}
+
+
+
+ {filteredPermissions.map((permission) => (
+
+
+
+
{permission.name}
+
{permission.code}
+
+
+ {permission.module}
+
+
+ {permission.action}
+
+
+
+
+ {roles.filter(r => r.isActive).map((role) => {
+ const roleHasPermission = hasPermission(role, permission.id)
+ const canModify = permission.level <= role.level
+
+ return (
+
+
+
+ )
+ })}
+
+ ))}
+
+
+
+
+
+
+ )
+}
+
+function RolePermissionEditor({ role, allPermissions, onSave }: {
+ role: Role
+ allPermissions: Permission[]
+ onSave: () => void
+}) {
+ const [open, setOpen] = React.useState(false)
+ const [selectedPermissions, setSelectedPermissions] = React.useState(
+ role.permissions.map(p => p.id)
+ )
+ const [searchTerm, setSearchTerm] = React.useState("")
+ const [moduleFilter, setModuleFilter] = React.useState("")
+ const [loading, setLoading] = React.useState(false)
+
+ const filteredPermissions = allPermissions.filter(permission => {
+ const matchesSearch = permission.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ permission.code.toLowerCase().includes(searchTerm.toLowerCase())
+ const matchesModule = !moduleFilter || permission.module === moduleFilter
+ return matchesSearch && matchesModule && permission.isActive
+ })
+
+ const modules = Array.from(new Set(allPermissions.map(p => p.module)))
+
+ const handlePermissionToggle = (permissionId: string) => {
+ setSelectedPermissions(prev =>
+ prev.includes(permissionId)
+ ? prev.filter(id => id !== permissionId)
+ : [...prev, permissionId]
+ )
+ }
+
+ const handleSelectAll = () => {
+ const allIds = filteredPermissions.map(p => p.id)
+ setSelectedPermissions(prev => {
+ const hasAll = allIds.every(id => prev.includes(id))
+ if (hasAll) {
+ return prev.filter(id => !allIds.includes(id))
+ } else {
+ return Array.from(new Set([...prev, ...allIds]))
+ }
+ })
+ }
+
+ const handleSave = async () => {
+ toast.info("批量保存功能正在开发中,请使用权限矩阵进行单个权限操作")
+ setOpen(false)
+ }
+
+ React.useEffect(() => {
+ if (open) {
+ setSelectedPermissions(role.permissions.map(p => p.id))
+ }
+ }, [open, role.permissions])
+
+ return (
+
+ )
+}
+
+export function RolePermissionManagement() {
+ const [selectedRole, setSelectedRole] = React.useState("")
+
+ const { data: rolesData, loading: rolesLoading, refetch: refetchRoles } = useQuery(GET_ROLES_WITH_PERMISSIONS, {
+ fetchPolicy: 'cache-and-network'
+ })
+
+ const { data: permissionsData, loading: permissionsLoading } = useQuery(GET_ALL_PERMISSIONS, {
+ fetchPolicy: 'cache-and-network'
+ })
+
+ const roles = rolesData?.roles?.items || []
+ const permissions = permissionsData?.permissions?.items || []
+
+ const activeRoles = roles.filter(r => r.isActive)
+ const currentRole = selectedRole ? roles.find(r => r.id === selectedRole) : null
+
+ return (
+
+ {/* 角色概览卡片 */}
+
+ {activeRoles.map((role) => (
+
setSelectedRole(selectedRole === role.id ? "" : role.id)}
+ >
+
+
+
+
+ {role.name}
+
+
+ Level {role.level}
+
+
+ {role.code}
+
+
+
+
+ 权限数: {role.permissions.length}
+
+
+
+
+
+ ))}
+
+
+ {/* 选中角色的权限详情 */}
+ {currentRole && (
+
+
+
+ {currentRole.name} 的权限详情
+
+
+ 查看角色 "{currentRole.name}" 当前拥有的所有权限
+
+
+
+ {currentRole.permissions.length === 0 ? (
+
+
+
暂无权限
+
+ 该角色尚未分配任何权限。
+
+
+
+
+
+ ) : (
+
+
+
+ 共 {currentRole.permissions.length} 个权限
+
+
+
+
+ {currentRole.permissions.map((permission) => (
+
+
+
+
+ {permission.name}
+
+
+ {permission.code}
+
+
+
+ {permission.module}
+
+
+ {permission.action}
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {/* 权限矩阵 */}
+ {!rolesLoading && !permissionsLoading && (
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/app/admin/permissions/role-table.tsx b/app/admin/permissions/role-table.tsx
new file mode 100644
index 0000000..5ff3e7d
--- /dev/null
+++ b/app/admin/permissions/role-table.tsx
@@ -0,0 +1,688 @@
+"use client"
+
+import * as React from "react"
+import { useQuery, useMutation, gql } from '@apollo/client';
+import {
+ ColumnDef,
+ ColumnFiltersState,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ SortingState,
+ useReactTable,
+ VisibilityState,
+} from "@tanstack/react-table"
+import { z } from "zod"
+import {
+ IconShield,
+ IconPlus,
+ IconDotsVertical,
+ IconPencil,
+ IconTrash,
+ IconUsers,
+ IconKey,
+ IconChevronDown,
+ IconChevronLeft,
+ IconChevronRight,
+ IconChevronsLeft,
+ IconChevronsRight,
+} from "@tabler/icons-react"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { toast } from "sonner"
+
+const roleSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ code: z.string(),
+ description: z.string().optional(),
+ level: z.number(),
+ isActive: z.boolean(),
+ userCount: z.number(),
+ permissionCount: z.number(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+})
+
+type Role = z.infer
+
+const GET_ROLES = gql`
+ query GetRoles($pagination: PaginationInput) {
+ roles(pagination: $pagination) {
+ items {
+ id
+ name
+ code
+ description
+ level
+ isActive
+ userCount
+ permissionCount
+ createdAt
+ updatedAt
+ }
+ total
+ page
+ perPage
+ totalPages
+ }
+ }
+`
+
+const CREATE_ROLE = gql`
+ mutation CreateRole($input: CreateRoleInput!) {
+ createRole(input: $input) {
+ id
+ name
+ code
+ isActive
+ }
+ }
+`
+
+const UPDATE_ROLE = gql`
+ mutation UpdateRole($id: UUID!, $input: UpdateRoleInput!) {
+ updateRole(id: $id, input: $input) {
+ id
+ name
+ code
+ isActive
+ updatedAt
+ }
+ }
+`
+
+const DELETE_ROLE = gql`
+ mutation DeleteRole($id: UUID!) {
+ deleteRole(id: $id)
+ }
+`
+
+const columns: ColumnDef[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "name",
+ header: "角色名称",
+ cell: ({ row }) => {
+ const role = row.original
+ return (
+
+
+
+
+
+
{role.name}
+
{role.code}
+
+
+ )
+ },
+ },
+ {
+ accessorKey: "description",
+ header: "描述",
+ cell: ({ row }) => {
+ const description = row.getValue("description") as string
+ return (
+
+
+ {description || "无描述"}
+
+
+ )
+ },
+ },
+ {
+ accessorKey: "level",
+ header: "级别",
+ cell: ({ row }) => {
+ const level = row.getValue("level") as number
+ const getLevelColor = (level: number) => {
+ if (level >= 90) return "text-red-600"
+ if (level >= 70) return "text-orange-600"
+ if (level >= 50) return "text-yellow-600"
+ return "text-green-600"
+ }
+ return (
+
+ {level}
+
+ )
+ },
+ },
+ {
+ accessorKey: "userCount",
+ header: ({ column }) => (
+
+
+ 用户数
+
+ ),
+ cell: ({ row }) => {
+ const count = row.getValue("userCount") as number
+ return (
+
+ {count}
+
+ )
+ },
+ },
+ {
+ accessorKey: "permissionCount",
+ header: ({ column }) => (
+
+
+ 权限数
+
+ ),
+ cell: ({ row }) => {
+ const count = row.getValue("permissionCount") as number
+ return (
+
+ {count}
+
+ )
+ },
+ },
+ {
+ accessorKey: "isActive",
+ header: "状态",
+ cell: ({ row }) => {
+ const isActive = row.getValue("isActive") as boolean
+ return (
+
+ {isActive ? "启用" : "禁用"}
+
+ )
+ },
+ },
+ {
+ id: "actions",
+ cell: ({ row, table }) => {
+ const role = row.original
+ const updateRole = (table.options.meta as any)?.updateRole
+ const deleteRole = (table.options.meta as any)?.deleteRole
+
+ return (
+
+
+
+
+
+
+
+ 编辑角色
+
+
+
+ 权限配置
+
+
+
+ 用户分配
+
+ {
+ updateRole({
+ variables: {
+ id: role.id,
+ input: { isActive: !role.isActive }
+ }
+ })
+ }}
+ >
+ {role.isActive ? "禁用" : "启用"}
+
+
+ {
+ if (confirm('确定要删除这个角色吗?此操作不可撤销。')) {
+ deleteRole({
+ variables: { id: role.id }
+ })
+ }
+ }}
+ >
+
+ 删除角色
+
+
+
+ )
+ },
+ },
+]
+
+function CreateRoleDialog({ onSuccess }: { onSuccess: () => void }) {
+ const [open, setOpen] = React.useState(false)
+ const [loading, setLoading] = React.useState(false)
+ const [createRole] = useMutation(CREATE_ROLE)
+
+ const [formData, setFormData] = React.useState({
+ name: "",
+ code: "",
+ description: "",
+ level: 10,
+ roleType: "CUSTOM", // 默认为自定义角色
+ isActive: true,
+ })
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!formData.name || !formData.code) {
+ toast.error("请填写角色名称和代码")
+ return
+ }
+
+ setLoading(true)
+ try {
+ await createRole({
+ variables: { input: formData }
+ })
+ toast.success("角色创建成功")
+ setOpen(false)
+ setFormData({
+ name: "",
+ code: "",
+ description: "",
+ level: 10,
+ roleType: "CUSTOM",
+ isActive: true,
+ })
+ onSuccess()
+ } catch (error) {
+ toast.error("创建失败")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+export function RoleTable() {
+ const [sorting, setSorting] = React.useState([])
+ const [columnFilters, setColumnFilters] = React.useState([])
+ const [columnVisibility, setColumnVisibility] = React.useState({})
+ const [rowSelection, setRowSelection] = React.useState({})
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+
+ const { data, loading, error, refetch } = useQuery(GET_ROLES, {
+ variables: {
+ pagination: {
+ page: pagination.pageIndex + 1,
+ perPage: pagination.pageSize,
+ }
+ },
+ fetchPolicy: 'cache-and-network'
+ })
+
+ const [updateRole] = useMutation(UPDATE_ROLE, {
+ onCompleted: () => {
+ refetch()
+ toast.success("角色状态已更新")
+ }
+ })
+
+ const [deleteRole] = useMutation(DELETE_ROLE, {
+ onCompleted: () => {
+ refetch()
+ toast.success("角色已删除")
+ }
+ })
+
+ const roles = data?.roles?.items || []
+ const totalCount = data?.roles?.total || 0
+
+ const table = useReactTable({
+ data: roles,
+ columns,
+ pageCount: Math.ceil(totalCount / pagination.pageSize),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ pagination,
+ },
+ enableRowSelection: true,
+ onRowSelectionChange: setRowSelection,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onPaginationChange: setPagination,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ manualPagination: true,
+ manualSorting: true,
+ meta: {
+ updateRole,
+ deleteRole,
+ },
+ })
+
+ if (error) {
+ return 加载角色数据失败
+ }
+
+ return (
+
+
+
+ 角色管理
+
+ 管理系统中的所有角色,包括权限级别、用户分配等
+
+
+
+
+
+
+ table.getColumn("name")?.setFilterValue(event.target.value)
+ }
+ className="max-w-sm"
+ />
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ ))}
+
+ ))}
+
+
+ {loading ? (
+
+
+ 加载中...
+
+
+ ) : table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ 暂无角色数据
+
+
+ )}
+
+
+
+
+
+
+ 已选择 {table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length} 行
+
+
+
+
每页显示
+
+
+
+ 第 {table.getState().pagination.pageIndex + 1} 页,共{" "}
+ {table.getPageCount()} 页
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/admin/permissions/user-role-management.tsx b/app/admin/permissions/user-role-management.tsx
new file mode 100644
index 0000000..8c839db
--- /dev/null
+++ b/app/admin/permissions/user-role-management.tsx
@@ -0,0 +1,586 @@
+"use client"
+
+import * as React from "react"
+import { useQuery, useMutation, gql } from '@apollo/client';
+import {
+ IconUsers,
+ IconShield,
+ IconPlus,
+ IconMinus,
+ IconUserCheck,
+ IconChevronDown,
+} from "@tabler/icons-react"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { toast } from "sonner"
+
+const GET_USERS_WITH_GROUPS = gql`
+ query GetUsersWithGroups($offset: Int, $limit: Int, $sortBy: String, $sortOrder: String, $filter: String) {
+ usersWithGroups(offset: $offset, limit: $limit, sortBy: $sortBy, sortOrder: $sortOrder, filter: $filter) {
+ user {
+ id
+ username
+ email
+ isActivate
+ createdAt
+ updatedAt
+ }
+ groups
+ }
+ }
+`
+
+const GET_ALL_ROLES = gql`
+ query GetAllRoles {
+ roles(pagination: { page: 1, perPage: 100 }) {
+ items {
+ id
+ name
+ code
+ level
+ isActive
+ }
+ }
+ }
+`
+
+const ASSIGN_ROLE_TO_USER = gql`
+ mutation AssignRoleToUser($userId: UUID!, $roleName: String!) {
+ assignRoleToUser(userId: $userId, roleName: $roleName)
+ }
+`
+
+const REMOVE_ROLE_FROM_USER = gql`
+ mutation RemoveRoleFromUser($userId: UUID!, $roleName: String!) {
+ removeRoleFromUser(userId: $userId, roleName: $roleName)
+ }
+`
+
+const BATCH_ASSIGN_ROLES_TO_USER = gql`
+ mutation BatchAssignRolesToUser($userId: UUID!, $roleIds: [UUID!]!) {
+ batchAssignRolesToUser(userId: $userId, roleIds: $roleIds)
+ }
+`
+
+interface UserWithGroups {
+ user: User
+ groups: string[]
+}
+
+interface User {
+ id: string
+ username: string
+ email: string
+ isActivate: boolean
+ createdAt: string
+ updatedAt: string
+ groups: string[]
+}
+
+interface Role {
+ id: string
+ name: string
+ code: string
+ level: number
+ isActive: boolean
+}
+
+function UserRoleCard({ user, allRoles, onRoleChange }: {
+ user: User
+ allRoles: Role[]
+ onRoleChange: () => void
+}) {
+ const [assignRole] = useMutation(ASSIGN_ROLE_TO_USER)
+ const [removeRole] = useMutation(REMOVE_ROLE_FROM_USER)
+ const [open, setOpen] = React.useState(false)
+
+ const availableRoles = allRoles.filter(role =>
+ role.isActive && !user.groups.some(group => group.toLowerCase() === role.code.toLowerCase())
+ )
+
+
+ const handleAssignRole = async (roleId: string) => {
+ const role = allRoles.find((r: Role) => r.id === roleId)
+ if (!role) return
+
+ try {
+ await assignRole({
+ variables: { userId: user.id, roleName: role.code }
+ })
+ toast.success("角色分配成功")
+ onRoleChange()
+ } catch (error) {
+ toast.error("角色分配失败")
+ }
+ }
+
+ const handleRemoveRole = async (roleName: string) => {
+ const role = allRoles.find((r: Role) => r.code === roleName)
+ if (!role) return
+
+ try {
+ await removeRole({
+ variables: { userId: user.id, roleName: role.code }
+ })
+ toast.success("角色移除成功")
+ onRoleChange()
+ } catch (error) {
+ toast.error("角色移除失败")
+ }
+ }
+
+ const getInitials = (username: string) => {
+ return username.slice(0, 2).toUpperCase()
+ }
+
+ return (
+
+
+
+
+ {getInitials(user.username)}
+
+
+ {user.username}
+ {user.email}
+
+
+ {user.isActivate ? "活跃" : "非活跃"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {availableRoles.length === 0 ? (
+
+ 没有可分配的角色
+
+ ) : (
+
+
+ 可分配的角色
+
+ {availableRoles.map((role) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ {user.groups.length === 0 ? (
+
+ 未分配任何角色
+
+ ) : (
+ user.groups.map((groupCode) => {
+ const role = allRoles.find((r: Role) => r.code.toLowerCase() === groupCode.toLowerCase())
+ return (
+
+
+
+
+
+ {role?.name || groupCode}
+
+
{groupCode}
+
+
+
+
+ {role ? `Level ${role.level}` : "角色"}
+
+
+
+
+ )
+ })
+ )}
+
+
+
+
+ )
+}
+
+function BatchAssignDialog({ selectedUsers, allRoles, onSuccess }: {
+ selectedUsers: User[]
+ allRoles: Role[]
+ onSuccess: () => void
+}) {
+ const [open, setOpen] = React.useState(false)
+ const [selectedRole, setSelectedRole] = React.useState("")
+ const [loading, setLoading] = React.useState(false)
+ const [batchAssignRoles] = useMutation(BATCH_ASSIGN_ROLES_TO_USER)
+
+ const handleBatchAssign = async () => {
+ if (!selectedRole || selectedUsers.length === 0) {
+ toast.error("请选择角色和用户")
+ return
+ }
+
+ const role = allRoles.find((r: Role) => r.id === selectedRole)
+ if (!role) return
+
+ setLoading(true)
+ try {
+ await Promise.all(
+ selectedUsers.map(user =>
+ batchAssignRoles({
+ variables: { userId: user.id, roleIds: [role.id] }
+ })
+ )
+ )
+ toast.success(`成功为 ${selectedUsers.length} 个用户分配角色`)
+ setOpen(false)
+ setSelectedRole("")
+ onSuccess()
+ } catch (error) {
+ toast.error("批量分配失败")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+export function UserRoleManagement() {
+ const [searchTerm, setSearchTerm] = React.useState("")
+ const [roleFilter, setRoleFilter] = React.useState("")
+ const [selectedUsers, setSelectedUsers] = React.useState([])
+ const [pagination, setPagination] = React.useState({
+ offset: 0,
+ limit: 12,
+ })
+
+ const { data: usersData, loading: usersLoading, refetch: refetchUsers } = useQuery(GET_USERS_WITH_GROUPS, {
+ variables: {
+ offset: pagination.offset,
+ limit: pagination.limit,
+ sortBy: "created_at",
+ sortOrder: "DESC",
+ filter: searchTerm || undefined,
+ },
+ fetchPolicy: 'cache-and-network'
+ })
+
+ // 当搜索词或角色筛选改变时,重新获取数据
+ React.useEffect(() => {
+ refetchUsers()
+ }, [searchTerm, refetchUsers])
+
+ const { data: rolesData } = useQuery(GET_ALL_ROLES, {
+ fetchPolicy: 'cache-and-network'
+ })
+
+ const rawUsersWithGroups = usersData?.usersWithGroups || []
+ const users: User[] = rawUsersWithGroups.map((userWithGroups: UserWithGroups) => ({
+ ...userWithGroups.user,
+ groups: userWithGroups.groups
+ }))
+ const allRoles = rolesData?.roles?.items || []
+ const totalUsers = users.length
+
+ const handleUserSelect = (user: User, checked: boolean) => {
+ if (checked) {
+ setSelectedUsers(prev => [...prev, user])
+ } else {
+ setSelectedUsers(prev => prev.filter(u => u.id !== user.id))
+ }
+ }
+
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedUsers(filteredUsers)
+ } else {
+ setSelectedUsers([])
+ }
+ }
+
+ // 按角色筛选用户
+ const filteredUsers = users.filter(user => {
+ if (!roleFilter) return true
+ const selectedRole = allRoles.find((r: Role) => r.id === roleFilter)
+ return selectedRole ? user.groups.some(group => group.toLowerCase() === selectedRole.code.toLowerCase()) : true
+ })
+
+ return (
+
+
+
+ 用户角色管理
+
+ 为用户分配和管理角色,支持单个和批量操作
+ {roleFilter && ` • 筛选角色: ${allRoles.find((r: Role) => r.id === roleFilter)?.name}`}
+ {searchTerm && ` • 搜索: "${searchTerm}"`}
+
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="max-w-sm"
+ />
+
+
+ {(searchTerm || roleFilter) && (
+
+ )}
+
+
+
+
+ 0}
+ onCheckedChange={(checked) => handleSelectAll(!!checked)}
+ />
+
+
+
{
+ refetchUsers()
+ setSelectedUsers([])
+ }}
+ />
+
+
+
+ {usersLoading ? (
+
+ {[...Array(6)].map((_, i) => (
+
+
+
+
+
+
+
+
+ ))}
+
+ ) : filteredUsers.length === 0 ? (
+
+ ) : (
+
+ {filteredUsers.map((user) => (
+
+
+ u.id === user.id)}
+ onCheckedChange={(checked) => handleUserSelect(user, !!checked)}
+ />
+
+
+
+ ))}
+
+ )}
+
+ {totalUsers >= pagination.limit && (
+
+
+
+ 显示 {pagination.offset + 1} - {pagination.offset + users.length} 项
+
+
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/admin/sidebar.tsx b/app/admin/sidebar.tsx
index 9c3fb31..6007807 100644
--- a/app/admin/sidebar.tsx
+++ b/app/admin/sidebar.tsx
@@ -15,6 +15,8 @@ import {
IconSearch,
IconSettings,
IconUsers,
+ IconFileText,
+ IconShield,
} from "@tabler/icons-react"
import { NavDocuments } from "./nav-documents"
@@ -53,6 +55,16 @@ const data = {
url: "/admin/users",
iconName: "users",
},
+ {
+ title: "Permissions",
+ url: "/admin/permissions",
+ iconName: "shield",
+ },
+ {
+ title: "Blogs",
+ url: "/admin/blogs",
+ iconName: "fileText",
+ },
{
title: "Settings",
url: "/admin/common",
diff --git a/app/admin/tags/create-tag-form.tsx b/app/admin/tags/create-tag-form.tsx
new file mode 100644
index 0000000..c483d87
--- /dev/null
+++ b/app/admin/tags/create-tag-form.tsx
@@ -0,0 +1,237 @@
+"use client"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { z } from "zod"
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useEffect, useState, useCallback } from "react"
+import { gql, useMutation } from "@apollo/client"
+import { IconLoader } from "@tabler/icons-react"
+
+const CREATE_TAG = gql`
+ mutation CreateTag($input: CreateTagInput!) {
+ createTag(input: $input) {
+ id
+ name
+ slug
+ isActive
+ }
+ }
+`
+
+const schema = z.object({
+ name: z.string().min(1, "标签名称不能为空"),
+ slug: z.string().min(1, "别名不能为空"),
+ description: z.string().optional(),
+ color: z.string().optional(),
+ isActive: z.boolean().default(true),
+})
+
+export default function CreateTagForm() {
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const [createTag] = useMutation(CREATE_TAG)
+
+ const form = useForm>({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ name: "",
+ slug: "",
+ description: "",
+ color: "#3b82f6",
+ isActive: true,
+ },
+ })
+
+ // 生成slug
+ const generateSlug = useCallback((name: string) => {
+ return name
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .trim();
+ }, []);
+
+ // 监听名称变化自动生成slug
+ const watchedName = form.watch("name");
+ useEffect(() => {
+ if (watchedName) {
+ const slug = generateSlug(watchedName);
+ form.setValue("slug", slug);
+ }
+ }, [watchedName, generateSlug, form]);
+
+ async function onSubmit(values: z.infer) {
+ try {
+ setIsLoading(true);
+ setError(null);
+
+ await createTag({
+ variables: {
+ input: {
+ name: values.name,
+ slug: values.slug,
+ description: values.description || null,
+ color: values.color || null,
+ isActive: values.isActive,
+ }
+ }
+ });
+
+ // 重置表单
+ form.reset();
+
+ // 跳转回标签列表
+ window.location.href = '/admin/tags';
+
+ } catch (err) {
+ setError(err instanceof Error ? err.message : '创建标签失败,请重试');
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/admin/tags/create/page.tsx b/app/admin/tags/create/page.tsx
new file mode 100644
index 0000000..9dc547b
--- /dev/null
+++ b/app/admin/tags/create/page.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import CreateTagForm from "../create-tag-form"
+
+export default function Page() {
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/admin/tags/page.tsx b/app/admin/tags/page.tsx
new file mode 100644
index 0000000..c533904
--- /dev/null
+++ b/app/admin/tags/page.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import { TagTable } from "./tag-table"
+import { SiteHeader } from "../site-header"
+
+export default function Page() {
+ return (
+ <>
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/app/admin/tags/tag-table.tsx b/app/admin/tags/tag-table.tsx
new file mode 100644
index 0000000..8bf6e05
--- /dev/null
+++ b/app/admin/tags/tag-table.tsx
@@ -0,0 +1,942 @@
+"use client"
+
+import * as React from "react"
+import { useQuery, useMutation, gql } from '@apollo/client';
+import {
+ closestCenter,
+ DndContext,
+ KeyboardSensor,
+ MouseSensor,
+ TouchSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core"
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
+import {
+ arrayMove,
+ SortableContext,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+import {
+ IconChevronDown,
+ IconChevronLeft,
+ IconChevronRight,
+ IconChevronsLeft,
+ IconChevronsRight,
+ IconDotsVertical,
+ IconGripVertical,
+ IconPlus,
+ IconTag,
+} from "@tabler/icons-react"
+import {
+ ColumnDef,
+ ColumnFiltersState,
+ flexRender,
+ getCoreRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ Row,
+ SortingState,
+ useReactTable,
+ VisibilityState,
+} from "@tanstack/react-table"
+import { z } from "zod"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs"
+import CreateTagForm from "./create-tag-form";
+
+export const tagSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ slug: z.string(),
+ description: z.string().optional(),
+ color: z.string().optional(),
+ isActive: z.boolean(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ createdBy: z.string().optional(),
+ updatedBy: z.string().optional(),
+ blogCount: z.number().optional(), // Number of blogs with this tag
+})
+
+function DragHandle({ id }: { id: string }) {
+ const { attributes, listeners } = useSortable({
+ id,
+ })
+
+ return (
+
+ )
+}
+
+const columns: ColumnDef>[] = [
+ {
+ id: "drag",
+ header: () => null,
+ cell: ({ row }) => ,
+ },
+ {
+ id: "select",
+ header: ({ table }) => (
+
+ table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+
+ ),
+ cell: ({ row }) => (
+
+ row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "name",
+ header: "标签名称",
+ cell: ({ row }) => {
+ return
+ },
+ enableHiding: false,
+ },
+ {
+ accessorKey: "slug",
+ header: "别名",
+ cell: ({ row }) => (
+
+
+ {row.original.slug}
+
+
+ ),
+ },
+ {
+ accessorKey: "blogCount",
+ header: "文章数",
+ cell: ({ row }) => (
+
+
+ {row.original.blogCount || 0}
+
+
+ ),
+ },
+ {
+ accessorKey: "isActive",
+ header: "状态",
+ cell: ({ row }) => (
+
+
+ {row.original.isActive ? "启用" : "禁用"}
+
+
+ ),
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => {
+ return (
+
+ )
+ },
+ cell: ({ row }) => {
+ const date = new Date(row.original.createdAt);
+ const now = new Date();
+ const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
+
+ let timeAgo = '';
+ if (diffInDays === 0) {
+ timeAgo = '今天';
+ } else if (diffInDays === 1) {
+ timeAgo = '昨天';
+ } else if (diffInDays < 7) {
+ timeAgo = `${diffInDays} 天前`;
+ } else if (diffInDays < 30) {
+ const weeks = Math.floor(diffInDays / 7);
+ timeAgo = `${weeks} 周前`;
+ } else if (diffInDays < 365) {
+ const months = Math.floor(diffInDays / 30);
+ timeAgo = `${months} 个月前`;
+ } else {
+ const years = Math.floor(diffInDays / 365);
+ timeAgo = `${years} 年前`;
+ }
+
+ return (
+
+
+
+ {date.toLocaleDateString('zh-CN', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ })}
+
+
+ {timeAgo}
+
+
+
+ );
+ },
+ enableSorting: true,
+ sortingFn: (rowA, rowB, columnId) => {
+ const dateA = new Date(rowA.original.createdAt);
+ const dateB = new Date(rowB.original.createdAt);
+ return dateA.getTime() - dateB.getTime();
+ },
+ },
+ {
+ id: "actions",
+ cell: ({ row, table }) => {
+ const updateTagMutation = (table.options.meta as any)?.updateTag
+ const deleteTagMutation = (table.options.meta as any)?.deleteTag
+
+ return (
+
+
+
+
+
+ 编辑
+ {
+ updateTagMutation({
+ variables: {
+ id: row.original.id,
+ input: { isActive: !row.original.isActive }
+ }
+ })
+ }}
+ >
+ {row.original.isActive ? "禁用" : "启用"}
+
+
+ {
+ if (confirm('确定要删除这个标签吗?此操作不可撤销。')) {
+ deleteTagMutation({
+ variables: {
+ id: row.original.id
+ }
+ })
+ }
+ }}
+ >
+ 删除
+
+
+
+ )
+ },
+ },
+]
+
+function DraggableRow({ row }: { row: Row> }) {
+ const { transform, transition, setNodeRef, isDragging } = useSortable({
+ id: row.original.id,
+ })
+
+ return (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ )
+}
+
+const GET_TAGS = gql`
+ query GetTags($filter: TagFilterInput, $sort: TagSortInput, $pagination: PaginationInput) {
+ tags(filter: $filter, sort: $sort, pagination: $pagination) {
+ items {
+ id
+ name
+ slug
+ description
+ color
+ isActive
+ createdAt
+ updatedAt
+ createdBy
+ updatedBy
+ blogCount
+ }
+ total
+ page
+ perPage
+ totalPages
+ }
+ }
+`
+
+const GET_TAG_STATS = gql`
+ query GetTagStats {
+ tagStats {
+ totalTags
+ activeTags
+ inactiveTags
+ totalBlogs
+ }
+ }
+`
+
+const UPDATE_TAG = gql`
+ mutation UpdateTag($id: UUID!, $input: UpdateTagInput!) {
+ updateTag(id: $id, input: $input) {
+ id
+ name
+ slug
+ isActive
+ updatedAt
+ }
+ }
+`
+
+const DELETE_TAG = gql`
+ mutation DeleteTag($id: UUID!) {
+ deleteTag(id: $id)
+ }
+`
+
+export function TagTable() {
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+ const [sorting, setSorting] = React.useState([])
+ const [filter, setFilter] = React.useState()
+
+ const { data, loading, error, refetch } = useQuery(GET_TAGS, {
+ variables: {
+ pagination: {
+ page: pagination.pageIndex + 1,
+ perPage: pagination.pageSize,
+ },
+ sort: sorting.length > 0 ? {
+ field: sorting[0].id,
+ direction: sorting[0].desc ? "DESC" : "ASC"
+ } : {
+ field: "createdAt",
+ direction: "DESC"
+ },
+ filter: filter ? { isActive: filter === "active" } : undefined
+ },
+ fetchPolicy: 'cache-and-network'
+ })
+
+ const { data: statsData, refetch: refetchStats } = useQuery(GET_TAG_STATS, {
+ fetchPolicy: 'cache-and-network'
+ })
+
+ const [updateTag] = useMutation(UPDATE_TAG, {
+ onCompleted: () => {
+ refetch()
+ refetchStats()
+ }
+ })
+
+ const [deleteTag] = useMutation(DELETE_TAG, {
+ onCompleted: () => {
+ refetch()
+ refetchStats()
+ }
+ })
+
+ const tags = data?.tags?.items || []
+ const totalCount = data?.tags?.total || 0
+ const stats = statsData?.tagStats
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event
+ if (active && over && active.id !== over.id) {
+ console.log('Reordering tags:', active.id, 'to', over.id)
+ }
+ }
+
+ const tagInfo = React.useMemo(() => {
+ if (stats) {
+ return {
+ totalTags: stats.totalTags,
+ activeTags: stats.activeTags,
+ inactiveTags: stats.inactiveTags,
+ totalBlogs: stats.totalBlogs,
+ }
+ }
+ return {
+ totalTags: totalCount,
+ activeTags: tags.filter((tag: any) => tag.isActive).length,
+ inactiveTags: tags.filter((tag: any) => !tag.isActive).length,
+ totalBlogs: tags.reduce((sum: number, tag: any) => sum + (tag.blogCount || 0), 0),
+ }
+ }, [tags, totalCount, stats])
+
+ return (
+ <>
+ {error && {typeof error === 'string' ? error : 'An error occurred'}
}
+
+ >
+ )
+}
+
+function TagTabs({
+ tags,
+ loading,
+ info,
+ handleDragEnd,
+ refetch,
+ pagination,
+ setPagination,
+ sorting,
+ setSorting,
+ filter,
+ setFilter,
+ totalCount,
+ updateTag,
+ deleteTag
+}: {
+ tags: any[]
+ loading: boolean
+ info: any
+ handleDragEnd: (event: DragEndEvent) => void
+ refetch: any
+ pagination: { pageIndex: number; pageSize: number }
+ setPagination: React.Dispatch>
+ sorting: SortingState
+ setSorting: React.Dispatch>
+ filter?: string
+ setFilter: React.Dispatch>
+ totalCount: number
+ updateTag: any
+ deleteTag: any
+}) {
+ return (
+
+
+ {/* Tag Stats Section */}
+ {info && (
+
+
+
+
{info.totalTags}
+
总标签
+
+
+
{info.activeTags}
+
启用
+
+
+
{info.inactiveTags}
+
禁用
+
+
+
{info.totalBlogs}
+
关联文章
+
+
+
+ )}
+
+
+
+
+
+ 所有标签
+
+ 启用 {info?.activeTags}
+
+
+ 禁用 {info?.inactiveTags}
+
+
+
+
+
+
+
+
+ setFilter(undefined)}
+ updateTag={updateTag}
+ deleteTag={deleteTag}
+ />
+
+
+
+ setFilter("active")}
+ updateTag={updateTag}
+ deleteTag={deleteTag}
+ />
+
+
+
+ setFilter("inactive")}
+ updateTag={updateTag}
+ deleteTag={deleteTag}
+ />
+
+
+
+ )
+}
+
+function TagDataTable({
+ data,
+ isLoading = false,
+ handleDragEnd,
+ pagination,
+ setPagination,
+ sorting,
+ setSorting,
+ totalCount,
+ onFilterChange,
+ updateTag,
+ deleteTag
+}: {
+ data: any[]
+ isLoading?: boolean
+ handleDragEnd: (event: DragEndEvent) => void
+ pagination: { pageIndex: number; pageSize: number }
+ setPagination: React.Dispatch>
+ sorting: SortingState
+ setSorting: React.Dispatch>
+ totalCount: number
+ onFilterChange: () => void
+ updateTag: any
+ deleteTag: any
+}) {
+ const [rowSelection, setRowSelection] = React.useState({})
+ const [columnVisibility, setColumnVisibility] = React.useState({})
+ const [columnFilters, setColumnFilters] = React.useState([])
+
+ React.useEffect(() => {
+ onFilterChange()
+ }, [onFilterChange])
+
+ function handleLocalDragEnd(event: DragEndEvent) {
+ handleDragEnd(event)
+ }
+
+ const sortableId = React.useId()
+ const sensors = useSensors(
+ useSensor(MouseSensor, {}),
+ useSensor(TouchSensor, {}),
+ useSensor(KeyboardSensor, {})
+ )
+
+ const table = useReactTable({
+ data: data || [],
+ columns,
+ state: {
+ sorting,
+ columnVisibility,
+ rowSelection,
+ columnFilters,
+ pagination,
+ },
+ pageCount: Math.ceil(totalCount / pagination.pageSize),
+ getRowId: (row) => row.id.toString(),
+ enableRowSelection: true,
+ onRowSelectionChange: setRowSelection,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onPaginationChange: setPagination,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFacetedRowModel: getFacetedRowModel(),
+ getFacetedUniqueValues: getFacetedUniqueValues(),
+ manualPagination: true,
+ manualSorting: true,
+ meta: {
+ updateTag,
+ deleteTag,
+ },
+ })
+
+ return (
+ <>
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ )
+ })}
+
+ ))}
+
+
+ {isLoading ? (
+
+
+ 加载中...
+
+
+ ) : table.getRowModel().rows?.length ? (
+ tag.id) || []}
+ strategy={verticalListSortingStrategy}
+ >
+ {table.getRowModel().rows.map((row) => (
+
+ ))}
+
+ ) : (
+
+
+ 暂无数据。
+
+
+ )}
+
+
+
+
+
+
+ 已选择 {table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length} 行。
+
+
+
+
+
+
+
+ 第 {table.getState().pagination.pageIndex + 1} 页,共 {table.getPageCount()} 页
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+function TagCellViewer({ item }: { item: z.infer }) {
+ const isMobile = useIsMobile()
+
+ return (
+
+
+
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+
+
+
+
+ {item.name}
+
+ 标签详情信息
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/api/casbin/roles/route.ts b/app/api/casbin/roles/route.ts
new file mode 100644
index 0000000..197fa39
--- /dev/null
+++ b/app/api/casbin/roles/route.ts
@@ -0,0 +1,96 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const CASBIN_SERVER_URL = process.env.CASBIN_SERVER_URL || 'http://localhost:8080';
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { action, role } = body;
+
+ if (!action || !role) {
+ return NextResponse.json(
+ { error: 'Missing action or role parameter' },
+ { status: 400 }
+ );
+ }
+
+ let endpoint = '';
+ let method = 'POST';
+
+ switch (action) {
+ case 'add':
+ endpoint = '/api/v1/roles';
+ method = 'POST';
+ break;
+ case 'delete':
+ endpoint = `/api/v1/roles/${encodeURIComponent(role)}`;
+ method = 'DELETE';
+ break;
+ default:
+ return NextResponse.json(
+ { error: 'Invalid action. Use "add" or "delete"' },
+ { status: 400 }
+ );
+ }
+
+ const casbinResponse = await fetch(`${CASBIN_SERVER_URL}${endpoint}`, {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': request.headers.get('authorization') || '',
+ },
+ body: action === 'add' ? JSON.stringify({ role }) : undefined,
+ });
+
+ if (!casbinResponse.ok) {
+ const errorText = await casbinResponse.text();
+ throw new Error(`Casbin server responded with status: ${casbinResponse.status}, body: ${errorText}`);
+ }
+
+ const data = await casbinResponse.json();
+
+ return NextResponse.json({
+ success: true,
+ data,
+ action,
+ role
+ });
+ } catch (error) {
+ console.error('Casbin role operation error:', error);
+ return NextResponse.json(
+ {
+ error: 'Failed to perform role operation',
+ message: error instanceof Error ? error.message : 'Unknown error'
+ },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const casbinResponse = await fetch(`${CASBIN_SERVER_URL}/api/v1/roles`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': request.headers.get('authorization') || '',
+ },
+ });
+
+ if (!casbinResponse.ok) {
+ throw new Error(`Casbin server responded with status: ${casbinResponse.status}`);
+ }
+
+ const data = await casbinResponse.json();
+
+ return NextResponse.json(data);
+ } catch (error) {
+ console.error('Casbin get roles error:', error);
+ return NextResponse.json(
+ {
+ error: 'Failed to get roles',
+ message: error instanceof Error ? error.message : 'Unknown error'
+ },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
index f08e44c..51c3165 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,6 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ClientProviders } from "./client-provider";
import { Toaster } from "@/components/ui/sonner"
+import { ResolvingMetadata } from "next";
+import { getSiteConfigs } from "@/lib/fetchers";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -20,7 +22,6 @@ export const metadata: Metadata = {
};
-
export default async function RootLayout({
children,
}: Readonly<{
diff --git a/app/page.tsx b/app/page.tsx
index d5828fa..799a7e4 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -4,18 +4,14 @@ import { MapComponent } from '@/components/map-component';
import { Timeline } from '@/app/tl';
import { WSProvider } from './ws-context'
import StatusBar from './status-bar'
+import { getSiteConfigs } from '@/lib/fetchers';
type Props = {
params: Promise<{ id: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
-async function getSiteConfigs() {
- const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
- const siteConfigs = await fetch(`${baseUrl}/api/site`);
- const data = await siteConfigs.json();
- return data;
-}
+
export async function generateMetadata(
{ params, searchParams }: Props,
@@ -29,7 +25,6 @@ export async function generateMetadata(
}
-
export default function Page() {
return (
diff --git a/components/admin/admin-section.tsx b/components/admin/admin-section.tsx
index eb13171..a5d3d09 100644
--- a/components/admin/admin-section.tsx
+++ b/components/admin/admin-section.tsx
@@ -37,14 +37,14 @@ export function AdminSection({
// Filter fields based on conditional rendering
const visibleFields = section.fields.filter(field => {
if (field.showWhen) {
- return field.showWhen(values);
+ return field.showWhen(values ?? {});
}
return true;
});
// Get field value helper
const getFieldValue = (field: FieldConfig) => {
- return values[field.id] ?? field.value;
+ return values?.[field.id] ?? field.value;
};
// Render field with label and description
@@ -87,54 +87,6 @@ export function AdminSection({
)}
/>
-
- //
- //
- //
- //
- // {field.description && (
- //
- // {field.description}
- //
- // )}
- //
- // {field.type === "switch" && (
- //
onChange(field.id, newValue)}
- // onBlur={() => onBlur?.(field.id)}
- // />
- // )}
- //
- // {field.type !== "switch" && (
- //
onChange(field.id, newValue)}
- // onBlur={() => onBlur?.(field.id)}
- // />
- // )}
- //
);
};
@@ -172,14 +124,12 @@ export function AdminSection({
{section.icon}
-
- {section.title}
- {section.description && (
-
- {section.description}
-
- )}
-
+
{section.title}
+ {section.description && (
+
+ {section.description}
+
+ )}
{content}
diff --git a/components/admin/index.ts b/components/admin/index.ts
deleted file mode 100644
index 1a2a1fc..0000000
--- a/components/admin/index.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-// Admin Panel Components
-export { AdminPanel } from "./admin-panel";
-export { AdminSection } from "./admin-section";
-export { FieldRenderer } from "./field-renderer";
-
-// Hooks
-export { useAdminPanel } from "@/hooks/use-admin-panel";
-
-// Types
-export type {
- AdminPanelConfig,
- TabConfig,
- SectionConfig,
- FieldConfig,
- FieldType,
- SelectOption,
- ValidationRule,
- ActionConfig,
- HeaderConfig,
- AdminPanelState,
- UseAdminPanelOptions,
- UseAdminPanelReturn,
- AdminDataProvider,
- PermissionChecker
-} from "@/types/admin-panel";
-
-// Configurations
-export {
- defaultAdminPanelConfig,
- simpleAdminPanelConfig
-} from "@/app/admin/common/admin-panel-config";
\ No newline at end of file
diff --git a/components/tiptap-templates/simple/enhanced-simple-editor.tsx b/components/tiptap-templates/simple/enhanced-simple-editor.tsx
new file mode 100644
index 0000000..69f3249
--- /dev/null
+++ b/components/tiptap-templates/simple/enhanced-simple-editor.tsx
@@ -0,0 +1,302 @@
+"use client"
+
+import * as React from "react"
+import { EditorContent, EditorContext, useEditor } from "@tiptap/react"
+
+// --- Tiptap Core Extensions ---
+import { StarterKit } from "@tiptap/starter-kit"
+import { Image } from "@tiptap/extension-image"
+import { TaskItem, TaskList } from "@tiptap/extension-list"
+import { TextAlign } from "@tiptap/extension-text-align"
+import { Typography } from "@tiptap/extension-typography"
+import { Highlight } from "@tiptap/extension-highlight"
+import { Subscript } from "@tiptap/extension-subscript"
+import { Superscript } from "@tiptap/extension-superscript"
+import { Selection } from "@tiptap/extensions"
+
+// --- UI Primitives ---
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { Spacer } from "@/components/tiptap-ui-primitive/spacer"
+import {
+ Toolbar,
+ ToolbarGroup,
+ ToolbarSeparator,
+} from "@/components/tiptap-ui-primitive/toolbar"
+
+// --- Tiptap Node ---
+import { ImageUploadNode } from "@/components/tiptap-node/image-upload-node/image-upload-node-extension"
+import { HorizontalRule } from "@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension"
+import "@/components/tiptap-node/blockquote-node/blockquote-node.scss"
+import "@/components/tiptap-node/code-block-node/code-block-node.scss"
+import "@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss"
+import "@/components/tiptap-node/list-node/list-node.scss"
+import "@/components/tiptap-node/image-node/image-node.scss"
+import "@/components/tiptap-node/heading-node/heading-node.scss"
+import "@/components/tiptap-node/paragraph-node/paragraph-node.scss"
+
+// --- Tiptap UI ---
+import { HeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu"
+import { ImageUploadButton } from "@/components/tiptap-ui/image-upload-button"
+import { ListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu"
+import { BlockquoteButton } from "@/components/tiptap-ui/blockquote-button"
+import { CodeBlockButton } from "@/components/tiptap-ui/code-block-button"
+import {
+ ColorHighlightPopover,
+ ColorHighlightPopoverContent,
+ ColorHighlightPopoverButton,
+} from "@/components/tiptap-ui/color-highlight-popover"
+import {
+ LinkPopover,
+ LinkContent,
+ LinkButton,
+} from "@/components/tiptap-ui/link-popover"
+import { MarkButton } from "@/components/tiptap-ui/mark-button"
+import { TextAlignButton } from "@/components/tiptap-ui/text-align-button"
+import { UndoRedoButton } from "@/components/tiptap-ui/undo-redo-button"
+
+// --- Icons ---
+import { ArrowLeftIcon } from "@/components/tiptap-icons/arrow-left-icon"
+import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
+import { LinkIcon } from "@/components/tiptap-icons/link-icon"
+
+// --- Hooks ---
+import { useIsMobile } from "@/hooks/use-mobile"
+import { useWindowSize } from "@/hooks/use-window-size"
+import { useCursorVisibility } from "@/hooks/use-cursor-visibility"
+
+// --- Components ---
+import { ThemeToggle } from "@/components/tiptap-templates/simple/theme-toggle"
+
+// --- Lib ---
+import { handleImageUpload, MAX_FILE_SIZE } from "@/lib/tiptap-utils"
+
+// --- Styles ---
+import "@/components/tiptap-templates/simple/simple-editor.scss"
+
+const MainToolbarContent = ({
+ onHighlighterClick,
+ onLinkClick,
+ isMobile,
+}: {
+ onHighlighterClick: () => void
+ onLinkClick: () => void
+ isMobile: boolean
+}) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!isMobile ? (
+
+ ) : (
+
+ )}
+ {!isMobile ? : }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isMobile &&
}
+
+
+
+
+ >
+ )
+}
+
+const MobileToolbarContent = ({
+ type,
+ onBack,
+}: {
+ type: "highlighter" | "link"
+ onBack: () => void
+}) => (
+ <>
+
+
+
+
+
+
+ {type === "highlighter" ? (
+
+ ) : (
+
+ )}
+ >
+)
+
+interface EnhancedSimpleEditorProps {
+ content?: any
+ onChange?: (content: any) => void
+}
+
+export function EnhancedSimpleEditor({ content, onChange }: EnhancedSimpleEditorProps) {
+ const isMobile = useIsMobile()
+ const { height } = useWindowSize()
+ const [mobileView, setMobileView] = React.useState<
+ "main" | "highlighter" | "link"
+ >("main")
+ const toolbarRef = React.useRef
(null)
+
+ const editor = useEditor({
+ immediatelyRender: false,
+ shouldRerenderOnTransaction: false,
+ editorProps: {
+ attributes: {
+ autocomplete: "off",
+ autocorrect: "off",
+ autocapitalize: "off",
+ "aria-label": "Main content area, start typing to enter text.",
+ class: "simple-editor",
+ },
+ },
+ extensions: [
+ StarterKit.configure({
+ horizontalRule: false,
+ link: {
+ openOnClick: false,
+ enableClickSelection: true,
+ },
+ }),
+ HorizontalRule,
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
+ TaskList,
+ TaskItem.configure({ nested: true }),
+ Highlight.configure({ multicolor: true }),
+ Image,
+ Typography,
+ Superscript,
+ Subscript,
+ Selection,
+ ImageUploadNode.configure({
+ accept: "image/*",
+ maxSize: MAX_FILE_SIZE,
+ limit: 3,
+ upload: handleImageUpload,
+ onError: (error) => console.error("Upload failed:", error),
+ }),
+ ],
+ content: content || "",
+ onUpdate: ({ editor }) => {
+ if (onChange) {
+ onChange(editor.getJSON())
+ }
+ }
+ })
+
+ // Update editor content when prop changes
+ React.useEffect(() => {
+ if (editor && content !== undefined) {
+ const currentContent = editor.getJSON()
+ const newContent = content || ""
+
+ // Only update if content actually changed to avoid infinite loops
+ if (JSON.stringify(currentContent) !== JSON.stringify(newContent)) {
+ editor.commands.setContent(newContent, false)
+ }
+ }
+ }, [editor, content])
+
+ const rect = useCursorVisibility({
+ editor,
+ overlayHeight: toolbarRef.current?.getBoundingClientRect().height ?? 0,
+ })
+
+ React.useEffect(() => {
+ if (!isMobile && mobileView !== "main") {
+ setMobileView("main")
+ }
+ }, [isMobile, mobileView])
+
+ return (
+
+
+
+ {mobileView === "main" ? (
+ setMobileView("highlighter")}
+ onLinkClick={() => setMobileView("link")}
+ isMobile={isMobile}
+ />
+ ) : (
+ setMobileView("main")}
+ />
+ )}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/ui/command.tsx b/components/ui/command.tsx
new file mode 100644
index 0000000..f329a0c
--- /dev/null
+++ b/components/ui/command.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import * as React from "react"
+import { type DialogProps } from "@radix-ui/react-dialog"
+import { Command as CommandPrimitive } from "cmdk"
+import { Search } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Dialog, DialogContent } from "@/components/ui/dialog"
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Command.displayName = CommandPrimitive.displayName
+
+interface CommandDialogProps extends DialogProps {}
+
+const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
+ return (
+
+ )
+}
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+
+CommandInput.displayName = CommandPrimitive.Input.displayName
+
+const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandList.displayName = CommandPrimitive.List.displayName
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+))
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName
+
+const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandItem.displayName = CommandPrimitive.Item.displayName
+
+const CommandShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+CommandShortcut.displayName = "CommandShortcut"
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
\ No newline at end of file
diff --git a/lib/fetchers.ts b/lib/fetchers.ts
index 43a8515..0ef5ec8 100644
--- a/lib/fetchers.ts
+++ b/lib/fetchers.ts
@@ -60,4 +60,11 @@ export async function fetchPage(slug: string, jwt?: string): Promise=6"
}
},
+ "node_modules/cmdk": {
+ "version": "1.1.1",
+ "resolved": "http://mirrors.cloud.tencent.com/npm/cmdk/-/cmdk-1.1.1.tgz",
+ "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "^1.1.1",
+ "@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-id": "^1.1.0",
+ "@radix-ui/react-primitive": "^2.0.2"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
+ }
+ },
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -11286,6 +11303,17 @@
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
},
+ "cmdk": {
+ "version": "1.1.1",
+ "resolved": "http://mirrors.cloud.tencent.com/npm/cmdk/-/cmdk-1.1.1.tgz",
+ "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
+ "requires": {
+ "@radix-ui/react-compose-refs": "^1.1.1",
+ "@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-id": "^1.1.0",
+ "@radix-ui/react-primitive": "^2.0.2"
+ }
+ },
"color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
diff --git a/package.json b/package.json
index 55b3978..87595ec 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"@tiptap/starter-kit": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"dnd-kit": "^0.0.2",