diff --git a/frontend/src/pages/usr/UserDetail/PermissionGroupList.tsx b/frontend/src/pages/usr/UserDetail/PermissionGroupList.tsx
new file mode 100644
index 0000000..3413a9c
--- /dev/null
+++ b/frontend/src/pages/usr/UserDetail/PermissionGroupList.tsx
@@ -0,0 +1,57 @@
+// REQ-USR-001 / REQ-USR-002: 权限分类勾选列表(表头全选 indeterminate + 逐项 Checkbox,BR10/BR11/D3)
+import { Checkbox } from 'antd';
+import type { PermissionItem } from '../../../api/types';
+import { PERM_LIST_HEADER } from './constants';
+import styles from './UserDetail.module.css';
+
+export interface PermissionGroupListProps {
+ permissions: PermissionItem[];
+ checkedIds: number[];
+ onToggle(id: number, checked: boolean): void;
+ onToggleAll(checked: boolean): void;
+ loading?: boolean;
+}
+
+export default function PermissionGroupList({
+ permissions,
+ checkedIds,
+ onToggle,
+ onToggleAll,
+}: PermissionGroupListProps) {
+ const total = permissions.length;
+ const checkedCount = permissions.filter((p) => checkedIds.includes(p.id)).length;
+ const allChecked = total > 0 && checkedCount === total;
+ const indeterminate = checkedCount > 0 && checkedCount < total;
+
+ return (
+
+
+ onToggleAll(e.target.checked)}
+ data-testid="perm-check-all"
+ />
+ {PERM_LIST_HEADER}
+
+ ⇅
+
+
+
+ {total === 0 ? (
+
+ ) : (
+ permissions.map((p) => (
+
+ ))
+ )}
+
+ );
+}
diff --git a/frontend/tests/unit/PermissionGroupList.test.tsx b/frontend/tests/unit/PermissionGroupList.test.tsx
new file mode 100644
index 0000000..e7922df
--- /dev/null
+++ b/frontend/tests/unit/PermissionGroupList.test.tsx
@@ -0,0 +1,102 @@
+// REQ-USR-001 / REQ-USR-002: PermissionGroupList 权限分类勾选列表单测(渲染/勾选集合/全选 indeterminate/回勾,BR10/BR11/D3)
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderShell } from './renderShell';
+import PermissionGroupList from '../../src/pages/usr/UserDetail/PermissionGroupList';
+import type { PermissionItem } from '../../src/api/types';
+
+const PERMS: PermissionItem[] = [
+ { id: 1, name: '默认显示', category: '基础' },
+ { id: 2, name: '高级查看', category: '基础' },
+ { id: 3, name: '导出', category: '报表' },
+];
+
+function setup(over: {
+ permissions?: PermissionItem[];
+ checkedIds?: number[];
+} = {}) {
+ const onToggle = vi.fn();
+ const onToggleAll = vi.fn();
+ renderShell(
+ ,
+ { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } },
+ );
+ return { onToggle, onToggleAll };
+}
+
+function rowCheckbox(id: number): HTMLInputElement {
+ return screen.getByTestId('perm-check-' + id) as HTMLInputElement;
+}
+
+function allCheckbox(): HTMLInputElement {
+ return screen.getByTestId('perm-check-all') as HTMLInputElement;
+}
+
+describe('PermissionGroupList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders header 权限分类 and one row per permission', () => {
+ setup();
+ expect(screen.getByText('权限分类')).toBeInTheDocument();
+ expect(screen.getByText('默认显示')).toBeInTheDocument();
+ expect(screen.getByText('高级查看')).toBeInTheDocument();
+ expect(screen.getByText('导出')).toBeInTheDocument();
+ });
+
+ it('checked rows reflect checkedIds', () => {
+ setup({ checkedIds: [1] });
+ expect(rowCheckbox(1).checked).toBe(true);
+ expect(rowCheckbox(2).checked).toBe(false);
+ expect(rowCheckbox(3).checked).toBe(false);
+ });
+
+ it('toggling a row calls onToggle(id, checked)', async () => {
+ const user = userEvent.setup();
+ const { onToggle } = setup({ checkedIds: [1] });
+ await user.click(rowCheckbox(2));
+ expect(onToggle).toHaveBeenCalledWith(2, true);
+ await user.click(rowCheckbox(1));
+ expect(onToggle).toHaveBeenCalledWith(1, false);
+ });
+
+ it('header select-all checked when all selected; indeterminate when partial', () => {
+ const { } = setup({ checkedIds: [1, 2, 3] });
+ expect(allCheckbox().checked).toBe(true);
+ });
+
+ it('header indeterminate when partial; unchecked when none', () => {
+ setup({ checkedIds: [1] });
+ const all = allCheckbox();
+ expect(all.checked).toBe(false);
+ // AntD 半选用 aria-checked='mixed' 表达于 wrapper;input 仍未 checked
+ const wrapper = all.closest('.ant-checkbox');
+ expect(wrapper?.classList.contains('ant-checkbox-indeterminate')).toBe(true);
+ });
+
+ it('header toggle calls onToggleAll', async () => {
+ const user = userEvent.setup();
+ const { onToggleAll } = setup({ checkedIds: [] });
+ await user.click(allCheckbox());
+ expect(onToggleAll).toHaveBeenCalledWith(true);
+ });
+
+ it('header toggle off when all selected', async () => {
+ const user = userEvent.setup();
+ const { onToggleAll } = setup({ checkedIds: [1, 2, 3] });
+ await user.click(allCheckbox());
+ expect(onToggleAll).toHaveBeenCalledWith(false);
+ });
+
+ it('empty permissions renders empty list (no rows)', () => {
+ setup({ permissions: [] });
+ expect(screen.queryByTestId('perm-check-1')).toBeNull();
+ });
+});