Files
lingxi/apps/web/src/pages/system/roles/RolesPage.tsx
T
Stev_Wang 61894df401 feat(identity): 完成 A2 角色权限与数据范围全栈落地
背景:A2 开卡前完成 H1 权限门,确认权限点目录、特殊权限、SoD 三对、六类数据范围与超管隐式全集口径(ADR-001)。

改动:
- shared:30 权限点 / 10 模块目录与元数据、特殊权限 6 编码、SoD 三对、角色与权限树契约;/me 改 dataScopes 多角色并集。
- prisma:expand 迁移新增 permissions / data_scopes;种子按原型口径重写角色权限、数据范围与 8 项特殊授予。
- api:角色 CRUD / 复制 / 停用 / 删除、权限点启动同步、SoD 40304、系统角色保护、六类范围 ScopeContext 与 CustomerDirectory 端口、变更审计与 outbox。
- web:方案 B 主从分栏三角色页面(列表 / 编辑器 / 新建),权限树、范围选择、特殊权限二次确认、保存影响、SoD 冲突、只读态与模板预填。
- 测试:角色集成 13 条 + A2 e2e 1 条;A1 全量回归。

验证:format:check / lint / typecheck / test(306)/ test:int(27)/ A2 e2e(1,复跑通过)/ openapi:export 全绿。
2026-11-03 12:26:51 +08:00

397 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, IconShield } 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 { PermissionCatalogDrawer } from './PermissionCatalogDrawer';
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 [catalogOpen, setCatalogOpen] = useState(false);
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>
<h1>角色权限</h1>
<p>配置功能权限、数据范围与特殊权限,变更即时生效并留痕</p>
</div>
<div className="lx-roles__header-actions">
<Button
icon={<IconShield size={14} />}
onClick={() => {
setCatalogOpen(true);
}}
>
权限点目录
</Button>
<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}」已启用`);
},
},
);
}}
onOpenCatalog={() => {
setCatalogOpen(true);
}}
/>
</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));
},
},
);
}}
/>
<PermissionCatalogDrawer
visible={catalogOpen}
tree={tree.data}
role={selectedDetail ?? null}
onClose={() => {
setCatalogOpen(false);
}}
/>
</div>
);
}