feat(categories): tree browser, CRUD dialog, pending approval queue with merge

This commit is contained in:
2026-07-07 22:19:07 +08:00
parent eb617ce436
commit 21ef5c1c37
5 changed files with 635 additions and 9 deletions
+168
View File
@@ -0,0 +1,168 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import type { Category } from '@/api/types'
import { deleteCategory, fetchCategoryTree } from '@/api/modules/categories'
import { handleApiError } from '@/lib/errors'
import { cn } from '@/lib/utils'
import { CategoryFormDialog } from '@/features/categories/category-form-dialog'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
export const Route = createFileRoute('/_authed/categories/')({
component: CategoriesPage,
})
function CategoriesPage() {
const queryClient = useQueryClient()
const tree = useQuery({
queryKey: ['categories', 'tree'],
queryFn: () => fetchCategoryTree(true),
})
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<Category | null>(null)
const [parent, setParent] = useState<Category | null>(null)
const openCreate = (parentNode: Category | null) => {
setEditing(null)
setParent(parentNode)
setDialogOpen(true)
}
const openEdit = (node: Category) => {
setEditing(node)
setParent(null)
setDialogOpen(true)
}
const remove = async (node: Category) => {
try {
await deleteCategory(node.id)
toast.success('分类已删除')
void queryClient.invalidateQueries({ queryKey: ['categories'] })
} catch (error) {
handleApiError(error)
}
}
return (
<div>
<PageHeader
title="分类树"
description="平台服务分类体系;含未启用分类"
actions={
<Button onClick={() => openCreate(null)}>
<Plus className="size-4" />
</Button>
}
/>
{tree.isPending ? (
<div className="flex flex-col gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (
<div className="rounded-lg border">
{(tree.data?.data ?? []).map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
onCreateChild={openCreate}
onEdit={openEdit}
onDelete={remove}
/>
))}
{(tree.data?.data ?? []).length === 0 && (
<p className="p-8 text-center text-sm text-muted-foreground"></p>
)}
</div>
)}
<CategoryFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
category={editing}
parent={parent}
/>
</div>
)
}
function TreeNode({
node,
depth,
onCreateChild,
onEdit,
onDelete,
}: {
node: Category
depth: number
onCreateChild: (parent: Category) => void
onEdit: (node: Category) => void
onDelete: (node: Category) => Promise<void>
}) {
const [open, setOpen] = useState(depth === 0)
const children = node.children ?? []
return (
<div className={cn(depth > 0 && 'border-l')} style={{ marginLeft: depth > 0 ? 20 : 0 }}>
<div className="group flex items-center gap-2 border-b px-3 py-2 last:border-b-0 hover:bg-muted/40">
<button
onClick={() => setOpen((v) => !v)}
className={cn(
'flex size-5 items-center justify-center rounded hover:bg-muted',
children.length === 0 && 'invisible',
)}
aria-label={open ? '收起' : '展开'}
>
<ChevronRight className={cn('size-4 transition-transform', open && 'rotate-90')} />
</button>
<span className="font-medium">{node.name ?? node.slug}</span>
<code className="font-mono text-xs text-muted-foreground">{node.slug}</code>
{!node.is_active && <Badge variant="secondary"></Badge>}
{node.approval_status === 'pending' && <Badge variant="outline"></Badge>}
<span className="ml-auto hidden items-center gap-1 group-hover:flex">
<Button variant="ghost" size="icon-sm" onClick={() => onCreateChild(node)} aria-label="新建子分类">
<Plus className="size-4" />
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => onEdit(node)} aria-label="编辑">
<Pencil className="size-4" />
</Button>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label="删除">
<Trash2 className="size-4 text-destructive" />
</Button>
}
title={`删除分类「${node.name ?? node.slug}」?`}
description="删除后不可恢复;有子分类或被引用时后端会拒绝。"
confirmLabel="删除"
destructive
onConfirm={() => onDelete(node)}
/>
</span>
</div>
{open &&
children.map((child) => (
<TreeNode
key={child.id}
node={child}
depth={depth + 1}
onCreateChild={onCreateChild}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
)
}
+202
View File
@@ -0,0 +1,202 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
import type { Category } from '@/api/types'
import {
approveCategory,
fetchCategoryTree,
fetchPendingCategories,
flattenTree,
mergeCategory,
rejectCategory,
} from '@/api/modules/categories'
import { handleApiError } from '@/lib/errors'
import { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/categories/pending')({
component: PendingCategoriesPage,
})
function PendingCategoriesPage() {
const queryClient = useQueryClient()
const list = useListPage('categories-pending', fetchPendingCategories)
const [mergeSource, setMergeSource] = useState<Category | null>(null)
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['categories'] })
const refresh = () => {
void invalidate()
void list.query.refetch()
}
const approve = async (row: Category) => {
try {
await approveCategory(row.id)
toast.success(`已通过「${row.name ?? row.slug}`)
refresh()
} catch (error) {
handleApiError(error)
}
}
const reject = async (row: Category) => {
try {
await rejectCategory(row.id)
toast.success(`已驳回「${row.name ?? row.slug}`)
refresh()
} catch (error) {
handleApiError(error)
}
}
const columns: ColumnDef<Category>[] = [
{
header: '名称',
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.name ?? row.original.slug}</div>
<code className="font-mono text-xs text-muted-foreground">{row.original.slug}</code>
</div>
),
},
{
header: '提交人',
cell: ({ row }) => (
<code className="font-mono text-xs">{row.original.created_by_uid ?? '—'}</code>
),
},
{
header: '提交时间',
cell: ({ row }) =>
row.original.created_at ? new Date(row.original.created_at).toLocaleString('zh-CN') : '—',
},
{
header: '操作',
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
<ConfirmDialog
trigger={<Button size="sm"></Button>}
title={`通过分类「${row.original.name ?? row.original.slug}」?`}
description="通过后将进入正式分类树,对用户可见。"
onConfirm={() => approve(row.original)}
/>
<ConfirmDialog
trigger={
<Button size="sm" variant="outline">
</Button>
}
title={`驳回分类「${row.original.name ?? row.original.slug}」?`}
confirmLabel="驳回"
destructive
onConfirm={() => reject(row.original)}
/>
<Button size="sm" variant="ghost" onClick={() => setMergeSource(row.original)}>
</Button>
</div>
),
},
]
return (
<div>
<PageHeader title="待审核分类" description="用户创建的分类,需要审核后才对外可见" />
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
emptyText="没有待审核的分类"
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<MergeDialog source={mergeSource} onClose={() => setMergeSource(null)} onMerged={refresh} />
</div>
)
}
function MergeDialog({
source,
onClose,
onMerged,
}: {
source: Category | null
onClose: () => void
onMerged: () => void
}) {
const [targetId, setTargetId] = useState('')
const [busy, setBusy] = useState(false)
const tree = useQuery({
queryKey: ['categories', 'tree'],
queryFn: () => fetchCategoryTree(true),
enabled: source !== null,
})
const options = flattenTree(tree.data?.data ?? []).filter((c) => c.id !== source?.id)
const submit = async () => {
if (!source || !targetId) return
setBusy(true)
try {
await mergeCategory(source.id, targetId)
toast.success('已合并')
onMerged()
onClose()
} catch (error) {
handleApiError(error)
} finally {
setBusy(false)
}
}
return (
<Dialog open={source !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{source?.name ?? source?.slug}</DialogTitle>
</DialogHeader>
<Select value={targetId} onValueChange={setTargetId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择目标分类" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{' '.repeat(option.depth)}
{option.name ?? option.slug}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
</Button>
<Button onClick={submit} disabled={!targetId || busy}>
{busy ? '合并中…' : '确认合并'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}