Files
lingxi/apps/web/src/pages/system/roles/RoleListPanel.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

307 lines
8.8 KiB
TypeScript

/* 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>
);
}