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,367 @@
|
||||
/* eslint-disable max-lines-per-function -- 详情面板按头部/摘要/范围/特殊/绑定用户分区,集中便于对齐原型 */
|
||||
import { Button } from '@douyinfe/semi-ui-19';
|
||||
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
|
||||
import { ErrorState, PageEmpty, PageSkeleton } from '@lingxi/ui';
|
||||
import { IconCopy, IconEdit, IconPause, IconPlay, IconShield, IconStar } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
buildPermissionMetaMap,
|
||||
formatRoleDateTime,
|
||||
permissionNameOf,
|
||||
scopeDescriptionOf,
|
||||
scopeLabelOf,
|
||||
summarizePermissionModules,
|
||||
} from './role-display';
|
||||
import { RoleStatusTag } from './RoleStatusTag';
|
||||
|
||||
export interface RoleDetailPanelProps {
|
||||
roleId: string | null;
|
||||
detail: RoleDetail | undefined;
|
||||
tree: PermissionTree | undefined;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
canManage: boolean;
|
||||
onRetry: () => void;
|
||||
onEdit: (roleId: string) => void;
|
||||
onCopy: (role: RoleDetail) => void;
|
||||
onRename: (role: RoleDetail) => void;
|
||||
onDisable: (role: RoleDetail) => void;
|
||||
onEnable: (role: RoleDetail) => void;
|
||||
onOpenCatalog: (role: RoleDetail) => void;
|
||||
}
|
||||
|
||||
function RoleAvatar({ status }: { status: 'active' | 'disabled' }): ReactNode {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
status === 'active' ? 'lx-role-detail__avatar' : 'lx-role-detail__avatar is-disabled'
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconShield size={22} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailHead({
|
||||
role,
|
||||
canManage,
|
||||
onCopy,
|
||||
onRename,
|
||||
onDisable,
|
||||
onEnable,
|
||||
}: Pick<RoleDetailPanelProps, 'canManage' | 'onCopy' | 'onRename' | 'onDisable' | 'onEnable'> & {
|
||||
role: RoleDetail;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<header className="lx-role-detail__head">
|
||||
<RoleAvatar status={role.status} />
|
||||
<div className="lx-role-detail__ident">
|
||||
<div className="lx-role-detail__name">
|
||||
<h2>{role.name}</h2>
|
||||
<RoleStatusTag status={role.status} />
|
||||
{role.isSystem ? <span className="lx-role-tag lx-role-tag--builtin">内置</span> : null}
|
||||
</div>
|
||||
<p className="lx-role-detail__desc">{role.description}</p>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="lx-role-detail__actions">
|
||||
<Button
|
||||
icon={<IconCopy size={14} />}
|
||||
onClick={() => {
|
||||
onCopy(role);
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
{role.isSystem ? (
|
||||
<Button
|
||||
icon={<IconEdit size={14} />}
|
||||
onClick={() => {
|
||||
onRename(role);
|
||||
}}
|
||||
>
|
||||
改名
|
||||
</Button>
|
||||
) : role.status === 'active' ? (
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconPause size={14} />}
|
||||
onClick={() => {
|
||||
onDisable(role);
|
||||
}}
|
||||
>
|
||||
停用
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon={<IconPlay size={14} />}
|
||||
onClick={() => {
|
||||
onEnable(role);
|
||||
}}
|
||||
>
|
||||
启用
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionSummary({
|
||||
role,
|
||||
tree,
|
||||
onOpenCatalog,
|
||||
}: {
|
||||
role: RoleDetail;
|
||||
tree: PermissionTree | undefined;
|
||||
onOpenCatalog: () => void;
|
||||
}): ReactNode {
|
||||
const selected = [...role.permissions, ...role.specialPermissions];
|
||||
const modules = tree === undefined ? [] : summarizePermissionModules(tree, selected);
|
||||
return (
|
||||
<section className="lx-role-detail__section">
|
||||
<div className="lx-role-detail__section-head">
|
||||
<h3>权限点摘要</h3>
|
||||
<span className="lx-role-detail__count">
|
||||
已选 {role.permissionCount + role.specialPermissionCount} /{' '}
|
||||
{tree?.totalPermissions ?? '—'}
|
||||
</span>
|
||||
<Button theme="borderless" size="small" onClick={onOpenCatalog}>
|
||||
查看权限点目录
|
||||
</Button>
|
||||
</div>
|
||||
{modules.length === 0 ? (
|
||||
<p className="lx-role-detail__muted">权限点目录加载中…</p>
|
||||
) : (
|
||||
<div className="lx-role-detail__modules">
|
||||
{modules.map((module) => (
|
||||
<div
|
||||
key={module.module}
|
||||
className={
|
||||
module.selected === 0 ? 'lx-role-detail__module is-zero' : 'lx-role-detail__module'
|
||||
}
|
||||
>
|
||||
<span className="lx-role-detail__module-name">{module.moduleLabel}</span>
|
||||
<span className="lx-role-detail__module-count">
|
||||
<b>{module.selected}</b> / {module.total}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeAndSpecial({
|
||||
role,
|
||||
tree,
|
||||
}: {
|
||||
role: RoleDetail;
|
||||
tree: PermissionTree | undefined;
|
||||
}): ReactNode {
|
||||
const meta = tree === undefined ? undefined : buildPermissionMetaMap(tree);
|
||||
return (
|
||||
<section className="lx-role-detail__section">
|
||||
<div className="lx-role-detail__cols">
|
||||
<div className="lx-role-detail__col">
|
||||
<div className="lx-role-detail__section-head">
|
||||
<h3>数据范围</h3>
|
||||
</div>
|
||||
<p className="lx-role-detail__scope">
|
||||
<b>{scopeLabelOf(role.dataScope.type)}</b>
|
||||
<span>{scopeDescriptionOf(role.dataScope.type)}</span>
|
||||
</p>
|
||||
{role.dataScope.type === 'customer_groups' ? (
|
||||
<p className="lx-role-detail__muted">
|
||||
{(role.dataScope.targetIds ?? []).length === 0
|
||||
? '暂未指定客户组,客户组将在客户模块上线后可选择。'
|
||||
: `已指定 ${String((role.dataScope.targetIds ?? []).length)} 个客户组,客户组名称将在客户模块上线后展示。`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="lx-role-detail__col">
|
||||
<div className="lx-role-detail__section-head">
|
||||
<h3>特殊权限</h3>
|
||||
<span className="lx-role-detail__count">{role.specialPermissions.length} / 6</span>
|
||||
</div>
|
||||
{role.specialPermissions.length === 0 ? (
|
||||
<p className="lx-role-detail__muted">
|
||||
未授予特殊权限,6 项均默认关闭,需单独授予并记录审计。
|
||||
</p>
|
||||
) : (
|
||||
<div className="lx-role-detail__special-chips">
|
||||
{role.specialPermissions.map((code) => (
|
||||
<span key={code} className="lx-role-detail__special-chip" title={code}>
|
||||
<IconStar size={12} />
|
||||
{meta === undefined ? code : permissionNameOf(meta, code)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BoundUsers({ role }: { role: RoleDetail }): ReactNode {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const shown = expanded ? role.boundUsers : role.boundUsers.slice(0, 3);
|
||||
return (
|
||||
<section className="lx-role-detail__section">
|
||||
<div className="lx-role-detail__section-head">
|
||||
<h3>绑定用户</h3>
|
||||
{role.boundUsers.length > 3 ? (
|
||||
<Button
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setExpanded((value) => !value);
|
||||
}}
|
||||
>
|
||||
{expanded ? '收起' : `查看全部 ${String(role.boundUsers.length)} 名`}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{role.boundUsers.length === 0 ? (
|
||||
<p className="lx-role-detail__muted">暂未绑定用户,可停用或删除该角色。</p>
|
||||
) : (
|
||||
<ul className="lx-role-detail__users">
|
||||
{shown.map((user) => (
|
||||
<li key={user.id} className="lx-role-detail__user">
|
||||
<span
|
||||
className={
|
||||
user.status === 'disabled'
|
||||
? 'lx-role-detail__user-avatar is-disabled'
|
||||
: 'lx-role-detail__user-avatar'
|
||||
}
|
||||
>
|
||||
{user.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="lx-role-detail__user-name">{user.name}</span>
|
||||
<span className="lx-role-detail__user-account">{user.account}</span>
|
||||
<span className="lx-role-detail__user-dept">{user.departmentName}</span>
|
||||
<span
|
||||
className={
|
||||
user.status === 'active' ? 'lx-status-tag is-active' : 'lx-status-tag is-disabled'
|
||||
}
|
||||
>
|
||||
{user.status === 'active' ? '在职' : '停用'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailState({
|
||||
roleId,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
}: Pick<RoleDetailPanelProps, 'roleId' | 'loading' | 'error' | 'onRetry'>): ReactNode {
|
||||
if (roleId === null) {
|
||||
return (
|
||||
<PageEmpty
|
||||
title="请选择一个角色"
|
||||
description="左侧列表支持搜索与状态筛选,选中角色后在此查看权限详情。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="lx-role-detail__state">
|
||||
<PageSkeleton rows={8} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error !== null) {
|
||||
return (
|
||||
<ErrorState
|
||||
className="lx-role-detail__state"
|
||||
title="角色详情加载失败"
|
||||
message={error.message}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 右侧角色详情面板(只读):权限摘要、数据范围、特殊权限、绑定用户与编辑入口 */
|
||||
export function RoleDetailPanel(props: RoleDetailPanelProps): ReactNode {
|
||||
const ready = props.roleId !== null && props.detail !== undefined && props.error === null;
|
||||
const detail = props.detail;
|
||||
return (
|
||||
<section className="lx-role-detail" aria-label="角色详情">
|
||||
{ready && detail !== undefined ? (
|
||||
<>
|
||||
<DetailHead
|
||||
role={detail}
|
||||
canManage={props.canManage}
|
||||
onCopy={props.onCopy}
|
||||
onRename={props.onRename}
|
||||
onDisable={props.onDisable}
|
||||
onEnable={props.onEnable}
|
||||
/>
|
||||
<div className="lx-role-detail__body">
|
||||
<div className="lx-role-detail__meta">
|
||||
<span>
|
||||
<em>编码</em>
|
||||
<code>{detail.code}</code>
|
||||
</span>
|
||||
<span>
|
||||
<em>绑定用户</em>
|
||||
{detail.userCount} 名
|
||||
</span>
|
||||
<span>
|
||||
<em>最近更新</em>
|
||||
{formatRoleDateTime(detail.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<PermissionSummary
|
||||
role={detail}
|
||||
tree={props.tree}
|
||||
onOpenCatalog={() => {
|
||||
props.onOpenCatalog(detail);
|
||||
}}
|
||||
/>
|
||||
<ScopeAndSpecial role={detail} tree={props.tree} />
|
||||
<BoundUsers role={detail} />
|
||||
</div>
|
||||
<footer className="lx-role-detail__foot">
|
||||
<span className="lx-role-detail__foot-note">
|
||||
{detail.isSystem ? '系统内置角色,不可停用或删除' : '自定义角色,可复制、停用或删除'}
|
||||
</span>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconEdit size={14} />}
|
||||
onClick={() => {
|
||||
props.onEdit(detail.id);
|
||||
}}
|
||||
>
|
||||
编辑角色
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
) : (
|
||||
<div className="lx-role-detail__empty">
|
||||
<DetailState
|
||||
roleId={props.roleId}
|
||||
loading={props.loading}
|
||||
error={props.error}
|
||||
onRetry={props.onRetry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user