feat(categories): tree browser, CRUD dialog, pending approval queue with merge
This commit is contained in:
80
src/api/modules/categories.ts
Normal file
80
src/api/modules/categories.ts
Normal 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),
|
||||
])
|
||||
}
|
||||
@ -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 =
|
||||
|
||||
170
src/features/categories/category-form-dialog.tsx
Normal file
170
src/features/categories/category-form-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
168
src/routes/_authed/categories/index.tsx
Normal file
168
src/routes/_authed/categories/index.tsx
Normal 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
src/routes/_authed/categories/pending.tsx
Normal file
202
src/routes/_authed/categories/pending.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user