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)
|
- **Production server**: `npm run start` - Starts production server (requires build first)
|
||||||
- **Lint**: `npm run lint` - Runs Next.js ESLint checks
|
- **Lint**: `npm run lint` - Runs Next.js ESLint checks
|
||||||
|
|
||||||
|
**Note**: No test framework is currently configured in this project.
|
||||||
|
|
||||||
## High-Level Architecture
|
## 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.
|
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/`
|
- shadcn/ui component library in `components/ui/`
|
||||||
- Sidebar layout using `components/ui/sidebar.tsx`
|
- 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
|
### Key Technical Details
|
||||||
|
|
||||||
**WebGL Timeline**:
|
**WebGL Timeline**:
|
||||||
@ -55,17 +68,26 @@ This is a Next.js 15 application built with React 19 that creates an interactive
|
|||||||
|
|
||||||
**Build Configuration**:
|
**Build Configuration**:
|
||||||
- Next.js 15 with App Router
|
- 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
|
- TypeScript with strict mode enabled
|
||||||
- Absolute imports using `@/*` path mapping
|
- 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
|
### File Structure Notes
|
||||||
|
|
||||||
- `app/` - Next.js App Router pages and components
|
- `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
|
- `components/` - Reusable React components
|
||||||
- `hooks/` - Custom React hooks
|
- `components/ui/` - shadcn/ui component library
|
||||||
- `lib/` - Utility functions
|
- `hooks/` - Custom React hooks for map, timeline, and mobile detection
|
||||||
|
- `lib/` - Utility functions including GraphQL clients and data fetchers
|
||||||
- `types/` - TypeScript type definitions
|
- `types/` - TypeScript type definitions
|
||||||
- `public/` - Static assets
|
- `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";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { gql, useMutation } from "@apollo/client";
|
import { gql, useMutation } from "@apollo/client";
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
Globe,
|
Globe,
|
||||||
@ -33,9 +31,6 @@ import { ConfigItemType } from "@/hooks/use-site-config";
|
|||||||
import { UpdateConfig } from "@/types/config";
|
import { UpdateConfig } from "@/types/config";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
|
|
||||||
// GraphQL Mutation
|
// GraphQL Mutation
|
||||||
@ -43,7 +38,7 @@ const UPDATE_CONFIG_BATCH = gql`
|
|||||||
mutation UpdateConfigBatch($input: [UpdateConfig!]!) {
|
mutation UpdateConfigBatch($input: [UpdateConfig!]!) {
|
||||||
updateConfigBatch(input: $input)
|
updateConfigBatch(input: $input)
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 创建基于后端数据的动态管理面板配置
|
// 创建基于后端数据的动态管理面板配置
|
||||||
export function createDynamicAdminConfig(
|
export function createDynamicAdminConfig(
|
||||||
|
|||||||
@ -170,88 +170,7 @@ export default function AdminPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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
|
<AdminPanel
|
||||||
config={adminConfig}
|
config={adminConfig}
|
||||||
initialValues={initialValuesFromConfigs}
|
initialValues={initialValuesFromConfigs}
|
||||||
@ -259,6 +178,5 @@ export default function AdminPage() {
|
|||||||
hasPermission={hasPermission}
|
hasPermission={hasPermission}
|
||||||
className="min-h-screen"
|
className="min-h-screen"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import React, { act, useEffect } from "react";
|
|
||||||
import { AdminPanelConfig, TabConfig } from "@/types/admin-panel";
|
import { AdminPanelConfig, TabConfig } from "@/types/admin-panel";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SiteHeader } from "../site-header";
|
import { SiteHeader } from "../site-header";
|
||||||
@ -13,12 +12,10 @@ import {
|
|||||||
TabsContent
|
TabsContent
|
||||||
} from "@/components/ui/tabs";
|
} from "@/components/ui/tabs";
|
||||||
import { useAdminPanel } from "@/hooks/use-admin-panel";
|
import { useAdminPanel } from "@/hooks/use-admin-panel";
|
||||||
import { AdminSection } from "@/components/admin";
|
import { AdminSection } from "@/components/admin/admin-section";
|
||||||
import { configFormSchema, ConfigFormValues } from "@/types/config"
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { Form } from "@/components/ui/form";
|
import { Form } from "@/components/ui/form";
|
||||||
import { toast } from "sonner";
|
import { useState } from "react";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
|
|
||||||
interface AdminPanelProps {
|
interface AdminPanelProps {
|
||||||
@ -48,7 +45,7 @@ export function AdminPanel({
|
|||||||
!tab.permissions || tab.permissions.some(p => hasPermission(p))
|
!tab.permissions || tab.permissions.some(p => hasPermission(p))
|
||||||
);
|
);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = React.useState(() => {
|
const [activeTab, setActiveTab] = useState(() => {
|
||||||
// Find first accessible tab
|
// Find first accessible tab
|
||||||
const firstAccessibleTab = config.tabs.find(tab =>
|
const firstAccessibleTab = config.tabs.find(tab =>
|
||||||
!tab.disabled && (!tab.permissions || tab.permissions.some(p => hasPermission(p)))
|
!tab.disabled && (!tab.permissions || tab.permissions.some(p => hasPermission(p)))
|
||||||
@ -168,9 +165,10 @@ export function AdminPanel({
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("min-h-screen bg-background", className)}>
|
<div className={cn("bg-background h-full flex flex-col", className)}>
|
||||||
{renderBreadcrumbs()}
|
{renderBreadcrumbs()}
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
<div className="flex items-center justify-between mt-8 px-8">
|
<div className="flex items-center justify-between mt-8 px-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
<h1 className="text-2xl font-bold text-foreground">
|
||||||
@ -252,6 +250,9 @@ export function AdminPanel({
|
|||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,435 @@
|
|||||||
"use client"
|
"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() {
|
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'>
|
const [blogData, setBlogData] = React.useState<BlogData>({
|
||||||
<SimpleEditor />
|
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>
|
||||||
|
|
||||||
|
<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"
|
"use client"
|
||||||
import { SiteHeader } from "../site-header"
|
|
||||||
import EditorComponent from "./editor-component"
|
import EditorComponent from "./editor-component"
|
||||||
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen">
|
||||||
return <div className="flex flex-col h-[98vh]">
|
|
||||||
<SiteHeader breadcrumbs={[{ label: "Home", href: "/" }, { label: "Editor", href: "/admin/editor" }]} />
|
|
||||||
<EditorComponent />
|
<EditorComponent />
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@ -1,11 +1,13 @@
|
|||||||
import { cookies } from "next/headers"
|
import { cookies } from "next/headers"
|
||||||
import { AppSidebar } from "./sidebar"
|
import { AppSidebar } from "./sidebar"
|
||||||
import { SiteHeader } from "./site-header"
|
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
import { Metadata, ResolvingMetadata } from "next";
|
||||||
|
import { getSiteConfigs } from "@/lib/fetchers"
|
||||||
|
|
||||||
|
|
||||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const isLoggedIn = (await cookies()).get('jwt')?.value
|
const isLoggedIn = (await cookies()).get('jwt')?.value
|
||||||
@ -24,8 +26,8 @@ export default async function Layout({ children }: { children: React.ReactNode }
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppSidebar variant="inset" />
|
<AppSidebar variant="inset" />
|
||||||
<SidebarInset>
|
<SidebarInset className="overflow-hidden">
|
||||||
<div className="h-[100px]">
|
<div className="max-h-[100vh] h-[98vh] flex flex-col">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|||||||
@ -7,11 +7,13 @@ import {
|
|||||||
IconCamera,
|
IconCamera,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconFileAi,
|
IconFileAi,
|
||||||
|
IconFileText,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconHelp,
|
IconHelp,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconDatabase,
|
IconDatabase,
|
||||||
IconReport
|
IconReport,
|
||||||
|
IconShield
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
@ -26,9 +28,11 @@ const iconMap = {
|
|||||||
dashboard: IconDashboard,
|
dashboard: IconDashboard,
|
||||||
chartBar: IconChartBar,
|
chartBar: IconChartBar,
|
||||||
users: IconUsers,
|
users: IconUsers,
|
||||||
|
shield: IconShield,
|
||||||
camera: IconCamera,
|
camera: IconCamera,
|
||||||
fileDescription: IconFileDescription,
|
fileDescription: IconFileDescription,
|
||||||
fileAi: IconFileAi,
|
fileAi: IconFileAi,
|
||||||
|
fileText: IconFileText,
|
||||||
settings: IconSettings,
|
settings: IconSettings,
|
||||||
help: IconHelp,
|
help: IconHelp,
|
||||||
search: IconSearch,
|
search: IconSearch,
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { redirect } from "next/navigation"
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useUser } from "../user-context";
|
import { useUser } from "../user-context";
|
||||||
|
import { Metadata, ResolvingMetadata } from "next";
|
||||||
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
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,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
|
IconFileText,
|
||||||
|
IconShield,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
|
|
||||||
import { NavDocuments } from "./nav-documents"
|
import { NavDocuments } from "./nav-documents"
|
||||||
@ -53,6 +55,16 @@ const data = {
|
|||||||
url: "/admin/users",
|
url: "/admin/users",
|
||||||
iconName: "users",
|
iconName: "users",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Permissions",
|
||||||
|
url: "/admin/permissions",
|
||||||
|
iconName: "shield",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Blogs",
|
||||||
|
url: "/admin/blogs",
|
||||||
|
iconName: "fileText",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
url: "/admin/common",
|
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 "./globals.css";
|
||||||
import { ClientProviders } from "./client-provider";
|
import { ClientProviders } from "./client-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
|
import { ResolvingMetadata } from "next";
|
||||||
|
import { getSiteConfigs } from "@/lib/fetchers";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -20,7 +22,6 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|||||||
@ -4,18 +4,14 @@ import { MapComponent } from '@/components/map-component';
|
|||||||
import { Timeline } from '@/app/tl';
|
import { Timeline } from '@/app/tl';
|
||||||
import { WSProvider } from './ws-context'
|
import { WSProvider } from './ws-context'
|
||||||
import StatusBar from './status-bar'
|
import StatusBar from './status-bar'
|
||||||
|
import { getSiteConfigs } from '@/lib/fetchers';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
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(
|
export async function generateMetadata(
|
||||||
{ params, searchParams }: Props,
|
{ params, searchParams }: Props,
|
||||||
@ -29,7 +25,6 @@ export async function generateMetadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row h-full">
|
<div className="flex flex-row h-full">
|
||||||
|
|||||||
@ -37,14 +37,14 @@ export function AdminSection({
|
|||||||
// Filter fields based on conditional rendering
|
// Filter fields based on conditional rendering
|
||||||
const visibleFields = section.fields.filter(field => {
|
const visibleFields = section.fields.filter(field => {
|
||||||
if (field.showWhen) {
|
if (field.showWhen) {
|
||||||
return field.showWhen(values);
|
return field.showWhen(values ?? {});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get field value helper
|
// Get field value helper
|
||||||
const getFieldValue = (field: FieldConfig) => {
|
const getFieldValue = (field: FieldConfig) => {
|
||||||
return values[field.id] ?? field.value;
|
return values?.[field.id] ?? field.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render field with label and description
|
// 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>
|
<CardHeader>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{section.icon}
|
{section.icon}
|
||||||
<div>
|
|
||||||
<CardTitle className="text-lg">{section.title}</CardTitle>
|
<CardTitle className="text-lg">{section.title}</CardTitle>
|
||||||
{section.description && (
|
{section.description && (
|
||||||
<CardDescription className="mt-1">
|
<CardDescription className="mt-1">
|
||||||
@ -180,7 +131,6 @@ export function AdminSection({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{content}
|
{content}
|
||||||
</Card>
|
</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;
|
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",
|
"@tiptap/starter-kit": "^3.1.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"dnd-kit": "^0.0.2",
|
"dnd-kit": "^0.0.2",
|
||||||
@ -4227,6 +4228,22 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/color": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
|
"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": {
|
"color": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
|
|||||||
@ -53,6 +53,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.1.0",
|
"@tiptap/starter-kit": "^3.1.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"dnd-kit": "^0.0.2",
|
"dnd-kit": "^0.0.2",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user