fischerX/apps/web/src/app/(dashboard)/content/page.tsx

1216 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}