From 455b7c54bb7672a61b865536ab69434a0c2b20e9 Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Fri, 19 Jun 2026 23:11:43 +0800 Subject: [PATCH] =?UTF-8?q?[feat]:[FL-118][=E8=8F=9C=E5=8D=95=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E5=AF=B9=E9=BD=90=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E8=A7=84=E8=8C=83]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- memory/2026-06-19.md | 21 ++++ web/src/app/admin/menus/page.tsx | 198 +++++++++++++------------------ web/src/app/globals.css | 66 +++++++++++ 3 files changed, 170 insertions(+), 115 deletions(-) diff --git a/memory/2026-06-19.md b/memory/2026-06-19.md index 401b6b0..1a5d2fc 100644 --- a/memory/2026-06-19.md +++ b/memory/2026-06-19.md @@ -16,3 +16,24 @@ - 风险与关注点: - 改动仅影响菜单管理页状态切换的前端请求路径、复用逻辑和行级 busy 状态,不改变后端接口、字段结构或菜单 CRUD 其他行为。 + +## Work Log - 菜单管理页对齐用户管理页规范(FL-118) + +- 背景: + - 菜单管理页需要对齐用户管理页的后台列表页布局、筛选、移动卡片和操作确认规范。 + +- 本次处理: + - 为菜单管理页补齐页面 Card flex/body 滚动样式、移动卡片容器/状态/字段/视觉样式。 + - 桌面关键词筛选改为 debounce 自动查询,状态筛选改为 allowClear 且即时生效,移除额外搜索按钮。 + - 菜单列表保存并使用后端 `MenuListResponse.total` 作为分页总数。 + - 启用/禁用统一调用 `/api/v1/admin/menus/{id}`,并复用统一状态更新 loading。 + - 移动卡片移除 body 底部重复“编辑/删除”文字按钮;删除下拉入口改为二次确认。 + +- 验证: + - 基线:`npm run lint` 因缺少 `node_modules` 无法执行;安装依赖后,项目全量 lint 因既有 Cesium public assets 与其他页面 hook 规则错误失败。 + - 修改后:`npx eslint src/app/admin/menus/page.tsx` 通过。 + - 修改后:`npx tsc --noEmit` 通过。 + - 修改后:`npm run lint -- --quiet` 仍因既有非本次改动错误失败。 + +- 风险与关注点: + - 改动仅涉及菜单管理前端页面与全局菜单页样式,不改变后端菜单/权限业务语义。 diff --git a/web/src/app/admin/menus/page.tsx b/web/src/app/admin/menus/page.tsx index 9e77c9f..3841c43 100644 --- a/web/src/app/admin/menus/page.tsx +++ b/web/src/app/admin/menus/page.tsx @@ -91,9 +91,10 @@ function normalizeMenuItemPath(menu: MenuItem): MenuItem { export default function AdminMenusPage() { const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); - const { message: messageApi } = App.useApp(); + const { message: messageApi, modal } = App.useApp(); 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); @@ -105,7 +106,6 @@ export default function AdminMenusPage() { const [keyword, setKeyword] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [activeKeyword, setActiveKeyword] = useState(""); - const [activeStatusFilter, setActiveStatusFilter] = useState("all"); const [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); const [tableScrollY, setTableScrollY] = useState(MENU_TABLE_MIN_SCROLL_Y); const viewMode: "table" | "card" = isMobile ? "card" : "table"; @@ -116,6 +116,8 @@ export default function AdminMenusPage() { const tableScrollAnchorRef = useRef(null); const keywordDebounceTimeoutRef = useRef(null); const pageCardRef = useRef(null); + const paginationCurrent = pagination.current; + const paginationPageSize = pagination.pageSize; const canRead = hasPermission("menu.read") || hasPermission("menu.manage"); const canManage = hasPermission("menu.manage"); @@ -145,7 +147,7 @@ export default function AdminMenusPage() { }, [menus]); const filteredMenus = useMemo(() => { - return menus.sort((a, b) => { + return [...menus].sort((a, b) => { if (a.sort_order !== b.sort_order) { return a.sort_order - b.sort_order; } @@ -153,7 +155,7 @@ export default function AdminMenusPage() { }); }, [menus]); - const loadMenus = useCallback(async (page = pagination.current, pageSize = pagination.pageSize) => { + const loadMenus = useCallback(async (page: number, pageSize: number) => { if (!canRead) { setLoading(false); return; @@ -168,8 +170,8 @@ export default function AdminMenusPage() { if (activeKeyword.trim()) { params.append("keyword", activeKeyword.trim()); } - if (activeStatusFilter !== "all") { - params.append("status", activeStatusFilter); + if (statusFilter !== "all") { + params.append("status", statusFilter); } const url = `/api/v1/admin/menus${params.toString() ? `?${params.toString()}` : ""}`; @@ -182,27 +184,28 @@ export default function AdminMenusPage() { const payload = (await response.json()) as MenuListResponse; setMenus(payload.items.map(normalizeMenuItemPath)); + setMenuTotal(payload.total); setLoading(false); return payload; - }, [canRead, fetchWithAuth, activeKeyword, activeStatusFilter, pagination.current, pagination.pageSize]); + }, [activeKeyword, canRead, fetchWithAuth, statusFilter]); useEffect(() => { if (!user || !canRead) { return; } queueMicrotask(() => { - void loadMenus(); + void loadMenus(paginationCurrent, paginationPageSize); }); - }, [canRead, loadMenus, user]); + }, [canRead, loadMenus, paginationCurrent, paginationPageSize, user]); useTopicSubscription( "admin.menus", useCallback(() => { if (user && canRead) { - void loadMenus(); + void loadMenus(paginationCurrent, paginationPageSize); } - }, [canRead, loadMenus, user]), + }, [canRead, loadMenus, paginationCurrent, paginationPageSize, user]), ); // Update allLoadedMenus when menus data changes in card view @@ -242,30 +245,25 @@ export default function AdminMenusPage() { const clientHeight = cardBody.clientHeight; if (scrollTop + clientHeight >= scrollHeight - 100) { - setIsLoadingMore(true); - setCardViewPage((prev) => { - const nextPage = prev + 1; - void loadMenus(nextPage, 20); - return nextPage; - }); + const loadedCount = allLoadedMenus.length; + + if (loadedCount < menuTotal) { + setIsLoadingMore(true); + setCardViewPage((prev) => prev + 1); + setPagination((prev) => ({ ...prev, current: prev.current + 1 })); + } } }; cardBody.addEventListener("scroll", handleScroll); return () => cardBody.removeEventListener("scroll", handleScroll); - }, [viewMode, isLoadingMore, loading, loadMenus, allLoadedMenus.length]); + }, [allLoadedMenus.length, loading, isLoadingMore, menuTotal, viewMode]); // Reset card view state when search conditions change useEffect(() => { setCardViewPage(1); setAllLoadedMenus([]); - }, [activeStatusFilter, activeKeyword]); - - const handleSearch = useCallback(() => { - setActiveKeyword(keyword); - setActiveStatusFilter(statusFilter); - setPagination((prev) => ({ ...prev, current: 1 })); - }, [keyword, statusFilter]); + }, [activeKeyword, statusFilter]); const handleKeywordChange = (value: string) => { setKeyword(value); @@ -276,9 +274,19 @@ export default function AdminMenusPage() { keywordDebounceTimeoutRef.current = setTimeout(() => { setActiveKeyword(value); + setPagination((prev) => ({ ...prev, current: 1 })); + setCardViewPage(1); + setAllLoadedMenus([]); }, 500); }; + const handleStatusFilterChange = (value?: Exclude) => { + setStatusFilter(value ?? "all"); + setPagination((prev) => ({ ...prev, current: 1 })); + setCardViewPage(1); + setAllLoadedMenus([]); + }; + const closeDialog = useCallback(() => { setDialogOpen(false); setEditingMenuId(null); @@ -350,7 +358,7 @@ export default function AdminMenusPage() { messageApi.success(editingMenuId ? "菜单已更新" : "菜单已创建"); closeDialog(); - await loadMenus(); + await loadMenus(paginationCurrent, paginationPageSize); } catch (candidate) { // Form 校验失败时不额外提示。 if ( @@ -368,7 +376,7 @@ export default function AdminMenusPage() { } finally { setSaving(false); } - }, [closeDialog, editingMenuId, fetchWithAuth, form, loadMenus, messageApi]); + }, [closeDialog, editingMenuId, fetchWithAuth, form, loadMenus, messageApi, paginationCurrent, paginationPageSize]); const removeMenu = useCallback(async (menu: MenuItem) => { setDeletingMenuId(menu.id); @@ -390,11 +398,23 @@ export default function AdminMenusPage() { if (editingMenuId === menu.id) { closeDialog(); } - await loadMenus(); + setMenuTotal((previous) => Math.max(0, previous - 1)); + setAllLoadedMenus((previous) => previous.filter((item) => item.id !== menu.id)); + await loadMenus(paginationCurrent, paginationPageSize); } finally { setDeletingMenuId(null); } - }, [closeDialog, editingMenuId, fetchWithAuth, loadMenus, messageApi]); + }, [closeDialog, editingMenuId, fetchWithAuth, loadMenus, messageApi, paginationCurrent, paginationPageSize]); + + const confirmRemoveMenu = useCallback((menu: MenuItem) => { + modal.confirm({ + title: `确认删除菜单 ${menu.name}(${menu.code})?`, + okText: "删除", + cancelText: "取消", + okButtonProps: { danger: true }, + onOk: () => removeMenu(menu), + }); + }, [modal, removeMenu]); const updateMenuStatus = useCallback(async (menu: MenuItem) => { const nextStatus: "enabled" | "disabled" = menu.status === "enabled" ? "disabled" : "enabled"; @@ -413,7 +433,7 @@ export default function AdminMenusPage() { throw new Error(await readApiError(response)); } - const payload = await loadMenus(); + const payload = await loadMenus(paginationCurrent, paginationPageSize); if (payload) { setSuccess(nextStatus === "enabled" ? "菜单已启用" : "菜单已禁用"); } @@ -422,7 +442,7 @@ export default function AdminMenusPage() { } finally { setUpdatingStatusMenuId(null); } - }, [fetchWithAuth, loadMenus]); + }, [fetchWithAuth, loadMenus, paginationCurrent, paginationPageSize]); const columns = useMemo>(() => { const base: TableColumnsType = [ @@ -479,23 +499,11 @@ export default function AdminMenusPage() { const menuBusy = updatingLoading || deleteLoading; const moreMenuItems: MenuProps["items"] = [ - { - key: "edit", - label: "编辑", - disabled: menuBusy, - onClick: () => startEdit(record), - }, - { - key: "delete", - label: "删除", - disabled: menuBusy, - onClick: () => removeMenu(record), - }, { key: "toggle-status", label: record.status === "enabled" ? "禁用" : "启用", disabled: menuBusy, - onClick: () => updateMenuStatus(record), + onClick: () => void updateMenuStatus(record), }, ]; @@ -535,31 +543,28 @@ export default function AdminMenusPage() { const menuBusy = updatingLoading || deleteLoading; const moreMenuItems: MenuProps["items"] = [ - { - key: "edit", - label: "编辑", - disabled: menuBusy || !canManage, - onClick: () => startEdit(menuItem), - }, { key: "delete", label: "删除", + danger: true, disabled: menuBusy || !canManage, - onClick: () => removeMenu(menuItem), + onClick: () => confirmRemoveMenu(menuItem), }, { key: "toggle-status", label: menuItem.status === "enabled" ? "禁用" : "启用", disabled: menuBusy || !canManage, - onClick: () => updateMenuStatus(menuItem), + onClick: () => void updateMenuStatus(menuItem), }, ]; return ( + {menuItem.name} {menuItem.status === "enabled" ? "已启用" : "已禁用"} @@ -583,54 +588,25 @@ export default function AdminMenusPage() { ) : null } > - -
- 菜单编码: + +
+ 菜单编码 {menuItem.code}
-
- 路径: - {menuItem.path || "-"} +
+ 路径 + {menuItem.path || "-"}
-
- 父菜单: - +
+ 父菜单 + {menuItem.parent_id ? menuNameById.get(menuItem.parent_id) ?? menuItem.parent_id : "-"}
-
- 排序: +
+ 排序 {menuItem.sort_order}
- {canManage && ( -
- - - - -
- )} ); @@ -776,32 +752,23 @@ export default function AdminMenusPage() { setKeyword(event.currentTarget.value)} - onPressEnter={handleSearch} + onChange={(event) => handleKeywordChange(event.currentTarget.value)} placeholder="按编码/名称/路径筛选" /> - - value={statusFilter} - onChange={(value) => setStatusFilter(value)} + > + value={statusFilter === "all" ? undefined : statusFilter} + allowClear + placeholder="全部" + onChange={handleStatusFilterChange} options={[ - { value: "all", label: "全部" }, { value: "enabled", label: "已启用" }, { value: "disabled", label: "已禁用" }, ]} /> - - - - )} @@ -816,14 +783,15 @@ export default function AdminMenusPage() { dataSource={filteredMenus} columns={columns} loading={loading} + tableLayout="fixed" scroll={{ x: 1200, y: tableScrollY }} pagination={{ current: pagination.current, pageSize: pagination.pageSize, - total: filteredMenus.length, + total: Math.max(menuTotal, 1), showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], - showTotal: (total) => `共 ${total} 条`, + showTotal: () => `共 ${menuTotal} 条`, hideOnSinglePage: false, style: { marginBottom: 0 }, onChange: (page, pageSize) => { @@ -838,18 +806,18 @@ export default function AdminMenusPage() { ) : (
{loading && allLoadedMenus.length === 0 ? ( -
+
) : allLoadedMenus.length === 0 ? ( -
+
) : ( - <> +
{allLoadedMenus.map((menuItem) => ( @@ -862,14 +830,14 @@ export default function AdminMenusPage() {
)} - {!loading && !isLoadingMore && allLoadedMenus.length > 0 && ( + {!loading && !isLoadingMore && allLoadedMenus.length >= menuTotal && allLoadedMenus.length > 0 && (
已加载全部 {allLoadedMenus.length} 条数据
)} - +
)}
)} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 9ba0070..fdaf7cd 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -311,6 +311,22 @@ body { min-height: var(--admin-menus-table-body-min-height, 180px); } +.admin-menus-page-card { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; +} + +.admin-menus-page-card > .ant-card-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + overflow-x: hidden; + overflow-y: auto; +} + .admin-users-table-anchor .ant-table-body { min-height: var(--admin-users-table-body-min-height, 180px); } @@ -419,6 +435,56 @@ body { padding-top: 12px; } +.admin-menus-card-view { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.admin-menus-card-view-content { + min-height: 0; + flex: 1; + padding: 2px 2px 4px; +} + +.admin-menus-card-view-state { + display: flex; + min-height: 240px; + flex: 1; + align-items: center; + justify-content: center; +} + +.admin-menus-menu-card { + height: 100%; + border-color: color-mix(in srgb, var(--fquiz-theme-primary) 26%, var(--ant-color-border-secondary)); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--fquiz-theme-bg-container) 96%, var(--fquiz-theme-primary) 4%) 0%, + var(--fquiz-theme-bg-container) 100% + ); + box-shadow: 0 8px 18px color-mix(in srgb, var(--fquiz-theme-text-primary) 8%, transparent); +} + +.admin-menus-menu-card > .ant-card-head { + min-height: 44px; + border-bottom-color: color-mix(in srgb, var(--fquiz-theme-primary) 18%, var(--ant-color-border-secondary)); + background: color-mix(in srgb, var(--fquiz-theme-primary) 6%, transparent); +} + +.admin-menus-menu-card > .ant-card-body { + padding-block: 14px; +} + +.admin-menus-menu-card-field { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 8px; + align-items: baseline; +} + .admin-workers-table-anchor .ant-table-body { min-height: var(--admin-workers-table-body-min-height, 180px); }