diff --git a/src/api/modules/categories.ts b/src/api/modules/categories.ts new file mode 100644 index 0000000..faeef59 --- /dev/null +++ b/src/api/modules/categories.ts @@ -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>(`${BASE}/tree`, { + include_inactive: includeInactive, + }) +} + +export function fetchPendingCategories(params: ListParams) { + return api.get>(`${BASE}/pending`, params) +} + +export function fetchCategory(id: string) { + return api.get>(`${BASE}/${id}`) +} + +export function createCategory(payload: CategoryPayload) { + return api.post>(BASE, payload) +} + +export function updateCategory(id: string, payload: CategoryPayload) { + return api.put>(`${BASE}/${id}`, payload) +} + +export function deleteCategory(id: string) { + return api.delete>(`${BASE}/${id}`) +} + +export function approveCategory(id: string) { + return api.post>(`${BASE}/${id}/approve`) +} + +export function rejectCategory(id: string) { + return api.post>(`${BASE}/${id}/reject`) +} + +export function mergeCategory(id: string, targetId: string) { + return api.post>(`${BASE}/${id}/merge`, { target_id: targetId }) +} + +export function fetchCategoryTranslations(id: string) { + return api.get>(`${BASE}/${id}/translations`) +} + +export function upsertCategoryTranslation( + id: string, + locale: string, + payload: { name: string; description?: string | null }, +) { + return api.post>(`${BASE}/${id}/translations/${locale}`, payload) +} + +export function deleteCategoryTranslation(id: string, locale: string) { + return api.delete>(`${BASE}/${id}/translations/${locale}`) +} + +/** Flatten the tree for parent/merge-target pickers. */ +export function flattenTree(nodes: Category[], depth = 0): Array { + return nodes.flatMap((node) => [ + { ...node, depth }, + ...flattenTree(node.children ?? [], depth + 1), + ]) +} diff --git a/src/api/types.ts b/src/api/types.ts index a526f53..d6da93a 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -72,29 +72,35 @@ export interface MeResponse extends ApiResponse { // ---------- 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 = diff --git a/src/features/categories/category-form-dialog.tsx b/src/features/categories/category-form-dialog.tsx new file mode 100644 index 0000000..9864432 --- /dev/null +++ b/src/features/categories/category-form-dialog.tsx @@ -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 + +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({ + 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 = [ + { 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 ( + + + + + {category ? '编辑分类' : parent ? `新建子分类 · ${parent.name ?? parent.slug}` : '新建顶级分类'} + + +
mutation.mutate(values))} + className="grid gap-4" + > +
+ + + + + + +
+ + + + +