"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" export const blogSchema = z.object({ id: z.string(), title: z.string(), slug: z.string(), excerpt: z.string().optional(), content: z.any(), // JSON content categoryId: z.string().optional(), status: z.string(), featuredImage: z.string().optional(), metaTitle: z.string().optional(), metaDescription: z.string().optional(), publishedAt: z.string().optional(), viewCount: z.number(), isFeatured: z.boolean(), isActive: z.boolean(), createdAt: z.string(), updatedAt: z.string(), createdBy: z.string().optional(), updatedBy: z.string().optional(), }) function DragHandle({ id }: { id: string }) { const { attributes, listeners } = useSortable({ id, }) return ( ) } const columns: ColumnDef>[] = [ { id: "drag", header: () => null, cell: ({ row }) => , }, { id: "select", header: ({ table }) => (
table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" />
), cell: ({ row }) => (
row.toggleSelected(!!value)} aria-label="Select row" />
), enableSorting: false, enableHiding: false, }, { accessorKey: "title", header: "标题", cell: ({ row }) => { return }, enableHiding: false, }, { accessorKey: "status", header: "状态", cell: ({ row }) => { const status = row.original.status; const statusMap: Record = { draft: { label: "草稿", variant: "secondary" }, published: { label: "已发布", variant: "default" }, archived: { label: "已归档", variant: "outline" }, }; const statusInfo = statusMap[status] || { label: status, variant: "outline" }; return (
{statusInfo.label}
); }, }, { accessorKey: "viewCount", header: "浏览量", cell: ({ row }) => (
{row.original.viewCount}
), }, { accessorKey: "isFeatured", header: "推荐", cell: ({ row }) => (
{row.original.isFeatured && ( 推荐 )}
), }, { accessorKey: "createdAt", header: ({ column }) => { return ( ) }, cell: ({ row }) => { const date = new Date(row.original.createdAt); const now = new Date(); const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); let timeAgo = ''; if (diffInDays === 0) { timeAgo = '今天'; } else if (diffInDays === 1) { timeAgo = '昨天'; } else if (diffInDays < 7) { timeAgo = `${diffInDays} 天前`; } else if (diffInDays < 30) { const weeks = Math.floor(diffInDays / 7); timeAgo = `${weeks} 周前`; } else if (diffInDays < 365) { const months = Math.floor(diffInDays / 30); timeAgo = `${months} 个月前`; } else { const years = Math.floor(diffInDays / 365); timeAgo = `${years} 年前`; } return (
{date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'short', day: 'numeric' })} {timeAgo}
); }, enableSorting: true, sortingFn: (rowA, rowB, columnId) => { const dateA = new Date(rowA.original.createdAt); const dateB = new Date(rowB.original.createdAt); return dateA.getTime() - dateB.getTime(); }, }, { id: "updatedAt", accessorFn: (row) => row.updatedAt, header: ({ column }) => { return ( ) }, cell: ({ row }) => { const date = new Date(row.original.updatedAt); const now = new Date(); const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60)); let timeAgo = ''; if (diffInMinutes < 1) { timeAgo = '刚刚'; } else if (diffInMinutes < 60) { timeAgo = `${diffInMinutes} 分钟前`; } else if (diffInMinutes < 1440) { const hours = Math.floor(diffInMinutes / 60); timeAgo = `${hours} 小时前`; } else { const days = Math.floor(diffInMinutes / 1440); timeAgo = `${days} 天前`; } return (
{date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} {timeAgo}
); }, enableSorting: true, sortingFn: (rowA, rowB, columnId) => { const dateA = new Date(rowA.original.updatedAt); const dateB = new Date(rowB.original.updatedAt); return dateA.getTime() - dateB.getTime(); }, }, { id: "actions", cell: ({ row, table }) => { const updateBlogMutation = (table.options.meta as any)?.updateBlog const deleteBlogMutation = (table.options.meta as any)?.deleteBlog return ( { window.location.href = `/admin/editor?id=${row.original.id}` }} > 编辑 预览 {row.original.status === "draft" && ( { updateBlogMutation({ variables: { id: row.original.id, input: { status: "published" } } }) }} > 发布 )} {row.original.status === "published" && ( { updateBlogMutation({ variables: { id: row.original.id, input: { status: "archived" } } }) }} > 归档 )} { updateBlogMutation({ variables: { id: row.original.id, input: { isFeatured: !row.original.isFeatured } } }) }} > {row.original.isFeatured ? "取消推荐" : "设为推荐"} { updateBlogMutation({ variables: { id: row.original.id, input: { isActive: !row.original.isActive } } }) }} > {row.original.isActive ? "停用" : "启用"} { if (confirm('确定要删除这篇博客吗?此操作不可撤销。')) { deleteBlogMutation({ variables: { id: row.original.id } }) } }} > 删除 ) }, }, ] function DraggableRow({ row }: { row: Row> }) { const { transform, transition, setNodeRef, isDragging } = useSortable({ id: row.original.id, }) return ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ) } const GET_BLOGS = gql` query GetBlogs($filter: BlogFilterInput, $sort: BlogSortInput, $pagination: PaginationInput) { blogs(filter: $filter, sort: $sort, pagination: $pagination) { items { id title slug excerpt content categoryId status featuredImage metaTitle metaDescription publishedAt viewCount isFeatured isActive createdAt updatedAt createdBy updatedBy } total page perPage totalPages } } ` const GET_BLOG_STATS = gql` query GetBlogStats { blogStats { totalBlogs publishedBlogs draftBlogs archivedBlogs totalCategories totalTags totalViews } } ` const GET_BLOG_TAGS = gql` query GetBlogTags($filter: BlogTagFilterInput) { blogTags(filter: $filter) { id name slug description color isActive createdAt updatedAt createdBy updatedBy } } ` const UPDATE_BLOG = gql` mutation UpdateBlog($id: UUID!, $input: UpdateBlogInput!) { updateBlog(id: $id, input: $input) { id title slug status isFeatured isActive updatedAt } } ` const DELETE_BLOG = gql` mutation DeleteBlog($id: UUID!) { deleteBlog(id: $id) } ` export function BlogTable() { const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 10, }) const [sorting, setSorting] = React.useState([]) const [filter, setFilter] = React.useState() const { data, loading, error, refetch } = useQuery(GET_BLOGS, { 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 ? { status: filter } : undefined }, fetchPolicy: 'cache-and-network' }) const { data: statsData, refetch: refetchStats } = useQuery(GET_BLOG_STATS, { fetchPolicy: 'cache-and-network' }) const [updateBlog] = useMutation(UPDATE_BLOG, { onCompleted: () => { refetch() refetchStats() } }) const [deleteBlog] = useMutation(DELETE_BLOG, { onCompleted: () => { refetch() refetchStats() } }) const blogs = data?.blogs?.items || [] const totalCount = data?.blogs?.total || 0 const stats = statsData?.blogStats function handleDragEnd(event: DragEndEvent) { const { active, over } = event if (active && over && active.id !== over.id) { // Handle drag end for reordering console.log('Reordering blogs:', active.id, 'to', over.id) // You can implement actual reordering API call here } } // Use real stats from API or calculate from current data const blogInfo = React.useMemo(() => { if (stats) { return { totalBlogs: stats.totalBlogs, totalDrafts: stats.draftBlogs, totalPublished: stats.publishedBlogs, totalArchived: stats.archivedBlogs, totalCategories: stats.totalCategories, totalTags: stats.totalTags, totalViews: stats.totalViews, } } // Fallback to calculating from current page data return { totalBlogs: totalCount, totalDrafts: blogs.filter((blog: any) => blog.status === 'draft').length, totalPublished: blogs.filter((blog: any) => blog.status === 'published').length, totalArchived: blogs.filter((blog: any) => blog.status === 'archived').length, totalCategories: 0, totalTags: 0, totalViews: blogs.reduce((sum: number, blog: any) => sum + (blog.viewCount || 0), 0), } }, [blogs, totalCount, stats]) return ( <> {error &&
{typeof error === 'string' ? error : 'An error occurred'}
} ) } function BlogTabs({ blogs, loading, info, handleDragEnd, refetch, pagination, setPagination, sorting, setSorting, filter, setFilter, totalCount, updateBlog, deleteBlog }: { blogs: any[] loading: boolean info: any handleDragEnd: (event: DragEndEvent) => void refetch: any pagination: { pageIndex: number; pageSize: number } setPagination: React.Dispatch> sorting: SortingState setSorting: React.Dispatch> filter?: string setFilter: React.Dispatch> totalCount: number updateBlog: any deleteBlog: any }) { return (
{/* Blog Stats Section */} {info && (
{info.totalBlogs}
总博客
{info.totalPublished}
已发布
{info.totalDrafts}
草稿
{info.totalArchived}
已归档
{info.totalCategories !== undefined && (
{info.totalCategories}
分类
)} {info.totalTags !== undefined && (
{info.totalTags}
标签
)} {info.totalViews !== undefined && (
{info.totalViews?.toLocaleString()}
总浏览
)}
)}
所有博客 已发布 {info?.totalPublished} 草稿 {info?.totalDrafts} 已归档 {info?.totalArchived}
setFilter(undefined)} updateBlog={updateBlog} deleteBlog={deleteBlog} /> setFilter("published")} updateBlog={updateBlog} deleteBlog={deleteBlog} /> setFilter("draft")} updateBlog={updateBlog} deleteBlog={deleteBlog} /> setFilter("archived")} updateBlog={updateBlog} deleteBlog={deleteBlog} />
) } function BlogDataTable({ data, isLoading = false, handleDragEnd, pagination, setPagination, sorting, setSorting, totalCount, onFilterChange, updateBlog, deleteBlog }: { data: any[] isLoading?: boolean handleDragEnd: (event: DragEndEvent) => void pagination: { pageIndex: number; pageSize: number } setPagination: React.Dispatch> sorting: SortingState setSorting: React.Dispatch> totalCount: number onFilterChange: () => void updateBlog: any deleteBlog: any }) { const [rowSelection, setRowSelection] = React.useState({}) const [columnVisibility, setColumnVisibility] = React.useState({}) const [columnFilters, setColumnFilters] = React.useState([]) // Call onFilterChange when component mounts to ensure proper filter is set 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: { updateBlog, deleteBlog, }, }) return ( <>
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { return ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} ) })} ))} {isLoading ? ( 加载中... ) : table.getRowModel().rows?.length ? ( blog.id) || []} strategy={verticalListSortingStrategy} > {table.getRowModel().rows.map((row) => ( ))} ) : ( 暂无数据。 )}
已选择 {table.getFilteredSelectedRowModel().rows.length} / {table.getFilteredRowModel().rows.length} 行。
第 {table.getState().pagination.pageIndex + 1} 页,共 {table.getPageCount()} 页
) } function BlogCellViewer({ item }: { item: z.infer }) { const isMobile = useIsMobile() return (
{item.excerpt && ( {item.excerpt} )}
{item.title} 博客详情信息
) }