1216 lines
43 KiB
TypeScript
1216 lines
43 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useMemo } from 'react';
|
||
import { toast } from 'sonner';
|
||
import {
|
||
Plus,
|
||
Search,
|
||
Pencil,
|
||
Trash2,
|
||
Send,
|
||
CheckCircle,
|
||
XCircle,
|
||
MoreHorizontal,
|
||
} from 'lucide-react';
|
||
import {
|
||
useArticles,
|
||
useCreateArticle,
|
||
useUpdateArticle,
|
||
useDeleteArticle,
|
||
usePublishArticle,
|
||
useSubmitForReview,
|
||
useCategories,
|
||
useCategoryTree,
|
||
useCreateCategory,
|
||
useUpdateCategory,
|
||
useDeleteCategory,
|
||
useTags,
|
||
useCreateTag,
|
||
useUpdateTag,
|
||
useDeleteTag,
|
||
useComments,
|
||
useApproveComment,
|
||
useRejectComment,
|
||
useDeleteComment,
|
||
} from '@/hooks/use-content';
|
||
import type {
|
||
Article,
|
||
Category,
|
||
Tag,
|
||
Comment,
|
||
CreateArticleRequest,
|
||
UpdateArticleRequest,
|
||
CreateCategoryRequest,
|
||
UpdateCategoryRequest,
|
||
CreateTagRequest,
|
||
UpdateTagRequest,
|
||
} from '@/lib/content-api';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { Checkbox } from '@/components/ui/checkbox';
|
||
import { Separator } from '@/components/ui/separator';
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select';
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog';
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu';
|
||
|
||
const ARTICLE_STATUS_OPTIONS = [
|
||
{ label: '全部', value: 'ALL' },
|
||
{ label: '草稿', value: 'draft' },
|
||
{ label: '待审核', value: 'pending' },
|
||
{ label: '已发布', value: 'published' },
|
||
{ label: '已拒绝', value: 'rejected' },
|
||
];
|
||
|
||
const ARTICLE_STATUS_LABEL: Record<string, string> = {
|
||
draft: '草稿',
|
||
pending: '待审核',
|
||
published: '已发布',
|
||
rejected: '已拒绝',
|
||
};
|
||
|
||
const ARTICLE_STATUS_CLASS: Record<string, string> = {
|
||
draft: 'bg-gray-100 text-gray-800 hover:bg-gray-100 border-gray-200',
|
||
pending: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100 border-yellow-200',
|
||
published: 'bg-green-100 text-green-800 hover:bg-green-100 border-green-200',
|
||
rejected: 'bg-red-100 text-red-800 hover:bg-red-100 border-red-200',
|
||
};
|
||
|
||
const COMMENT_STATUS_LABEL: Record<string, string> = {
|
||
pending: '待审核',
|
||
approved: '已通过',
|
||
rejected: '已拒绝',
|
||
};
|
||
|
||
const COMMENT_STATUS_CLASS: Record<string, string> = {
|
||
pending: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100 border-yellow-200',
|
||
approved: 'bg-green-100 text-green-800 hover:bg-green-100 border-green-200',
|
||
rejected: 'bg-red-100 text-red-800 hover:bg-red-100 border-red-200',
|
||
};
|
||
|
||
function buildCategoryTree(categories: Category[]): (Category & { children: Category[] })[] {
|
||
const map = new Map<string, Category & { children: Category[] }>();
|
||
const roots: (Category & { children: Category[] })[] = [];
|
||
categories.forEach((cat) => map.set(cat.id, { ...cat, children: [] }));
|
||
categories.forEach((cat) => {
|
||
const node = map.get(cat.id)!;
|
||
if (cat.parentId && map.has(cat.parentId)) {
|
||
map.get(cat.parentId)!.children.push(node);
|
||
} else {
|
||
roots.push(node);
|
||
}
|
||
});
|
||
return roots;
|
||
}
|
||
|
||
function flattenCategoryTree(
|
||
nodes: (Category & { children: Category[] })[],
|
||
depth = 0
|
||
): { category: Category; depth: number }[] {
|
||
const result: { category: Category; depth: number }[] = [];
|
||
nodes.forEach((node) => {
|
||
result.push({ category: node, depth });
|
||
if (node.children.length > 0) {
|
||
result.push(...flattenCategoryTree(node.children as (Category & { children: Category[] })[], depth + 1));
|
||
}
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function ArticleTab() {
|
||
const [page, setPage] = useState(1);
|
||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||
const [searchKeyword, setSearchKeyword] = useState('');
|
||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
||
const [formTitle, setFormTitle] = useState('');
|
||
const [formContent, setFormContent] = useState('');
|
||
const [formCategoryId, setFormCategoryId] = useState('');
|
||
const [formTags, setFormTags] = useState<string[]>([]);
|
||
|
||
const filterStatus = statusFilter === 'ALL' ? undefined : statusFilter;
|
||
const { data: articlesData, isLoading } = useArticles({
|
||
page,
|
||
limit: 20,
|
||
status: filterStatus,
|
||
keyword: searchKeyword || undefined,
|
||
});
|
||
const { data: categoriesData } = useCategories();
|
||
const { data: tagsData } = useTags();
|
||
const createArticle = useCreateArticle();
|
||
const updateArticle = useUpdateArticle();
|
||
const deleteArticle = useDeleteArticle();
|
||
const publishArticle = usePublishArticle();
|
||
const submitForReview = useSubmitForReview();
|
||
|
||
const articles = (articlesData as any)?.data?.articles || [];
|
||
const categories = (categoriesData as any)?.data || [];
|
||
const tags = (tagsData as any)?.data?.tags || [];
|
||
|
||
const resetForm = () => {
|
||
setFormTitle('');
|
||
setFormContent('');
|
||
setFormCategoryId('');
|
||
setFormTags([]);
|
||
};
|
||
|
||
const openCreateDialog = () => {
|
||
resetForm();
|
||
setCreateDialogOpen(true);
|
||
};
|
||
|
||
const openEditDialog = (article: Article) => {
|
||
setSelectedArticle(article);
|
||
setFormTitle(article.title);
|
||
setFormContent(article.content);
|
||
setFormCategoryId(article.categoryId || '');
|
||
setFormTags(article.tags || []);
|
||
setEditDialogOpen(true);
|
||
};
|
||
|
||
const openDeleteDialog = (article: Article) => {
|
||
setSelectedArticle(article);
|
||
setDeleteDialogOpen(true);
|
||
};
|
||
|
||
const handleCreate = async () => {
|
||
try {
|
||
const data: CreateArticleRequest = {
|
||
title: formTitle,
|
||
slug: formTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\u4e00-\u9fa5-]/g, ''),
|
||
content: formContent,
|
||
categoryId: formCategoryId || undefined,
|
||
tags: formTags.length > 0 ? formTags : undefined,
|
||
};
|
||
await createArticle.mutateAsync(data);
|
||
toast.success('文章创建成功');
|
||
setCreateDialogOpen(false);
|
||
resetForm();
|
||
} catch {
|
||
toast.error('创建文章失败');
|
||
}
|
||
};
|
||
|
||
const handleUpdate = async () => {
|
||
if (!selectedArticle) return;
|
||
try {
|
||
const data: UpdateArticleRequest = {
|
||
title: formTitle,
|
||
content: formContent,
|
||
categoryId: formCategoryId || undefined,
|
||
tags: formTags.length > 0 ? formTags : undefined,
|
||
};
|
||
await updateArticle.mutateAsync({ id: selectedArticle.id, data });
|
||
toast.success('文章更新成功');
|
||
setEditDialogOpen(false);
|
||
resetForm();
|
||
} catch {
|
||
toast.error('更新文章失败');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!selectedArticle) return;
|
||
try {
|
||
await deleteArticle.mutateAsync(selectedArticle.id);
|
||
toast.success('文章删除成功');
|
||
setDeleteDialogOpen(false);
|
||
setSelectedArticle(null);
|
||
} catch {
|
||
toast.error('删除文章失败');
|
||
}
|
||
};
|
||
|
||
const handlePublish = async (article: Article) => {
|
||
try {
|
||
await publishArticle.mutateAsync(article.id);
|
||
toast.success('文章已发布');
|
||
} catch {
|
||
toast.error('发布失败');
|
||
}
|
||
};
|
||
|
||
const handleSubmitForReview = async (article: Article) => {
|
||
try {
|
||
await submitForReview.mutateAsync(article.id);
|
||
toast.success('已提交审核');
|
||
} catch {
|
||
toast.error('提交审核失败');
|
||
}
|
||
};
|
||
|
||
const toggleTag = (tagName: string) => {
|
||
setFormTags((prev) =>
|
||
prev.includes(tagName) ? prev.filter((t) => t !== tagName) : [...prev, tagName]
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<div className="relative flex-1 max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="搜索文章标题..."
|
||
value={searchKeyword}
|
||
onChange={(e) => {
|
||
setSearchKeyword(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1); }}>
|
||
<SelectTrigger className="w-[140px]">
|
||
<SelectValue placeholder="全部状态" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{ARTICLE_STATUS_OPTIONS.map((opt) => (
|
||
<SelectItem key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<Button onClick={openCreateDialog}>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
新建文章
|
||
</Button>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="space-y-3">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-12 w-full" />
|
||
))}
|
||
</div>
|
||
) : articles.length === 0 ? (
|
||
<div className="text-center py-12 text-muted-foreground">暂无文章</div>
|
||
) : (
|
||
<div className="border rounded-lg">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>标题</TableHead>
|
||
<TableHead>分类</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>作者</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{articles.map((article: Article) => (
|
||
<TableRow key={article.id}>
|
||
<TableCell className="font-medium max-w-[200px] truncate">
|
||
{article.title}
|
||
</TableCell>
|
||
<TableCell>
|
||
{categories.find((c: Category) => c.id === article.categoryId)?.name || '-'}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge
|
||
variant="outline"
|
||
className={ARTICLE_STATUS_CLASS[article.status] || ''}
|
||
>
|
||
{ARTICLE_STATUS_LABEL[article.status] || article.status}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground">{article.authorId}</TableCell>
|
||
<TableCell className="text-muted-foreground">
|
||
{new Date(article.createdAt).toLocaleString('zh-CN')}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon">
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={() => openEditDialog(article)}>
|
||
<Pencil className="mr-2 h-4 w-4" />
|
||
编辑
|
||
</DropdownMenuItem>
|
||
{article.status === 'draft' && (
|
||
<DropdownMenuItem onClick={() => handleSubmitForReview(article)}>
|
||
<Send className="mr-2 h-4 w-4" />
|
||
提交审核
|
||
</DropdownMenuItem>
|
||
)}
|
||
{article.status === 'pending' && (
|
||
<DropdownMenuItem onClick={() => handlePublish(article)}>
|
||
<CheckCircle className="mr-2 h-4 w-4" />
|
||
发布
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
onClick={() => openDeleteDialog(article)}
|
||
className="text-destructive"
|
||
>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>新建文章</DialogTitle>
|
||
<DialogDescription>填写文章信息以创建新文章</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>标题</Label>
|
||
<Input value={formTitle} onChange={(e) => setFormTitle(e.target.value)} placeholder="请输入文章标题" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>内容</Label>
|
||
<Textarea value={formContent} onChange={(e) => setFormContent(e.target.value)} placeholder="请输入文章内容" rows={8} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>分类</Label>
|
||
<Select value={formCategoryId} onValueChange={setFormCategoryId}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择分类" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{categories.map((cat: Category) => (
|
||
<SelectItem key={cat.id} value={cat.id}>
|
||
{cat.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>标签</Label>
|
||
<div className="flex flex-wrap gap-3">
|
||
{tags.map((tag: Tag) => (
|
||
<div key={tag.id} className="flex items-center gap-2">
|
||
<Checkbox
|
||
id={`tag-create-${tag.id}`}
|
||
checked={formTags.includes(tag.name)}
|
||
onCheckedChange={() => toggleTag(tag.name)}
|
||
/>
|
||
<Label htmlFor={`tag-create-${tag.id}`} className="cursor-pointer">
|
||
{tag.name}
|
||
</Label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>取消</Button>
|
||
<Button onClick={handleCreate} disabled={createArticle.isPending}>
|
||
{createArticle.isPending ? '创建中...' : '创建'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>编辑文章</DialogTitle>
|
||
<DialogDescription>修改文章信息</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>标题</Label>
|
||
<Input value={formTitle} onChange={(e) => setFormTitle(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>内容</Label>
|
||
<Textarea value={formContent} onChange={(e) => setFormContent(e.target.value)} rows={8} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>分类</Label>
|
||
<Select value={formCategoryId} onValueChange={setFormCategoryId}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择分类" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{categories.map((cat: Category) => (
|
||
<SelectItem key={cat.id} value={cat.id}>
|
||
{cat.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>标签</Label>
|
||
<div className="flex flex-wrap gap-3">
|
||
{tags.map((tag: Tag) => (
|
||
<div key={tag.id} className="flex items-center gap-2">
|
||
<Checkbox
|
||
id={`tag-edit-${tag.id}`}
|
||
checked={formTags.includes(tag.name)}
|
||
onCheckedChange={() => toggleTag(tag.name)}
|
||
/>
|
||
<Label htmlFor={`tag-edit-${tag.id}`} className="cursor-pointer">
|
||
{tag.name}
|
||
</Label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>取消</Button>
|
||
<Button onClick={handleUpdate} disabled={updateArticle.isPending}>
|
||
{updateArticle.isPending ? '保存中...' : '保存'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>删除文章</DialogTitle>
|
||
<DialogDescription>
|
||
确定要删除文章「{selectedArticle?.title}」吗?此操作不可撤销。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>取消</Button>
|
||
<Button variant="destructive" onClick={handleDelete} disabled={deleteArticle.isPending}>
|
||
{deleteArticle.isPending ? '删除中...' : '确认删除'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CategoryTab() {
|
||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
|
||
const [formName, setFormName] = useState('');
|
||
const [formSlug, setFormSlug] = useState('');
|
||
const [formDescription, setFormDescription] = useState('');
|
||
const [formParentId, setFormParentId] = useState('');
|
||
|
||
const { data: categoriesData, isLoading } = useCategories();
|
||
const { data: treeData } = useCategoryTree();
|
||
const createCategory = useCreateCategory();
|
||
const updateCategory = useUpdateCategory();
|
||
const deleteCategory = useDeleteCategory();
|
||
|
||
const categories = (categoriesData as any)?.data || [];
|
||
const tree = (treeData as any)?.data || [];
|
||
|
||
const flatTree = useMemo(() => {
|
||
const source = tree.length > 0 ? tree : categories;
|
||
const built = buildCategoryTree(source as Category[]);
|
||
return flattenCategoryTree(built);
|
||
}, [tree, categories]);
|
||
|
||
const resetForm = () => {
|
||
setFormName('');
|
||
setFormSlug('');
|
||
setFormDescription('');
|
||
setFormParentId('');
|
||
};
|
||
|
||
const openCreateDialog = () => {
|
||
resetForm();
|
||
setCreateDialogOpen(true);
|
||
};
|
||
|
||
const openEditDialog = (cat: Category) => {
|
||
setSelectedCategory(cat);
|
||
setFormName(cat.name);
|
||
setFormSlug(cat.slug);
|
||
setFormDescription(cat.description || '');
|
||
setFormParentId(cat.parentId || '');
|
||
setEditDialogOpen(true);
|
||
};
|
||
|
||
const openDeleteDialog = (cat: Category) => {
|
||
setSelectedCategory(cat);
|
||
setDeleteDialogOpen(true);
|
||
};
|
||
|
||
const handleCreate = async () => {
|
||
try {
|
||
const data: CreateCategoryRequest = {
|
||
name: formName,
|
||
slug: formSlug,
|
||
description: formDescription || undefined,
|
||
parentId: formParentId || undefined,
|
||
};
|
||
await createCategory.mutateAsync(data);
|
||
toast.success('分类创建成功');
|
||
setCreateDialogOpen(false);
|
||
resetForm();
|
||
} catch {
|
||
toast.error('创建分类失败');
|
||
}
|
||
};
|
||
|
||
const handleUpdate = async () => {
|
||
if (!selectedCategory) return;
|
||
try {
|
||
const data: UpdateCategoryRequest = {
|
||
name: formName,
|
||
slug: formSlug,
|
||
description: formDescription || undefined,
|
||
parentId: formParentId || undefined,
|
||
};
|
||
await updateCategory.mutateAsync({ id: selectedCategory.id, data });
|
||
toast.success('分类更新成功');
|
||
setEditDialogOpen(false);
|
||
resetForm();
|
||
} catch {
|
||
toast.error('更新分类失败');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!selectedCategory) return;
|
||
try {
|
||
await deleteCategory.mutateAsync(selectedCategory.id);
|
||
toast.success('分类删除成功');
|
||
setDeleteDialogOpen(false);
|
||
setSelectedCategory(null);
|
||
} catch {
|
||
toast.error('删除分类失败');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold">分类列表</h2>
|
||
<Button onClick={openCreateDialog}>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
新建分类
|
||
</Button>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="space-y-3">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-10 w-full" />
|
||
))}
|
||
</div>
|
||
) : flatTree.length === 0 ? (
|
||
<div className="text-center py-12 text-muted-foreground">暂无分类</div>
|
||
) : (
|
||
<div className="border rounded-lg">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>分类名称</TableHead>
|
||
<TableHead>Slug</TableHead>
|
||
<TableHead>描述</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{flatTree.map(({ category, depth }) => (
|
||
<TableRow key={category.id}>
|
||
<TableCell className="font-medium" style={{ paddingLeft: `${(depth + 1) * 24}px` }}>
|
||
{depth > 0 && <span className="text-muted-foreground mr-1">└</span>}
|
||
{category.name}
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground">{category.slug}</TableCell>
|
||
<TableCell className="text-muted-foreground max-w-[200px] truncate">
|
||
{category.description || '-'}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon">
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={() => openEditDialog(category)}>
|
||
<Pencil className="mr-2 h-4 w-4" />
|
||
编辑
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
onClick={() => openDeleteDialog(category)}
|
||
className="text-destructive"
|
||
>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>新建分类</DialogTitle>
|
||
<DialogDescription>填写分类信息</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>名称</Label>
|
||
<Input value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="请输入分类名称" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Slug</Label>
|
||
<Input value={formSlug} onChange={(e) => setFormSlug(e.target.value)} placeholder="请输入 Slug" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>描述</Label>
|
||
<Textarea value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="请输入分类描述" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>父级分类</Label>
|
||
<Select value={formParentId} onValueChange={setFormParentId}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="无父级分类" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{categories
|
||
.filter((c: Category) => c.id !== selectedCategory?.id)
|
||
.map((cat: Category) => (
|
||
<SelectItem key={cat.id} value={cat.id}>
|
||
{cat.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>取消</Button>
|
||
<Button onClick={handleCreate} disabled={createCategory.isPending}>
|
||
{createCategory.isPending ? '创建中...' : '创建'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>编辑分类</DialogTitle>
|
||
<DialogDescription>修改分类信息</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>名称</Label>
|
||
<Input value={formName} onChange={(e) => setFormName(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Slug</Label>
|
||
<Input value={formSlug} onChange={(e) => setFormSlug(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>描述</Label>
|
||
<Textarea value={formDescription} onChange={(e) => setFormDescription(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>父级分类</Label>
|
||
<Select value={formParentId} onValueChange={setFormParentId}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="无父级分类" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{categories
|
||
.filter((c: Category) => c.id !== selectedCategory?.id)
|
||
.map((cat: Category) => (
|
||
<SelectItem key={cat.id} value={cat.id}>
|
||
{cat.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>取消</Button>
|
||
<Button onClick={handleUpdate} disabled={updateCategory.isPending}>
|
||
{updateCategory.isPending ? '保存中...' : '保存'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>删除分类</DialogTitle>
|
||
<DialogDescription>
|
||
确定要删除分类「{selectedCategory?.name}」吗?此操作不可撤销。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>取消</Button>
|
||
<Button variant="destructive" onClick={handleDelete} disabled={deleteCategory.isPending}>
|
||
{deleteCategory.isPending ? '删除中...' : '确认删除'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TagTab() {
|
||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||
const [formName, setFormName] = useState('');
|
||
const [formSlug, setFormSlug] = useState('');
|
||
const [formDescription, setFormDescription] = useState('');
|
||
|
||
const { data: tagsData, isLoading } = useTags();
|
||
const createTag = useCreateTag();
|
||
const updateTag = useUpdateTag();
|
||
const deleteTag = useDeleteTag();
|
||
|
||
const tags = (tagsData as any)?.data?.tags || [];
|
||
|
||
const resetForm = () => {
|
||
setFormName('');
|
||
setFormSlug('');
|
||
setFormDescription('');
|
||
};
|
||
|
||
const openCreateDialog = () => {
|
||
resetForm();
|
||
setCreateDialogOpen(true);
|
||
};
|
||
|
||
const openEditDialog = (tag: Tag) => {
|
||
setSelectedTag(tag);
|
||
setFormName(tag.name);
|
||
setFormSlug(tag.slug);
|
||
setFormDescription(tag.description || '');
|
||
setEditDialogOpen(true);
|
||
};
|
||
|
||
const openDeleteDialog = (tag: Tag) => {
|
||
setSelectedTag(tag);
|
||
setDeleteDialogOpen(true);
|
||
};
|
||
|
||
const handleCreate = async () => {
|
||
try {
|
||
const data: CreateTagRequest = {
|
||
name: formName,
|
||
slug: formSlug,
|
||
description: formDescription || undefined,
|
||
};
|
||
await createTag.mutateAsync(data);
|
||
toast.success('标签创建成功');
|
||
setCreateDialogOpen(false);
|
||
resetForm();
|
||
} catch {
|
||
toast.error('创建标签失败');
|
||
}
|
||
};
|
||
|
||
const handleUpdate = async () => {
|
||
if (!selectedTag) return;
|
||
try {
|
||
const data: UpdateTagRequest = {
|
||
name: formName,
|
||
slug: formSlug,
|
||
description: formDescription || undefined,
|
||
};
|
||
await updateTag.mutateAsync({ id: selectedTag.id, data });
|
||
toast.success('标签更新成功');
|
||
setEditDialogOpen(false);
|
||
resetForm();
|
||
} catch {
|
||
toast.error('更新标签失败');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!selectedTag) return;
|
||
try {
|
||
await deleteTag.mutateAsync(selectedTag.id);
|
||
toast.success('标签删除成功');
|
||
setDeleteDialogOpen(false);
|
||
setSelectedTag(null);
|
||
} catch {
|
||
toast.error('删除标签失败');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold">标签列表</h2>
|
||
<Button onClick={openCreateDialog}>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
新建标签
|
||
</Button>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="space-y-3">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-10 w-full" />
|
||
))}
|
||
</div>
|
||
) : tags.length === 0 ? (
|
||
<div className="text-center py-12 text-muted-foreground">暂无标签</div>
|
||
) : (
|
||
<div className="border rounded-lg">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>名称</TableHead>
|
||
<TableHead>Slug</TableHead>
|
||
<TableHead>描述</TableHead>
|
||
<TableHead>文章数</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{tags.map((tag: Tag) => (
|
||
<TableRow key={tag.id}>
|
||
<TableCell className="font-medium">{tag.name}</TableCell>
|
||
<TableCell className="text-muted-foreground">{tag.slug}</TableCell>
|
||
<TableCell className="text-muted-foreground max-w-[200px] truncate">
|
||
{tag.description || '-'}
|
||
</TableCell>
|
||
<TableCell>{tag.articleCount ?? 0}</TableCell>
|
||
<TableCell className="text-muted-foreground">
|
||
{new Date(tag.createdAt).toLocaleString('zh-CN')}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon">
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={() => openEditDialog(tag)}>
|
||
<Pencil className="mr-2 h-4 w-4" />
|
||
编辑
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
onClick={() => openDeleteDialog(tag)}
|
||
className="text-destructive"
|
||
>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>新建标签</DialogTitle>
|
||
<DialogDescription>填写标签信息</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>名称</Label>
|
||
<Input value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="请输入标签名称" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Slug</Label>
|
||
<Input value={formSlug} onChange={(e) => setFormSlug(e.target.value)} placeholder="请输入 Slug" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>描述</Label>
|
||
<Textarea value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="请输入标签描述" />
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>取消</Button>
|
||
<Button onClick={handleCreate} disabled={createTag.isPending}>
|
||
{createTag.isPending ? '创建中...' : '创建'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>编辑标签</DialogTitle>
|
||
<DialogDescription>修改标签信息</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>名称</Label>
|
||
<Input value={formName} onChange={(e) => setFormName(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Slug</Label>
|
||
<Input value={formSlug} onChange={(e) => setFormSlug(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>描述</Label>
|
||
<Textarea value={formDescription} onChange={(e) => setFormDescription(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>取消</Button>
|
||
<Button onClick={handleUpdate} disabled={updateTag.isPending}>
|
||
{updateTag.isPending ? '保存中...' : '保存'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>删除标签</DialogTitle>
|
||
<DialogDescription>
|
||
确定要删除标签「{selectedTag?.name}」吗?此操作不可撤销。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>取消</Button>
|
||
<Button variant="destructive" onClick={handleDelete} disabled={deleteTag.isPending}>
|
||
{deleteTag.isPending ? '删除中...' : '确认删除'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CommentTab() {
|
||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||
|
||
const filterStatus = statusFilter === 'ALL' ? undefined : statusFilter;
|
||
const { data: commentsData, isLoading } = useComments({ status: filterStatus });
|
||
const approveComment = useApproveComment();
|
||
const rejectComment = useRejectComment();
|
||
const deleteComment = useDeleteComment();
|
||
|
||
const comments = (commentsData as any)?.data?.comments || [];
|
||
|
||
const handleApprove = async (id: string) => {
|
||
try {
|
||
await approveComment.mutateAsync(id);
|
||
toast.success('评论已通过');
|
||
} catch {
|
||
toast.error('审核通过失败');
|
||
}
|
||
};
|
||
|
||
const handleReject = async (id: string) => {
|
||
try {
|
||
await rejectComment.mutateAsync(id);
|
||
toast.success('评论已拒绝');
|
||
} catch {
|
||
toast.error('拒绝评论失败');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: string) => {
|
||
try {
|
||
await deleteComment.mutateAsync(id);
|
||
toast.success('评论已删除');
|
||
} catch {
|
||
toast.error('删除评论失败');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold">评论审核</h2>
|
||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||
<SelectTrigger className="w-[140px]">
|
||
<SelectValue placeholder="全部状态" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="ALL">全部</SelectItem>
|
||
<SelectItem value="pending">待审核</SelectItem>
|
||
<SelectItem value="approved">已通过</SelectItem>
|
||
<SelectItem value="rejected">已拒绝</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="space-y-3">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-10 w-full" />
|
||
))}
|
||
</div>
|
||
) : comments.length === 0 ? (
|
||
<div className="text-center py-12 text-muted-foreground">暂无评论</div>
|
||
) : (
|
||
<div className="border rounded-lg">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>文章ID</TableHead>
|
||
<TableHead>评论内容</TableHead>
|
||
<TableHead>评论者</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{comments.map((comment: Comment) => (
|
||
<TableRow key={comment.id}>
|
||
<TableCell className="font-medium">{comment.articleId}</TableCell>
|
||
<TableCell className="max-w-[200px] truncate">{comment.content}</TableCell>
|
||
<TableCell className="text-muted-foreground">{comment.authorId}</TableCell>
|
||
<TableCell>
|
||
<Badge
|
||
variant="outline"
|
||
className={COMMENT_STATUS_CLASS[comment.status] || ''}
|
||
>
|
||
{COMMENT_STATUS_LABEL[comment.status] || comment.status}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground">
|
||
{new Date(comment.createdAt).toLocaleString('zh-CN')}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon">
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
{comment.status === 'pending' && (
|
||
<>
|
||
<DropdownMenuItem onClick={() => handleApprove(comment.id)}>
|
||
<CheckCircle className="mr-2 h-4 w-4" />
|
||
通过
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => handleReject(comment.id)}>
|
||
<XCircle className="mr-2 h-4 w-4" />
|
||
拒绝
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
</>
|
||
)}
|
||
<DropdownMenuItem
|
||
onClick={() => handleDelete(comment.id)}
|
||
className="text-destructive"
|
||
>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ContentPage() {
|
||
return (
|
||
<div className="p-6 space-y-6">
|
||
<h1 className="text-2xl font-bold">内容管理</h1>
|
||
|
||
<Tabs defaultValue="articles">
|
||
<TabsList>
|
||
<TabsTrigger value="articles">文章管理</TabsTrigger>
|
||
<TabsTrigger value="categories">分类管理</TabsTrigger>
|
||
<TabsTrigger value="tags">标签管理</TabsTrigger>
|
||
<TabsTrigger value="comments">评论审核</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="articles" className="mt-4">
|
||
<ArticleTab />
|
||
</TabsContent>
|
||
|
||
<TabsContent value="categories" className="mt-4">
|
||
<CategoryTab />
|
||
</TabsContent>
|
||
|
||
<TabsContent value="tags" className="mt-4">
|
||
<TagTab />
|
||
</TabsContent>
|
||
|
||
<TabsContent value="comments" className="mt-4">
|
||
<CommentTab />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
);
|
||
}
|