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

View File

@ -0,0 +1,80 @@
import { api } from '@/api/client'
import type {
ApiResponse,
Category,
CategoryTranslation,
ListParams,
PaginatedResponse,
} from '@/api/types'
const BASE = '/api/admin/v1/categories'
export interface CategoryPayload {
parent_id?: string | null
slug?: string
sort_order?: number
is_active?: boolean
translations?: Array<{ locale: string; name: string; description?: string | null }>
}
export function fetchCategoryTree(includeInactive = true) {
return api.get<ApiResponse<Category[]>>(`${BASE}/tree`, {
include_inactive: includeInactive,
})
}
export function fetchPendingCategories(params: ListParams) {
return api.get<PaginatedResponse<Category>>(`${BASE}/pending`, params)
}
export function fetchCategory(id: string) {
return api.get<ApiResponse<Category>>(`${BASE}/${id}`)
}
export function createCategory(payload: CategoryPayload) {
return api.post<ApiResponse<Category>>(BASE, payload)
}
export function updateCategory(id: string, payload: CategoryPayload) {
return api.put<ApiResponse<Category>>(`${BASE}/${id}`, payload)
}
export function deleteCategory(id: string) {
return api.delete<ApiResponse<null>>(`${BASE}/${id}`)
}
export function approveCategory(id: string) {
return api.post<ApiResponse<Category>>(`${BASE}/${id}/approve`)
}
export function rejectCategory(id: string) {
return api.post<ApiResponse<Category>>(`${BASE}/${id}/reject`)
}
export function mergeCategory(id: string, targetId: string) {
return api.post<ApiResponse<Category>>(`${BASE}/${id}/merge`, { target_id: targetId })
}
export function fetchCategoryTranslations(id: string) {
return api.get<ApiResponse<CategoryTranslation[]>>(`${BASE}/${id}/translations`)
}
export function upsertCategoryTranslation(
id: string,
locale: string,
payload: { name: string; description?: string | null },
) {
return api.post<ApiResponse<CategoryTranslation>>(`${BASE}/${id}/translations/${locale}`, payload)
}
export function deleteCategoryTranslation(id: string, locale: string) {
return api.delete<ApiResponse<null>>(`${BASE}/${id}/translations/${locale}`)
}
/** Flatten the tree for parent/merge-target pickers. */
export function flattenTree(nodes: Category[], depth = 0): Array<Category & { depth: number }> {
return nodes.flatMap((node) => [
{ ...node, depth },
...flattenTree(node.children ?? [], depth + 1),
])
}

View File

@ -72,29 +72,35 @@ export interface MeResponse extends ApiResponse<AdminUser> {
// ---------- category ----------
export interface CategoryTranslation {
locale: string
name: string
description: string | null
}
export interface Category {
kind: 'category'
id: string
parent_id: string | null
level: number
slug: string
name: string
name: string | null
description: string | null
status: string
sort_order: number
is_leaf?: boolean
is_active: boolean
visibility?: string
approval_status?: 'pending' | 'approved' | 'rejected' | string
source?: string
created_by_uid?: string | null
approved_by_uid?: string | null
approved_at?: string | null
merged_into_category_id?: string | null
translations?: CategoryTranslation[]
children?: Category[]
created_at?: string
updated_at?: string
}
export interface CategoryTranslation {
locale: string
name: string
description: string | null
}
// ---------- studio ----------
export type ArticleStatus =

View File

@ -0,0 +1,170 @@
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { Category } from '@/api/types'
import {
createCategory,
updateCategory,
type CategoryPayload,
} from '@/api/modules/categories'
import { handleApiError } from '@/lib/errors'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
const schema = z.object({
slug: z.string().min(1, '请输入 slug').max(128),
sort_order: z.coerce.number().int().min(0),
is_active: z.boolean(),
name_zh: z.string().min(1, '请输入中文名称').max(255),
description_zh: z.string().optional(),
name_en: z.string().max(255).optional(),
description_en: z.string().optional(),
})
type FormValues = z.infer<typeof schema>
interface CategoryFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** editing this category; null = creating */
category: Category | null
/** parent for new sub-categories */
parent?: Category | null
}
export function CategoryFormDialog({ open, onOpenChange, category, parent }: CategoryFormDialogProps) {
const queryClient = useQueryClient()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { slug: '', sort_order: 0, is_active: true, name_zh: '' },
})
useEffect(() => {
if (!open) return
const zh = category?.translations?.find((t) => t.locale.toLowerCase().startsWith('zh'))
const en = category?.translations?.find((t) => t.locale === 'en')
form.reset({
slug: category?.slug ?? '',
sort_order: category?.sort_order ?? 0,
is_active: category?.is_active ?? true,
name_zh: zh?.name ?? category?.name ?? '',
description_zh: zh?.description ?? '',
name_en: en?.name ?? '',
description_en: en?.description ?? '',
})
}, [open, category, form])
const mutation = useMutation({
mutationFn: (values: FormValues) => {
const translations: NonNullable<CategoryPayload['translations']> = [
{ locale: 'zh-CN', name: values.name_zh, description: values.description_zh || null },
]
if (values.name_en) {
translations.push({
locale: 'en',
name: values.name_en,
description: values.description_en || null,
})
}
const payload: CategoryPayload = {
slug: values.slug,
sort_order: values.sort_order,
is_active: values.is_active,
translations,
}
if (!category && parent) payload.parent_id = parent.id
return category ? updateCategory(category.id, payload) : createCategory(payload)
},
onSuccess: () => {
toast.success(category ? '分类已更新' : '分类已创建')
void queryClient.invalidateQueries({ queryKey: ['categories'] })
onOpenChange(false)
},
onError: (error) => handleApiError(error, form.setError),
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{category ? '编辑分类' : parent ? `新建子分类 · ${parent.name ?? parent.slug}` : '新建顶级分类'}
</DialogTitle>
</DialogHeader>
<form
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
className="grid gap-4"
>
<div className="grid grid-cols-2 gap-4">
<Field label="Slug" error={form.formState.errors.slug?.message}>
<Input {...form.register('slug')} placeholder="home-cleaning" />
</Field>
<Field label="排序" error={form.formState.errors.sort_order?.message}>
<Input type="number" {...form.register('sort_order')} />
</Field>
</div>
<Field label="中文名称" error={form.formState.errors.name_zh?.message}>
<Input {...form.register('name_zh')} placeholder="打扫卫生" />
</Field>
<Field label="中文描述">
<Textarea rows={2} {...form.register('description_zh')} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="英文名称(可选)">
<Input {...form.register('name_en')} placeholder="House Cleaning" />
</Field>
<Field label="英文描述(可选)">
<Input {...form.register('description_en')} />
</Field>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={form.watch('is_active')}
onCheckedChange={(checked) => form.setValue('is_active', checked === true)}
/>
</label>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '保存中…' : '保存'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function Field({
label,
error,
children,
}: {
label: string
error?: string
children: React.ReactNode
}) {
return (
<div className="grid gap-1.5">
<Label>{label}</Label>
{children}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)
}

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

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