[fix]:[FL-151][补齐菜单管理页一致性细节]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-20 11:09:55 +08:00
parent 260e6598ef
commit f8eef853a7
2 changed files with 93 additions and 80 deletions
+23
View File
@@ -39,6 +39,29 @@
- 风险与关注点:
- 改动仅迁移菜单管理页前端数据管理架构,不改变 `/api/v1/admin/menus*` 接口路径、请求字段或权限语义。
## Follow-up - 菜单管理页细节一致性补齐(FL-151)
- 背景:
- 复查继续指出菜单管理页在筛选宽度、移动卡片间距、编辑弹窗标题、导入顺序、表格列类型、状态命名和筛选语义等细节上仍未完全对齐用户管理页。
- 本次处理:
- 菜单页 import 顺序调整为 React Query、Ant Design、Icons、`antd/es/table` 类型、Next Link、React hooks 的顺序。
- 表格列类型改为 `ColumnsType<MenuItem>`,并将 `columns``useMemo` 改为普通常量数组。
- 关键词输入 state 从 `keyword` 改为 `keywordInput`,桌面关键词宽度改为 `260px`
- 状态筛选改为 `undefined` 表示“全部”,对齐用户页的 `statusFilter` 语义。
- 移动端卡片容器移除显式 `mt-4`,对齐用户页间距处理。
- 编辑菜单弹窗标题补充当前菜单名称和 ID;移动端删除确认改为下拉项内联 `Modal.confirm`
- 权限变量定义顺序调整为先 `canManage`,后 `canRead`
- 验证:
- 基线:`npm --workspace web exec eslint src/app/admin/menus/page.tsx` 通过。
- 基线:`npm --workspace web exec tsc --noEmit` 因既有 `src/app/admin/elevation-records/page.tsx` 类型错误失败,菜单页无报错。
- 修改后:`npm --workspace web exec eslint src/app/admin/menus/page.tsx` 通过。
- 修改后:`npm --workspace web exec tsc --noEmit --pretty false` 仍仅因既有 `src/app/admin/elevation-records/page.tsx` 类型错误失败,菜单页无报错。
- 风险与关注点:
- 改动仅涉及菜单管理页前端一致性细节,不改变后端接口、权限判断或菜单 CRUD 请求语义。
# Work Log - 角色管理页对齐用户管理页规范(FL-152)
- 背景:
+70 -80
View File
@@ -1,8 +1,6 @@
"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,
Card,
@@ -24,10 +22,11 @@ import {
Typography,
type CardProps,
type MenuProps,
type TableColumnsType,
} from "antd";
import { MoreOutlined, EditOutlined } from "@ant-design/icons";
import type { CSSProperties, ComponentType, RefAttributes } from "react";
import type { ColumnsType } from "antd/es/table";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react";
import { useAuth } from "@/components/auth-provider";
import { useToastFeedback } from "@/hooks/use-toast-feedback";
@@ -39,8 +38,6 @@ import type { MenuItem, MenuListResponse } from "@/types/auth";
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
type FilterStatus = "all" | "enabled" | "disabled";
type MenuFormValues = {
code: string;
name: string;
@@ -113,8 +110,8 @@ export default function AdminMenusPage() {
const [success, setSuccess] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editingMenuId, setEditingMenuId] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<FilterStatus>("all");
const [keywordInput, setKeywordInput] = useState("");
const [statusFilter, setStatusFilter] = useState<"enabled" | "disabled" | undefined>(undefined);
const [searchKeyword, setSearchKeyword] = useState("");
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
const [tableScrollY, setTableScrollY] = useState(MENU_TABLE_MIN_SCROLL_Y);
@@ -129,8 +126,8 @@ export default function AdminMenusPage() {
const paginationCurrent = pagination.current;
const paginationPageSize = pagination.pageSize;
const canRead = hasPermission("menu.read") || hasPermission("menu.manage");
const canManage = hasPermission("menu.manage");
const canRead = hasPermission("menu.read") || hasPermission("menu.manage");
const trimmedKeyword = searchKeyword.trim();
const menusQueryParams = useMemo(() => {
@@ -140,7 +137,7 @@ export default function AdminMenusPage() {
if (trimmedKeyword) {
params.set("keyword", trimmedKeyword);
}
if (statusFilter !== "all") {
if (statusFilter) {
params.set("status", statusFilter);
}
return params.toString();
@@ -200,6 +197,10 @@ export default function AdminMenusPage() {
return compareMenuIds(a.id, b.id);
});
}, [menus]);
const editingMenu = useMemo(
() => menus.find((menu) => menu.id === editingMenuId) ?? null,
[editingMenuId, menus],
);
useTopicSubscription(
"admin.menus",
@@ -274,7 +275,7 @@ export default function AdminMenusPage() {
}, [allLoadedMenus.length, menusQuery.isLoading, isLoadingMore, menuTotal, viewMode]);
const handleKeywordChange = (value: string) => {
setKeyword(value);
setKeywordInput(value);
if (keywordDebounceTimeoutRef.current) {
clearTimeout(keywordDebounceTimeoutRef.current);
@@ -288,8 +289,8 @@ export default function AdminMenusPage() {
}, 500);
};
const handleStatusFilterChange = (value?: Exclude<FilterStatus, "all">) => {
setStatusFilter(value ?? "all");
const handleStatusFilterChange = (value?: "enabled" | "disabled") => {
setStatusFilter(value);
setPagination((prev) => ({ ...prev, current: 1 }));
setCardViewPage(1);
setAllLoadedMenus([]);
@@ -456,66 +457,49 @@ export default function AdminMenusPage() {
}
}, [createMenuMutation, editingMenuId, form, updateMenuMutation]);
const confirmRemoveMenu = useCallback((menu: MenuItem) => {
Modal.confirm({
title: `确认删除菜单 ${menu.name}${menu.code})?`,
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => deleteMenuMutation.mutate(menu.id),
});
}, [deleteMenuMutation]);
const updateMenuStatus = useCallback(async (menu: MenuItem) => {
const nextStatus: "enabled" | "disabled" = menu.status === "enabled" ? "disabled" : "enabled";
updateMenuMutation.mutate({ menuId: menu.id, payload: { status: nextStatus } });
}, [updateMenuMutation]);
const columns = useMemo<TableColumnsType<MenuItem>>(() => {
const base: TableColumnsType<MenuItem> = [
{ title: "ID", dataIndex: "id", width: 110 },
{
title: "编码",
dataIndex: "code",
width: 220,
render: (value: string) => <span className="font-mono text-xs">{value}</span>,
},
{
title: "名称",
dataIndex: "name",
width: 180,
},
{
title: "路径",
dataIndex: "path",
width: 220,
render: (value: string | null) => value || "-",
},
{
title: "父菜单",
dataIndex: "parent_id",
width: 220,
render: (value: string | null) => (value ? menuNameById.get(value) ?? value : "-"),
},
{
title: "状态",
dataIndex: "status",
width: 110,
render: (value: string) =>
value === "enabled" ? <Tag color="success"></Tag> : <Tag color="default"></Tag>,
},
{
title: "排序",
dataIndex: "sort_order",
width: 90,
},
];
if (!canManage) {
return base;
}
base.push({
const columns: ColumnsType<MenuItem> = [
{ title: "ID", dataIndex: "id", width: 110 },
{
title: "编码",
dataIndex: "code",
width: 220,
render: (value: string) => <span className="font-mono text-xs">{value}</span>,
},
{
title: "名称",
dataIndex: "name",
width: 180,
},
{
title: "路径",
dataIndex: "path",
width: 220,
render: (value: string | null) => value || "-",
},
{
title: "父菜单",
dataIndex: "parent_id",
width: 220,
render: (value: string | null) => (value ? menuNameById.get(value) ?? value : "-"),
},
{
title: "状态",
dataIndex: "status",
width: 110,
render: (value: string) =>
value === "enabled" ? <Tag color="success"></Tag> : <Tag color="default"></Tag>,
},
{
title: "排序",
dataIndex: "sort_order",
width: 90,
},
...(canManage ? [{
title: "操作",
key: "actions",
fixed: "right",
@@ -559,10 +543,8 @@ export default function AdminMenusPage() {
</Space>
);
},
});
return base;
}, [canManage, deleteMenuMutation, deletingMenuId, menuNameById, startEdit, updateMenuStatus, updatingStatusMenuId]);
} satisfies ColumnsType<MenuItem>[number]] : []),
];
const renderMenuCard = (menuItem: MenuItem) => {
const updatingLoading = updatingStatusMenuId === menuItem.id;
@@ -575,7 +557,15 @@ export default function AdminMenusPage() {
label: "删除",
danger: true,
disabled: menuBusy || !canManage,
onClick: () => confirmRemoveMenu(menuItem),
onClick: () => {
Modal.confirm({
title: `确认删除菜单 ${menuItem.name}${menuItem.code})?`,
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => deleteMenuMutation.mutate(menuItem.id),
});
},
},
{
key: "toggle-status",
@@ -770,7 +760,7 @@ export default function AdminMenusPage() {
<Form.Item style={{ marginBottom: 0 }}>
<Input
allowClear
value={keyword}
value={keywordInput}
onChange={(event) => handleKeywordChange(event.currentTarget.value)}
placeholder="按编码/名称/路径筛选"
/>
@@ -778,18 +768,18 @@ export default function AdminMenusPage() {
</Form>
) : (
<Form layout="inline" style={{ rowGap: 12 }}>
<Form.Item label="关键词" style={{ width: 240 }}>
<Form.Item label="关键词" style={{ width: 260 }}>
<Input
allowClear
value={keyword}
value={keywordInput}
onChange={(event) => handleKeywordChange(event.currentTarget.value)}
placeholder="按编码/名称/路径筛选"
/>
</Form.Item>
<Form.Item label="状态" style={{ width: 170 }}>
<Select<Exclude<FilterStatus, "all">>
value={statusFilter === "all" ? undefined : statusFilter}
<Select<"enabled" | "disabled">
value={statusFilter}
allowClear
placeholder="全部"
onChange={handleStatusFilterChange}
@@ -839,7 +829,7 @@ export default function AdminMenusPage() {
/>
</div>
) : (
<div className="admin-menus-card-view mt-4">
<div className="admin-menus-card-view">
{menusQuery.isLoading && allLoadedMenus.length === 0 ? (
<div className="admin-menus-card-view-state">
<Spin tip="加载中..." />
@@ -879,7 +869,7 @@ export default function AdminMenusPage() {
</AntCard>
<Modal
title={editingMenuId ? "编辑菜单" : "新建菜单"}
title={editingMenu ? `编辑菜单:${editingMenu.name}${editingMenu.id}` : editingMenuId ? "编辑菜单" : "新建菜单"}
open={dialogOpen}
onCancel={closeDialog}
onOk={() => void submit()}