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 全绿。
This commit is contained in:
1 parent
f97f97b0cd
commit
61894df401
80 files changed
+14380
-237
No files matched your search
@@ -0,0 +1,396 @@
|
||||
/* 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user