Files
lingxi/apps/web/src/pages/system/roles/RolesPage.tsx
T
Stev_Wang f5a8afe835
ci / 质量门禁 (push) Canceled after 0s
refactor(web): 侧栏与页面模块名由角色权限改为角色管理
背景:角色权限只描述模块的一部分能力,侧栏同级均为模块级命名(用户与组织 / 客户 / 行业树);角色页实际覆盖角色 CRUD、权限点、数据范围、特殊权限与 SoD。

改动:
- 侧栏、面包屑、页面标题统一为「角色管理」,页面内权限点 / 数据范围 / 特殊权限分区名不变;
- 用户详情与超管授予弹窗中的模块引用同步改为「角色管理」;
- 开发计划页面清单与 A2 实施报告同步;
- 更新导航与面包屑单测、A2 e2e 标题断言。

验证:format:check / lint / typecheck / test 全绿;5173 实测侧栏、面包屑、页标题均为「角色管理」。
2026-11-03 14:03:13 +08:00

376 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable max-lines-per-function, complexity -- 列表页负责主从联动、筛选、菜单与五类弹窗编排,集中便于追踪数据流 */
import { Button, Input, Switch } from '@douyinfe/semi-ui-19';
import { Permission, type RoleDetail, type RoleListItem } from '@lingxi/shared';
import { notifySuccess } from '@lingxi/ui';
import { IconAdd, IconSearch } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { Can } from '@/components/Can';
import {
useCreateRoleMutation,
useDeleteRoleMutation,
usePermissionTreeQuery,
useRoleDetailQuery,
useRoleListQuery,
useUpdateRoleMutation,
} from '@/hooks/queries/use-roles';
import { usePermission } from '@/hooks/use-permission';
import { roleDetailPath, ROUTES } from '@/routes';
import {
CopyRoleDialog,
DeleteRoleDialog,
DisableRoleDialog,
RenameRoleDialog,
} from './role-dialogs';
import {
DEFAULT_ROLE_LIST_FILTERS,
filterRoles,
hasActiveRoleFilters,
type RoleListFilters,
summarizeRoles,
} from './role-display';
import { RoleDetailPanel } from './RoleDetailPanel';
import { RoleListPanel, type RoleRowAction } from './RoleListPanel';
/** 角色管理列表查询口径:角色 ≤ 20,一次拉全量后本地筛选(分页由列表卡兜底) */
const ROLE_LIST_QUERY = {
limit: 100,
status: 'all',
sort: 'created_at',
order: 'desc',
} as const;
type DialogState = { kind: 'disable' | 'delete' | 'rename' | 'copy'; role: RoleListItem } | null;
function RoleFilterBar({
filters,
stats,
onChange,
onReset,
}: {
filters: RoleListFilters;
stats: ReturnType<typeof summarizeRoles>;
onChange: (next: Partial<RoleListFilters>) => void;
onReset: () => void;
}): ReactNode {
return (
<section className="lx-role-filters" aria-label="筛选角色">
<Input
className="lx-role-filters__keyword"
value={filters.keyword}
prefix={<IconSearch size={16} />}
placeholder="搜索角色名称或说明"
aria-label="搜索角色"
showClear
onChange={(value) => {
onChange({ keyword: value });
}}
/>
<div className="lx-seg" role="group" aria-label="角色状态">
{[
{ value: 'all' as const, label: '全部', count: stats.total },
{ value: 'active' as const, label: '启用', count: stats.active },
{ value: 'disabled' as const, label: '停用', count: stats.disabled },
].map((option) => (
<button
key={option.value}
type="button"
className={option.value === filters.status ? 'lx-seg__item is-active' : 'lx-seg__item'}
aria-pressed={option.value === filters.status}
onClick={() => {
onChange({ status: option.value });
}}
>
{option.label}
<i className="lx-role-filters__count">{option.count}</i>
</button>
))}
</div>
<label className="lx-role-filters__special">
<Switch
size="small"
checked={filters.specialOnly}
onChange={(checked) => {
onChange({ specialOnly: checked });
}}
/>
只看含特殊权限
</label>
<div className="lx-role-filters__stats" aria-label="角色统计">
<span className="lx-role-filters__chip">
<i>绑定用户</i>
<b>{stats.boundUsers}</b>
</span>
<span className="lx-role-filters__chip">
<i>特殊权限授予</i>
<b>{stats.specialGrants}</b>
</span>
{hasActiveRoleFilters(filters) ? (
<Button theme="borderless" size="small" onClick={onReset}>
清除筛选
</Button>
) : null}
</div>
</section>
);
}
/** 角色管理列表页:左角色列表 + 右只读详情面板(/system/roles) */
export function RolesPage(): ReactNode {
const navigate = useNavigate();
const canManage = usePermission(Permission.ROLE_MANAGE);
const tree = usePermissionTreeQuery();
const listQuery = useRoleListQuery(ROLE_LIST_QUERY);
const roles = useMemo(() => listQuery.data?.items ?? [], [listQuery.data]);
const [filters, setFilters] = useState<RoleListFilters>(DEFAULT_ROLE_LIST_FILTERS);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [dialog, setDialog] = useState<DialogState>(null);
const filteredRoles = useMemo(() => filterRoles(roles, filters), [roles, filters]);
const stats = useMemo(() => summarizeRoles(roles), [roles]);
const effectiveSelectedId = filteredRoles.some((role) => role.id === selectedId)
? selectedId
: (filteredRoles[0]?.id ?? null);
const detail = useRoleDetailQuery(effectiveSelectedId);
const copySource = useRoleDetailQuery(dialog?.kind === 'copy' ? dialog.role.id : null);
const updateRole = useUpdateRoleMutation();
const deleteRole = useDeleteRoleMutation();
const createRole = useCreateRoleMutation();
const openDialog = (kind: 'disable' | 'delete' | 'rename' | 'copy', role: RoleListItem): void => {
setDialog({ kind, role });
};
const handleRowAction = (action: RoleRowAction, role: RoleListItem): void => {
setSelectedId(role.id);
if (action === 'view' || action === 'edit') {
void navigate(roleDetailPath(role.id));
return;
}
if (action === 'enable') {
updateRole.mutate(
{ roleId: role.id, input: { status: 'enabled' } },
{
onSuccess: () => {
notifySuccess(`「${role.name}」已启用,${String(role.userCount)} 名用户下一次请求生效`);
},
},
);
return;
}
openDialog(action, role);
};
const selectedDetail: RoleDetail | undefined = detail.data;
const activeRole = dialog?.role ?? selectedDetail ?? null;
return (
<div className="lx-roles">
<header className="lx-roles__header">
<div className="lx-roles__head-txt">
<h1>角色管理</h1>
<p>配置功能权限、数据范围与特殊权限,变更即时生效并留痕</p>
</div>
<div className="lx-roles__header-actions">
<Can permission={Permission.ROLE_MANAGE}>
<Button
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
onClick={() => {
void navigate(ROUTES.system.roleNew);
}}
>
新建角色
</Button>
</Can>
</div>
</header>
<RoleFilterBar
filters={filters}
stats={stats}
onChange={(next) => {
setFilters((current) => ({ ...current, ...next }));
}}
onReset={() => {
setFilters(DEFAULT_ROLE_LIST_FILTERS);
}}
/>
<div className="lx-roles__split">
<RoleListPanel
roles={filteredRoles}
totalCount={roles.length}
loading={listQuery.isPending}
error={listQuery.error}
selectedRoleId={effectiveSelectedId}
note={`内置 ${String(stats.systemCount)} · 自定义 ${String(stats.total - stats.systemCount)}`}
canManage={canManage}
showRowMenu
isFiltered={hasActiveRoleFilters(filters)}
onSelect={setSelectedId}
onRowAction={handleRowAction}
onCreate={() => {
void navigate(ROUTES.system.roleNew);
}}
onResetFilters={() => {
setFilters(DEFAULT_ROLE_LIST_FILTERS);
}}
onRetry={() => {
void listQuery.refetch();
}}
/>
<RoleDetailPanel
roleId={effectiveSelectedId}
detail={selectedDetail}
tree={tree.data}
loading={detail.isPending}
error={detail.error}
canManage={canManage}
onRetry={() => {
void detail.refetch();
}}
onEdit={(roleId) => {
void navigate(roleDetailPath(roleId));
}}
onCopy={(role) => {
openDialog('copy', role);
}}
onRename={(role) => {
openDialog('rename', role);
}}
onDisable={(role) => {
openDialog('disable', role);
}}
onEnable={(role) => {
updateRole.mutate(
{ roleId: role.id, input: { status: 'enabled' } },
{
onSuccess: () => {
notifySuccess(`「${role.name}」已启用`);
},
},
);
}}
/>
</div>
<DisableRoleDialog
key={`disable-${dialog?.kind === 'disable' ? dialog.role.id : 'closed'}`}
visible={dialog?.kind === 'disable'}
role={activeRole}
loading={updateRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={(reason) => {
if (dialog === null) {
return;
}
updateRole.mutate(
{
roleId: dialog.role.id,
input: { status: 'disabled', ...(reason === '' ? {} : { reason }) },
},
{
onSuccess: () => {
setDialog(null);
notifySuccess(
`「${dialog.role.name}」已停用,${String(dialog.role.userCount)} 名用户下一次请求生效`,
);
},
},
);
}}
/>
<DeleteRoleDialog
key={`delete-${dialog?.kind === 'delete' ? dialog.role.id : 'closed'}`}
visible={dialog?.kind === 'delete'}
role={activeRole}
loading={deleteRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={() => {
if (dialog === null) {
return;
}
deleteRole.mutate(dialog.role.id, {
onSuccess: () => {
setSelectedId(null);
setDialog(null);
notifySuccess('角色已删除');
},
});
}}
/>
<RenameRoleDialog
key={`rename-${dialog?.kind === 'rename' ? dialog.role.id : 'closed'}`}
visible={dialog?.kind === 'rename'}
role={activeRole}
loading={updateRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={(name) => {
if (dialog === null) {
return;
}
updateRole.mutate(
{ roleId: dialog.role.id, input: { name } },
{
onSuccess: () => {
setDialog(null);
notifySuccess('角色名称已更新');
},
},
);
}}
/>
<CopyRoleDialog
key={`copy-${
dialog?.kind === 'copy'
? `${dialog.role.id}-${copySource.data?.id ?? 'loading'}`
: 'closed'
}`}
visible={dialog?.kind === 'copy'}
source={copySource.data ?? null}
sourceLoading={copySource.isPending}
loading={createRole.isPending}
onCancel={() => {
setDialog(null);
}}
onConfirm={(name) => {
const source = copySource.data;
if (source === undefined) {
return;
}
createRole.mutate(
{
name,
description: source.description,
permissions: [...source.permissions],
dataScope: {
type: source.dataScope.type,
...(source.dataScope.targetIds === undefined
? {}
: { targetIds: [...source.dataScope.targetIds] }),
},
},
{
onSuccess: (result) => {
setDialog(null);
notifySuccess('已创建「' + result.role.name + '」,可在编辑器中继续调整权限');
void navigate(roleDetailPath(result.role.id));
},
},
);
}}
/>
</div>
);
}