[feat]:[FL-118][菜单管理页对齐用户管理页规范]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-19 23:11:43 +08:00
parent 22ef1f0055
commit 455b7c54bb
3 changed files with 170 additions and 115 deletions
+21
View File
@@ -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` 仍因既有非本次改动错误失败。
- 风险与关注点:
- 改动仅涉及菜单管理前端页面与全局菜单页样式,不改变后端菜单/权限业务语义。
+82 -114
View File
@@ -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<MenuItem[]>([]);
const [menuTotal, setMenuTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [deletingMenuId, setDeletingMenuId] = useState<string | null>(null);
@@ -105,7 +106,6 @@ export default function AdminMenusPage() {
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<FilterStatus>("all");
const [activeKeyword, setActiveKeyword] = useState("");
const [activeStatusFilter, setActiveStatusFilter] = useState<FilterStatus>("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<HTMLDivElement | null>(null);
const keywordDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pageCardRef = useRef<HTMLDivElement | null>(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) {
const loadedCount = allLoadedMenus.length;
if (loadedCount < menuTotal) {
setIsLoadingMore(true);
setCardViewPage((prev) => {
const nextPage = prev + 1;
void loadMenus(nextPage, 20);
return nextPage;
});
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<FilterStatus, "all">) => {
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<TableColumnsType<MenuItem>>(() => {
const base: TableColumnsType<MenuItem> = [
@@ -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 (
<AntCard
key={menuItem.id}
className="admin-menus-menu-card"
size="small"
title={
<Space>
<Space className="min-w-0" size={8}>
<Typography.Text strong>{menuItem.name}</Typography.Text>
<Tag color={menuItem.status === "enabled" ? "success" : "default"}>
{menuItem.status === "enabled" ? "已启用" : "已禁用"}
@@ -583,54 +588,25 @@ export default function AdminMenusPage() {
) : null
}
>
<Space direction="vertical" size={4} style={{ width: "100%" }}>
<div>
<Typography.Text type="secondary"></Typography.Text>
<Space direction="vertical" size={10} style={{ width: "100%" }}>
<div className="admin-menus-menu-card-field">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text className="font-mono text-xs">{menuItem.code}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text>{menuItem.path || "-"}</Typography.Text>
<div className="admin-menus-menu-card-field">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text ellipsis={{ tooltip: menuItem.path || "-" }}>{menuItem.path || "-"}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text>
<div className="admin-menus-menu-card-field">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text ellipsis>
{menuItem.parent_id ? menuNameById.get(menuItem.parent_id) ?? menuItem.parent_id : "-"}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
<div className="admin-menus-menu-card-field">
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text>{menuItem.sort_order}</Typography.Text>
</div>
{canManage && (
<div style={{ marginTop: 8 }}>
<Space wrap>
<Button
size="small"
disabled={menuBusy}
onClick={() => startEdit(menuItem)}
>
</Button>
<Button
danger
size="small"
disabled={menuBusy}
onClick={() => {
Modal.confirm({
title: `确认删除菜单 ${menuItem.name}${menuItem.code})?`,
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => removeMenu(menuItem),
});
}}
>
</Button>
</Space>
</div>
)}
</Space>
</AntCard>
);
@@ -776,32 +752,23 @@ export default function AdminMenusPage() {
<Input
allowClear
value={keyword}
onChange={(event) => setKeyword(event.currentTarget.value)}
onPressEnter={handleSearch}
onChange={(event) => handleKeywordChange(event.currentTarget.value)}
placeholder="按编码/名称/路径筛选"
/>
</Form.Item>
<Form.Item label="状态" className="min-w-[170px]">
<Select<FilterStatus>
value={statusFilter}
onChange={(value) => setStatusFilter(value)}
<Select<Exclude<FilterStatus, "all">>
value={statusFilter === "all" ? undefined : statusFilter}
allowClear
placeholder="全部"
onChange={handleStatusFilterChange}
options={[
{ value: "all", label: "全部" },
{ value: "enabled", label: "已启用" },
{ value: "disabled", label: "已禁用" },
]}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
onClick={handleSearch}
>
</Button>
</Form.Item>
</Form>
)}
@@ -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() {
) : (
<div className="admin-menus-card-view mt-4">
{loading && allLoadedMenus.length === 0 ? (
<div style={{ textAlign: "center", padding: "60px 0" }}>
<div className="admin-menus-card-view-state">
<Spin tip="加载中..." />
</div>
) : allLoadedMenus.length === 0 ? (
<div style={{ textAlign: "center", padding: "60px 0" }}>
<div className="admin-menus-card-view-state">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的菜单项。"
/>
</div>
) : (
<>
<div className="admin-menus-card-view-content">
<Row gutter={[12, 12]}>
{allLoadedMenus.map((menuItem) => (
<Col key={menuItem.id} xs={24} sm={24} md={12} lg={8} xl={6}>
@@ -862,14 +830,14 @@ export default function AdminMenusPage() {
<Spin tip="加载更多..." />
</div>
)}
{!loading && !isLoadingMore && allLoadedMenus.length > 0 && (
{!loading && !isLoadingMore && allLoadedMenus.length >= menuTotal && allLoadedMenus.length > 0 && (
<div style={{ textAlign: "center", padding: "20px 0" }}>
<Typography.Text type="secondary">
{allLoadedMenus.length}
</Typography.Text>
</div>
)}
</>
</div>
)}
</div>
)}
+66
View File
@@ -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);
}