From 6989775abecc25cc10df23374ded2d5ae55b99d1 Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Sat, 20 Jun 2026 04:13:56 +0800 Subject: [PATCH] =?UTF-8?q?[fix]:[FL-151][=E8=8F=9C=E5=8D=95=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5React=20Query=E6=9E=B6=E6=9E=84=E5=AF=B9?= =?UTF-8?q?=E9=BD=90]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- memory/2026-06-20.md | 21 ++ web/src/app/admin/menus/page.tsx | 357 +++++++++++++++++-------------- 2 files changed, 221 insertions(+), 157 deletions(-) diff --git a/memory/2026-06-20.md b/memory/2026-06-20.md index 05441ea..2cb60f8 100644 --- a/memory/2026-06-20.md +++ b/memory/2026-06-20.md @@ -18,6 +18,27 @@ - 风险与关注点: - 改动仅影响菜单管理页前端展示与提示机制,不改变菜单接口、字段结构或权限语义。 +## Follow-up - 菜单管理页 React Query 架构对齐(FL-151) + +- 背景: + - 评审继续指出菜单管理页仍使用手动 `useState` + `loadMenus` 数据管理,与用户管理页 React Query 架构不一致。 + +- 本次处理: + - 菜单列表查询改为 `useQuery`,由 `menusQuery` 承接 loading、error、数据与 total。 + - 创建、编辑、删除、启用/禁用改为 `useMutation`,并通过 `queryClient.refetchQueries` 刷新菜单数据。 + - `activeKeyword` 统一命名为 `searchKeyword`,移除手动 `loading` / `saving` / `menus` / `menuTotal` state。 + - 实时订阅改为 `queryClient.invalidateQueries({ queryKey: ["admin.menus"] })`。 + - 错误反馈改为组合本地错误与 `menusQuery.error` 后交给 `useToastFeedback`。 + +- 验证: + - 基线:`npm --workspace web exec eslint src/app/admin/menus/page.tsx` 通过。 + - 基线:`npm --workspace web exec tsc --noEmit` 通过。 + - 修改后:`npm --workspace web exec eslint src/app/admin/menus/page.tsx` 通过。 + - 修改后:`npm --workspace web exec tsc --noEmit` 通过。 + +- 风险与关注点: + - 改动仅迁移菜单管理页前端数据管理架构,不改变 `/api/v1/admin/menus*` 接口路径、请求字段或权限语义。 + # Work Log - 角色管理页对齐用户管理页规范(FL-152) - 背景: diff --git a/web/src/app/admin/menus/page.tsx b/web/src/app/admin/menus/page.tsx index f73bf3a..fdeea33 100644 --- a/web/src/app/admin/menus/page.tsx +++ b/web/src/app/admin/menus/page.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button, @@ -54,6 +55,20 @@ type MenuFormValues = { component?: string; }; +type MenuMutationPayload = { + code: string; + name: string; + path: string | null; + icon: string | null; + parent_id: string | null; + type: MenuFormValues["type"]; + sort_order: number; + status: MenuFormValues["status"]; + visible: boolean; + cacheable: boolean; + component: string | null; +}; + const DEFAULT_FORM_VALUES: MenuFormValues = { code: "", name: "", @@ -90,11 +105,8 @@ function normalizeMenuItemPath(menu: MenuItem): MenuItem { export default function AdminMenusPage() { const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); + const queryClient = useQueryClient(); const isMobile = useMobileDetection(); - const [menus, setMenus] = useState([]); - const [menuTotal, setMenuTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); const [deletingMenuId, setDeletingMenuId] = useState(null); const [updatingStatusMenuId, setUpdatingStatusMenuId] = useState(null); const [error, setError] = useState(""); @@ -103,7 +115,7 @@ export default function AdminMenusPage() { const [editingMenuId, setEditingMenuId] = useState(null); const [keyword, setKeyword] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); - const [activeKeyword, setActiveKeyword] = useState(""); + const [searchKeyword, setSearchKeyword] = useState(""); const [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); const [tableScrollY, setTableScrollY] = useState(MENU_TABLE_MIN_SCROLL_Y); const viewMode: "table" | "card" = isMobile ? "card" : "table"; @@ -120,8 +132,44 @@ export default function AdminMenusPage() { const canRead = hasPermission("menu.read") || hasPermission("menu.manage"); const canManage = hasPermission("menu.manage"); + const trimmedKeyword = searchKeyword.trim(); + const menusQueryParams = useMemo(() => { + const params = new URLSearchParams(); + params.set("limit", String(paginationPageSize)); + params.set("offset", String((paginationCurrent - 1) * paginationPageSize)); + if (trimmedKeyword) { + params.set("keyword", trimmedKeyword); + } + if (statusFilter !== "all") { + params.set("status", statusFilter); + } + return params.toString(); + }, [paginationCurrent, paginationPageSize, statusFilter, trimmedKeyword]); + const menusPath = `/api/v1/admin/menus?${menusQueryParams}`; + + const loadMenus = useCallback(async () => { + const response = await fetchWithAuth(menusPath); + if (!response.ok) throw new Error(await readApiError(response)); + const payload = (await response.json()) as MenuListResponse; + return { + ...payload, + items: payload.items.map(normalizeMenuItemPath), + } satisfies MenuListResponse; + }, [fetchWithAuth, menusPath]); + + const menusQuery = useQuery({ + queryKey: ["admin.menus", menusQueryParams], + queryFn: loadMenus, + enabled: !!user && canRead, + }); + + const menus = useMemo(() => menusQuery.data?.items ?? [], [menusQuery.data?.items]); + const menuTotal = menusQuery.data?.total ?? 0; + const queryError = menusQuery.error instanceof Error ? menusQuery.error.message : ""; + const anyError = error || queryError; + useToastFeedback({ - errorMessage: error, + errorMessage: anyError, successMessage: success, clearError: () => setError(""), clearSuccess: () => setSuccess(""), @@ -153,64 +201,28 @@ export default function AdminMenusPage() { }); }, [menus]); - const loadMenus = useCallback(async (page: number, pageSize: number) => { - if (!canRead) { - setLoading(false); - return; - } - - setLoading(true); - setError(""); - - const params = new URLSearchParams(); - params.append("limit", String(pageSize)); - params.append("offset", String((page - 1) * pageSize)); - if (activeKeyword.trim()) { - params.append("keyword", activeKeyword.trim()); - } - if (statusFilter !== "all") { - params.append("status", statusFilter); - } - - const url = `/api/v1/admin/menus${params.toString() ? `?${params.toString()}` : ""}`; - const response = await fetchWithAuth(url); - if (!response.ok) { - setError(await readApiError(response)); - setLoading(false); - return; - } - - const payload = (await response.json()) as MenuListResponse; - setMenus(payload.items.map(normalizeMenuItemPath)); - setMenuTotal(payload.total); - setLoading(false); - return payload; - }, [activeKeyword, canRead, fetchWithAuth, statusFilter]); - - useEffect(() => { - if (!user || !canRead) { - return; - } - queueMicrotask(() => { - void loadMenus(paginationCurrent, paginationPageSize); - }); - }, [canRead, loadMenus, paginationCurrent, paginationPageSize, user]); - - useTopicSubscription( "admin.menus", useCallback(() => { if (user && canRead) { - void loadMenus(paginationCurrent, paginationPageSize); + void queryClient.invalidateQueries({ queryKey: ["admin.menus"] }); } - }, [canRead, loadMenus, paginationCurrent, paginationPageSize, user]), + }, [canRead, queryClient, user]), ); + const refreshData = async () => { + await queryClient.refetchQueries({ queryKey: ["admin.menus"] }); + }; + // Update allLoadedMenus when menus data changes in card view useEffect(() => { - if (viewMode === "card" && !loading) { + if (viewMode !== "card" || menusQuery.isLoading) { + return; + } + + const frameId = window.requestAnimationFrame(() => { if (cardViewPage === 1) { - setAllLoadedMenus(menus); + setAllLoadedMenus(() => menus); } else { setAllLoadedMenus((prev) => { if (menus.length === 0) { @@ -222,8 +234,12 @@ export default function AdminMenusPage() { }); } setIsLoadingMore(false); - } - }, [menus, loading, viewMode, cardViewPage]); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [menus, menusQuery.isLoading, viewMode, cardViewPage]); // Handle infinite scroll for card view useEffect(() => { @@ -236,7 +252,7 @@ export default function AdminMenusPage() { if (!cardBody) return; const handleScroll = () => { - if (isLoadingMore || loading) return; + if (isLoadingMore || menusQuery.isLoading) return; const scrollTop = cardBody.scrollTop; const scrollHeight = cardBody.scrollHeight; @@ -255,13 +271,7 @@ export default function AdminMenusPage() { cardBody.addEventListener("scroll", handleScroll); return () => cardBody.removeEventListener("scroll", handleScroll); - }, [allLoadedMenus.length, loading, isLoadingMore, menuTotal, viewMode]); - - // Reset card view state when search conditions change - useEffect(() => { - setCardViewPage(1); - setAllLoadedMenus([]); - }, [activeKeyword, statusFilter]); + }, [allLoadedMenus.length, menusQuery.isLoading, isLoadingMore, menuTotal, viewMode]); const handleKeywordChange = (value: string) => { setKeyword(value); @@ -271,7 +281,7 @@ export default function AdminMenusPage() { } keywordDebounceTimeoutRef.current = setTimeout(() => { - setActiveKeyword(value); + setSearchKeyword(value); setPagination((prev) => ({ ...prev, current: 1 })); setCardViewPage(1); setAllLoadedMenus([]); @@ -315,14 +325,114 @@ export default function AdminMenusPage() { setDialogOpen(true); }, [form]); - const submit = useCallback(async () => { - try { - setSaving(true); + const createMenuMutation = useMutation({ + mutationFn: async (payload: MenuMutationPayload) => { + const response = await fetchWithAuth("/api/v1/admin/menus", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(await readApiError(response)); + return response.json() as Promise; + }, + onMutate: () => { setError(""); setSuccess(""); + }, + onSuccess: async () => { + setSuccess("菜单已创建"); + closeDialog(); + await refreshData(); + }, + onError: (candidate) => { + setSuccess(""); + setError(candidate instanceof Error ? candidate.message : "创建菜单失败"); + }, + }); + const updateMenuMutation = useMutation({ + mutationFn: async ({ menuId, payload }: { menuId: string; payload: MenuMutationPayload }) => { + const response = await fetchWithAuth(`/api/v1/admin/menus/${menuId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(await readApiError(response)); + return response.json() as Promise; + }, + onMutate: () => { + setError(""); + setSuccess(""); + }, + onSuccess: async () => { + setSuccess("菜单已更新"); + closeDialog(); + await refreshData(); + }, + onError: (candidate) => { + setSuccess(""); + setError(candidate instanceof Error ? candidate.message : "更新菜单失败"); + }, + }); + + const deleteMenuMutation = useMutation({ + mutationFn: async (menuId: string) => { + const response = await fetchWithAuth(`/api/v1/admin/menus/${menuId}`, { + method: "DELETE", + }); + if (!response.ok) throw new Error(await readApiError(response)); + return response.json() as Promise<{ message: string }>; + }, + onMutate: (menuId) => { + setDeletingMenuId(menuId); + setError(""); + setSuccess(""); + }, + onSuccess: async (_, menuId) => { + setSuccess("菜单已删除"); + if (editingMenuId === menuId) { + closeDialog(); + } + setAllLoadedMenus((previous) => previous.filter((item) => item.id !== menuId)); + await refreshData(); + }, + onError: (candidate) => { + setSuccess(""); + setError(candidate instanceof Error ? candidate.message : "菜单删除失败"); + }, + onSettled: () => setDeletingMenuId(null), + }); + + const updateMenuStatusMutation = useMutation({ + mutationFn: async ({ menuId, status }: { menuId: string; status: "enabled" | "disabled" }) => { + const response = await fetchWithAuth(`/api/v1/admin/menus/${menuId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + if (!response.ok) throw new Error(await readApiError(response)); + return response.json() as Promise; + }, + onMutate: ({ menuId }) => { + setUpdatingStatusMenuId(menuId); + setError(""); + setSuccess(""); + }, + onSuccess: async (_, variables) => { + setSuccess(variables.status === "enabled" ? "菜单已启用" : "菜单已禁用"); + await refreshData(); + }, + onError: (candidate) => { + setSuccess(""); + setError(candidate instanceof Error ? candidate.message : "菜单状态更新失败"); + }, + onSettled: () => setUpdatingStatusMenuId(null), + }); + + const submit = useCallback(async () => { + try { const values = await form.validateFields(); - const payload = { + const payload: MenuMutationPayload = { code: values.code.trim(), name: values.name.trim(), path: normalizeAppRoutePath(values.path?.trim() ? values.path.trim() : null), @@ -336,27 +446,11 @@ export default function AdminMenusPage() { component: values.component?.trim() ? values.component.trim() : null, }; - const response = editingMenuId - ? await fetchWithAuth(`/api/v1/admin/menus/${editingMenuId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }) - : await fetchWithAuth("/api/v1/admin/menus", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const msg = await readApiError(response); - setError(msg); - return; + if (editingMenuId) { + updateMenuMutation.mutate({ menuId: editingMenuId, payload }); + } else { + createMenuMutation.mutate(payload); } - - setSuccess(editingMenuId ? "菜单已更新" : "菜单已创建"); - closeDialog(); - await loadMenus(paginationCurrent, paginationPageSize); } catch (candidate) { // Form 校验失败时不额外提示。 if ( @@ -370,39 +464,8 @@ export default function AdminMenusPage() { const msg = candidate instanceof Error ? candidate.message : "提交失败,请稍后重试"; setError(msg); - } finally { - setSaving(false); } - }, [closeDialog, editingMenuId, fetchWithAuth, form, loadMenus, paginationCurrent, paginationPageSize]); - - const removeMenu = useCallback(async (menu: MenuItem) => { - setDeletingMenuId(menu.id); - setError(""); - setSuccess(""); - - try { - const response = await fetchWithAuth(`/api/v1/admin/menus/${menu.id}`, { - method: "DELETE", - }); - if (!response.ok) { - const msg = await readApiError(response); - setError(msg); - return; - } - - setSuccess("菜单已删除"); - if (editingMenuId === menu.id) { - closeDialog(); - } - setMenuTotal((previous) => Math.max(0, previous - 1)); - setAllLoadedMenus((previous) => previous.filter((item) => item.id !== menu.id)); - await loadMenus(paginationCurrent, paginationPageSize); - } catch (candidate) { - setError(candidate instanceof Error ? candidate.message : "菜单删除失败"); - } finally { - setDeletingMenuId(null); - } - }, [closeDialog, editingMenuId, fetchWithAuth, loadMenus, paginationCurrent, paginationPageSize]); + }, [createMenuMutation, editingMenuId, form, updateMenuMutation]); const confirmRemoveMenu = useCallback((menu: MenuItem) => { Modal.confirm({ @@ -410,37 +473,14 @@ export default function AdminMenusPage() { okText: "删除", cancelText: "取消", okButtonProps: { danger: true }, - onOk: () => removeMenu(menu), + onOk: () => deleteMenuMutation.mutate(menu.id), }); - }, [removeMenu]); + }, [deleteMenuMutation]); const updateMenuStatus = useCallback(async (menu: MenuItem) => { const nextStatus: "enabled" | "disabled" = menu.status === "enabled" ? "disabled" : "enabled"; - - setUpdatingStatusMenuId(menu.id); - setError(""); - setSuccess(""); - - try { - const response = await fetchWithAuth(`/api/v1/admin/menus/${menu.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: nextStatus }), - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - - const payload = await loadMenus(paginationCurrent, paginationPageSize); - if (payload) { - setSuccess(nextStatus === "enabled" ? "菜单已启用" : "菜单已禁用"); - } - } catch (candidate) { - setError(candidate instanceof Error ? candidate.message : "菜单状态更新失败"); - } finally { - setUpdatingStatusMenuId(null); - } - }, [fetchWithAuth, loadMenus, paginationCurrent, paginationPageSize]); + updateMenuStatusMutation.mutate({ menuId: menu.id, status: nextStatus }); + }, [updateMenuStatusMutation]); const columns = useMemo>(() => { const base: TableColumnsType = [ @@ -516,7 +556,7 @@ export default function AdminMenusPage() { okText="删除" cancelText="取消" okButtonProps={{ danger: true, loading: deleteLoading }} - onConfirm={() => removeMenu(record)} + onConfirm={() => deleteMenuMutation.mutate(record.id)} disabled={menuBusy} >