1166 lines
45 KiB
TypeScript
1166 lines
45 KiB
TypeScript
"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 (
|
||
<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 blogSchema>>[] = [
|
||
{
|
||
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: "title",
|
||
header: "标题",
|
||
cell: ({ row }) => {
|
||
return <BlogCellViewer item={row.original} />
|
||
},
|
||
enableHiding: false,
|
||
},
|
||
{
|
||
accessorKey: "status",
|
||
header: "状态",
|
||
cell: ({ row }) => {
|
||
const status = row.original.status;
|
||
const statusMap: Record<string, { label: string; variant: "secondary" | "default" | "outline" }> = {
|
||
draft: { label: "草稿", variant: "secondary" },
|
||
published: { label: "已发布", variant: "default" },
|
||
archived: { label: "已归档", variant: "outline" },
|
||
};
|
||
|
||
const statusInfo = statusMap[status] || { label: status, variant: "outline" };
|
||
|
||
return (
|
||
<div className="w-20">
|
||
<Badge variant={statusInfo.variant} className="text-xs">
|
||
{statusInfo.label}
|
||
</Badge>
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
accessorKey: "viewCount",
|
||
header: "浏览量",
|
||
cell: ({ row }) => (
|
||
<div className="w-20">
|
||
<span className="text-sm text-muted-foreground">
|
||
{row.original.viewCount}
|
||
</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "isFeatured",
|
||
header: "推荐",
|
||
cell: ({ row }) => (
|
||
<div className="w-20">
|
||
{row.original.isFeatured && (
|
||
<Badge variant="default" className="text-xs">
|
||
推荐
|
||
</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: "updatedAt",
|
||
accessorFn: (row) => row.updatedAt,
|
||
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.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 (
|
||
<div className="w-40">
|
||
<div className="flex flex-row gap-2">
|
||
<span className="text-sm font-medium">
|
||
{date.toLocaleDateString('zh-CN', {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</span>
|
||
<Badge variant="outline" className="text-xs w-fit">
|
||
{timeAgo}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
);
|
||
},
|
||
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 (
|
||
<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
|
||
onClick={() => {
|
||
window.location.href = `/admin/editor?id=${row.original.id}`
|
||
}}
|
||
>
|
||
编辑
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem>预览</DropdownMenuItem>
|
||
{row.original.status === "draft" && (
|
||
<DropdownMenuItem
|
||
onClick={() => {
|
||
updateBlogMutation({
|
||
variables: {
|
||
id: row.original.id,
|
||
input: { status: "published" }
|
||
}
|
||
})
|
||
}}
|
||
>
|
||
发布
|
||
</DropdownMenuItem>
|
||
)}
|
||
{row.original.status === "published" && (
|
||
<DropdownMenuItem
|
||
onClick={() => {
|
||
updateBlogMutation({
|
||
variables: {
|
||
id: row.original.id,
|
||
input: { status: "archived" }
|
||
}
|
||
})
|
||
}}
|
||
>
|
||
归档
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuItem
|
||
onClick={() => {
|
||
updateBlogMutation({
|
||
variables: {
|
||
id: row.original.id,
|
||
input: { isFeatured: !row.original.isFeatured }
|
||
}
|
||
})
|
||
}}
|
||
>
|
||
{row.original.isFeatured ? "取消推荐" : "设为推荐"}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
onClick={() => {
|
||
updateBlogMutation({
|
||
variables: {
|
||
id: row.original.id,
|
||
input: { isActive: !row.original.isActive }
|
||
}
|
||
})
|
||
}}
|
||
>
|
||
{row.original.isActive ? "停用" : "启用"}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
onClick={() => {
|
||
if (confirm('确定要删除这篇博客吗?此操作不可撤销。')) {
|
||
deleteBlogMutation({
|
||
variables: {
|
||
id: row.original.id
|
||
}
|
||
})
|
||
}
|
||
}}
|
||
>
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)
|
||
},
|
||
},
|
||
]
|
||
|
||
function DraggableRow({ row }: { row: Row<z.infer<typeof blogSchema>> }) {
|
||
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_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<SortingState>([])
|
||
const [filter, setFilter] = React.useState<string>()
|
||
|
||
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 && <div className="text-red-500">{typeof error === 'string' ? error : 'An error occurred'}</div>}
|
||
<BlogTabs
|
||
blogs={blogs}
|
||
loading={loading}
|
||
info={blogInfo}
|
||
handleDragEnd={handleDragEnd}
|
||
refetch={refetch}
|
||
pagination={pagination}
|
||
setPagination={setPagination}
|
||
sorting={sorting}
|
||
setSorting={setSorting}
|
||
filter={filter}
|
||
setFilter={setFilter}
|
||
totalCount={totalCount}
|
||
updateBlog={updateBlog}
|
||
deleteBlog={deleteBlog}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
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<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
|
||
updateBlog: any
|
||
deleteBlog: any
|
||
}) {
|
||
return (
|
||
<div>
|
||
<Tabs
|
||
defaultValue="all_blogs"
|
||
className="w-full flex-col justify-start gap-6"
|
||
>
|
||
{/* Blog 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 lg:grid-cols-7 gap-4 text-center">
|
||
<div>
|
||
<div className="text-2xl font-bold text-blue-600">{info.totalBlogs}</div>
|
||
<div className="text-xs text-muted-foreground">总博客</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-2xl font-bold text-green-600">{info.totalPublished}</div>
|
||
<div className="text-xs text-muted-foreground">已发布</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-2xl font-bold text-yellow-600">{info.totalDrafts}</div>
|
||
<div className="text-xs text-muted-foreground">草稿</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-2xl font-bold text-gray-600">{info.totalArchived}</div>
|
||
<div className="text-xs text-muted-foreground">已归档</div>
|
||
</div>
|
||
{info.totalCategories !== undefined && (
|
||
<div>
|
||
<div className="text-2xl font-bold text-purple-600">{info.totalCategories}</div>
|
||
<div className="text-xs text-muted-foreground">分类</div>
|
||
</div>
|
||
)}
|
||
{info.totalTags !== undefined && (
|
||
<div>
|
||
<div className="text-2xl font-bold text-indigo-600">{info.totalTags}</div>
|
||
<div className="text-xs text-muted-foreground">标签</div>
|
||
</div>
|
||
)}
|
||
{info.totalViews !== undefined && (
|
||
<div>
|
||
<div className="text-2xl font-bold text-red-600">{info.totalViews?.toLocaleString()}</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_blogs">
|
||
<SelectTrigger
|
||
className="flex w-fit @4xl/main:hidden"
|
||
size="sm"
|
||
id="view-selector"
|
||
>
|
||
<SelectValue placeholder="选择视图" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all_blogs">所有博客</SelectItem>
|
||
<SelectItem value="published_blogs">已发布</SelectItem>
|
||
<SelectItem value="draft_blogs">草稿</SelectItem>
|
||
<SelectItem value="archived_blogs">已归档</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_blogs">所有博客</TabsTrigger>
|
||
<TabsTrigger value="published_blogs">
|
||
已发布 <Badge variant="secondary">{info?.totalPublished}</Badge>
|
||
</TabsTrigger>
|
||
<TabsTrigger value="draft_blogs">
|
||
草稿 <Badge variant="secondary">{info?.totalDrafts}</Badge>
|
||
</TabsTrigger>
|
||
<TabsTrigger value="archived_blogs">已归档 <Badge variant="secondary">{info?.totalArchived}</Badge></TabsTrigger>
|
||
</TabsList>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => window.location.href = '/admin/editor'}
|
||
>
|
||
<IconPlus />
|
||
<span className="hidden lg:inline">新建博客</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<TabsContent value="all_blogs" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
|
||
<BlogDataTable
|
||
data={blogs}
|
||
isLoading={loading}
|
||
handleDragEnd={handleDragEnd}
|
||
pagination={pagination}
|
||
setPagination={setPagination}
|
||
sorting={sorting}
|
||
setSorting={setSorting}
|
||
totalCount={totalCount}
|
||
onFilterChange={() => setFilter(undefined)}
|
||
updateBlog={updateBlog}
|
||
deleteBlog={deleteBlog}
|
||
/>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="published_blogs" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
|
||
<BlogDataTable
|
||
data={filter === "published" ? blogs : []}
|
||
isLoading={loading && filter === "published"}
|
||
handleDragEnd={handleDragEnd}
|
||
pagination={pagination}
|
||
setPagination={setPagination}
|
||
sorting={sorting}
|
||
setSorting={setSorting}
|
||
totalCount={filter === "published" ? totalCount : 0}
|
||
onFilterChange={() => setFilter("published")}
|
||
updateBlog={updateBlog}
|
||
deleteBlog={deleteBlog}
|
||
/>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="draft_blogs" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
|
||
<BlogDataTable
|
||
data={filter === "draft" ? blogs : []}
|
||
isLoading={loading && filter === "draft"}
|
||
handleDragEnd={handleDragEnd}
|
||
pagination={pagination}
|
||
setPagination={setPagination}
|
||
sorting={sorting}
|
||
setSorting={setSorting}
|
||
totalCount={filter === "draft" ? totalCount : 0}
|
||
onFilterChange={() => setFilter("draft")}
|
||
updateBlog={updateBlog}
|
||
deleteBlog={deleteBlog}
|
||
/>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="archived_blogs" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
|
||
<BlogDataTable
|
||
data={filter === "archived" ? blogs : []}
|
||
isLoading={loading && filter === "archived"}
|
||
handleDragEnd={handleDragEnd}
|
||
pagination={pagination}
|
||
setPagination={setPagination}
|
||
sorting={sorting}
|
||
setSorting={setSorting}
|
||
totalCount={filter === "archived" ? totalCount : 0}
|
||
onFilterChange={() => setFilter("archived")}
|
||
updateBlog={updateBlog}
|
||
deleteBlog={deleteBlog}
|
||
/>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<React.SetStateAction<{ pageIndex: number; pageSize: number }>>
|
||
sorting: SortingState
|
||
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
|
||
totalCount: number
|
||
onFilterChange: () => void
|
||
updateBlog: any
|
||
deleteBlog: any
|
||
}) {
|
||
const [rowSelection, setRowSelection] = React.useState({})
|
||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||
|
||
// 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 (
|
||
<>
|
||
<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((blog: any) => blog.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 BlogCellViewer({ item }: { item: z.infer<typeof blogSchema> }) {
|
||
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">
|
||
{item.title}
|
||
</Button>
|
||
{item.excerpt && (
|
||
<span className="text-xs text-muted-foreground line-clamp-2 max-w-60">
|
||
{item.excerpt}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</DrawerTrigger>
|
||
|
||
<DrawerContent>
|
||
<DrawerHeader className="gap-1">
|
||
<DrawerTitle>{item.title}</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="title">标题</Label>
|
||
<Input id="title" defaultValue={item.title} />
|
||
</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="excerpt">摘要</Label>
|
||
<Input id="excerpt" defaultValue={item.excerpt} />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="flex flex-col gap-3">
|
||
<Label htmlFor="status">状态</Label>
|
||
<Select defaultValue={item.status}>
|
||
<SelectTrigger id="status" className="w-full">
|
||
<SelectValue placeholder="选择状态" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="draft">草稿</SelectItem>
|
||
<SelectItem value="published">已发布</SelectItem>
|
||
<SelectItem value="archived">已归档</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex flex-col gap-3">
|
||
<Label htmlFor="viewCount">浏览量</Label>
|
||
<Input id="viewCount" defaultValue={item.viewCount.toString()} />
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="flex flex-col gap-3">
|
||
<Label htmlFor="featured">推荐状态</Label>
|
||
<Select defaultValue={item.isFeatured ? "true" : "false"}>
|
||
<SelectTrigger id="featured" className="w-full">
|
||
<SelectValue placeholder="选择推荐状态" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="true">推荐</SelectItem>
|
||
<SelectItem value="false">普通</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex flex-col gap-3">
|
||
<Label htmlFor="active">激活状态</Label>
|
||
<Select defaultValue={item.isActive ? "true" : "false"}>
|
||
<SelectTrigger id="active" className="w-full">
|
||
<SelectValue placeholder="选择激活状态" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="true">激活</SelectItem>
|
||
<SelectItem value="false">停用</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<DrawerFooter>
|
||
<Button
|
||
onClick={() => {
|
||
window.location.href = `/admin/editor?id=${item.id}`
|
||
}}
|
||
>
|
||
编辑博客
|
||
</Button>
|
||
<DrawerClose asChild>
|
||
<Button variant="outline">关闭</Button>
|
||
</DrawerClose>
|
||
</DrawerFooter>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
)
|
||
} |