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,306 @@
|
||||
/* eslint-disable max-lines-per-function -- 列表卡包含筛选态、行菜单与骨架,集中在一处便于对齐状态 */
|
||||
import { Button, Dropdown, Skeleton } from '@douyinfe/semi-ui-19';
|
||||
import type { RoleListItem } from '@lingxi/shared';
|
||||
import { ErrorState, PageEmpty } from '@lingxi/ui';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDelete,
|
||||
IconEdit,
|
||||
IconMore,
|
||||
IconPause,
|
||||
IconPlay,
|
||||
IconShield,
|
||||
IconView,
|
||||
} from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { scopeLabelOf } from './role-display';
|
||||
import { RoleStatusTag } from './RoleStatusTag';
|
||||
|
||||
export type RoleRowAction = 'view' | 'rename' | 'edit' | 'copy' | 'disable' | 'enable' | 'delete';
|
||||
|
||||
export interface RoleListPanelProps {
|
||||
roles: readonly RoleListItem[];
|
||||
/** 未筛选的角色总数,用于区分「没有匹配」与「还没有自定义角色」 */
|
||||
totalCount: number;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
selectedRoleId: string | null;
|
||||
/** 卡头右侧说明:内置 / 自定义计数或「当前:角色名」 */
|
||||
note: string;
|
||||
footerNote?: string;
|
||||
canManage: boolean;
|
||||
showRowMenu: boolean;
|
||||
isFiltered: boolean;
|
||||
onSelect: (roleId: string) => void;
|
||||
onRowAction: (action: RoleRowAction, role: RoleListItem) => void;
|
||||
onCreate: () => void;
|
||||
onResetFilters: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function rolePermissionText(role: RoleListItem): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<b>{role.permissionCount}</b> 普通
|
||||
{role.specialPermissionCount > 0 ? (
|
||||
<span className="lx-role-row__special"> · ★{role.specialPermissionCount}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RowMenu({
|
||||
role,
|
||||
canManage,
|
||||
onRowAction,
|
||||
}: {
|
||||
role: RoleListItem;
|
||||
canManage: boolean;
|
||||
onRowAction: (action: RoleRowAction, role: RoleListItem) => void;
|
||||
}): ReactNode {
|
||||
const [visible, setVisible] = useState(false);
|
||||
if (!canManage) {
|
||||
return null;
|
||||
}
|
||||
const trigger = (action: RoleRowAction): void => {
|
||||
setVisible(false);
|
||||
onRowAction(action, role);
|
||||
};
|
||||
const menuItems: ReactNode[] = role.isSystem
|
||||
? [
|
||||
<Dropdown.Item
|
||||
key="view"
|
||||
icon={<IconView size={14} />}
|
||||
onClick={() => {
|
||||
trigger('view');
|
||||
}}
|
||||
>
|
||||
查看角色
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Item
|
||||
key="rename"
|
||||
icon={<IconEdit size={14} />}
|
||||
onClick={() => {
|
||||
trigger('rename');
|
||||
}}
|
||||
>
|
||||
改名
|
||||
</Dropdown.Item>,
|
||||
]
|
||||
: [
|
||||
<Dropdown.Item
|
||||
key="edit"
|
||||
icon={<IconEdit size={14} />}
|
||||
onClick={() => {
|
||||
trigger('edit');
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Item
|
||||
key="copy"
|
||||
icon={<IconCopy size={14} />}
|
||||
onClick={() => {
|
||||
trigger('copy');
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Item
|
||||
key="toggle"
|
||||
icon={role.status === 'active' ? <IconPause size={14} /> : <IconPlay size={14} />}
|
||||
onClick={() => {
|
||||
trigger(role.status === 'active' ? 'disable' : 'enable');
|
||||
}}
|
||||
>
|
||||
{role.status === 'active' ? '停用' : '启用'}
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Divider key="divider" />,
|
||||
<Dropdown.Item
|
||||
key="delete"
|
||||
type="danger"
|
||||
icon={<IconDelete size={14} />}
|
||||
disabled={role.userCount > 0}
|
||||
onClick={() => {
|
||||
trigger('delete');
|
||||
}}
|
||||
>
|
||||
{role.userCount > 0 ? '删除(有绑定用户)' : '删除'}
|
||||
</Dropdown.Item>,
|
||||
];
|
||||
return (
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
visible={visible}
|
||||
onVisibleChange={setVisible}
|
||||
render={<Dropdown.Menu>{menuItems}</Dropdown.Menu>}
|
||||
>
|
||||
<Button
|
||||
className="lx-role-row__more"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
icon={<IconMore size={16} />}
|
||||
aria-label={`${role.name} 更多操作`}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function ListBody({
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
selectedRoleId,
|
||||
canManage,
|
||||
showRowMenu,
|
||||
isFiltered,
|
||||
onSelect,
|
||||
onRowAction,
|
||||
onCreate,
|
||||
onResetFilters,
|
||||
onRetry,
|
||||
}: Pick<
|
||||
RoleListPanelProps,
|
||||
| 'roles'
|
||||
| 'loading'
|
||||
| 'error'
|
||||
| 'selectedRoleId'
|
||||
| 'canManage'
|
||||
| 'showRowMenu'
|
||||
| 'isFiltered'
|
||||
| 'onSelect'
|
||||
| 'onRowAction'
|
||||
| 'onCreate'
|
||||
| 'onResetFilters'
|
||||
| 'onRetry'
|
||||
>): ReactNode {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="lx-role-list__state" aria-busy="true">
|
||||
<Skeleton placeholder={<Skeleton.Paragraph rows={9} />} loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error !== null) {
|
||||
return (
|
||||
<ErrorState
|
||||
className="lx-role-list__state"
|
||||
title="角色列表加载失败"
|
||||
message={error.message}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (roles.length === 0) {
|
||||
if (isFiltered) {
|
||||
return (
|
||||
<PageEmpty
|
||||
title="没有匹配的角色"
|
||||
description="换个角色名称、编码或说明再试,也可以清除当前筛选。"
|
||||
action={<Button onClick={onResetFilters}>清除筛选</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PageEmpty
|
||||
title="还没有自定义角色"
|
||||
description="内置角色由系统维护不可删除;可按岗位新建自定义角色并分配权限。"
|
||||
action={
|
||||
<Button theme="solid" type="primary" onClick={onCreate}>
|
||||
新建角色
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="lx-role-list__items" role="listbox" aria-label="角色">
|
||||
{roles.map((role) => {
|
||||
const selected = role.id === selectedRoleId;
|
||||
return (
|
||||
<li key={role.id}>
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={0}
|
||||
className={[
|
||||
'lx-role-row',
|
||||
selected ? 'is-selected' : '',
|
||||
role.status === 'disabled' ? 'is-disabled' : '',
|
||||
]
|
||||
.filter((item) => item !== '')
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
onSelect(role.id);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect(role.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="lx-role-row__icon" aria-hidden="true">
|
||||
<IconShield size={16} />
|
||||
</span>
|
||||
<span className="lx-role-row__main">
|
||||
<span className="lx-role-row__name">
|
||||
<RoleStatusTag status={role.status} dotOnly />
|
||||
<b>{role.name}</b>
|
||||
{role.isSystem ? (
|
||||
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
|
||||
) : null}
|
||||
{role.status === 'disabled' ? <span className="lx-role-tag">停用</span> : null}
|
||||
</span>
|
||||
<span className="lx-role-row__meta">
|
||||
{role.code}
|
||||
<span className="lx-role-row__dot">·</span>
|
||||
{scopeLabelOf(role.dataScope.type)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="lx-role-row__side">
|
||||
<span className="lx-role-row__perm">{rolePermissionText(role)}</span>
|
||||
<span className="lx-role-row__users">{role.userCount} 人绑定</span>
|
||||
</span>
|
||||
{showRowMenu ? (
|
||||
<span
|
||||
className="lx-role-row__menu"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<RowMenu role={role} canManage={canManage} onRowAction={onRowAction} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** 左侧角色列表卡:卡头说明 + 列表 / 三态 + 页脚统计 */
|
||||
export function RoleListPanel(props: RoleListPanelProps): ReactNode {
|
||||
return (
|
||||
<section className="lx-role-list" aria-label="角色列表">
|
||||
<header className="lx-role-list__head">
|
||||
<span className="lx-role-list__title">角色</span>
|
||||
<span className="lx-role-list__note">{props.note}</span>
|
||||
</header>
|
||||
<div className="lx-role-list__body">
|
||||
<ListBody {...props} />
|
||||
</div>
|
||||
<footer className="lx-role-list__foot">
|
||||
<span className="lx-role-list__range">共 {props.roles.length} 个角色</span>
|
||||
<span className="lx-role-list__foot-note">
|
||||
{props.footerNote ??
|
||||
`启用 ${String(props.roles.filter((role) => role.status === 'active').length)} · 停用 ${String(props.roles.filter((role) => role.status === 'disabled').length)}`}
|
||||
</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user