update admin page

This commit is contained in:
Tsuki 2025-08-17 20:28:13 +08:00
parent 4bafe4d601
commit 23905d33fd
39 changed files with 7883 additions and 2287 deletions

View File

@ -9,6 +9,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Production server**: `npm run start` - Starts production server (requires build first)
- **Lint**: `npm run lint` - Runs Next.js ESLint checks
**Note**: No test framework is currently configured in this project.
## High-Level Architecture
This is a Next.js 15 application built with React 19 that creates an interactive map visualization with a custom WebGL timeline component.
@ -35,6 +37,17 @@ This is a Next.js 15 application built with React 19 that creates an interactive
- shadcn/ui component library in `components/ui/`
- Sidebar layout using `components/ui/sidebar.tsx`
**Admin System**:
- Comprehensive admin interface in `app/admin/` with dashboard, analytics, user management, and content editor
- Admin pages include dynamic configuration system and navigation components
- Uses both Apollo Client and GraphQL Request for data fetching
**Data Layer**:
- GraphQL API integration using Apollo Client and GraphQL Request
- Backend communication via `/api/bff` endpoint (configured in `lib/gr-client.ts`)
- Page-based content management with block-based architecture
- Authentication support with JWT tokens
### Key Technical Details
**WebGL Timeline**:
@ -55,17 +68,26 @@ This is a Next.js 15 application built with React 19 that creates an interactive
**Build Configuration**:
- Next.js 15 with App Router
- Custom webpack config for GLSL file loading via `raw-loader`
- Custom webpack config for GLSL file loading via `raw-loader` (see `next.config.ts`)
- TypeScript with strict mode enabled
- Absolute imports using `@/*` path mapping
**GraphQL Integration**:
- Uses both Apollo Client (`@apollo/client`) and GraphQL Request (`graphql-request`) for different use cases
- Page content fetching via `lib/fetchers.ts` with support for authentication
- Block-based content architecture supporting TextBlock, ChartBlock, SettingsBlock, and HeroBlock types
- Environment variables: `GRAPHQL_BACKEND_URL` and `NEXTAUTH_URL`
### File Structure Notes
- `app/` - Next.js App Router pages and components
- `app/admin/` - Complete admin interface with dashboard, analytics, users, and content editor
- `app/glsl/timeline/` - Custom GLSL shaders for WebGL timeline rendering
- `components/` - Reusable React components
- `hooks/` - Custom React hooks
- `lib/` - Utility functions
- `components/ui/` - shadcn/ui component library
- `hooks/` - Custom React hooks for map, timeline, and mobile detection
- `lib/` - Utility functions including GraphQL clients and data fetchers
- `types/` - TypeScript type definitions
- `public/` - Static assets
The application combines modern web mapping with custom WebGL visualization to create an interactive timeline-driven map interface.
The application combines modern web mapping with custom WebGL visualization to create an interactive timeline-driven map interface, complemented by a full-featured admin system for content management.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,336 @@
"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { DialogClose, DialogFooter } from "@/components/ui/dialog"
import { IconLoader } from "@tabler/icons-react"
const CREATE_BLOG = gql`
mutation CreateBlog($input: CreateBlogInput!) {
createBlog(input: $input) {
id
title
slug
status
}
}
`
const schema = z.object({
title: z.string().min(1, "标题不能为空"),
slug: z.string().min(1, "链接不能为空"),
excerpt: z.string().optional(),
content: z.string().min(1, "内容不能为空"),
status: z.enum(["draft", "published", "archived"]),
metaTitle: z.string().optional(),
metaDescription: z.string().optional(),
isFeatured: z.boolean().default(false),
isActive: z.boolean().default(true),
})
export default function CreateBlogForm() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createBlog] = useMutation(CREATE_BLOG)
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
title: "",
slug: "",
excerpt: "",
content: "",
status: "draft" as const,
metaTitle: "",
metaDescription: "",
isFeatured: false,
isActive: true,
},
})
// 生成slug
const generateSlug = useCallback((title: string) => {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '') // 移除特殊字符
.replace(/\s+/g, '-') // 空格替换为连字符
.replace(/-+/g, '-') // 多个连字符合并为一个
.trim();
}, []);
// 监听标题变化自动生成slug
const watchedTitle = form.watch("title");
useEffect(() => {
if (watchedTitle) {
const slug = generateSlug(watchedTitle);
form.setValue("slug", slug);
}
}, [watchedTitle, generateSlug, form]);
async function onSubmit(values: z.infer<typeof schema>) {
try {
setIsLoading(true);
setError(null);
await createBlog({
variables: {
input: {
title: values.title,
slug: values.slug,
content: JSON.stringify(values.content), // Convert to JSON
status: values.status,
excerpt: values.excerpt || null,
metaTitle: values.metaTitle || null,
metaDescription: values.metaDescription || null,
isFeatured: values.isFeatured,
isActive: values.isActive,
}
}
});
// 重置表单
form.reset();
} catch (err) {
setError(err instanceof Error ? err.message : '创建博客失败,请重试');
} finally {
setIsLoading(false);
}
}
return (
<Form {...form} >
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
{error && (
<div className="text-sm text-red-600 bg-red-50 p-3 rounded-md">
{error}
</div>
)}
<div className="grid gap-4">
<div className="grid gap-3">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input id="title" placeholder="请输入博客标题" {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input id="slug" placeholder="博客链接地址" {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="excerpt"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
id="excerpt"
placeholder="请输入博客摘要(可选)"
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={(value) => field.onChange(value)}
>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="选择发布状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="published"></SelectItem>
<SelectItem value="archived"></SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-col gap-3">
<FormField
control={form.control}
name="metaTitle"
render={({ field }) => (
<FormItem>
<FormLabel>Meta </FormLabel>
<FormControl>
<Input
id="metaTitle"
placeholder="SEO 标题(可选)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="metaDescription"
render={({ field }) => (
<FormItem>
<FormLabel>Meta </FormLabel>
<FormControl>
<Textarea
id="metaDescription"
placeholder="SEO 描述(可选)"
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<FormField
control={form.control}
name="isFeatured"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
className="h-4 w-4"
/>
</FormControl>
<FormLabel className="text-sm font-normal"></FormLabel>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-col gap-3">
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
className="h-4 w-4"
/>
</FormControl>
<FormLabel className="text-sm font-normal"></FormLabel>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Textarea
id="content"
placeholder="请输入博客内容(支持 Markdown"
rows={12}
{...field}
required
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
</Button>
</DialogClose>
<Button
type="button"
variant="secondary"
onClick={() => {
form.setValue("status", "draft");
form.handleSubmit(onSubmit)();
}}
disabled={isLoading}
>
稿
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
form.watch("status") === "published" ? "发布博客" : "创建博客"
)}
</Button>
</DialogFooter>
</form>
</Form >
)
}

20
app/admin/blogs/page.tsx Normal file
View File

@ -0,0 +1,20 @@
"use client"
import { BlogTable } from "./blog-table"
import { SiteHeader } from "../site-header"
export default function Page() {
return (
<>
<SiteHeader breadcrumbs={[{ label: "Home", href: "/" }, { label: "Blogs", href: "/admin/blogs" }]} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<h1 className="text-2xl font-bold px-6">Blogs</h1>
<BlogTable />
</div>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,944 @@
"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,
IconTrendingUp,
} 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 CreateCategoryForm from "./create-category-form";
export const categorySchema = z.object({
id: z.string(),
name: z.string(),
slug: z.string(),
description: z.string().optional(),
parentId: z.string().optional(),
color: z.string().optional(),
icon: 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 in this category
})
function DragHandle({ id }: { id: string }) {
const { attributes, listeners } = useSortable({
id,
})
return (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="text-muted-foreground size-7 hover:bg-transparent"
>
<IconGripVertical className="text-muted-foreground size-3" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof categorySchema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "分类名称",
cell: ({ row }) => {
return <CategoryCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "slug",
header: "别名",
cell: ({ row }) => (
<div className="w-32">
<span className="text-sm font-mono text-muted-foreground">
{row.original.slug}
</span>
</div>
),
},
{
accessorKey: "blogCount",
header: "文章数",
cell: ({ row }) => (
<div className="w-20">
<Badge variant="secondary" className="text-xs">
{row.original.blogCount || 0}
</Badge>
</div>
),
},
{
accessorKey: "isActive",
header: "状态",
cell: ({ row }) => (
<div className="w-20">
<Badge variant={row.original.isActive ? "default" : "secondary"} className="text-xs">
{row.original.isActive ? "启用" : "禁用"}
</Badge>
</div>
),
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="px-0 hover:bg-transparent"
>
<IconChevronDown
className={`ml-2 h-4 w-4 transition-transform ${column.getIsSorted() === "asc" ? "rotate-180" : ""
}`}
/>
</Button>
)
},
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 (
<div className="w-40">
<div className="flex flex-row gap-2">
<span className="text-sm font-medium">
{date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
<Badge variant="secondary" className="text-xs w-fit">
{timeAgo}
</Badge>
</div>
</div>
);
},
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 updateCategoryMutation = (table.options.meta as any)?.updateCategory
const deleteCategoryMutation = (table.options.meta as any)?.deleteCategory
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-42">
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
updateCategoryMutation({
variables: {
id: row.original.id,
input: { isActive: !row.original.isActive }
}
})
}}
>
{row.original.isActive ? "禁用" : "启用"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => {
if (confirm('确定要删除这个分类吗?此操作不可撤销。')) {
deleteCategoryMutation({
variables: {
id: row.original.id
}
})
}
}}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof categorySchema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
const GET_CATEGORIES = gql`
query GetCategories($filter: CategoryFilterInput, $sort: CategorySortInput, $pagination: PaginationInput) {
categories(filter: $filter, sort: $sort, pagination: $pagination) {
items {
id
name
slug
description
parentId
color
icon
isActive
createdAt
updatedAt
createdBy
updatedBy
blogCount
}
total
page
perPage
totalPages
}
}
`
const GET_CATEGORY_STATS = gql`
query GetCategoryStats {
categoryStats {
totalCategories
activeCategories
inactiveCategories
totalBlogs
}
}
`
const UPDATE_CATEGORY = gql`
mutation UpdateCategory($id: UUID!, $input: UpdateCategoryInput!) {
updateCategory(id: $id, input: $input) {
id
name
slug
isActive
updatedAt
}
}
`
const DELETE_CATEGORY = gql`
mutation DeleteCategory($id: UUID!) {
deleteCategory(id: $id)
}
`
export function CategoryTable() {
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const [sorting, setSorting] = React.useState<SortingState>([])
const [filter, setFilter] = React.useState<string>()
const { data, loading, error, refetch } = useQuery(GET_CATEGORIES, {
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_CATEGORY_STATS, {
fetchPolicy: 'cache-and-network'
})
const [updateCategory] = useMutation(UPDATE_CATEGORY, {
onCompleted: () => {
refetch()
refetchStats()
}
})
const [deleteCategory] = useMutation(DELETE_CATEGORY, {
onCompleted: () => {
refetch()
refetchStats()
}
})
const categories = data?.categories?.items || []
const totalCount = data?.categories?.total || 0
const stats = statsData?.categoryStats
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (active && over && active.id !== over.id) {
console.log('Reordering categories:', active.id, 'to', over.id)
}
}
const categoryInfo = React.useMemo(() => {
if (stats) {
return {
totalCategories: stats.totalCategories,
activeCategories: stats.activeCategories,
inactiveCategories: stats.inactiveCategories,
totalBlogs: stats.totalBlogs,
}
}
return {
totalCategories: totalCount,
activeCategories: categories.filter((cat: any) => cat.isActive).length,
inactiveCategories: categories.filter((cat: any) => !cat.isActive).length,
totalBlogs: categories.reduce((sum: number, cat: any) => sum + (cat.blogCount || 0), 0),
}
}, [categories, totalCount, stats])
return (
<>
{error && <div className="text-red-500">{typeof error === 'string' ? error : 'An error occurred'}</div>}
<CategoryTabs
categories={categories}
loading={loading}
info={categoryInfo}
handleDragEnd={handleDragEnd}
refetch={refetch}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
filter={filter}
setFilter={setFilter}
totalCount={totalCount}
updateCategory={updateCategory}
deleteCategory={deleteCategory}
/>
</>
)
}
function CategoryTabs({
categories,
loading,
info,
handleDragEnd,
refetch,
pagination,
setPagination,
sorting,
setSorting,
filter,
setFilter,
totalCount,
updateCategory,
deleteCategory
}: {
categories: any[]
loading: boolean
info: any
handleDragEnd: (event: DragEndEvent) => void
refetch: any
pagination: { pageIndex: number; pageSize: number }
setPagination: React.Dispatch<React.SetStateAction<{ pageIndex: number; pageSize: number }>>
sorting: SortingState
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
filter?: string
setFilter: React.Dispatch<React.SetStateAction<string | undefined>>
totalCount: number
updateCategory: any
deleteCategory: any
}) {
return (
<div>
<Tabs
defaultValue="all_categories"
className="w-full flex-col justify-start gap-6"
>
{/* Category Stats Section */}
{info && (
<div className="px-4 lg:px-6 py-4 bg-muted/50 rounded-lg mx-4 lg:mx-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-blue-600">{info.totalCategories}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-green-600">{info.activeCategories}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-gray-600">{info.inactiveCategories}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-purple-600">{info.totalBlogs}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="all_categories">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="选择视图" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all_categories"></SelectItem>
<SelectItem value="active_categories"></SelectItem>
<SelectItem value="inactive_categories"></SelectItem>
</SelectContent>
</Select>
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="all_categories"></TabsTrigger>
<TabsTrigger value="active_categories">
<Badge variant="secondary">{info?.activeCategories}</Badge>
</TabsTrigger>
<TabsTrigger value="inactive_categories">
<Badge variant="secondary">{info?.inactiveCategories}</Badge>
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.location.href = '/admin/categories/create'}
>
<IconPlus />
<span className="hidden lg:inline"></span>
</Button>
</div>
</div>
<TabsContent value="all_categories" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<CategoryDataTable
data={categories}
isLoading={loading}
handleDragEnd={handleDragEnd}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
totalCount={totalCount}
onFilterChange={() => setFilter(undefined)}
updateCategory={updateCategory}
deleteCategory={deleteCategory}
/>
</TabsContent>
<TabsContent value="active_categories" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<CategoryDataTable
data={filter === "active" ? categories : []}
isLoading={loading && filter === "active"}
handleDragEnd={handleDragEnd}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
totalCount={filter === "active" ? totalCount : 0}
onFilterChange={() => setFilter("active")}
updateCategory={updateCategory}
deleteCategory={deleteCategory}
/>
</TabsContent>
<TabsContent value="inactive_categories" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<CategoryDataTable
data={filter === "inactive" ? categories : []}
isLoading={loading && filter === "inactive"}
handleDragEnd={handleDragEnd}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
totalCount={filter === "inactive" ? totalCount : 0}
onFilterChange={() => setFilter("inactive")}
updateCategory={updateCategory}
deleteCategory={deleteCategory}
/>
</TabsContent>
</Tabs>
</div>
)
}
function CategoryDataTable({
data,
isLoading = false,
handleDragEnd,
pagination,
setPagination,
sorting,
setSorting,
totalCount,
onFilterChange,
updateCategory,
deleteCategory
}: {
data: any[]
isLoading?: boolean
handleDragEnd: (event: DragEndEvent) => void
pagination: { pageIndex: number; pageSize: number }
setPagination: React.Dispatch<React.SetStateAction<{ pageIndex: number; pageSize: number }>>
sorting: SortingState
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
totalCount: number
onFilterChange: () => void
updateCategory: any
deleteCategory: any
}) {
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
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: {
updateCategory,
deleteCategory,
},
})
return (
<>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleLocalDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center">
...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
<SortableContext
items={data?.map((category: any) => category.id) || []}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length}
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
{table.getState().pagination.pageIndex + 1} {table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</>
)
}
function CategoryCellViewer({ item }: { item: z.infer<typeof categorySchema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<div className="flex flex-col gap-1">
<Button variant="link" className="text-foreground w-fit px-0 text-left justify-start">
<div className="flex items-center gap-2">
{item.color && (
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
)}
{item.name}
</div>
</Button>
{item.description && (
<span className="text-xs text-muted-foreground line-clamp-2 max-w-60">
{item.description}
</span>
)}
</div>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.name}</DrawerTitle>
<DrawerDescription>
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="name"></Label>
<Input id="name" defaultValue={item.name} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="slug"></Label>
<Input id="slug" defaultValue={item.slug} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="description"></Label>
<Input id="description" defaultValue={item.description} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="color"></Label>
<Input id="color" defaultValue={item.color} type="color" />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status"></Label>
<Select defaultValue={item.isActive ? "true" : "false"}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="选择状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true"></SelectItem>
<SelectItem value="false"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</form>
</div>
<DrawerFooter>
<Button>
</Button>
<DrawerClose asChild>
<Button variant="outline"></Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View File

@ -0,0 +1,255 @@
"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { IconLoader } from "@tabler/icons-react"
const CREATE_CATEGORY = gql`
mutation CreateCategory($input: CreateCategoryInput!) {
createCategory(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(),
icon: z.string().optional(),
parentId: z.string().optional(),
isActive: z.boolean().default(true),
})
export default function CreateCategoryForm() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createCategory] = useMutation(CREATE_CATEGORY)
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
slug: "",
description: "",
color: "#3b82f6",
icon: "",
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<typeof schema>) {
try {
setIsLoading(true);
setError(null);
await createCategory({
variables: {
input: {
name: values.name,
slug: values.slug,
description: values.description || null,
color: values.color || null,
icon: values.icon || null,
parentId: values.parentId || null,
isActive: values.isActive,
}
}
});
// 重置表单
form.reset();
// 跳转回分类列表
window.location.href = '/admin/categories';
} catch (err) {
setError(err instanceof Error ? err.message : '创建分类失败,请重试');
} finally {
setIsLoading(false);
}
}
return (
<div className="max-w-2xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Form {...form}>
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
{error && (
<div className="text-sm text-red-600 bg-red-50 p-3 rounded-md">
{error}
</div>
)}
<div className="grid gap-4">
<div className="grid gap-3">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input placeholder="请输入分类名称" {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input placeholder="分类别名" {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
placeholder="分类描述(可选)"
rows={4}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="color"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-col gap-3">
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
placeholder="图标 URL 或类名(可选)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
className="h-4 w-4"
/>
</FormControl>
<FormLabel className="text-sm font-normal"></FormLabel>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => window.history.back()}
>
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"创建分类"
)}
</Button>
</div>
</form>
</Form>
</div>
)
}

View File

@ -0,0 +1,11 @@
"use client"
import CreateCategoryForm from "../create-category-form"
export default function Page() {
return (
<div className="min-h-screen bg-gray-50">
<CreateCategoryForm />
</div>
)
}

View File

@ -0,0 +1,20 @@
"use client"
import { CategoryTable } from "./category-table"
import { SiteHeader } from "../site-header"
export default function Page() {
return (
<>
<SiteHeader breadcrumbs={[{ label: "Home", href: "/" }, { label: "Categories", href: "/admin/categories" }]} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<h1 className="text-2xl font-bold px-6"></h1>
<CategoryTable />
</div>
</div>
</div>
</>
)
}

View File

@ -1,496 +0,0 @@
import {
Settings,
Users,
Database,
Shield,
Bell,
Mail,
Globe,
Palette,
Monitor,
Smartphone,
Save,
RefreshCw,
Eye,
EyeOff,
Zap,
HardDrive,
Lock,
Server
} from "lucide-react";
import { AdminPanelConfig } from "@/types/admin-panel";
// 示例配置:完整的后台管理面板配置
export const defaultAdminPanelConfig: AdminPanelConfig = {
header: {
title: "后台管理面板",
description: "系统设置和配置管理",
breadcrumbs: [
{ label: "首页", href: "/" },
{ label: "管理", href: "/admin" },
{ label: "系统设置" }
],
actions: [
{
id: "refresh",
label: "刷新",
icon: <RefreshCw className="h-4 w-4" />,
variant: "outline",
onClick: () => window.location.reload()
},
{
id: "export",
label: "导出配置",
icon: <Save className="h-4 w-4" />,
variant: "outline",
onClick: () => console.log("导出配置")
}
]
},
tabs: [
{
id: "general",
title: "常规设置",
icon: <Settings className="h-4 w-4" />,
sections: [
{
id: "site-info",
title: "网站信息",
description: "网站基本信息和配置",
icon: <Globe className="h-5 w-5" />,
fields: [
{
id: "siteName",
label: "网站名称",
description: "显示在浏览器标题栏的网站名称",
type: "input",
value: "我的网站",
placeholder: "请输入网站名称",
validation: {
required: true,
minLength: 2,
maxLength: 50
}
},
{
id: "siteDescription",
label: "网站描述",
description: "网站的简短描述用于SEO",
type: "textarea",
value: "这是一个很棒的网站",
rows: 3,
validation: {
maxLength: 200
}
},
{
id: "adminEmail",
label: "管理员邮箱",
description: "接收系统通知的邮箱地址",
type: "email",
value: "admin@example.com",
validation: {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
}
},
{
id: "language",
label: "默认语言",
description: "网站的默认显示语言",
type: "select",
value: "zh-CN",
options: [
{ label: "简体中文", value: "zh-CN" },
{ label: "English", value: "en-US" },
{ label: "日本語", value: "ja-JP" },
{ label: "한국어", value: "ko-KR" }
]
},
{
id: "timezone",
label: "时区",
description: "服务器时区设置",
type: "select",
value: "Asia/Shanghai",
options: [
{ label: "北京时间 (UTC+8)", value: "Asia/Shanghai" },
{ label: "东京时间 (UTC+9)", value: "Asia/Tokyo" },
{ label: "纽约时间 (UTC-5)", value: "America/New_York" },
{ label: "伦敦时间 (UTC+0)", value: "Europe/London" }
]
}
]
}
]
},
{
id: "system",
title: "系统设置",
icon: <Database className="h-4 w-4" />,
sections: [
{
id: "performance",
title: "性能设置",
description: "系统性能和资源配置",
icon: <Zap className="h-5 w-5" />,
columns: 2,
fields: [
{
id: "enableMaintenance",
label: "维护模式",
description: "启用后网站将显示维护页面",
type: "switch",
value: false
},
{
id: "cacheEnabled",
label: "启用缓存",
description: "开启页面缓存以提高性能",
type: "switch",
value: true
},
{
id: "maxUsers",
label: "最大用户数",
description: "系统允许的最大注册用户数量",
type: "slider",
value: 1000,
min: 100,
max: 10000,
step: 100
},
{
id: "sessionTimeout",
label: "会话超时时间(分钟)",
description: "用户登录会话的超时时间",
type: "slider",
value: 30,
min: 5,
max: 120,
step: 5
},
{
id: "backupFrequency",
label: "备份频率",
description: "自动备份数据的频率",
type: "select",
value: "daily",
options: [
{ label: "每小时", value: "hourly", description: "适合高频更新的网站" },
{ label: "每天", value: "daily", description: "推荐设置" },
{ label: "每周", value: "weekly", description: "适合低频更新的网站" },
{ label: "每月", value: "monthly", description: "仅适合静态网站" }
]
},
{
id: "logLevel",
label: "日志级别",
description: "系统日志记录的详细程度",
type: "select",
value: "info",
options: [
{ label: "错误", value: "error" },
{ label: "警告", value: "warn" },
{ label: "信息", value: "info" },
{ label: "调试", value: "debug" }
]
}
]
}
]
},
{
id: "security",
title: "安全设置",
icon: <Shield className="h-4 w-4" />,
sections: [
{
id: "auth-settings",
title: "认证设置",
description: "用户认证和访问控制",
icon: <Lock className="h-5 w-5" />,
fields: [
{
id: "enableRegistration",
label: "允许用户注册",
description: "是否允许新用户注册账户",
type: "switch",
value: true
},
{
id: "enableSSL",
label: "强制HTTPS",
description: "强制所有连接使用HTTPS协议",
type: "switch",
value: true
},
{
id: "securityLevel",
label: "安全级别",
description: "系统安全防护级别 (1-10)",
type: "slider",
value: 8,
min: 1,
max: 10,
step: 1
},
{
id: "maxLoginAttempts",
label: "最大登录尝试次数",
description: "账户锁定前允许的最大失败登录次数",
type: "number",
value: 5,
min: 1,
max: 20
},
{
id: "passwordPolicy",
label: "密码策略",
description: "密码复杂度要求",
type: "select",
value: "medium",
options: [
{ label: "简单", value: "simple", description: "至少6位字符" },
{ label: "中等", value: "medium", description: "至少8位包含字母和数字" },
{ label: "复杂", value: "complex", description: "至少12位包含大小写字母、数字和特殊字符" }
]
}
]
}
]
},
{
id: "appearance",
title: "外观设置",
icon: <Palette className="h-4 w-4" />,
sections: [
{
id: "theme-settings",
title: "主题设置",
description: "界面主题和视觉配置",
icon: <Monitor className="h-5 w-5" />,
fields: [
{
id: "theme",
label: "主题模式",
description: "选择网站的主题外观",
type: "radio",
value: "light",
options: [
{ label: "浅色主题", value: "light" },
{ label: "深色主题", value: "dark" },
{ label: "自动切换", value: "auto" }
]
},
{
id: "primaryColor",
label: "主色调",
description: "网站的主要颜色",
type: "color",
value: "#3b82f6"
},
{
id: "enableComments",
label: "启用评论",
description: "是否在文章页面显示评论功能",
type: "switch",
value: true
},
{
id: "performanceScore",
label: "性能优化级别",
description: "网站性能优化程度 (0-100)",
type: "slider",
value: 75,
min: 0,
max: 100,
step: 5
},
{
id: "customCSS",
label: "自定义样式",
description: "添加自定义CSS代码",
type: "textarea",
value: "",
rows: 6,
placeholder: "/* 在这里添加自定义CSS */",
showWhen: (values) => values.theme === "dark"
}
]
}
]
},
{
id: "notifications",
title: "通知设置",
icon: <Bell className="h-4 w-4" />,
sections: [
{
id: "notification-settings",
title: "通知配置",
description: "系统通知和邮件设置",
icon: <Mail className="h-5 w-5" />,
fields: [
{
id: "enableNotifications",
label: "启用通知",
description: "是否接收系统通知",
type: "switch",
value: true
},
{
id: "emailNotifications",
label: "邮件通知类型",
description: "选择要接收的邮件通知类型",
type: "checkbox",
value: true,
// Note: For multiple checkboxes, you'd typically use a different approach
// This is a simplified example
},
{
id: "maxFileSize",
label: "最大文件大小 (MB)",
description: "允许上传的最大文件大小",
type: "slider",
value: 10,
min: 1,
max: 100,
step: 1
},
{
id: "enableCDN",
label: "启用CDN",
description: "使用内容分发网络加速",
type: "switch",
value: false
},
{
id: "notificationSound",
label: "通知声音",
description: "上传自定义通知声音文件",
type: "file",
value: null,
accept: "audio/*"
}
]
}
]
},
{
id: "users",
title: "用户管理",
icon: <Users className="h-4 w-4" />,
badge: "4",
sections: [
{
id: "user-list",
title: "用户列表",
description: "管理系统用户账户和权限",
icon: <Users className="h-5 w-5" />,
fields: [
{
id: "userSearch",
label: "搜索用户",
description: "输入用户名或邮箱进行搜索",
type: "input",
value: "",
placeholder: "搜索用户..."
},
{
id: "userRole",
label: "默认用户角色",
description: "新注册用户的默认角色",
type: "select",
value: "user",
options: [
{ label: "用户", value: "user" },
{ label: "编辑", value: "editor" },
{ label: "管理员", value: "admin" }
]
}
]
}
]
}
],
// 配置选项
autoSave: true,
autoSaveDelay: 3000,
validateOnChange: true,
validateOnSubmit: false,
// 主题设置
theme: {
spacing: "normal",
layout: "tabs"
},
// 回调函数
onValueChange: (path, value, allValues) => {
console.log(`配置项 ${path} 已更改为:`, value);
},
onSave: async (values) => {
console.log("保存配置:", values);
// 这里可以添加保存到服务器的逻辑
},
onReset: () => {
console.log("重置配置");
},
onValidate: (values) => {
const errors: Record<string, string> = {};
// 自定义验证逻辑
if (values.siteName && values.siteName.includes("测试")) {
errors.siteName = "网站名称不能包含'测试'字样";
}
if (values.maxUsers && values.sessionTimeout &&
values.maxUsers > 5000 && values.sessionTimeout < 15) {
errors.sessionTimeout = "当最大用户数超过5000时会话超时时间不能少于15分钟";
}
return errors;
}
};
// 简化版配置示例
export const simpleAdminPanelConfig: AdminPanelConfig = {
header: {
title: "快速设置",
description: "基本配置选项"
},
tabs: [
{
id: "basic",
title: "基本设置",
icon: <Settings className="h-4 w-4" />,
sections: [
{
id: "basic-info",
title: "基本信息",
icon: <Globe className="h-5 w-5" />,
fields: [
{
id: "name",
label: "名称",
type: "input",
value: "",
validation: { required: true }
},
{
id: "enabled",
label: "启用",
type: "switch",
value: true
}
]
}
]
}
]
};

View File

@ -1,338 +0,0 @@
"use client";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Settings,
Globe,
Palette,
Shield,
Users,
Database,
Mail,
FileText,
Server,
HardDrive,
Lock,
User,
ToggleLeft,
RefreshCw,
Download,
Upload,
Save,
CheckCircle,
AlertCircle
} from "lucide-react";
import { SiteOpsConfigType } from "@/types/site-config";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// 表单验证模式
const configFormSchema = z.object({
'site.name': z.string().min(2, "网站名称至少需要2个字符").max(50, "网站名称不能超过50个字符"),
'site.description': z.string().optional(),
'site.keywords': z.string().optional(),
'site.url': z.string()
.refine((url) => {
if (!url || url === "") return true; // 允许空值
return url.startsWith("http://") || url.startsWith("https://");
}, "无效的URL格式必须以http://或https://开头")
.optional().or(z.literal("")),
'site.logo': z.string().optional(),
'site.color_style': z.enum(["light", "dark"]),
'user.default_role': z.enum(["user", "vip", "admin"]),
'user.register_invite_code': z.boolean(),
'user.register_email_verification': z.boolean(),
'switch.open_register': z.boolean(),
'switch.open_comment': z.boolean(),
});
type ConfigFormValues = z.infer<typeof configFormSchema>;
// 配置管理表单组件
function DynamicAdminConfigForm({
data,
onSave,
onExport,
onImport,
loading = false
}: {
data?: SiteOpsConfigType;
onSave: (values: ConfigFormValues) => Promise<void>;
onExport?: () => Promise<void>;
onImport?: (file: File) => Promise<void>;
loading?: boolean;
}) {
const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string>('');
// 初始化表单
const form = useForm<ConfigFormValues>({
resolver: zodResolver(configFormSchema),
defaultValues: {
'site.name': data?.site?.info?.name || "MMAP System",
'site.description': "",
'site.keywords': "",
'site.url': "/",
'site.logo': data?.site?.brand?.logo_url || "/images/logo.png",
'site.color_style': (data?.site?.brand?.dark_mode_default ? 'dark' : 'light') as "light" | "dark",
'user.default_role': 'user',
'user.register_invite_code': data?.ops?.features?.invite_code_required ?? false,
'user.register_email_verification': data?.ops?.features?.email_verification ?? false,
'switch.open_register': data?.ops?.features?.registration_enabled ?? true,
'switch.open_comment': true,
}
});
// 处理表单提交
const onSubmit = async (values: ConfigFormValues) => {
setSaveStatus('loading');
setErrorMessage('');
try {
await onSave(values);
setSaveStatus('success');
toast.success("配置保存成功!");
setTimeout(() => setSaveStatus('idle'), 3000);
} catch (error) {
setSaveStatus('error');
setErrorMessage('保存失败,请重试');
toast.error("配置保存失败,请重试");
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* 头部 */}
<div className="bg-white border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600"></p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={() => window.location.reload()}>
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={onExport}>
<Download className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file && onImport) onImport(file);
};
input.click();
}}>
<Upload className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</div>
{/* 主要内容 */}
<div className="flex-1 px-6 py-6">
<div className="max-w-7xl mx-auto">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Tabs defaultValue="content" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="content"></TabsTrigger>
<TabsTrigger value="user"></TabsTrigger>
<TabsTrigger value="email"></TabsTrigger>
<TabsTrigger value="system"></TabsTrigger>
</TabsList>
{/* 内容配置标签页 */}
<TabsContent value="content" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
</CardTitle>
<p className="text-sm text-gray-500"></p>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="site.name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入网站名称" {...field} />
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="site.description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea placeholder="请输入网站描述" rows={3} {...field} />
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</TabsContent>
{/* 用户管理标签页 */}
<TabsContent value="user" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="user.default_role"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择默认角色" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user"></SelectItem>
<SelectItem value="vip">VIP用户</SelectItem>
<SelectItem value="admin"></SelectItem>
</SelectContent>
</Select>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="switch.open_register"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base"></FormLabel>
<FormDescription></FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
</TabsContent>
{/* 其他标签页内容可以继续添加 */}
<TabsContent value="email" className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500">...</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system" className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500">...</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 保存按钮和状态 */}
<div className="flex items-center justify-between pt-6 border-t">
<div className="flex items-center gap-2">
{saveStatus === 'loading' && (
<>
<RefreshCw className="h-4 w-4 animate-spin text-blue-500" />
<span className="text-blue-600">...</span>
</>
)}
{saveStatus === 'success' && (
<>
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-green-600"></span>
</>
)}
{saveStatus === 'error' && (
<>
<AlertCircle className="h-4 w-4 text-red-500" />
<span className="text-red-600">{errorMessage || '保存失败'}</span>
</>
)}
</div>
<Button
type="submit"
disabled={loading || saveStatus === 'loading'}
className="flex items-center gap-2"
>
{saveStatus === 'loading' ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</Form>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,7 @@
"use client";
import React, { useState } from "react";
import React from "react";
import { gql, useMutation } from "@apollo/client";
import { useForm } from "react-hook-form"
import { z } from "zod"
import {
Settings,
Globe,
@ -33,9 +31,6 @@ import { ConfigItemType } from "@/hooks/use-site-config";
import { UpdateConfig } from "@/types/config";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { zodResolver } from "@hookform/resolvers/zod";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
// GraphQL Mutation

View File

@ -170,88 +170,7 @@ export default function AdminPage() {
);
return (
<div>
{/* 状态信息栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* 保存状态 */}
{lastSaved && (
<Badge variant="outline" className="gap-1">
<CheckCircle className="h-3 w-3" />
: {lastSaved.toLocaleTimeString()}
</Badge>
)}
{/* 更新状态 */}
{updating && (
<Badge variant="outline" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
...
</Badge>
)}
</div>
{/* 验证状态 */}
<div className="flex items-center gap-2">
{validationLoading ? (
<Badge variant="outline" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
...
</Badge>
) : validation ? (
<Badge
variant={validation.valid ? "default" : "destructive"}
className="gap-1"
>
{validation.valid ? (
<CheckCircle className="h-3 w-3" />
) : (
<AlertCircle className="h-3 w-3" />
)}
{validation.valid ? '配置有效' : `${validation.errors.length} 个错误`}
</Badge>
) : null}
</div>
</div>
{validation && !validation.valid && (
<Card className="border-destructive bg-destructive/5">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<ul className="list-disc list-inside text-sm space-y-1">
{validation.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* 验证警告 */}
{validation && validation.warnings.length > 0 && (
<Card className="border-yellow-500/50 bg-yellow-50/50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-yellow-700 text-sm">
<AlertCircle className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<ul className="list-disc list-inside text-sm space-y-1">
{validation.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* 管理面板 */}
<AdminPanel
config={adminConfig}
initialValues={initialValuesFromConfigs}
@ -259,6 +178,5 @@ export default function AdminPage() {
hasPermission={hasPermission}
className="min-h-screen"
/>
</div>
);
}

View File

@ -1,4 +1,3 @@
import React, { act, useEffect } from "react";
import { AdminPanelConfig, TabConfig } from "@/types/admin-panel";
import { cn } from "@/lib/utils";
import { SiteHeader } from "../site-header";
@ -13,12 +12,10 @@ import {
TabsContent
} from "@/components/ui/tabs";
import { useAdminPanel } from "@/hooks/use-admin-panel";
import { AdminSection } from "@/components/admin";
import { configFormSchema, ConfigFormValues } from "@/types/config"
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AdminSection } from "@/components/admin/admin-section";
import { Form } from "@/components/ui/form";
import { toast } from "sonner";
import { useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
interface AdminPanelProps {
@ -48,7 +45,7 @@ export function AdminPanel({
!tab.permissions || tab.permissions.some(p => hasPermission(p))
);
const [activeTab, setActiveTab] = React.useState(() => {
const [activeTab, setActiveTab] = useState(() => {
// Find first accessible tab
const firstAccessibleTab = config.tabs.find(tab =>
!tab.disabled && (!tab.permissions || tab.permissions.some(p => hasPermission(p)))
@ -168,9 +165,10 @@ export function AdminPanel({
return (
<div className={cn("min-h-screen bg-background", className)}>
<div className={cn("bg-background h-full flex flex-col", className)}>
{renderBreadcrumbs()}
<ScrollArea className="flex-1 min-h-0">
<div className="flex items-center justify-between mt-8 px-8">
<div>
<h1 className="text-2xl font-bold text-foreground">
@ -252,6 +250,9 @@ export function AdminPanel({
</div>
</ScrollArea>
</div>
);

View File

@ -1,10 +1,435 @@
"use client"
import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor'
import * as React from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useQuery, useMutation, gql } from '@apollo/client'
import { EnhancedSimpleEditor } from '@/components/tiptap-templates/simple/enhanced-simple-editor'
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { IconLoader, IconDeviceFloppy, IconEye, IconArrowLeft } from "@tabler/icons-react"
import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "sonner"
const GET_BLOG = gql`
query GetBlog($id: UUID!) {
blog(id: $id) {
id
title
slug
excerpt
content
categoryId
status
featuredImage
metaTitle
metaDescription
publishedAt
viewCount
isFeatured
isActive
createdAt
updatedAt
}
}
`
const CREATE_BLOG = gql`
mutation CreateBlog($input: CreateBlogInput!) {
createBlog(input: $input) {
id
title
slug
status
}
}
`
const UPDATE_BLOG = gql`
mutation UpdateBlog($id: UUID!, $input: UpdateBlogInput!) {
updateBlog(id: $id, input: $input) {
id
title
slug
status
updatedAt
}
}
`
interface BlogData {
title: string
slug: string
excerpt: string
content: any
categoryId?: string
status: 'draft' | 'published' | 'archived'
featuredImage?: string
metaTitle?: string
metaDescription?: string
isFeatured: boolean
isActive: boolean
}
export default function EditorComponent() {
const router = useRouter()
const searchParams = useSearchParams()
const blogId = searchParams.get('id')
const isEditing = Boolean(blogId)
return <div className='flex-1 min-h-0 overflow-hidden'>
<SimpleEditor />
const [blogData, setBlogData] = React.useState<BlogData>({
title: '',
slug: '',
excerpt: '',
content: null,
status: 'draft',
isFeatured: false,
isActive: true
})
const [isLoading, setIsLoading] = React.useState(false)
const { data: blogQuery, loading: blogLoading } = useQuery(GET_BLOG, {
variables: { id: blogId },
skip: !isEditing,
onCompleted: (data) => {
if (data?.blog) {
setBlogData({
title: data.blog.title || '',
slug: data.blog.slug || '',
excerpt: data.blog.excerpt || '',
content: data.blog.content,
categoryId: data.blog.categoryId,
status: data.blog.status || 'draft',
featuredImage: data.blog.featuredImage,
metaTitle: data.blog.metaTitle || '',
metaDescription: data.blog.metaDescription || '',
isFeatured: data.blog.isFeatured || false,
isActive: data.blog.isActive ?? true
})
}
}
})
const [createBlog] = useMutation(CREATE_BLOG)
const [updateBlog] = useMutation(UPDATE_BLOG)
const generateSlug = React.useCallback((title: string) => {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
}, [])
React.useEffect(() => {
if (blogData.title && !isEditing) {
const slug = generateSlug(blogData.title)
setBlogData(prev => ({ ...prev, slug }))
}
}, [blogData.title, generateSlug, isEditing])
const handleSave = async (publishStatus?: 'draft' | 'published') => {
if (!blogData.title || !blogData.slug || !blogData.content) {
toast.error('请填写标题、链接和内容')
return
}
setIsLoading(true)
try {
const input = {
title: blogData.title,
slug: blogData.slug,
excerpt: blogData.excerpt || undefined,
content: blogData.content,
categoryId: blogData.categoryId || undefined,
status: publishStatus || blogData.status,
featuredImage: blogData.featuredImage || undefined,
metaTitle: blogData.metaTitle || undefined,
metaDescription: blogData.metaDescription || undefined,
isFeatured: blogData.isFeatured,
isActive: blogData.isActive
}
if (isEditing) {
await updateBlog({
variables: {
id: blogId,
input
}
})
toast.success('博客已更新')
} else {
const result = await createBlog({
variables: { input }
})
toast.success('博客已创建')
const newBlogId = result.data?.createBlog?.id
if (newBlogId) {
router.push(`/admin/editor?id=${newBlogId}`)
}
}
} catch (error) {
toast.error(isEditing ? '更新失败' : '创建失败')
console.error('Save error:', error)
} finally {
setIsLoading(false)
}
}
const handleContentChange = (content: any) => {
setBlogData(prev => ({ ...prev, content }))
}
if (blogLoading) {
return (
<div className="flex items-center justify-center h-full">
<IconLoader className="h-8 w-8 animate-spin" />
</div>
)
}
return (
<div className="flex h-full">
{/* Main Editor Area */}
<div className="flex-1 flex flex-col">
{/* Header Toolbar */}
<div className="border-b sticky top-0 z-10">
<div className="flex h-14 items-center justify-between px-6">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/admin/blogs')}
>
<IconArrowLeft className="h-4 w-4 mr-2" />
</Button>
<Badge variant={blogData.status === 'published' ? 'default' : 'secondary'}>
{blogData.status === 'draft' ? '草稿' :
blogData.status === 'published' ? '已发布' : '已归档'}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleSave('draft')}
disabled={isLoading}
>
<IconDeviceFloppy className="h-4 w-4 mr-2" />
稿
</Button>
<Button
size="sm"
onClick={() => handleSave('published')}
disabled={isLoading}
>
{isLoading ? (
<IconLoader className="h-4 w-4 mr-2 animate-spin" />
) : (
<IconEye className="h-4 w-4 mr-2" />
)}
{blogData.status === 'published' ? '更新' : '发布'}
</Button>
</div>
</div>
</div>
{/* Editor Content */}
<div className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto px-6 py-8">
{/* Title Input - WordPress Style */}
<div className="mb-6">
<input
type="text"
value={blogData.title}
onChange={(e) => setBlogData(prev => ({ ...prev, title: e.target.value }))}
placeholder="在此处输入标题"
className="w-full text-4xl font-bold border-none outline-none placeholder:text-gray-400 bg-transparent resize-none"
style={{ lineHeight: '1.2' }}
/>
</div>
{/* Slug Display (read-only for now) */}
{blogData.slug && (
<div className="mb-6 text-sm text-gray-500">
: <span className="text-blue-600">/blog/{blogData.slug}</span>
</div>
)}
{/* Editor */}
<div className="min-h-[600px]">
<EnhancedSimpleEditor
content={blogData.content}
onChange={handleContentChange}
/>
</div>
</div>
</div>
</div>
{/* Sidebar */}
<div className="w-80 border-l overflow-auto">
<div className="p-6 space-y-6">
{/* Publish Section */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="status" className="text-xs font-medium text-gray-600"></Label>
<Select
value={blogData.status}
onValueChange={(value: 'draft' | 'published' | 'archived') =>
setBlogData(prev => ({ ...prev, status: value }))
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="published"></SelectItem>
<SelectItem value="archived"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="isFeatured"
checked={blogData.isFeatured}
onCheckedChange={(checked) =>
setBlogData(prev => ({ ...prev, isFeatured: !!checked }))
}
/>
<Label htmlFor="isFeatured" className="text-sm"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="isActive"
checked={blogData.isActive}
onCheckedChange={(checked) =>
setBlogData(prev => ({ ...prev, isActive: !!checked }))
}
/>
<Label htmlFor="isActive" className="text-sm"></Label>
</div>
</div>
</CardContent>
</Card>
{/* Featured Image */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{blogData.featuredImage ? (
<div className="relative">
<img
src={blogData.featuredImage}
alt="特色图片"
className="w-full h-32 object-cover rounded-md"
/>
<Button
variant="outline"
size="sm"
onClick={() => setBlogData(prev => ({ ...prev, featuredImage: '' }))}
className="mt-2 w-full"
>
</Button>
</div>
) : (
<div className="border-2 border-dashed border-gray-300 rounded-md p-6 text-center">
<p className="text-sm text-gray-500 mb-3"></p>
<Input
placeholder="图片URL"
value={blogData.featuredImage || ''}
onChange={(e) => setBlogData(prev => ({ ...prev, featuredImage: e.target.value }))}
/>
</div>
)}
</div>
</CardContent>
</Card>
{/* Excerpt */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={blogData.excerpt}
onChange={(e) => setBlogData(prev => ({ ...prev, excerpt: e.target.value }))}
placeholder="输入摘要..."
rows={4}
className="resize-none"
/>
<p className="text-xs text-gray-500 mt-2"></p>
</CardContent>
</Card>
{/* SEO Settings */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">SEO </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="metaTitle" className="text-xs font-medium text-gray-600">Meta </Label>
<Input
id="metaTitle"
value={blogData.metaTitle || ''}
onChange={(e) => setBlogData(prev => ({ ...prev, metaTitle: e.target.value }))}
placeholder="SEO 标题"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="metaDescription" className="text-xs font-medium text-gray-600">Meta </Label>
<Textarea
id="metaDescription"
value={blogData.metaDescription || ''}
onChange={(e) => setBlogData(prev => ({ ...prev, metaDescription: e.target.value }))}
placeholder="SEO 描述"
rows={3}
className="mt-1 resize-none"
/>
</div>
<div>
<Label htmlFor="slug" className="text-xs font-medium text-gray-600">URL </Label>
<Input
id="slug"
value={blogData.slug}
onChange={(e) => setBlogData(prev => ({ ...prev, slug: e.target.value }))}
placeholder="url-slug"
className="mt-1"
/>
<p className="text-xs text-gray-500 mt-1">URL </p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@ -1,13 +1,11 @@
"use client"
import { SiteHeader } from "../site-header"
import EditorComponent from "./editor-component"
export default function Page() {
return <div className="flex flex-col h-[98vh]">
<SiteHeader breadcrumbs={[{ label: "Home", href: "/" }, { label: "Editor", href: "/admin/editor" }]} />
return (
<div className="h-screen">
<EditorComponent />
</div>
)
}

View File

@ -1,11 +1,13 @@
import { cookies } from "next/headers"
import { AppSidebar } from "./sidebar"
import { SiteHeader } from "./site-header"
import {
SidebarInset,
SidebarProvider,
} from "@/components/ui/sidebar"
import { redirect } from "next/navigation"
import { Metadata, ResolvingMetadata } from "next";
import { getSiteConfigs } from "@/lib/fetchers"
export default async function Layout({ children }: { children: React.ReactNode }) {
const isLoggedIn = (await cookies()).get('jwt')?.value
@ -24,8 +26,8 @@ export default async function Layout({ children }: { children: React.ReactNode }
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<div className="h-[100px]">
<SidebarInset className="overflow-hidden">
<div className="max-h-[100vh] h-[98vh] flex flex-col">
{children}
</div>
</SidebarInset>

View File

@ -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,

View File

@ -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() {

View File

@ -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 (
<>
<SiteHeader breadcrumbs={[{ label: "Home", href: "/" }, { label: "权限管理", href: "/admin/permissions" }]} />
<ScrollArea className="h-full">
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2 py-4">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="px-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<IconShield className="h-6 w-6" />
</h1>
<p className="text-muted-foreground mt-1">
</p>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 px-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<IconUsers className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">1,234</div>
<p className="text-xs text-muted-foreground">+20% </p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<IconShield className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">8</div>
<p className="text-xs text-muted-foreground">5</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<IconKey className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">45</div>
<p className="text-xs text-muted-foreground">12</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">访</CardTitle>
<IconSettings className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">2,847</div>
<p className="text-xs text-muted-foreground">访</p>
</CardContent>
</Card>
</div>
{/* 主要功能标签页 */}
<div className="px-6">
<Tabs defaultValue="roles" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="roles" className="flex items-center gap-2">
<IconShield className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="permissions" className="flex items-center gap-2">
<IconKey className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="user-roles" className="flex items-center gap-2">
<IconUsers className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="role-permissions" className="flex items-center gap-2">
<IconSettings className="h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="roles" className="mt-6">
<RoleTable />
</TabsContent>
<TabsContent value="permissions" className="mt-6">
<PermissionTable />
</TabsContent>
<TabsContent value="user-roles" className="mt-6">
<UserRoleManagement />
</TabsContent>
<TabsContent value="role-permissions" className="mt-6">
<RolePermissionManagement />
</TabsContent>
</Tabs>
</div>
</div>
</div>
</div>
</ScrollArea>
</>
)
}

View File

@ -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<typeof permissionSchema>
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<Permission>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "权限名称",
cell: ({ row }) => {
const permission = row.original
return (
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
<IconKey className="h-4 w-4 text-primary" />
</div>
<div>
<div className="font-medium">{permission.name}</div>
<div className="text-sm text-muted-foreground">{permission.code}</div>
</div>
</div>
)
},
},
{
accessorKey: "description",
header: "描述",
cell: ({ row }) => {
const description = row.getValue("description") as string
return (
<div className="max-w-xs">
<span className="text-sm text-muted-foreground line-clamp-2">
{description || "无描述"}
</span>
</div>
)
},
},
{
accessorKey: "module",
header: "模块",
cell: ({ row }) => {
const module = row.getValue("module") as string
return (
<Badge variant="outline" className="font-mono">
{module}
</Badge>
)
},
},
{
accessorKey: "action",
header: "操作",
cell: ({ row }) => {
const action = row.getValue("action") as string
return (
<Badge className={`${getActionColor(action)} border-0`}>
{action}
</Badge>
)
},
},
{
accessorKey: "resource",
header: "资源",
cell: ({ row }) => {
const resource = row.getValue("resource") as string
return (
<Badge variant="secondary" className="font-mono">
{resource}
</Badge>
)
},
},
{
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 (
<span className={`font-medium ${getLevelColor(level)}`}>
{level}
</span>
)
},
},
{
accessorKey: "roleCount",
header: "角色数",
cell: ({ row }) => {
const count = row.getValue("roleCount") as number
return (
<div className="text-center">
<Badge variant="outline">{count}</Badge>
</div>
)
},
},
{
accessorKey: "isActive",
header: "状态",
cell: ({ row }) => {
const isActive = row.getValue("isActive") as boolean
return (
<Badge variant={isActive ? "default" : "secondary"}>
{isActive ? "启用" : "禁用"}
</Badge>
)
},
},
{
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<IconDotsVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem>
<IconPencil className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem>
<IconShield className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
updatePermission({
variables: {
id: permission.id,
input: { isActive: !permission.isActive }
}
})
}}
>
{permission.isActive ? "禁用" : "启用"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onClick={() => {
if (confirm('确定要删除这个权限吗?此操作不可撤销。')) {
deletePermission({
variables: { id: permission.id }
})
}
}}
>
<IconTrash className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<IconPlus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
使
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="查看用户"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="code"> *</Label>
<Input
id="code"
value={formData.code}
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
placeholder="USER_READ"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="描述这个权限的作用和范围"
rows={2}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="module"> *</Label>
<Select
value={formData.module}
onValueChange={(value) => setFormData(prev => ({ ...prev, module: value }))}
>
<SelectTrigger>
<SelectValue placeholder="选择模块" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user"></SelectItem>
<SelectItem value="role"></SelectItem>
<SelectItem value="permission"></SelectItem>
<SelectItem value="content"></SelectItem>
<SelectItem value="analytics"></SelectItem>
<SelectItem value="system"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="action"> *</Label>
<Select
value={formData.action}
onValueChange={(value) => setFormData(prev => ({ ...prev, action: value }))}
>
<SelectTrigger>
<SelectValue placeholder="选择操作" />
</SelectTrigger>
<SelectContent>
<SelectItem value="create"></SelectItem>
<SelectItem value="read"></SelectItem>
<SelectItem value="update"></SelectItem>
<SelectItem value="delete"></SelectItem>
<SelectItem value="manage"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="resource"> *</Label>
<Input
id="resource"
value={formData.resource}
onChange={(e) => setFormData(prev => ({ ...prev, resource: e.target.value }))}
placeholder="users"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="level"></Label>
<Select
value={formData.level.toString()}
onValueChange={(value) => setFormData(prev => ({ ...prev, level: parseInt(value) }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 - </SelectItem>
<SelectItem value="30">30 - </SelectItem>
<SelectItem value="50">50 - </SelectItem>
<SelectItem value="70">70 - </SelectItem>
<SelectItem value="90">90 - </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex items-center space-x-2 pt-2">
<Checkbox
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isActive: !!checked }))}
/>
<Label htmlFor="isActive" className="text-sm font-normal">
</Label>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="submit" disabled={loading}>
{loading ? "创建中..." : "创建权限"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function PermissionTable() {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
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 <div className="text-red-500"></div>
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between py-4">
<div className="flex items-center space-x-2">
<Input
placeholder="搜索权限..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("name")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
<Select
value={(table.getColumn("module")?.getFilterValue() as string) ?? "all"}
onValueChange={(value) => table.getColumn("module")?.setFilterValue(value === "all" ? "" : value)}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="筛选模块" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="user"></SelectItem>
<SelectItem value="role"></SelectItem>
<SelectItem value="permission"></SelectItem>
<SelectItem value="content"></SelectItem>
<SelectItem value="analytics"></SelectItem>
<SelectItem value="system"></SelectItem>
</SelectContent>
</Select>
</div>
<CreatePermissionDialog onSuccess={refetch} />
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length}
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium"></p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
{table.getState().pagination.pageIndex + 1} {" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<IconChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<IconChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<IconChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<IconChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -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 (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-4">
<Select value={selectedModule || "all"} onValueChange={(value) => setSelectedModule(value === "all" ? "" : value)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="筛选模块" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{modules.map((module) => (
<SelectItem key={module} value={module}>
{module}
</SelectItem>
))}
</SelectContent>
</Select>
<Badge variant="outline">
{filteredPermissions.length}
</Badge>
</div>
<div className="rounded-md border overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[300px] sticky left-0 bg-background"></TableHead>
{roles.filter(r => r.isActive).map((role) => (
<TableHead key={role.id} className="text-center min-w-[120px]">
<div className="space-y-1">
<div className="font-medium">{role.name}</div>
<Badge variant="outline" className="text-xs">
Level {role.level}
</Badge>
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{filteredPermissions.map((permission) => (
<TableRow key={permission.id}>
<TableCell className="sticky left-0 bg-background">
<div className="space-y-1">
<div className="font-medium">{permission.name}</div>
<div className="text-sm text-muted-foreground">{permission.code}</div>
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-xs">
{permission.module}
</Badge>
<Badge className={`${getActionColor(permission.action)} border-0 text-xs`}>
{permission.action}
</Badge>
</div>
</div>
</TableCell>
{roles.filter(r => r.isActive).map((role) => {
const roleHasPermission = hasPermission(role, permission.id)
const canModify = permission.level <= role.level
return (
<TableCell key={role.id} className="text-center">
<Button
variant="ghost"
size="sm"
className={`h-8 w-8 p-0 ${
roleHasPermission
? "text-green-600 hover:text-green-700"
: "text-muted-foreground hover:text-foreground"
}`}
disabled={!canModify}
onClick={() => handlePermissionToggle(
role.id,
permission.id,
roleHasPermission
)}
>
{roleHasPermission ? (
<IconCheck className="h-4 w-4" />
) : (
<IconX className="h-4 w-4" />
)}
</Button>
</TableCell>
)
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
)
}
function RolePermissionEditor({ role, allPermissions, onSave }: {
role: Role
allPermissions: Permission[]
onSave: () => void
}) {
const [open, setOpen] = React.useState(false)
const [selectedPermissions, setSelectedPermissions] = React.useState<string[]>(
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<IconSettings className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>
- {role.name}
</DialogTitle>
<DialogDescription>
"{role.name}"
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-4">
<Input
placeholder="搜索权限..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1"
/>
<Select value={moduleFilter || "all"} onValueChange={(value) => setModuleFilter(value === "all" ? "" : value)}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="筛选模块" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{modules.map((module) => (
<SelectItem key={module} value={module}>
{module}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={handleSelectAll}>
{filteredPermissions.every(p => selectedPermissions.includes(p.id)) ? "取消全选" : "全选"}
</Button>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span> {selectedPermissions.length} </span>
<span> {filteredPermissions.length} </span>
</div>
<div className="border rounded-md max-h-96 overflow-auto">
<div className="space-y-0">
{filteredPermissions.map((permission) => (
<div
key={permission.id}
className="flex items-center space-x-3 p-3 border-b last:border-b-0 hover:bg-muted/50"
>
<Checkbox
checked={selectedPermissions.includes(permission.id)}
onCheckedChange={() => handlePermissionToggle(permission.id)}
/>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{permission.name}</span>
<Badge variant="outline" className="text-xs">
{permission.module}
</Badge>
<Badge className={`${getActionColor(permission.action)} border-0 text-xs`}>
{permission.action}
</Badge>
</div>
<div className="text-sm text-muted-foreground">
{permission.code} - {permission.description || "无描述"}
</div>
</div>
<div className="text-sm text-muted-foreground">
Level {permission.level}
</div>
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? "保存中..." : "保存权限"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
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 (
<div className="space-y-6">
{/* 角色概览卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{activeRoles.map((role) => (
<Card
key={role.id}
className={`cursor-pointer transition-colors ${
selectedRole === role.id ? 'ring-2 ring-primary' : 'hover:bg-muted/50'
}`}
onClick={() => setSelectedRole(selectedRole === role.id ? "" : role.id)}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconShield className="h-5 w-5 text-primary" />
<CardTitle className="text-base">{role.name}</CardTitle>
</div>
<Badge variant="outline">
Level {role.level}
</Badge>
</div>
<CardDescription>{role.code}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
: {role.permissions.length}
</div>
<RolePermissionEditor
role={role}
allPermissions={permissions}
onSave={refetchRoles}
/>
</div>
</CardContent>
</Card>
))}
</div>
{/* 选中角色的权限详情 */}
{currentRole && (
<Card>
<CardHeader>
<CardTitle>
{currentRole.name}
</CardTitle>
<CardDescription>
"{currentRole.name}"
</CardDescription>
</CardHeader>
<CardContent>
{currentRole.permissions.length === 0 ? (
<div className="text-center py-8">
<IconKey className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-2 text-sm font-semibold"></h3>
<p className="mt-1 text-sm text-muted-foreground">
</p>
<div className="mt-4">
<RolePermissionEditor
role={currentRole}
allPermissions={permissions}
onSave={refetchRoles}
/>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{currentRole.permissions.length}
</div>
<RolePermissionEditor
role={currentRole}
allPermissions={permissions}
onSave={refetchRoles}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{currentRole.permissions.map((permission) => (
<Card key={permission.id} className="p-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<IconKey className="h-4 w-4 text-primary" />
<span className="font-medium text-sm">{permission.name}</span>
</div>
<div className="text-xs text-muted-foreground">
{permission.code}
</div>
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-xs">
{permission.module}
</Badge>
<Badge className={`${getActionColor(permission.action)} border-0 text-xs`}>
{permission.action}
</Badge>
</div>
</div>
</Card>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* 权限矩阵 */}
{!rolesLoading && !permissionsLoading && (
<PermissionMatrix
roles={roles}
permissions={permissions}
onPermissionChange={refetchRoles}
/>
)}
</div>
)
}

View File

@ -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<typeof roleSchema>
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<Role>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "角色名称",
cell: ({ row }) => {
const role = row.original
return (
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
<IconShield className="h-4 w-4 text-primary" />
</div>
<div>
<div className="font-medium">{role.name}</div>
<div className="text-sm text-muted-foreground">{role.code}</div>
</div>
</div>
)
},
},
{
accessorKey: "description",
header: "描述",
cell: ({ row }) => {
const description = row.getValue("description") as string
return (
<div className="max-w-xs">
<span className="text-sm text-muted-foreground line-clamp-2">
{description || "无描述"}
</span>
</div>
)
},
},
{
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 (
<span className={`font-medium ${getLevelColor(level)}`}>
{level}
</span>
)
},
},
{
accessorKey: "userCount",
header: ({ column }) => (
<div className="flex items-center gap-1">
<IconUsers className="h-4 w-4" />
</div>
),
cell: ({ row }) => {
const count = row.getValue("userCount") as number
return (
<div className="text-center">
<Badge variant="outline">{count}</Badge>
</div>
)
},
},
{
accessorKey: "permissionCount",
header: ({ column }) => (
<div className="flex items-center gap-1">
<IconKey className="h-4 w-4" />
</div>
),
cell: ({ row }) => {
const count = row.getValue("permissionCount") as number
return (
<div className="text-center">
<Badge variant="outline">{count}</Badge>
</div>
)
},
},
{
accessorKey: "isActive",
header: "状态",
cell: ({ row }) => {
const isActive = row.getValue("isActive") as boolean
return (
<Badge variant={isActive ? "default" : "secondary"}>
{isActive ? "启用" : "禁用"}
</Badge>
)
},
},
{
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<IconDotsVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem>
<IconPencil className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem>
<IconKey className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem>
<IconUsers className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
updateRole({
variables: {
id: role.id,
input: { isActive: !role.isActive }
}
})
}}
>
{role.isActive ? "禁用" : "启用"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onClick={() => {
if (confirm('确定要删除这个角色吗?此操作不可撤销。')) {
deleteRole({
variables: { id: role.id }
})
}
}}
>
<IconTrash className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<IconPlus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="管理员"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="code"> *</Label>
<Input
id="code"
value={formData.code}
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
placeholder="ADMIN"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="描述这个角色的职责和权限范围"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="roleType"></Label>
<Select
value={formData.roleType}
onValueChange={(value) => setFormData(prev => ({ ...prev, roleType: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SYSTEM"></SelectItem>
<SelectItem value="CUSTOM"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="level"></Label>
<Select
value={formData.level.toString()}
onValueChange={(value) => setFormData(prev => ({ ...prev, level: parseInt(value) }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 - </SelectItem>
<SelectItem value="30">30 - </SelectItem>
<SelectItem value="50">50 - </SelectItem>
<SelectItem value="70">70 - </SelectItem>
<SelectItem value="90">90 - </SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex items-center space-x-2">
<Checkbox
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isActive: !!checked }))}
/>
<Label htmlFor="isActive" className="text-sm font-normal">
</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="submit" disabled={loading}>
{loading ? "创建中..." : "创建角色"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function RoleTable() {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
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 <div className="text-red-500"></div>
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between py-4">
<div className="flex items-center space-x-2">
<Input
placeholder="搜索角色..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("name")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
</div>
<CreateRoleDialog onSuccess={refetch} />
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length}
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium"></p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
{table.getState().pagination.pageIndex + 1} {" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<IconChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<IconChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<IconChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<IconChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -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 (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarFallback>{getInitials(user.username)}</AvatarFallback>
</Avatar>
<div className="flex-1">
<CardTitle className="text-base">{user.username}</CardTitle>
<CardDescription>{user.email}</CardDescription>
</div>
<Badge variant={user.isActivate ? "default" : "secondary"}>
{user.isActivate ? "活跃" : "非活跃"}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"></Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<IconPlus className="mr-1 h-3 w-3" />
<IconChevronDown className="ml-1 h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="end">
<div className="w-64">
{availableRoles.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">
</div>
) : (
<div className="p-1">
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
</div>
{availableRoles.map((role) => (
<button
key={role.id}
className="w-full flex items-center gap-2 px-2 py-2 text-left hover:bg-accent hover:text-accent-foreground rounded-sm"
onClick={() => {
handleAssignRole(role.id)
setOpen(false)
}}
>
<IconShield className="h-4 w-4" />
<div className="flex-1">
<div className="font-medium text-sm">{role.name}</div>
<div className="text-xs text-muted-foreground">{role.code}</div>
</div>
</button>
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
{user.groups.length === 0 ? (
<div className="text-sm text-muted-foreground py-2 text-center">
</div>
) : (
user.groups.map((groupCode) => {
const role = allRoles.find((r: Role) => r.code.toLowerCase() === groupCode.toLowerCase())
return (
<div
key={groupCode}
className="flex items-center justify-between p-2 border rounded-lg bg-muted/30"
>
<div className="flex items-center gap-2">
<IconShield className="h-4 w-4 text-primary" />
<div>
<div className="font-medium text-sm">
{role?.name || groupCode}
</div>
<div className="text-xs text-muted-foreground">{groupCode}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{role ? `Level ${role.level}` : "角色"}
</Badge>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-600"
onClick={() => handleRemoveRole(groupCode)}
>
<IconMinus className="h-3 w-3" />
</Button>
</div>
</div>
)
})
)}
</div>
</div>
</CardContent>
</Card>
)
}
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={selectedUsers.length === 0}>
<IconUserCheck className="mr-2 h-4 w-4" />
({selectedUsers.length})
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{selectedUsers.length}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Select value={selectedRole} onValueChange={setSelectedRole}>
<SelectTrigger>
<SelectValue placeholder="选择要分配的角色" />
</SelectTrigger>
<SelectContent>
{allRoles.filter(role => role.isActive).map((role) => (
<SelectItem key={role.id} value={role.id}>
<div className="flex items-center gap-2">
<IconShield className="h-4 w-4" />
{role.name} ({role.code})
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<div className="max-h-32 overflow-y-auto space-y-1">
{selectedUsers.map((user) => (
<div key={user.id} className="flex items-center gap-2 text-sm">
<Avatar className="h-6 w-6">
<AvatarFallback className="text-xs">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{user.username} ({user.email})
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button onClick={handleBatchAssign} disabled={loading || !selectedRole}>
{loading ? "分配中..." : "确认分配"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function UserRoleManagement() {
const [searchTerm, setSearchTerm] = React.useState("")
const [roleFilter, setRoleFilter] = React.useState("")
const [selectedUsers, setSelectedUsers] = React.useState<User[]>([])
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 (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{roleFilter && ` • 筛选角色: ${allRoles.find((r: Role) => r.id === roleFilter)?.name}`}
{searchTerm && ` • 搜索: "${searchTerm}"`}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4 mb-6">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="搜索用户..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="max-w-sm"
/>
</div>
<Select value={roleFilter || "all"} onValueChange={(value) => setRoleFilter(value === "all" ? "" : value)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="筛选角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{allRoles.map((role: Role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
{(searchTerm || roleFilter) && (
<Button
variant="outline"
size="sm"
onClick={() => {
setSearchTerm("")
setRoleFilter("")
}}
>
</Button>
)}
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedUsers.length === filteredUsers.length && filteredUsers.length > 0}
onCheckedChange={(checked) => handleSelectAll(!!checked)}
/>
<Label className="text-sm">
({selectedUsers.length}/{filteredUsers.length})
</Label>
</div>
<BatchAssignDialog
selectedUsers={selectedUsers}
allRoles={allRoles}
onSuccess={() => {
refetchUsers()
setSelectedUsers([])
}}
/>
</div>
</div>
{usersLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<Card key={i} className="animate-pulse">
<CardHeader>
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-muted" />
<div className="space-y-2 flex-1">
<div className="h-4 bg-muted rounded w-2/3" />
<div className="h-3 bg-muted rounded w-1/2" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="h-3 bg-muted rounded w-1/3" />
<div className="h-8 bg-muted rounded" />
</div>
</CardContent>
</Card>
))}
</div>
) : filteredUsers.length === 0 ? (
<div className="text-center py-8">
<IconUsers className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-2 text-sm font-semibold text-gray-900"></h3>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredUsers.map((user) => (
<div key={user.id} className="relative">
<div className="absolute top-3 left-3 z-10">
<Checkbox
checked={selectedUsers.some(u => u.id === user.id)}
onCheckedChange={(checked) => handleUserSelect(user, !!checked)}
/>
</div>
<UserRoleCard
user={user}
allRoles={allRoles}
onRoleChange={refetchUsers}
/>
</div>
))}
</div>
)}
{totalUsers >= pagination.limit && (
<div className="flex items-center justify-center gap-2 mt-6">
<Button
variant="outline"
size="sm"
onClick={() => setPagination(prev => ({ ...prev, offset: Math.max(0, prev.offset - prev.limit) }))}
disabled={pagination.offset === 0}
>
</Button>
<span className="text-sm text-muted-foreground">
{pagination.offset + 1} - {pagination.offset + users.length}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPagination(prev => ({ ...prev, offset: prev.offset + prev.limit }))}
disabled={users.length < pagination.limit}
>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -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",

View File

@ -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<string | null>(null);
const [createTag] = useMutation(CREATE_TAG)
const form = useForm<z.infer<typeof schema>>({
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<typeof schema>) {
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 (
<div className="max-w-2xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Form {...form}>
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
{error && (
<div className="text-sm text-red-600 bg-red-50 p-3 rounded-md">
{error}
</div>
)}
<div className="grid gap-4">
<div className="grid gap-3">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input placeholder="请输入标签名称" {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input placeholder="标签别名" {...field} required />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
placeholder="标签描述(可选)"
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="flex items-center gap-3">
<Input
type="color"
className="w-16 h-10"
{...field}
/>
<Input
placeholder="#3b82f6"
value={field.value || ""}
onChange={field.onChange}
className="flex-1"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-3">
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
className="h-4 w-4"
/>
</FormControl>
<FormLabel className="text-sm font-normal"></FormLabel>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => window.history.back()}
>
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"创建标签"
)}
</Button>
</div>
</form>
</Form>
</div>
)
}

View File

@ -0,0 +1,11 @@
"use client"
import CreateTagForm from "../create-tag-form"
export default function Page() {
return (
<div className="min-h-screen bg-gray-50">
<CreateTagForm />
</div>
)
}

20
app/admin/tags/page.tsx Normal file
View File

@ -0,0 +1,20 @@
"use client"
import { TagTable } from "./tag-table"
import { SiteHeader } from "../site-header"
export default function Page() {
return (
<>
<SiteHeader breadcrumbs={[{ label: "Home", href: "/" }, { label: "Tags", href: "/admin/tags" }]} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<h1 className="text-2xl font-bold px-6"></h1>
<TagTable />
</div>
</div>
</div>
</>
)
}

View File

@ -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 (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="text-muted-foreground size-7 hover:bg-transparent"
>
<IconGripVertical className="text-muted-foreground size-3" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof tagSchema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "标签名称",
cell: ({ row }) => {
return <TagCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "slug",
header: "别名",
cell: ({ row }) => (
<div className="w-32">
<span className="text-sm font-mono text-muted-foreground">
{row.original.slug}
</span>
</div>
),
},
{
accessorKey: "blogCount",
header: "文章数",
cell: ({ row }) => (
<div className="w-20">
<Badge variant="secondary" className="text-xs">
{row.original.blogCount || 0}
</Badge>
</div>
),
},
{
accessorKey: "isActive",
header: "状态",
cell: ({ row }) => (
<div className="w-20">
<Badge variant={row.original.isActive ? "default" : "secondary"} className="text-xs">
{row.original.isActive ? "启用" : "禁用"}
</Badge>
</div>
),
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="px-0 hover:bg-transparent"
>
<IconChevronDown
className={`ml-2 h-4 w-4 transition-transform ${column.getIsSorted() === "asc" ? "rotate-180" : ""
}`}
/>
</Button>
)
},
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 (
<div className="w-40">
<div className="flex flex-row gap-2">
<span className="text-sm font-medium">
{date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
<Badge variant="secondary" className="text-xs w-fit">
{timeAgo}
</Badge>
</div>
</div>
);
},
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-42">
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
updateTagMutation({
variables: {
id: row.original.id,
input: { isActive: !row.original.isActive }
}
})
}}
>
{row.original.isActive ? "禁用" : "启用"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => {
if (confirm('确定要删除这个标签吗?此操作不可撤销。')) {
deleteTagMutation({
variables: {
id: row.original.id
}
})
}
}}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof tagSchema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
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<SortingState>([])
const [filter, setFilter] = React.useState<string>()
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 && <div className="text-red-500">{typeof error === 'string' ? error : 'An error occurred'}</div>}
<TagTabs
tags={tags}
loading={loading}
info={tagInfo}
handleDragEnd={handleDragEnd}
refetch={refetch}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
filter={filter}
setFilter={setFilter}
totalCount={totalCount}
updateTag={updateTag}
deleteTag={deleteTag}
/>
</>
)
}
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<React.SetStateAction<{ pageIndex: number; pageSize: number }>>
sorting: SortingState
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
filter?: string
setFilter: React.Dispatch<React.SetStateAction<string | undefined>>
totalCount: number
updateTag: any
deleteTag: any
}) {
return (
<div>
<Tabs
defaultValue="all_tags"
className="w-full flex-col justify-start gap-6"
>
{/* Tag Stats Section */}
{info && (
<div className="px-4 lg:px-6 py-4 bg-muted/50 rounded-lg mx-4 lg:mx-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-blue-600">{info.totalTags}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-green-600">{info.activeTags}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-gray-600">{info.inactiveTags}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-purple-600">{info.totalBlogs}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="all_tags">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="选择视图" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all_tags"></SelectItem>
<SelectItem value="active_tags"></SelectItem>
<SelectItem value="inactive_tags"></SelectItem>
</SelectContent>
</Select>
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="all_tags"></TabsTrigger>
<TabsTrigger value="active_tags">
<Badge variant="secondary">{info?.activeTags}</Badge>
</TabsTrigger>
<TabsTrigger value="inactive_tags">
<Badge variant="secondary">{info?.inactiveTags}</Badge>
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.location.href = '/admin/tags/create'}
>
<IconPlus />
<span className="hidden lg:inline"></span>
</Button>
</div>
</div>
<TabsContent value="all_tags" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<TagDataTable
data={tags}
isLoading={loading}
handleDragEnd={handleDragEnd}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
totalCount={totalCount}
onFilterChange={() => setFilter(undefined)}
updateTag={updateTag}
deleteTag={deleteTag}
/>
</TabsContent>
<TabsContent value="active_tags" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<TagDataTable
data={filter === "active" ? tags : []}
isLoading={loading && filter === "active"}
handleDragEnd={handleDragEnd}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
totalCount={filter === "active" ? totalCount : 0}
onFilterChange={() => setFilter("active")}
updateTag={updateTag}
deleteTag={deleteTag}
/>
</TabsContent>
<TabsContent value="inactive_tags" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
<TagDataTable
data={filter === "inactive" ? tags : []}
isLoading={loading && filter === "inactive"}
handleDragEnd={handleDragEnd}
pagination={pagination}
setPagination={setPagination}
sorting={sorting}
setSorting={setSorting}
totalCount={filter === "inactive" ? totalCount : 0}
onFilterChange={() => setFilter("inactive")}
updateTag={updateTag}
deleteTag={deleteTag}
/>
</TabsContent>
</Tabs>
</div>
)
}
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<React.SetStateAction<{ pageIndex: number; pageSize: number }>>
sorting: SortingState
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
totalCount: number
onFilterChange: () => void
updateTag: any
deleteTag: any
}) {
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
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 (
<>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleLocalDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center">
...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
<SortableContext
items={data?.map((tag: any) => tag.id) || []}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length}
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
{table.getState().pagination.pageIndex + 1} {table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</>
)
}
function TagCellViewer({ item }: { item: z.infer<typeof tagSchema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<div className="flex flex-col gap-1">
<Button variant="link" className="text-foreground w-fit px-0 text-left justify-start">
<div className="flex items-center gap-2">
{item.color ? (
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
) : (
<IconTag className="w-3 h-3" />
)}
{item.name}
</div>
</Button>
{item.description && (
<span className="text-xs text-muted-foreground line-clamp-2 max-w-60">
{item.description}
</span>
)}
</div>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.name}</DrawerTitle>
<DrawerDescription>
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="name"></Label>
<Input id="name" defaultValue={item.name} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="slug"></Label>
<Input id="slug" defaultValue={item.slug} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="description"></Label>
<Input id="description" defaultValue={item.description} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="color"></Label>
<Input id="color" defaultValue={item.color} type="color" />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status"></Label>
<Select defaultValue={item.isActive ? "true" : "false"}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="选择状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true"></SelectItem>
<SelectItem value="false"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</form>
</div>
<DrawerFooter>
<Button>
</Button>
<DrawerClose asChild>
<Button variant="outline"></Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View File

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

View File

@ -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<{

View File

@ -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 (
<div className="flex flex-row h-full">

View File

@ -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({
)}
/>
// <div
// key={field.id}
// className={cn(
// "space-y-2",
// field.grid?.span && `col-span-${field.grid.span}`,
// field.grid?.offset && `col-start-${field.grid.offset + 1}`
// )}
// >
// <div className="flex items-center justify-between">
// <div className="space-y-1">
// <Label
// htmlFor={field.id}
// className={cn(
// "text-sm font-medium",
// field.validation?.required && "after:content-['*'] after:ml-0.5 after:text-destructive"
// )}
// >
// {field.label}
// </Label>
// {field.description && (
// <p className="text-xs text-muted-foreground">
// {field.description}
// </p>
// )}
// </div>
// {field.type === "switch" && (
// <FieldRenderer
// field={field}
// value={value}
// error={error}
// disabled={fieldDisabled}
// onChange={(newValue) => onChange(field.id, newValue)}
// onBlur={() => onBlur?.(field.id)}
// />
// )}
// </div>
// {field.type !== "switch" && (
// <FieldRenderer
// field={field}
// value={value}
// error={error}
// disabled={fieldDisabled}
// onChange={(newValue) => onChange(field.id, newValue)}
// onBlur={() => onBlur?.(field.id)}
// />
// )}
// </div>
);
};
@ -172,7 +124,6 @@ export function AdminSection({
<CardHeader>
<div className="flex items-center space-x-3">
{section.icon}
<div>
<CardTitle className="text-lg">{section.title}</CardTitle>
{section.description && (
<CardDescription className="mt-1">
@ -180,7 +131,6 @@ export function AdminSection({
</CardDescription>
)}
</div>
</div>
</CardHeader>
{content}
</Card>

View File

@ -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";

View File

@ -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 (
<>
<Spacer />
<ToolbarGroup>
<UndoRedoButton action="undo" />
<UndoRedoButton action="redo" />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<HeadingDropdownMenu levels={[1, 2, 3, 4]} portal={isMobile} />
<ListDropdownMenu
types={["bulletList", "orderedList", "taskList"]}
portal={isMobile}
/>
<BlockquoteButton />
<CodeBlockButton />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<MarkButton type="bold" />
<MarkButton type="italic" />
<MarkButton type="strike" />
<MarkButton type="code" />
<MarkButton type="underline" />
{!isMobile ? (
<ColorHighlightPopover />
) : (
<ColorHighlightPopoverButton onClick={onHighlighterClick} />
)}
{!isMobile ? <LinkPopover /> : <LinkButton onClick={onLinkClick} />}
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<MarkButton type="superscript" />
<MarkButton type="subscript" />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<TextAlignButton align="left" />
<TextAlignButton align="center" />
<TextAlignButton align="right" />
<TextAlignButton align="justify" />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<ImageUploadButton text="Add" />
</ToolbarGroup>
<Spacer />
{isMobile && <ToolbarSeparator />}
<ToolbarGroup>
<ThemeToggle />
</ToolbarGroup>
</>
)
}
const MobileToolbarContent = ({
type,
onBack,
}: {
type: "highlighter" | "link"
onBack: () => void
}) => (
<>
<ToolbarGroup>
<Button data-style="ghost" onClick={onBack}>
<ArrowLeftIcon className="tiptap-button-icon" />
{type === "highlighter" ? (
<HighlighterIcon className="tiptap-button-icon" />
) : (
<LinkIcon className="tiptap-button-icon" />
)}
</Button>
</ToolbarGroup>
<ToolbarSeparator />
{type === "highlighter" ? (
<ColorHighlightPopoverContent />
) : (
<LinkContent />
)}
</>
)
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<HTMLDivElement>(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 (
<div className="simple-editor-wrapper">
<EditorContext.Provider value={{ editor }}>
<Toolbar
ref={toolbarRef}
style={{
...(isMobile
? {
bottom: `calc(100% - ${height - rect.y}px)`,
}
: {}),
}}
>
{mobileView === "main" ? (
<MainToolbarContent
onHighlighterClick={() => setMobileView("highlighter")}
onLinkClick={() => setMobileView("link")}
isMobile={isMobile}
/>
) : (
<MobileToolbarContent
type={mobileView === "highlighter" ? "highlighter" : "link"}
onBack={() => setMobileView("main")}
/>
)}
</Toolbar>
<EditorContent
editor={editor}
role="presentation"
className="simple-editor-content"
/>
</EditorContext.Provider>
</div>
)
}

155
components/ui/command.tsx Normal file
View File

@ -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<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -61,3 +61,10 @@ export async function fetchPage(slug: string, jwt?: string): Promise<PageData |
return null;
}
}
export async function getSiteConfigs() {
const baseUrl = process.env.GRAPHQL_BACKEND_URL || 'http://localhost:3000';
const siteConfigs = await fetch(`${baseUrl}/api/site`);
const data = await siteConfigs.json();
return data;
}

28
package-lock.json generated
View File

@ -52,6 +52,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",
@ -4227,6 +4228,22 @@
"node": ">=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",

View File

@ -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",