update admin page
This commit is contained in:
parent
4bafe4d601
commit
23905d33fd
30
CLAUDE.md
30
CLAUDE.md
@ -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.
|
||||
1166
app/admin/blogs/blog-table.tsx
Normal file
1166
app/admin/blogs/blog-table.tsx
Normal file
File diff suppressed because it is too large
Load Diff
336
app/admin/blogs/create-blog-form.tsx
Normal file
336
app/admin/blogs/create-blog-form.tsx
Normal 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
20
app/admin/blogs/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
944
app/admin/categories/category-table.tsx
Normal file
944
app/admin/categories/category-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
app/admin/categories/create-category-form.tsx
Normal file
255
app/admin/categories/create-category-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
app/admin/categories/create/page.tsx
Normal file
11
app/admin/categories/create/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
app/admin/categories/page.tsx
Normal file
20
app/admin/categories/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -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
@ -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
|
||||
@ -43,7 +38,7 @@ const UPDATE_CONFIG_BATCH = gql`
|
||||
mutation UpdateConfigBatch($input: [UpdateConfig!]!) {
|
||||
updateConfigBatch(input: $input)
|
||||
}
|
||||
`;
|
||||
`;
|
||||
|
||||
// 创建基于后端数据的动态管理面板配置
|
||||
export function createDynamicAdminConfig(
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() {
|
||||
|
||||
|
||||
123
app/admin/permissions/page.tsx
Normal file
123
app/admin/permissions/page.tsx
Normal 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>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
773
app/admin/permissions/permission-table.tsx
Normal file
773
app/admin/permissions/permission-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
571
app/admin/permissions/role-permission-management.tsx
Normal file
571
app/admin/permissions/role-permission-management.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
688
app/admin/permissions/role-table.tsx
Normal file
688
app/admin/permissions/role-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
586
app/admin/permissions/user-role-management.tsx
Normal file
586
app/admin/permissions/user-role-management.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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",
|
||||
|
||||
237
app/admin/tags/create-tag-form.tsx
Normal file
237
app/admin/tags/create-tag-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
app/admin/tags/create/page.tsx
Normal file
11
app/admin/tags/create/page.tsx
Normal 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
20
app/admin/tags/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
942
app/admin/tags/tag-table.tsx
Normal file
942
app/admin/tags/tag-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
app/api/casbin/roles/route.ts
Normal file
96
app/api/casbin/roles/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<{
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
302
components/tiptap-templates/simple/enhanced-simple-editor.tsx
Normal file
302
components/tiptap-templates/simple/enhanced-simple-editor.tsx
Normal 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
155
components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
@ -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
28
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user