[feat]:[FL-120][角色管理页面对齐用户管理分页交互]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-19 23:25:52 +08:00
parent 455b7c54bb
commit 4834a567a8
5 changed files with 232 additions and 103 deletions
+6 -2
View File
@@ -47,19 +47,23 @@ router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/roles", response_model=RoleListResponse)
def get_roles(
keyword: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
_: CurrentUser = Depends(require_any_permission("role.read", "role.manage")),
db: Session = Depends(get_db),
) -> RoleListResponse:
return list_roles(db, keyword=keyword)
return list_roles(db, keyword=keyword, limit=limit, offset=offset)
@router.get("/roles-with-menus", response_model=RolesWithMenusResponse)
def get_roles_with_menus(
keyword: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
_: CurrentUser = Depends(require_any_permission("role.read", "role.manage")),
db: Session = Depends(get_db),
) -> RolesWithMenusResponse:
return list_roles_with_menus(db, keyword=keyword)
return list_roles_with_menus(db, keyword=keyword, limit=limit, offset=offset)
@router.post("/roles", response_model=RolePublic)
+95 -46
View File
@@ -111,9 +111,21 @@ PROTECTED_MENU_CODES = {
}
def list_roles(db: Session, keyword: str | None = None) -> RoleListResponse:
def list_roles(
db: Session,
keyword: str | None = None,
*,
limit: int | None = None,
offset: int = 0,
) -> RoleListResponse:
role_source = "legacy" if _legacy_role_table_exists(db) else "modern"
rows = _load_role_rows(db, role_source=role_source, keyword=keyword)
rows, total = _load_role_page(
db,
role_source=role_source,
keyword=keyword,
limit=limit,
offset=offset,
)
role_ids = [str(row["id"]) for row in rows]
role_menu_ids = _load_role_menu_ids_map(db, role_ids, role_source=role_source)
menu_rows = _load_menus_map(
@@ -131,20 +143,6 @@ def list_roles(db: Session, keyword: str | None = None) -> RoleListResponse:
permission_codes = set(role_permission_codes.get(role_id, []))
permission_codes.update(_permission_codes_from_menu_rows(menu_rows, menu_ids))
# Apply keyword filter on menu names if keyword provided
if keyword:
normalized_keyword = keyword.strip().lower()
# Collect menu names for this role
menu_names = " ".join(
str(menu_rows.get(menu_id, {}).get("menu_name", ""))
for menu_id in menu_ids
)
# Build search haystack
haystack = f"{role_code} {row.get('name', '')} {menu_names}".lower()
# Skip if keyword not found
if normalized_keyword not in haystack:
continue
items.append(
RolePublic(
id=role_id,
@@ -154,7 +152,7 @@ def list_roles(db: Session, keyword: str | None = None) -> RoleListResponse:
menu_ids=menu_ids,
)
)
return RoleListResponse(items=items, total=len(items))
return RoleListResponse(items=items, total=total)
def get_role_by_id(db: Session, role_id: str) -> RolePublic | None:
@@ -495,9 +493,15 @@ def list_menus(db: Session, keyword: str | None = None, status: str | None = Non
return MenuListResponse(items=items, total=len(items))
def list_roles_with_menus(db: Session, keyword: str | None = None) -> RolesWithMenusResponse:
def list_roles_with_menus(
db: Session,
keyword: str | None = None,
*,
limit: int | None = None,
offset: int = 0,
) -> RolesWithMenusResponse:
"""Get roles and menus in a single request to reduce network calls."""
roles_response = list_roles(db, keyword=keyword)
roles_response = list_roles(db, keyword=keyword, limit=limit, offset=offset)
menus_response = list_menus(db)
return RolesWithMenusResponse(
roles=roles_response.items,
@@ -992,42 +996,87 @@ def _load_menus_map(db: Session, menu_ids: list[str], *, role_source: str = "leg
}
def _load_role_rows(db: Session, *, role_source: str, keyword: str | None = None) -> list[dict[str, object]]:
where_clause = ""
params = {}
if keyword:
normalized_keyword = f"%{keyword.strip().lower()}%"
where_clause = "WHERE LOWER(id) LIKE :keyword OR LOWER(name) LIKE :keyword"
params["keyword"] = normalized_keyword
def _load_role_page(
db: Session,
*,
role_source: str,
keyword: str | None = None,
limit: int | None = None,
offset: int = 0,
) -> tuple[list[dict[str, object]], int]:
normalized_offset = max(offset, 0)
normalized_limit = max(limit, 0) if limit is not None else None
params: dict[str, object] = {"offset": normalized_offset}
limit_clause = ""
if normalized_limit is not None:
limit_clause = "LIMIT :limit"
params["limit"] = normalized_limit
if role_source == "legacy":
rows = db.execute(
text(
f"""
SELECT id, name
FROM user_role
{where_clause}
ORDER BY create_date DESC NULLS LAST, id ASC
from_clause = """
FROM user_role r
WHERE (
:keyword IS NULL
OR LOWER(r.id) LIKE :keyword
OR LOWER(r.name) LIKE :keyword
OR EXISTS (
SELECT 1
FROM role_menu_rela rmr
JOIN menus m ON m.id::text = rmr.menu_id OR m.code = rmr.menu_id
WHERE rmr.role_id = r.id
AND m.code NOT IN :removed_menu_codes
AND (
LOWER(m.code) LIKE :keyword
OR LOWER(m.name) LIKE :keyword
)
)
)
"""
),
params
).mappings().all()
select_clause = "SELECT r.id, r.name"
order_clause = "ORDER BY r.create_date DESC NULLS LAST, r.id ASC"
else:
if keyword:
where_clause = "WHERE LOWER(code) LIKE :keyword OR LOWER(name) LIKE :keyword"
from_clause = """
FROM roles r
WHERE (
:keyword IS NULL
OR LOWER(r.code) LIKE :keyword
OR LOWER(r.name) LIKE :keyword
OR EXISTS (
SELECT 1
FROM role_menus rm
JOIN menus m ON m.id = rm.menu_id
WHERE rm.role_id = r.id
AND m.code NOT IN :removed_menu_codes
AND (
LOWER(m.code) LIKE :keyword
OR LOWER(m.name) LIKE :keyword
)
)
)
"""
select_clause = "SELECT r.id::text AS id, r.code, r.name"
order_clause = "ORDER BY r.id ASC"
trimmed_keyword = keyword.strip() if keyword else ""
params["keyword"] = f"%{trimmed_keyword.lower()}%" if trimmed_keyword else None
total = db.scalar(
text(f"SELECT COUNT(*) {from_clause}").bindparams(bindparam("removed_menu_codes", expanding=True)),
{**params, "removed_menu_codes": tuple(REMOVED_MENU_CODES)},
) or 0
rows = db.execute(
text(
f"""
SELECT id::text AS id, code, name
FROM roles
{where_clause}
ORDER BY id ASC
{select_clause}
{from_clause}
{order_clause}
{limit_clause}
OFFSET :offset
"""
),
params
).bindparams(bindparam("removed_menu_codes", expanding=True)),
{**params, "removed_menu_codes": tuple(REMOVED_MENU_CODES)},
).mappings().all()
return [dict(row) for row in rows]
return [dict(row) for row in rows], int(total)
def _load_role_permission_codes_map(
@@ -0,0 +1,53 @@
from __future__ import annotations
from app.services import legacy_admin_rbac_service as service
def _role_row(index: int) -> dict[str, object]:
return {"id": f"role-{index}", "code": f"role-{index}", "name": f"Role {index}"}
def test_list_roles_returns_filtered_total_before_pagination(monkeypatch) -> None:
captured: dict[str, object] = {}
monkeypatch.setattr(service, "_legacy_role_table_exists", lambda db: True)
def load_page(db, *, role_source, keyword=None, limit=None, offset=0):
captured.update({"role_source": role_source, "keyword": keyword, "limit": limit, "offset": offset})
return [_role_row(3), _role_row(4)], 5
monkeypatch.setattr(service, "_load_role_page", load_page)
monkeypatch.setattr(service, "_load_role_menu_ids_map", lambda db, role_ids, *, role_source: {role_id: [] for role_id in role_ids})
monkeypatch.setattr(service, "_load_menus_map", lambda db, menu_ids, *, role_source: {})
monkeypatch.setattr(service, "_load_role_permission_codes_map", lambda db, role_ids, *, role_source: {role_id: [] for role_id in role_ids})
response = service.list_roles(object(), keyword="role", limit=2, offset=2)
assert response.total == 5
assert [role.id for role in response.items] == ["role-3", "role-4"]
assert captured == {"role_source": "legacy", "keyword": "role", "limit": 2, "offset": 2}
def test_list_roles_with_menus_paginates_roles_only(monkeypatch) -> None:
captured: dict[str, object] = {}
menu_response = service.MenuListResponse(items=[], total=2)
monkeypatch.setattr(service, "_legacy_role_table_exists", lambda db: False)
def load_page(db, *, role_source, keyword=None, limit=None, offset=0):
captured.update({"role_source": role_source, "keyword": keyword, "limit": limit, "offset": offset})
return [_role_row(2)], 3
monkeypatch.setattr(service, "_load_role_page", load_page)
monkeypatch.setattr(service, "_load_role_menu_ids_map", lambda db, role_ids, *, role_source: {role_id: [] for role_id in role_ids})
monkeypatch.setattr(service, "_load_menus_map", lambda db, menu_ids, *, role_source: {})
monkeypatch.setattr(service, "_load_role_permission_codes_map", lambda db, role_ids, *, role_source: {role_id: [] for role_id in role_ids})
monkeypatch.setattr(service, "list_menus", lambda db: menu_response)
response = service.list_roles_with_menus(object(), limit=1, offset=1)
assert response.roles_total == 3
assert [role.id for role in response.roles] == ["role-2"]
assert response.menus == []
assert response.menus_total == 2
assert captured == {"role_source": "modern", "keyword": None, "limit": 1, "offset": 1}
+59 -35
View File
@@ -7,6 +7,7 @@ import {
Button,
Card,
Col,
Dropdown,
Empty,
Form,
Input,
@@ -21,7 +22,7 @@ import {
type CardProps,
type MenuProps,
} from "antd";
import { EditOutlined, DeleteOutlined } from "@ant-design/icons";
import { EditOutlined, MoreOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import type { CSSProperties, ComponentType } from "react";
@@ -30,12 +31,10 @@ import { useToastFeedback } from "@/hooks/use-toast-feedback";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { useMobileDetection } from "@/hooks/use-mobile-detection";
import { readApiError } from "@/lib/api";
import type { MenuItem, RoleItem, RoleListResponse } from "@/types/auth";
import type { MenuItem, RoleItem } from "@/types/auth";
const AntCard = Card as unknown as ComponentType<CardProps>;
type MenuListResponse = { items: MenuItem[]; total: number };
type RolesWithMenusResponse = {
roles: RoleItem[];
roles_total: number;
@@ -68,6 +67,7 @@ export default function AdminRolesPage() {
const [keywordInput, setKeywordInput] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const keywordDebounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
@@ -85,14 +85,19 @@ export default function AdminRolesPage() {
const canRead = hasPermission("role.read") || hasPermission("role.manage");
const canManage = hasPermission("role.manage");
const { current: paginationCurrent, pageSize: paginationPageSize } = pagination;
const trimmedKeyword = searchKeyword.trim();
const rolesQueryUrl = useMemo(() => {
const url = trimmedKeyword
? `/api/v1/admin/roles-with-menus?keyword=${encodeURIComponent(trimmedKeyword)}`
: "/api/v1/admin/roles-with-menus";
return url;
}, [trimmedKeyword]);
const rolesQueryParams = useMemo(() => {
const params = new URLSearchParams();
params.set("limit", String(paginationPageSize));
params.set("offset", String((paginationCurrent - 1) * paginationPageSize));
if (trimmedKeyword) {
params.set("keyword", trimmedKeyword);
}
return params.toString();
}, [paginationCurrent, paginationPageSize, trimmedKeyword]);
const rolesQueryUrl = `/api/v1/admin/roles-with-menus?${rolesQueryParams}`;
const loadRolesWithMenus = useCallback(async () => {
const response = await fetchWithAuth(rolesQueryUrl);
@@ -101,7 +106,7 @@ export default function AdminRolesPage() {
}, [fetchWithAuth, rolesQueryUrl]);
const rolesQuery = useQuery({
queryKey: ["admin.roles", rolesQueryUrl],
queryKey: ["admin.roles", rolesQueryParams],
queryFn: loadRolesWithMenus,
enabled: !!user && canRead,
});
@@ -294,6 +299,7 @@ export default function AdminRolesPage() {
keywordDebounceTimeoutRef.current = setTimeout(() => {
setSearchKeyword(value);
setPagination((prev) => ({ ...prev, current: 1 }));
setCardViewPage(1);
setAllLoadedRoles([]);
}, 500);
@@ -311,7 +317,11 @@ export default function AdminRolesPage() {
// Update allLoadedRoles when roles data changes in card view
useEffect(() => {
if (viewMode === "card" && !rolesQuery.isLoading) {
if (viewMode !== "card" || rolesQuery.isLoading) {
return;
}
const frameId = window.requestAnimationFrame(() => {
if (cardViewPage === 1) {
setAllLoadedRoles(roles);
} else {
@@ -325,7 +335,11 @@ export default function AdminRolesPage() {
});
}
setIsLoadingMore(false);
}
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [roles, rolesQuery.isLoading, viewMode, cardViewPage]);
// Handle infinite scroll for card view
@@ -352,6 +366,7 @@ export default function AdminRolesPage() {
if (loadedCount < total) {
setIsLoadingMore(true);
setCardViewPage((prev) => prev + 1);
setPagination((prev) => ({ ...prev, current: prev.current + 1 }));
}
}
};
@@ -360,12 +375,6 @@ export default function AdminRolesPage() {
return () => cardBody.removeEventListener("scroll", handleScroll);
}, [viewMode, isLoadingMore, rolesQuery.isLoading, rolesQuery.data?.roles_total, allLoadedRoles.length]);
// Reset card view state when filters change
useEffect(() => {
setCardViewPage(1);
setAllLoadedRoles([]);
}, [trimmedKeyword]);
const columns = useMemo<ColumnsType<RoleItem>>(() => {
const base: ColumnsType<RoleItem> = [
{
@@ -460,6 +469,24 @@ export default function AdminRolesPage() {
const isDeleting = deletingRoleId === role.id;
const isSaving = savingRoleId === role.id;
const rowBusy = isDeleting || isSaving || createRoleMutation.isPending || updateRoleMutation.isPending;
const moreMenuItems: MenuProps["items"] = [
{
key: "delete",
label: "删除",
danger: true,
disabled: rowBusy,
onClick: () => {
Modal.confirm({
title: `确认删除角色 ${role.code} 吗?`,
content: "删除后无法恢复,请谨慎操作。",
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true, loading: isDeleting },
onOk: () => deleteRoleMutation.mutate(role.id),
});
},
},
];
const menuLabels = role.menu_ids.length > 0
? role.menu_ids.map((menuId) => menuNameById.get(menuId) ?? String(menuId))
@@ -489,24 +516,14 @@ export default function AdminRolesPage() {
disabled={rowBusy}
onClick={() => startEdit(role)}
/>
<Popconfirm
title={`确认删除角色 ${role.code} 吗?`}
description="删除后无法恢复,请谨慎操作。"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: isDeleting }}
onConfirm={() => deleteRoleMutation.mutate(role.id)}
disabled={rowBusy}
>
<Dropdown menu={{ items: moreMenuItems }} trigger={["click"]}>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
loading={isDeleting}
icon={<MoreOutlined />}
disabled={rowBusy}
/>
</Popconfirm>
</Dropdown>
</Space>
) : null
}
@@ -561,7 +578,7 @@ export default function AdminRolesPage() {
return;
}
window.requestAnimationFrame(updateTableScrollY);
}, [anyError, roles.length, rolesQuery.isFetching, updateTableScrollY]);
}, [anyError, paginationCurrent, paginationPageSize, roles.length, rolesQuery.isFetching, updateTableScrollY]);
useEffect(() => {
if (typeof window === "undefined") {
@@ -691,15 +708,22 @@ export default function AdminRolesPage() {
columns={columns}
dataSource={roles}
loading={rolesQuery.isLoading}
tableLayout="fixed"
scroll={{ y: tableScrollY }}
pagination={{
pageSize: 20,
total: Math.max(roles.length, 1),
current: paginationCurrent,
pageSize: paginationPageSize,
total: rolesQuery.data?.roles_total ?? 0,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (total) => `${total}`,
hideOnSinglePage: false,
style: { marginBottom: 0 },
onChange: (page, pageSize) => {
setPagination({ current: page, pageSize });
setCardViewPage(page);
setAllLoadedRoles([]);
},
}}
locale={{
emptyText: (
+13 -14
View File
@@ -21,7 +21,7 @@ import {
type CardProps,
type MenuProps,
} from "antd";
import { MoreOutlined, LeftOutlined, RightOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons";
import { MoreOutlined, EditOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType } from "react";
@@ -102,7 +102,6 @@ export default function AdminUsersPage() {
const [success, setSuccess] = useState("");
const [userIdValidationError, setUserIdValidationError] = useState("");
const [editUserIdValidationError, setEditUserIdValidationError] = useState("");
const [checkingUserId, setCheckingUserId] = useState(false);
const userIdCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const canManage = hasPermission("user.manage");
@@ -123,7 +122,7 @@ export default function AdminUsersPage() {
return params.toString();
}, [paginationCurrent, paginationPageSize, statusFilter, trimmedKeyword]);
const usersPath = `/api/v1/users?${usersQueryParams}`;
const rolesPath = "/api/v1/admin/roles";
const rolesPath = "/api/v1/admin/roles?limit=200&offset=0";
const loadUsers = useCallback(async () => {
const response = await fetchWithAuth(usersPath);
@@ -186,10 +185,6 @@ export default function AdminUsersPage() {
return roleCodeToName.get(code) || code;
}, [roleCodeToName]);
const existingUserIds = useMemo(
() => new Set(users.map((item) => item.id.trim().toLowerCase())),
[users],
);
const existingEmails = useMemo(
() => new Set(users.map((item) => item.email.trim().toLowerCase())),
[users],
@@ -397,16 +392,12 @@ export default function AdminUsersPage() {
return;
}
setCheckingUserId(true);
userIdCheckTimeoutRef.current = setTimeout(async () => {
const result = await checkUserIdAvailability(
trimmedValue,
isEdit && editingUser ? editingUser.id : undefined
);
setCheckingUserId(false);
if (isEdit) {
setEditUserIdValidationError(result.available ? "" : result.message);
} else {
@@ -495,7 +486,7 @@ export default function AdminUsersPage() {
},
},
);
} catch (validationError) {
} catch {
// Validation errors are already shown in the form
}
};
@@ -614,7 +605,11 @@ export default function AdminUsersPage() {
// Update allLoadedUsers when users data changes in card view
useEffect(() => {
if (viewMode === "card" && !usersQuery.isLoading) {
if (viewMode !== "card" || usersQuery.isLoading) {
return;
}
const frameId = window.requestAnimationFrame(() => {
if (cardViewPage === 1) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Mobile card view intentionally mirrors paged query results into an accumulated list.
setAllLoadedUsers(() => users);
@@ -629,7 +624,11 @@ export default function AdminUsersPage() {
});
}
setIsLoadingMore(false);
}
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [users, usersQuery.isLoading, viewMode, cardViewPage]);
// Handle infinite scroll for card view