feat(categories): tree browser, CRUD dialog, pending approval queue with merge
This commit is contained in:
@@ -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),
|
||||||
|
])
|
||||||
|
}
|
||||||
+15
-9
@@ -72,29 +72,35 @@ export interface MeResponse extends ApiResponse<AdminUser> {
|
|||||||
|
|
||||||
// ---------- category ----------
|
// ---------- category ----------
|
||||||
|
|
||||||
|
export interface CategoryTranslation {
|
||||||
|
locale: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
kind: 'category'
|
kind: 'category'
|
||||||
id: string
|
id: string
|
||||||
parent_id: string | null
|
parent_id: string | null
|
||||||
level: number
|
level: number
|
||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
status: string
|
|
||||||
sort_order: number
|
sort_order: number
|
||||||
is_leaf?: boolean
|
is_active: boolean
|
||||||
|
visibility?: string
|
||||||
|
approval_status?: 'pending' | 'approved' | 'rejected' | string
|
||||||
|
source?: string
|
||||||
created_by_uid?: string | null
|
created_by_uid?: string | null
|
||||||
|
approved_by_uid?: string | null
|
||||||
|
approved_at?: string | null
|
||||||
|
merged_into_category_id?: string | null
|
||||||
|
translations?: CategoryTranslation[]
|
||||||
children?: Category[]
|
children?: Category[]
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryTranslation {
|
|
||||||
locale: string
|
|
||||||
name: string
|
|
||||||
description: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- studio ----------
|
// ---------- studio ----------
|
||||||
|
|
||||||
export type ArticleStatus =
|
export type ArticleStatus =
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user