背景: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 全绿。
397 lines
12 KiB
TypeScript
397 lines
12 KiB
TypeScript
/* 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>
|
||
);
|
||
}
|