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:
Stev_Wang committed 2026-11-03 12:26:51 +08:00
1 parent f97f97b0cd
commit 61894df401
80 files changed
+14380 -237

No files matched your search

@@ -0,0 +1,97 @@
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
import { Button, SideSheet } from '@douyinfe/semi-ui-19';
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
import { IconCheck, IconStar } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { scopeLabelOf } from './role-display';
export interface PermissionCatalogDrawerProps {
visible: boolean;
tree: PermissionTree | undefined;
role: RoleDetail | null;
onClose: () => void;
}
/** 权限点目录抽屉:只读展示 30 个权限点与当前角色的持有情况 */
export function PermissionCatalogDrawer({
visible,
tree,
role,
onClose,
}: PermissionCatalogDrawerProps): ReactNode {
const granted = new Set<string>([
...(role?.permissions ?? []),
...(role?.specialPermissions ?? []),
]);
return (
<SideSheet
visible={visible}
title="权限点目录"
width={480}
placement="right"
onCancel={onClose}
footer={
<div className="lx-role-catalog__foot">
<span>权限点变更即时对绑定用户生效</span>
<Button onClick={onClose}>关闭</Button>
</div>
}
>
<div className="lx-role-catalog">
<p className="lx-role-catalog__sub">
{tree?.totalPermissions ?? 30} 个权限点 · {tree?.specialPermissions.length ?? 6}{' '}
个特殊权限;★ 默认关闭、需单独授予。
{role === null
? ' 当前角色尚未加载。'
: ` 当前勾选为「${role.name}」持有(${String(role.permissionCount + role.specialPermissionCount)} 个)。`}
</p>
{tree === undefined ? (
<p className="lx-role-catalog__empty">权限点目录加载中…</p>
) : (
tree.modules.map((module) => (
<section key={module.module} className="lx-role-catalog__module">
<header className="lx-role-catalog__module-head">
<b>{module.moduleLabel}</b>
<span>
{module.permissions.filter((permission) => granted.has(permission.code)).length} /{' '}
{module.permissions.length}
</span>
</header>
{module.permissions.map((permission) => {
const owned = granted.has(permission.code);
return (
<div
key={permission.code}
className={owned ? 'lx-role-catalog__row is-granted' : 'lx-role-catalog__row'}
>
<span className="lx-role-catalog__check" aria-hidden="true">
{owned ? <IconCheck size={12} /> : null}
</span>
<span className="lx-role-catalog__copy">
<span className="lx-role-catalog__name">
{permission.name}
{permission.isSpecial ? (
<span className="lx-role-tag lx-role-tag--special">★ 特殊</span>
) : null}
</span>
<span className="lx-role-catalog__desc">{permission.description}</span>
</span>
<code className="lx-role-catalog__code">{permission.code}</code>
</div>
);
})}
</section>
))
)}
<div className="lx-role-catalog__note">
<IconStar size={14} />
<span>
特殊权限 6 项与普通权限分开授予,不随角色复制带入;当前角色数据范围为「
{role === null ? '—' : scopeLabelOf(role.dataScope.type)}」。
</span>
</div>
</div>
</SideSheet>
);
}
@@ -0,0 +1,176 @@
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
import {
PERMISSION_CATALOG,
permissionsByModule,
permissionTreeSchema,
roleDetailSchema,
SOD_PAIRS,
} from '@lingxi/shared';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RoleDetailPanel } from './RoleDetailPanel';
vi.mock('@lingxi/ui', () => ({
PageEmpty: ({ title }: { title?: string }) => <div data-testid="page-empty">{title}</div>,
ErrorState: ({ title }: { title?: string }) => <div data-testid="error-state">{title}</div>,
PageSkeleton: () => <div data-testid="page-skeleton" />,
}));
vi.mock('@douyinfe/semi-ui-19', () => ({
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
}));
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
function roleNode(permission: (typeof PERMISSION_CATALOG)[number]): {
code: string;
name: string;
description: string;
isSpecial: boolean;
} {
return {
code: permission.code,
name: permission.name,
description: permission.description,
isSpecial: permission.isSpecial,
};
}
const tree: PermissionTree = permissionTreeSchema.parse({
modules: permissionsByModule().map((module) => ({
module: module.module,
moduleLabel: module.moduleLabel,
sortOrder: module.sortOrder,
permissions: module.permissions.map(roleNode),
})),
specialPermissions: PERMISSION_CATALOG.filter((permission) => permission.isSpecial).map(roleNode),
sodPairs: SOD_PAIRS.map((pair) => ({
id: pair.id,
label: pair.label,
description: pair.description,
permissions: [pair.permissions[0], pair.permissions[1]],
})),
totalPermissions: PERMISSION_CATALOG.length,
});
const detail: RoleDetail = roleDetailSchema.parse({
id: ROLE_ID,
code: 'account_owner',
name: '客户负责人',
description: '客户资料、脚本审核与交付验收',
isSystem: false,
status: 'active',
permissionCount: 2,
specialPermissionCount: 1,
userCount: 4,
dataScope: { type: 'customer_groups', targetIds: [] },
permissions: ['script:read', 'script:review'],
specialPermissions: ['delivery:export'],
boundUsers: [
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZ3',
name: '陈默',
account: 'chen.mo',
phoneMasked: '138****2046',
departmentName: '内容生产中心 / 素材一组',
status: 'active',
},
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZ4',
name: '高岑',
account: 'gao.cen',
phoneMasked: '186****4402',
departmentName: '交付中心 / 客户成功组',
status: 'active',
},
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZ5',
name: '江岚',
account: 'jiang.lan',
phoneMasked: '159****3320',
departmentName: '平台技术部 / 平台运维组',
status: 'active',
},
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZ6',
name: '何岸',
account: 'he.an',
phoneMasked: '135****6641',
departmentName: '交付中心 / 客户成功组',
status: 'disabled',
},
],
createdAt: '2026-10-25T01:30:00.000Z',
updatedAt: '2026-10-25T01:30:00.000Z',
});
function renderPanel(overrides: Partial<Parameters<typeof RoleDetailPanel>[0]> = {}): void {
render(
<RoleDetailPanel
roleId={ROLE_ID}
detail={detail}
tree={tree}
loading={false}
error={null}
canManage
onRetry={vi.fn()}
onEdit={vi.fn()}
onCopy={vi.fn()}
onRename={vi.fn()}
onDisable={vi.fn()}
onEnable={vi.fn()}
onOpenCatalog={vi.fn()}
{...overrides}
/>,
);
}
afterEach(() => {
cleanup();
});
describe('角色详情面板', () => {
it('渲染头部、权限摘要、数据范围与特殊权限', () => {
renderPanel();
expect(screen.getByText('客户负责人')).toBeTruthy();
expect(screen.getByText('启用')).toBeTruthy();
expect(screen.getByText('account_owner')).toBeTruthy();
expect(screen.getByText('指定客户组')).toBeTruthy();
expect(screen.getByText('发起导出')).toBeTruthy();
expect(document.querySelector('.lx-role-detail__module-count')?.textContent).toContain('2 / 3');
expect(document.querySelector('.lx-role-detail__meta')?.textContent).toContain('4 名');
});
it('绑定用户默认展示前 3 名,可展开全部', () => {
renderPanel();
expect(screen.getByText('陈默')).toBeTruthy();
expect(screen.queryByText('何岸')).toBeNull();
fireEvent.click(screen.getByText('查看全部 4 名'));
expect(screen.getByText('何岸')).toBeTruthy();
expect(screen.getByText('收起')).toBeTruthy();
expect(screen.queryByText('查看全部 4 名')).toBeNull();
});
it('无管理权限时不渲染复制 / 停用等写控件', () => {
renderPanel({ canManage: false });
expect(screen.queryByText('复制')).toBeNull();
expect(screen.queryByText('停用')).toBeNull();
expect(screen.getByText('编辑角色')).toBeTruthy();
});
it('未选择角色时展示空态', () => {
renderPanel({ roleId: null, detail: undefined });
expect(screen.getByTestId('page-empty')).toBeTruthy();
});
it('详情加载失败时展示错误态并可重试', () => {
renderPanel({ detail: undefined, error: new Error('boom') });
expect(screen.getByTestId('error-state').textContent).toContain('角色详情加载失败');
});
});
@@ -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>
);
}
@@ -0,0 +1,703 @@
/* eslint-disable max-lines-per-function, complexity -- 编辑器页负责角色加载、草稿编辑、保存编排与离开拦截,集中便于追踪数据流 */
import { Button } from '@douyinfe/semi-ui-19';
import {
Permission,
type PermissionNode,
type PermissionTree,
type RoleDetail,
type RoleListItem,
type SodConflict,
} from '@lingxi/shared';
import { ErrorState, notifySuccess, notifyWarning, PageSkeleton } from '@lingxi/ui';
import { IconAlert, IconBack, IconCopy, IconPause, IconPlay, IconShield } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useBlocker, useNavigate, useParams } from 'react-router';
import { ApiError } from '@/api/client';
import { Can } from '@/components/Can';
import {
usePermissionTreeQuery,
useRoleDetailQuery,
useRoleListQuery,
useUpdateRoleMutation,
} from '@/hooks/queries/use-roles';
import { usePermission } from '@/hooks/use-permission';
import { roleDetailPath, roleNewPath, ROUTES } from '@/routes';
import {
DisableRoleDialog,
LeaveRoleDialog,
SaveRoleDialog,
SpecialPermissionDialog,
} from './role-dialogs';
import { buildPermissionMetaMap, formatRoleDateTime, scopeLabelOf } from './role-display';
import {
conflictsFromErrorDetails,
type RoleDraftErrors,
saveResultMessage,
validateUpdateDraft,
} from './role-utils';
import { type RoleEditorTabKey, RoleEditorTabs } from './RoleEditorTabs';
import { RoleListPanel } from './RoleListPanel';
import { RoleStatusTag } from './RoleStatusTag';
import { useRoleEditor } from './use-role-editor';
/** 左侧角色切换列表:与列表页同一查询口径,全部状态一次拉取 */
const PEER_ROLE_QUERY = {
limit: 100,
status: 'all',
sort: 'created_at',
order: 'desc',
} as const;
interface EditorContentProps {
detail: RoleDetail;
tree: PermissionTree | undefined;
treeLoading: boolean;
treeError: Error | null;
onRetryTree: () => void;
peers: readonly RoleListItem[];
peersLoading: boolean;
peersError: Error | null;
onRetryPeers: () => void;
onSelectPeer: (roleId: string) => void;
canManage: boolean;
}
function mergeConflicts(
local: readonly SodConflict[],
server: readonly SodConflict[],
): SodConflict[] {
const seen = new Set<string>();
const merged: SodConflict[] = [];
for (const conflict of [...local, ...server]) {
if (seen.has(conflict.pairId)) {
continue;
}
seen.add(conflict.pairId);
merged.push(conflict);
}
return merged;
}
function conflictMessages(
conflicts: readonly SodConflict[],
meta: ReadonlyMap<string, { name: string }> | null,
): string {
return conflicts
.map((conflict) => {
const left = meta?.get(conflict.permissions[0])?.name ?? conflict.permissions[0];
const right = meta?.get(conflict.permissions[1])?.name ?? conflict.permissions[1];
return `命中职责互斥:${conflict.label}。请移除「${left}」或「${right}」后重试。`;
})
.join(' ');
}
function EditorAlert({
readonly,
locked,
conflicts,
saveError,
meta,
}: {
readonly: boolean;
locked: boolean;
conflicts: readonly SodConflict[];
saveError: string | null;
meta: ReadonlyMap<string, { name: string }> | null;
}): ReactNode {
if (readonly) {
return (
<div className="lx-role-editor__alert is-info">
<IconAlert size={15} />
<span>你没有角色管理权限(role:manage),当前为只读视图。如需调整请联系平台管理员。</span>
</div>
);
}
if (locked) {
return (
<div className="lx-role-editor__alert is-info">
<IconAlert size={15} />
<span>超级管理员为系统内置角色,仅显示名可修改;权限、数据范围与特殊权限由系统保证。</span>
</div>
);
}
if (conflicts.length > 0) {
return (
<div className="lx-role-editor__alert is-danger">
<IconAlert size={15} />
<span>{conflictMessages(conflicts, meta)}</span>
</div>
);
}
if (saveError !== null) {
return (
<div className="lx-role-editor__alert is-danger">
<IconAlert size={15} />
<span>{saveError}</span>
</div>
);
}
return null;
}
function EditorFoot({
readonly,
locked,
conflicts,
editor,
saving,
affectedUsers,
onCancel,
onSave,
}: {
readonly: boolean;
locked: boolean;
conflicts: readonly SodConflict[];
editor: ReturnType<typeof useRoleEditor>;
saving: boolean;
affectedUsers: number;
onCancel: () => void;
onSave: () => void;
}): ReactNode {
if (readonly) {
return (
<footer className="lx-role-editor__foot">
<span className="lx-role-editor__impact is-clean">
只读视图:可以查看权限配置,但没有修改权限
</span>
</footer>
);
}
const dirty = editor.isDirty;
return (
<footer className="lx-role-editor__foot">
<div className="lx-role-editor__impact">
{conflicts.length > 0 ? (
<span className="is-blocked">
<IconAlert size={14} />
存在职责互斥,保存不可用
</span>
) : !dirty ? (
<span className="is-clean">未做修改</span>
) : (
<>
<span className="is-dirty">未保存</span>
{editor.diff.totalAdded > 0 ? (
<span>
本次新增 <b>{editor.diff.totalAdded}</b>
</span>
) : null}
{editor.diff.totalRemoved > 0 ? (
<span>
移除 <b>{editor.diff.totalRemoved}</b>
</span>
) : null}
{editor.diff.nameChanged || editor.diff.descriptionChanged ? (
<span>基本信息已修改</span>
) : null}
{editor.diff.scopeChanged ? <span>数据范围已修改</span> : null}
<span>
影响 <b>{affectedUsers}</b> 名用户
</span>
</>
)}
</div>
<div className="lx-role-editor__foot-actions">
<Button onClick={onCancel} disabled={saving}>
取消
</Button>
<Button
theme="solid"
type="primary"
loading={saving}
disabled={conflicts.length > 0 || !dirty}
onClick={onSave}
>
{locked ? '保存名称' : '保存'}
</Button>
</div>
</footer>
);
}
function EditorContent({
detail,
tree,
treeLoading,
treeError,
onRetryTree,
peers,
peersLoading,
peersError,
onRetryPeers,
onSelectPeer,
canManage,
}: EditorContentProps): ReactNode {
const navigate = useNavigate();
const editor = useRoleEditor(detail);
const updateRole = useUpdateRoleMutation();
const locked = detail.isSystem;
const readonly = !canManage;
const [activeTab, setActiveTab] = useState<RoleEditorTabKey>('basic');
const [errors, setErrors] = useState<RoleDraftErrors>({});
const draftFingerprint = useMemo(() => JSON.stringify(editor.draft), [editor.draft]);
const [serverState, setServerState] = useState<{
fingerprint: string;
conflicts: readonly SodConflict[];
saveError: string | null;
} | null>(null);
const serverConflicts = useMemo<readonly SodConflict[]>(
() =>
serverState !== null && serverState.fingerprint === draftFingerprint
? serverState.conflicts
: [],
[serverState, draftFingerprint],
);
const saveError =
serverState !== null && serverState.fingerprint === draftFingerprint
? serverState.saveError
: null;
const [saveOpen, setSaveOpen] = useState(false);
const [specialPrompt, setSpecialPrompt] = useState(false);
const [specialDialog, setSpecialDialog] = useState<{
permission: PermissionNode;
granted: boolean;
} | null>(null);
const [disableOpen, setDisableOpen] = useState(false);
const blocker = useBlocker(editor.isDirty);
const conflicts = useMemo(
() => (detail.isSystem ? [] : mergeConflicts(editor.conflicts, serverConflicts)),
[detail.isSystem, editor.conflicts, serverConflicts],
);
const meta = useMemo(() => (tree === undefined ? null : buildPermissionMetaMap(tree)), [tree]);
useEffect(() => {
if (!editor.isDirty) {
return undefined;
}
const handler = (event: BeforeUnloadEvent): void => {
event.preventDefault();
// eslint-disable-next-line @typescript-eslint/no-deprecated -- 浏览器离开提示仍依赖 returnValue
event.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => {
window.removeEventListener('beforeunload', handler);
};
}, [editor.isDirty]);
const specialChangedCodes = useMemo(
() => [...editor.diff.addedSpecial, ...editor.diff.removedSpecial],
[editor.diff.addedSpecial, editor.diff.removedSpecial],
);
const attemptSave = (): void => {
if (readonly) {
return;
}
const validation = validateUpdateDraft(detail, editor.draft);
setErrors(validation.errors);
if (validation.input === null) {
setActiveTab('basic');
notifyWarning(validation.errors.name ?? validation.errors.description ?? '请检查角色信息');
return;
}
if (!editor.isDirty) {
notifyWarning('未做修改');
return;
}
if (editor.diff.hasSpecialChanges) {
setSpecialPrompt(true);
return;
}
setSaveOpen(true);
};
const doSave = (): void => {
const validation = validateUpdateDraft(detail, editor.draft);
if (validation.input === null) {
setErrors(validation.errors);
setSaveOpen(false);
setActiveTab('basic');
notifyWarning(validation.errors.name ?? '请检查角色信息');
return;
}
updateRole.mutate(
{ roleId: detail.id, input: validation.input },
{
onSuccess: (result) => {
setSaveOpen(false);
setSpecialPrompt(false);
setErrors({});
editor.markSaved();
if (detail.isSystem) {
notifySuccess('角色名称已更新');
} else {
notifySuccess(saveResultMessage(result));
}
if (result.warnings.length > 0) {
notifyWarning(result.warnings.join(';'));
}
},
onError: (error) => {
setSaveOpen(false);
setSpecialPrompt(false);
if (error instanceof ApiError && error.code === 40304) {
setServerState({
fingerprint: draftFingerprint,
conflicts: conflictsFromErrorDetails(error.details),
saveError: '保存时服务端检测到职责互斥,请调整冲突权限点后重试。',
});
setActiveTab('permissions');
return;
}
if (error instanceof ApiError) {
setServerState({
fingerprint: draftFingerprint,
conflicts: [],
saveError: error.message,
});
}
},
},
);
};
const toggleSpecial = (code: string, granted: boolean): void => {
const permission = tree?.specialPermissions.find((item) => item.code === code);
if (permission === undefined) {
return;
}
setSpecialDialog({ permission, granted });
};
const headerTitle = editor.draft.name.trim() === '' ? detail.name : editor.draft.name;
const impactUsers = detail.userCount;
return (
<>
<header className="lx-roles__header">
<button
type="button"
className="lx-roles__back"
aria-label="返回角色列表"
onClick={() => {
void navigate(ROUTES.system.roles);
}}
>
<IconBack size={18} />
</button>
<div className="lx-roles__head-txt">
<div className="lx-roles__title-line">
<h1>{headerTitle}</h1>
<RoleStatusTag status={detail.status} />
{detail.isSystem ? (
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
) : null}
{readonly ? <span className="lx-role-tag">只读</span> : null}
</div>
<p>调整角色权限、数据范围与特殊权限,保存后对绑定用户生效</p>
</div>
<div className="lx-roles__header-actions">
<Can permission={Permission.ROLE_MANAGE}>
<Button
icon={<IconCopy size={14} />}
onClick={() => {
void navigate(roleNewPath(detail.id));
}}
>
复制
</Button>
{detail.isSystem ? null : detail.status === 'active' ? (
<Button
type="danger"
icon={<IconPause size={14} />}
onClick={() => {
setDisableOpen(true);
}}
>
停用
</Button>
) : (
<Button
icon={<IconPlay size={14} />}
onClick={() => {
updateRole.mutate(
{ roleId: detail.id, input: { status: 'enabled' } },
{
onSuccess: () => {
notifySuccess(`「${detail.name}」已启用`);
},
},
);
}}
>
启用
</Button>
)}
</Can>
</div>
</header>
<div className="lx-roles__split">
<RoleListPanel
roles={peers}
totalCount={peers.length}
loading={peersLoading}
error={peersError}
selectedRoleId={detail.id}
note={`当前:${detail.name}`}
footerNote="切换后继续编辑"
canManage={canManage}
showRowMenu={false}
isFiltered={false}
onSelect={onSelectPeer}
onRowAction={() => {
// 编辑器内左侧列表仅用于切换角色,不提供行菜单
}}
onCreate={() => {
void navigate(ROUTES.system.roleNew);
}}
onResetFilters={() => {
// 编辑器内左侧列表不展示筛选
}}
onRetry={onRetryPeers}
/>
<section className="lx-role-editor" aria-label="角色编辑器">
<header className="lx-role-editor__head">
<span className="lx-role-editor__avatar" aria-hidden="true">
<IconShield size={20} />
</span>
<div className="lx-role-editor__ident">
<div className="lx-role-editor__name">
<b>{headerTitle}</b>
<RoleStatusTag status={detail.status} />
{detail.isSystem ? (
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
) : null}
</div>
<div className="lx-role-editor__meta">
<code>{detail.code}</code>
<i>·</i>
<span>{detail.userCount} 名用户</span>
<i>·</i>
<span>最近更新 {formatRoleDateTime(detail.updatedAt)}</span>
<i>·</i>
<span>{scopeLabelOf(detail.dataScope.type)}</span>
</div>
</div>
</header>
<div className="lx-role-editor__alert-wrap">
<EditorAlert
readonly={readonly}
locked={locked}
conflicts={conflicts}
saveError={saveError}
meta={meta}
/>
</div>
<RoleEditorTabs
detail={detail}
tree={tree}
treeLoading={treeLoading}
treeError={treeError}
onRetryTree={onRetryTree}
editor={editor}
locked={locked}
readonly={readonly}
activeTab={activeTab}
onChangeTab={setActiveTab}
errors={errors}
onSpecialRequest={toggleSpecial}
/>
<EditorFoot
readonly={readonly}
locked={locked}
conflicts={conflicts}
editor={editor}
saving={updateRole.isPending}
affectedUsers={detail.userCount}
onCancel={() => {
void navigate(ROUTES.system.roles);
}}
onSave={attemptSave}
/>
</section>
</div>
<DisableRoleDialog
key={`disable-${disableOpen ? detail.id : 'closed'}`}
visible={disableOpen}
role={detail}
loading={updateRole.isPending}
onCancel={() => {
setDisableOpen(false);
}}
onConfirm={(reason) => {
updateRole.mutate(
{
roleId: detail.id,
input: { status: 'disabled', ...(reason === '' ? {} : { reason }) },
},
{
onSuccess: () => {
setDisableOpen(false);
notifySuccess(`「${detail.name}」已停用,${impactUsers} 名用户下一次请求生效`);
},
},
);
}}
/>
<SpecialPermissionDialog
visible={specialDialog !== null}
permission={specialDialog?.permission ?? null}
granted={specialDialog?.granted ?? false}
holderCount={impactUsers}
onCancel={() => {
setSpecialDialog(null);
}}
onConfirm={() => {
if (specialDialog === null) {
return;
}
editor.setSpecialPermission(specialDialog.permission.code, specialDialog.granted);
setSpecialDialog(null);
notifySuccess(
specialDialog.granted
? `已设置「${specialDialog.permission.name}」,保存后生效`
: `已取消「${specialDialog.permission.name}」,保存后生效`,
);
}}
/>
{editor.diff.hasSpecialChanges ? (
<SpecialPermissionDialog
visible={specialPrompt}
permission={
tree?.specialPermissions.find((item) => item.code === specialChangedCodes[0]) ?? null
}
granted={editor.diff.addedSpecial.length > 0}
holderCount={impactUsers}
onCancel={() => {
setSpecialPrompt(false);
}}
onConfirm={() => {
setSpecialPrompt(false);
setSaveOpen(true);
}}
/>
) : null}
<SaveRoleDialog
visible={saveOpen}
role={detail}
diff={editor.diff}
meta={meta}
baseName={editor.base.name}
draftName={editor.draft.name}
baseScope={scopeLabelOf(editor.base.dataScope.type)}
draftScope={scopeLabelOf(editor.draft.dataScope.type)}
loading={updateRole.isPending}
onCancel={() => {
setSaveOpen(false);
}}
onConfirm={doSave}
/>
<LeaveRoleDialog
visible={blocker.state === 'blocked'}
roleName={detail.name}
onCancel={() => {
if (blocker.state === 'blocked') {
blocker.reset();
}
}}
onConfirm={() => {
if (blocker.state === 'blocked') {
blocker.proceed();
}
}}
/>
</>
);
}
function EditorLoading(): ReactNode {
return (
<div className="lx-roles__split">
<section className="lx-role-list lx-role-list--loading">
<PageSkeleton rows={9} />
</section>
<section className="lx-role-editor lx-role-editor--loading">
<PageSkeleton rows={10} />
</section>
</div>
);
}
/** 角色编辑器页(/system/roles/:id):左列表常驻高亮 + 右 Tab 编辑器 + 底部保存条 */
export function RoleEditorPage(): ReactNode {
const params = useParams<{ id: string }>();
const roleId = params.id ?? null;
const navigate = useNavigate();
const canManage = usePermission(Permission.ROLE_MANAGE);
const tree = usePermissionTreeQuery();
const peers = useRoleListQuery(PEER_ROLE_QUERY);
const detail = useRoleDetailQuery(roleId);
const selectPeer = (nextRoleId: string): void => {
if (nextRoleId === roleId) {
return;
}
void navigate(roleDetailPath(nextRoleId));
};
const peerRoles: readonly RoleListItem[] = useMemo(() => peers.data?.items ?? [], [peers.data]);
if (roleId === null) {
return <ErrorState title="角色不存在" message="链接缺少角色标识,请返回角色列表重新选择。" />;
}
if (detail.isPending) {
return (
<div className="lx-roles">
<EditorLoading />
</div>
);
}
if (!detail.isSuccess) {
return (
<div className="lx-roles">
<EditorLoading />
</div>
);
}
return (
<div className="lx-roles">
<EditorContent
key={detail.data.id}
detail={detail.data}
tree={tree.data}
treeLoading={tree.isPending}
treeError={tree.error}
onRetryTree={() => {
void tree.refetch();
}}
peers={peerRoles}
peersLoading={peers.isPending}
peersError={peers.error}
onRetryPeers={() => {
void peers.refetch();
}}
onSelectPeer={selectPeer}
canManage={canManage}
/>
</div>
);
}
@@ -0,0 +1,387 @@
/* eslint-disable max-lines-per-function -- 编辑器四个 Tab 的内容区集中导出,便于与设计稿逐区对照 */
import { Input, Tabs, TextArea } from '@douyinfe/semi-ui-19';
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
import { IconAlert, IconLock } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { formatRoleDateTime } from './role-display';
import type { RoleDraftErrors } from './role-utils';
import { RolePermissionTree } from './RolePermissionTree';
import { RoleScopePicker } from './RoleScopePicker';
import { RoleSpecialPermissions } from './RoleSpecialPermissions';
import { RoleStatusTag } from './RoleStatusTag';
import type { RoleEditorState } from './use-role-editor';
export type RoleEditorTabKey = 'basic' | 'permissions' | 'scope' | 'special';
export interface RoleEditorTabsProps {
detail: RoleDetail;
tree: PermissionTree | undefined;
treeLoading: boolean;
treeError: Error | null;
onRetryTree: () => void;
editor: RoleEditorState;
/** 系统角色:权限 / 范围 / 特殊权限锁定,仅显示名可改 */
locked: boolean;
/** 无 role:manage:所有写控件只读 */
readonly: boolean;
activeTab: RoleEditorTabKey;
onChangeTab: (key: RoleEditorTabKey) => void;
errors: RoleDraftErrors;
onSpecialRequest: (code: string, granted: boolean) => void;
}
function FieldError({ message }: { message: string | undefined }): ReactNode {
return message === undefined ? null : <span className="lx-role-field__error">{message}</span>;
}
function BasicTab({
detail,
editor,
readonly,
locked,
errors,
}: {
detail: RoleDetail;
editor: RoleEditorState;
readonly: boolean;
locked: boolean;
errors: RoleDraftErrors;
}): ReactNode {
const nameLocked = readonly;
const descLocked = readonly || locked;
return (
<div className="lx-role-editor__basic">
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>基本信息</h3>
<span>用于列表展示、成员分配与审计检索</span>
</header>
<div className="lx-role-editor__grid">
<label className="lx-role-field">
<span className="lx-role-field__label">
角色名称
<i className="lx-role-field__required">*</i>
<span className="lx-role-field__hint">{editor.draft.name.length}/64</span>
</span>
<Input
value={editor.draft.name}
maxLength={64}
placeholder="例如:审核员"
disabled={nameLocked}
onChange={editor.setName}
/>
<FieldError message={errors.name} />
</label>
<label className="lx-role-field">
<span className="lx-role-field__label">角色编码</span>
<span className="lx-role-field__readonly">
<IconLock size={13} />
{detail.code}
</span>
<span className="lx-role-field__sub">创建后不可修改,接口与审计使用该编码</span>
</label>
</div>
<label className="lx-role-field">
<span className="lx-role-field__label">
角色说明
<span className="lx-role-field__hint">{editor.draft.description.length}/255</span>
</span>
<TextArea
value={editor.draft.description}
maxLength={255}
autosize={{ minRows: 2, maxRows: 4 }}
placeholder="一句话说明该角色的职责"
disabled={descLocked}
onChange={editor.setDescription}
/>
<FieldError message={errors.description} />
</label>
</section>
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>绑定用户</h3>
<span>停用或删除角色前请先转交</span>
</header>
<div className="lx-role-editor__bind-head">
<b>{detail.userCount} 名用户</b>
<span>绑定后立即获得该角色的权限与数据范围</span>
</div>
{detail.boundUsers.length === 0 ? (
<p className="lx-role-editor__muted">暂未绑定用户</p>
) : (
<ul className="lx-role-editor__users">
{detail.boundUsers.map((user) => (
<li key={user.id} className="lx-role-editor__user">
<span
className={
user.status === 'disabled'
? 'lx-role-editor__user-avatar is-disabled'
: 'lx-role-editor__user-avatar'
}
>
{user.name.slice(0, 1)}
</span>
<span className="lx-role-editor__user-name">{user.name}</span>
<span className="lx-role-editor__user-account">{user.account}</span>
<span className="lx-role-editor__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>
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>生效与审计</h3>
</header>
<p className="lx-role-editor__note">
<IconAlert size={14} />
权限与数据范围变更在保存后写入审计日志(role.updated),绑定用户下一次请求即生效。
</p>
<p className="lx-role-editor__meta-line">
当前状态 <RoleStatusTag status={detail.status} /> · 最近更新{' '}
{formatRoleDateTime(detail.updatedAt)}
</p>
</section>
</div>
);
}
function TreeState({
loading,
error,
onRetry,
}: {
loading: boolean;
error: Error | null;
onRetry: () => void;
}): ReactNode {
if (loading) {
return <p className="lx-role-editor__muted">权限点目录加载中…</p>;
}
if (error !== null) {
return (
<p className="lx-role-editor__muted">
权限点目录加载失败。
<button type="button" className="lx-role-editor__link" onClick={onRetry}>
重新加载
</button>
</p>
);
}
return <p className="lx-role-editor__muted">权限点目录暂不可用</p>;
}
function PermissionsTab({
tree,
treeLoading,
treeError,
onRetryTree,
editor,
locked,
readonly,
}: Pick<
RoleEditorTabsProps,
'tree' | 'treeLoading' | 'treeError' | 'onRetryTree' | 'editor' | 'locked' | 'readonly'
>): ReactNode {
if (tree === undefined) {
return <TreeState loading={treeLoading} error={treeError} onRetry={onRetryTree} />;
}
return (
<div className="lx-role-editor__perm">
<RolePermissionTree
tree={tree}
selected={new Set(editor.draft.permissions)}
special={new Set(editor.draft.specialPermissions)}
conflicts={editor.conflictCodes}
readonly={readonly || locked}
onTogglePermission={editor.togglePermission}
onToggleModule={editor.toggleModule}
/>
{locked ? (
<p className="lx-role-editor__note">
<IconLock size={14} />
系统内置角色的权限由系统保证,不支持在此调整。
</p>
) : null}
</div>
);
}
function ScopeTab({
editor,
locked,
readonly,
}: Pick<RoleEditorTabsProps, 'editor' | 'locked' | 'readonly'>): ReactNode {
return (
<div className="lx-role-editor__scope">
<header className="lx-role-editor__block-head">
<h3>数据范围</h3>
<span>决定该角色可见的客户与数据边界,与权限点同时生效取交集</span>
</header>
<RoleScopePicker
value={{
type: editor.draft.dataScope.type,
targetIds: editor.draft.dataScope.targetIds,
}}
readonly={readonly || locked}
onChange={editor.setScopeType}
/>
</div>
);
}
function SpecialTab({
tree,
treeLoading,
treeError,
onRetryTree,
editor,
detail,
locked,
readonly,
onSpecialRequest,
}: Pick<
RoleEditorTabsProps,
| 'tree'
| 'treeLoading'
| 'treeError'
| 'onRetryTree'
| 'editor'
| 'detail'
| 'locked'
| 'readonly'
| 'onSpecialRequest'
>): ReactNode {
if (tree === undefined) {
return <TreeState loading={treeLoading} error={treeError} onRetry={onRetryTree} />;
}
return (
<div className="lx-role-editor__special">
<header className="lx-role-editor__block-head">
<h3>特殊权限</h3>
<span>默认关闭、单独授予、不随角色复制带入</span>
</header>
<RoleSpecialPermissions
tree={tree}
granted={new Set(editor.draft.specialPermissions)}
readonly={readonly || locked}
holderCount={detail.userCount}
onRequest={onSpecialRequest}
/>
{locked ? (
<p className="lx-role-editor__note">
<IconLock size={14} />
系统内置角色的特殊权限由系统保证,不支持在此调整。
</p>
) : null}
</div>
);
}
function tabLabel(label: string, badge?: string, danger = false): ReactNode {
return (
<span className="lx-role-editor__tab-label">
{label}
{badge === undefined ? null : (
<span
className={danger ? 'lx-role-editor__tab-badge is-danger' : 'lx-role-editor__tab-badge'}
>
{badge}
</span>
)}
</span>
);
}
/** 编辑器头部 Tabs:基本信息 / 权限点 / 数据范围 / 特殊权限 */
export function RoleEditorTabs(props: RoleEditorTabsProps): ReactNode {
const totalPermissions =
props.tree?.totalPermissions ??
props.detail.permissionCount + props.detail.specialPermissionCount;
const selectedCount =
props.editor.draft.permissions.length + props.editor.draft.specialPermissions.length;
return (
<Tabs
className="lx-role-editor__tabs"
type="line"
activeKey={props.activeTab}
onChange={(key) => {
props.onChangeTab(key as RoleEditorTabKey);
}}
>
<Tabs.TabPane key="basic" itemKey="basic" tab={tabLabel('基本信息')}>
{props.activeTab === 'basic' ? (
<BasicTab
detail={props.detail}
editor={props.editor}
readonly={props.readonly}
locked={props.locked}
errors={props.errors}
/>
) : null}
</Tabs.TabPane>
<Tabs.TabPane
key="permissions"
itemKey="permissions"
tab={tabLabel(
'权限点',
`${String(selectedCount)} / ${String(totalPermissions)}`,
props.editor.conflicts.length > 0,
)}
>
{props.activeTab === 'permissions' ? (
<PermissionsTab
tree={props.tree}
treeLoading={props.treeLoading}
treeError={props.treeError}
onRetryTree={props.onRetryTree}
editor={props.editor}
locked={props.locked}
readonly={props.readonly}
/>
) : null}
</Tabs.TabPane>
<Tabs.TabPane key="scope" itemKey="scope" tab={tabLabel('数据范围')}>
{props.activeTab === 'scope' ? (
<ScopeTab editor={props.editor} locked={props.locked} readonly={props.readonly} />
) : null}
</Tabs.TabPane>
<Tabs.TabPane
key="special"
itemKey="special"
tab={tabLabel(
'特殊权限',
`${String(props.editor.draft.specialPermissions.length)} / ${String(
props.tree?.specialPermissions.length ?? 6,
)}`,
)}
>
{props.activeTab === 'special' ? (
<SpecialTab
tree={props.tree}
treeLoading={props.treeLoading}
treeError={props.treeError}
onRetryTree={props.onRetryTree}
editor={props.editor}
detail={props.detail}
locked={props.locked}
readonly={props.readonly}
onSpecialRequest={props.onSpecialRequest}
/>
) : null}
</Tabs.TabPane>
</Tabs>
);
}
@@ -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>
);
}
@@ -0,0 +1,530 @@
/* eslint-disable max-lines-per-function, complexity -- 新建页负责模板预填、表单草稿与创建编排,集中便于对照原型 */
import { Button, Input, TextArea } from '@douyinfe/semi-ui-19';
import {
isPermissionCode,
type PermissionCode,
type PermissionModuleNode,
type PermissionNode,
type RoleDetail,
type RoleListItem,
} from '@lingxi/shared';
import { ErrorState, notifySuccess, notifyWarning, PageSkeleton } from '@lingxi/ui';
import { IconAdd, IconBack, IconCopy, IconDocs, IconShield } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import {
useCreateRoleMutation,
usePermissionTreeQuery,
useRoleDetailQuery,
useRoleListQuery,
} from '@/hooks/queries/use-roles';
import { roleDetailPath, ROUTES } from '@/routes';
import { SpecialPermissionDialog } from './role-dialogs';
import { scopeLabelOf } from './role-display';
import {
createEmptyDraft,
draftFromDetail,
findDraftSodConflicts,
type RoleDraft,
type RoleDraftErrors,
validateCreateDraft,
} from './role-utils';
import { RolePermissionTree } from './RolePermissionTree';
import { RoleScopePicker } from './RoleScopePicker';
import { RoleSpecialPermissions } from './RoleSpecialPermissions';
const TEMPLATE_ROLE_QUERY = {
limit: 100,
status: 'active',
sort: 'created_at',
order: 'desc',
} as const;
const BLANK_TEMPLATE = '__blank__';
interface TemplateListProps {
templates: readonly RoleListItem[];
loading: boolean;
error: Error | null;
selectedKey: string;
onSelect: (key: string) => void;
onRetry: () => void;
}
function TemplateList({
templates,
loading,
error,
selectedKey,
onSelect,
onRetry,
}: TemplateListProps): ReactNode {
return (
<section className="lx-role-templates" aria-label="角色模板">
<header className="lx-role-templates__head">
<span className="lx-role-templates__title">角色模板</span>
<span className="lx-role-templates__note">
{templates.length === 0 ? '仅空白' : `${String(templates.length + 1)} 个`}
</span>
</header>
<div className="lx-role-templates__body">
{loading ? (
<div className="lx-role-templates__state">
<PageSkeleton rows={8} />
</div>
) : error !== null ? (
<ErrorState
className="lx-role-templates__state"
title="角色模板加载失败"
message={error.message}
onRetry={onRetry}
/>
) : (
<div role="listbox" aria-label="选择模板">
<button
type="button"
role="option"
aria-selected={selectedKey === BLANK_TEMPLATE}
className={
selectedKey === BLANK_TEMPLATE ? 'lx-role-template is-active' : 'lx-role-template'
}
onClick={() => {
onSelect(BLANK_TEMPLATE);
}}
>
<span className="lx-role-template__icon" aria-hidden="true">
<IconDocs size={16} />
</span>
<span className="lx-role-template__main">
<span className="lx-role-template__name">从空白开始</span>
<span className="lx-role-template__desc">不预置任何权限,逐项配置</span>
<span className="lx-role-template__meta">
<span>权限 0</span>
<i>·</i>
<span>未设置范围</span>
<i>·</i>
<span>特殊 0</span>
</span>
</span>
</button>
{templates.map((role) => {
const active = selectedKey === role.id;
return (
<button
key={role.id}
type="button"
role="option"
aria-selected={active}
className={active ? 'lx-role-template is-active' : 'lx-role-template'}
onClick={() => {
onSelect(role.id);
}}
>
<span className="lx-role-template__icon" aria-hidden="true">
<IconShield size={16} />
</span>
<span className="lx-role-template__main">
<span className="lx-role-template__name">
{role.name}
{role.isSystem ? (
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
) : null}
</span>
<span className="lx-role-template__desc">{role.description}</span>
<span className="lx-role-template__meta">
<span>权限 {role.permissionCount}</span>
<i>·</i>
<span>{scopeLabelOf(role.dataScope.type)}</span>
<i>·</i>
<span className={role.specialPermissionCount > 0 ? 'has-special' : ''}>
{role.specialPermissionCount > 0
? `★特殊 ${String(role.specialPermissionCount)}`
: '特殊 0'}
</span>
</span>
</span>
</button>
);
})}
</div>
)}
</div>
<footer className="lx-role-templates__foot">
模板只带入普通权限与数据范围,★ 特殊权限需创建后单独授予
</footer>
</section>
);
}
function NewFormHeader({
templateRole,
fromCopy,
templateLoading,
}: {
templateRole: RoleDetail | undefined;
fromCopy: boolean;
templateLoading: boolean;
}): ReactNode {
const title =
templateRole === undefined
? '从空白开始创建'
: fromCopy
? `复制「${templateRole.name}」`
: `从「${templateRole.name}」模板创建`;
const sub =
templateRole === undefined
? '未预置任何权限与数据范围,按需逐项配置'
: fromCopy
? '已带入权限与数据范围,特殊权限需创建后单独授予'
: templateRole.description;
return (
<header className="lx-role-new__head">
<div>
<h2>{title}</h2>
<p>{templateLoading ? '正在读取来源角色…' : sub}</p>
</div>
<span className="lx-role-tag lx-role-tag--brand">
{templateRole === undefined ? '空白配置' : fromCopy ? '复制角色' : '已套用模板'}
</span>
</header>
);
}
function FieldError({ message }: { message: string | undefined }): ReactNode {
return message === undefined ? null : <span className="lx-role-field__error">{message}</span>;
}
/** 新建 / 复制角色页(/system/roles/new?from=):左模板列表 + 右表单 */
export function RoleNewPage(): ReactNode {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const fromRoleId = searchParams.get('from');
const listQuery = useRoleListQuery(TEMPLATE_ROLE_QUERY);
const tree = usePermissionTreeQuery();
const createRole = useCreateRoleMutation();
const templates = useMemo(
() => (listQuery.data?.items ?? []).filter((role) => !role.isSystem),
[listQuery.data],
);
const [selectedKey, setSelectedKey] = useState<string>(fromRoleId ?? BLANK_TEMPLATE);
const [draft, setDraft] = useState<RoleDraft>(createEmptyDraft);
const [errors, setErrors] = useState<RoleDraftErrors>({});
const [specialDialog, setSpecialDialog] = useState<{
permission: PermissionNode;
granted: boolean;
} | null>(null);
const [prefilledId, setPrefilledId] = useState<string | null>(null);
const templateRoleId = selectedKey === BLANK_TEMPLATE ? null : selectedKey;
const templateDetail = useRoleDetailQuery(templateRoleId);
const fromCopy = fromRoleId !== null && fromRoleId === selectedKey;
const conflicts = findDraftSodConflicts(draft);
const conflictCodes = useMemo(() => {
const codes = new Set<string>();
for (const conflict of conflicts) {
codes.add(conflict.permissions[0]);
codes.add(conflict.permissions[1]);
}
return codes;
}, [conflicts]);
// 模板数据到达后一次性预填(渲染期对齐状态,避免 effect 级联渲染)
const templateRole = templateDetail.data;
if (templateRoleId === null) {
if (prefilledId !== null) {
setPrefilledId(null);
setDraft(createEmptyDraft());
setErrors({});
}
} else if (templateRole !== undefined && prefilledId !== templateRole.id) {
setPrefilledId(templateRole.id);
setDraft(draftFromDetail(templateRole, { includeSpecial: false, nameSuffix: ' 副本' }));
setErrors({});
}
const togglePermission = (code: string): void => {
if (!isPermissionCode(code)) {
return;
}
setDraft((current) => {
const has = current.permissions.includes(code);
return {
...current,
permissions: has
? current.permissions.filter((item) => item !== code)
: [...current.permissions, code],
};
});
};
const toggleModule = (module: PermissionModuleNode, selectAll: boolean): void => {
const normalCodes: PermissionCode[] = module.permissions
.filter((permission) => !permission.isSpecial && isPermissionCode(permission.code))
.map((permission) => permission.code)
.filter(isPermissionCode);
setDraft((current) => {
const next = new Set(current.permissions);
for (const code of normalCodes) {
if (selectAll) {
next.add(code);
} else {
next.delete(code);
}
}
return { ...current, permissions: [...next] };
});
};
const submit = (): void => {
const validation = validateCreateDraft(draft);
setErrors(validation.errors);
if (validation.input === null) {
notifyWarning(validation.errors.name ?? '请检查角色信息');
return;
}
if (conflicts.length > 0) {
notifyWarning(`存在职责互斥:${conflicts[0]?.label ?? ''},请调整权限点`);
return;
}
createRole.mutate(validation.input, {
onSuccess: (result) => {
notifySuccess('角色已创建,正在进入编辑器');
void navigate(roleDetailPath(result.role.id), { replace: true });
},
});
};
return (
<div className="lx-roles">
<header className="lx-roles__header">
<button
type="button"
className="lx-roles__back"
aria-label="返回角色列表"
onClick={() => {
void navigate(ROUTES.system.roles);
}}
>
<IconBack size={18} />
</button>
<div className="lx-roles__head-txt">
<h1>新建角色</h1>
<p>从模板开始或从空白配置,创建后可继续调整权限与数据范围</p>
</div>
<div className="lx-roles__header-actions">
<Button
onClick={() => {
void navigate(ROUTES.system.roles);
}}
>
取消
</Button>
<Button
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
loading={createRole.isPending}
onClick={submit}
>
创建角色
</Button>
</div>
</header>
<div className="lx-roles__split">
<TemplateList
templates={templates}
loading={listQuery.isPending}
error={listQuery.error}
selectedKey={selectedKey}
onSelect={(key) => {
setSelectedKey(key);
setPrefilledId(null);
}}
onRetry={() => {
void listQuery.refetch();
}}
/>
<section className="lx-role-new" aria-label="新建角色表单">
<NewFormHeader
templateRole={templateDetail.data}
fromCopy={fromCopy}
templateLoading={templateDetail.isPending && templateRoleId !== null}
/>
<div className="lx-role-new__body">
{fromCopy ? (
<div className="lx-role-new__alert is-info">
<IconCopy size={15} />
<span>
已复制「{templateDetail.data?.name ?? ''}」的权限与数据范围,特殊权限未带入。
</span>
</div>
) : null}
{conflicts.length > 0 ? (
<div className="lx-role-new__alert is-danger">
<span>
命中职责互斥:{conflicts.map((conflict) => conflict.label).join(';')}
,请调整冲突权限点后重试。
</span>
</div>
) : null}
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>基本信息</h3>
<span>名称与说明用于列表展示和成员分配</span>
</header>
<div className="lx-role-editor__grid">
<label className="lx-role-field">
<span className="lx-role-field__label">
角色名称
<i className="lx-role-field__required">*</i>
<span className="lx-role-field__hint">{draft.name.length}/64</span>
</span>
<Input
value={draft.name}
maxLength={64}
placeholder="例如:审核员"
onChange={(value) => {
setDraft((current) => ({ ...current, name: value }));
setErrors({});
}}
/>
<FieldError message={errors.name} />
</label>
</div>
<label className="lx-role-field">
<span className="lx-role-field__label">
角色说明
<span className="lx-role-field__hint">{draft.description.length}/255</span>
</span>
<TextArea
value={draft.description}
maxLength={255}
autosize={{ minRows: 2, maxRows: 4 }}
placeholder="一句话说明该角色的职责"
onChange={(value) => {
setDraft((current) => ({ ...current, description: value }));
}}
/>
<FieldError message={errors.description} />
</label>
</section>
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>权限点</h3>
<span>模板已带入的权限点可继续增减</span>
</header>
{tree.data === undefined ? (
<p className="lx-role-editor__muted">
{tree.isPending
? '权限点目录加载中…'
: '权限点目录加载失败,创建后可在编辑器中继续配置。'}
</p>
) : (
<RolePermissionTree
tree={tree.data}
selected={new Set(draft.permissions)}
special={new Set(draft.specialPermissions)}
conflicts={conflictCodes}
readonly={false}
onTogglePermission={togglePermission}
onToggleModule={toggleModule}
/>
)}
</section>
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>数据范围</h3>
<span>与权限点同时生效,取交集</span>
</header>
<RoleScopePicker
value={{ type: draft.dataScope.type, targetIds: draft.dataScope.targetIds }}
readonly={false}
onChange={(type) => {
setDraft((current) => ({
...current,
dataScope: {
type,
targetIds: type === 'customer_groups' ? current.dataScope.targetIds : [],
},
}));
}}
/>
</section>
<section className="lx-role-editor__block">
<header className="lx-role-editor__block-head">
<h3>特殊权限</h3>
<span>默认全关,创建后也可在编辑器中授予</span>
</header>
{tree.data === undefined ? (
<p className="lx-role-editor__muted">特殊权限目录加载中…</p>
) : (
<RoleSpecialPermissions
tree={tree.data}
granted={new Set(draft.specialPermissions)}
readonly={false}
holderCount={0}
onRequest={(code, granted) => {
const permission = tree.data.specialPermissions.find(
(item) => item.code === code,
);
if (permission !== undefined) {
setSpecialDialog({ permission, granted });
}
}}
/>
)}
</section>
</div>
<footer className="lx-role-new__foot">
<span className="lx-role-new__foot-note">
已选 {draft.permissions.length + draft.specialPermissions.length} /{' '}
{tree.data?.totalPermissions ?? 30} 权限点 · {scopeLabelOf(draft.dataScope.type)} ·
特殊权限 {draft.specialPermissions.length}
</span>
<span className="lx-role-new__foot-hint">创建后进入编辑器继续配置</span>
</footer>
</section>
</div>
<SpecialPermissionDialog
visible={specialDialog !== null}
permission={specialDialog?.permission ?? null}
granted={specialDialog?.granted ?? false}
holderCount={0}
onCancel={() => {
setSpecialDialog(null);
}}
onConfirm={() => {
if (specialDialog === null) {
return;
}
const code = specialDialog.permission.code;
if (!isPermissionCode(code)) {
setSpecialDialog(null);
return;
}
setDraft((current) => ({
...current,
specialPermissions: specialDialog.granted
? [...current.specialPermissions, code]
: current.specialPermissions.filter((item) => item !== code),
}));
setSpecialDialog(null);
notifySuccess(
specialDialog.granted
? `已选择「${specialDialog.permission.name}」,创建后生效`
: `已取消「${specialDialog.permission.name}」`,
);
}}
/>
</div>
);
}
@@ -0,0 +1,187 @@
import type { PermissionTree } from '@lingxi/shared';
import {
PERMISSION_CATALOG,
permissionsByModule,
permissionTreeSchema,
SOD_PAIRS,
} from '@lingxi/shared';
import { notifyWarning } from '@lingxi/ui';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RolePermissionTree } from './RolePermissionTree';
vi.mock('@lingxi/ui', () => ({
notifyWarning: vi.fn(),
}));
// jsdom 无 canvas,Semi 整包会加载 Lottie;测试只渲染树,替换所需基础组件
vi.mock('@douyinfe/semi-ui-19', () => ({
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
Input: ({
value,
placeholder,
onChange,
}: {
value?: string;
placeholder?: string;
onChange?: (value: string) => void;
}) => (
<input
value={value ?? ''}
placeholder={placeholder}
onChange={(event) => {
onChange?.(event.target.value);
}}
/>
),
}));
const mockedNotifyWarning = vi.mocked(notifyWarning);
function buildTree(): PermissionTree {
const roleNode = (permission: (typeof PERMISSION_CATALOG)[number]) => ({
code: permission.code,
name: permission.name,
description: permission.description,
isSpecial: permission.isSpecial,
});
return permissionTreeSchema.parse({
modules: permissionsByModule().map((module) => ({
module: module.module,
moduleLabel: module.moduleLabel,
sortOrder: module.sortOrder,
permissions: module.permissions.map(roleNode),
})),
specialPermissions: PERMISSION_CATALOG.filter((permission) => permission.isSpecial).map(
roleNode,
),
sodPairs: SOD_PAIRS.map((pair) => ({
id: pair.id,
label: pair.label,
description: pair.description,
permissions: [pair.permissions[0], pair.permissions[1]],
})),
totalPermissions: PERMISSION_CATALOG.length,
});
}
interface RenderOptions {
selected?: string[];
special?: string[];
conflicts?: string[];
readonly?: boolean;
}
function renderTree(options: RenderOptions = {}): {
onTogglePermission: ReturnType<typeof vi.fn>;
onToggleModule: ReturnType<typeof vi.fn>;
container: HTMLElement;
} {
const onTogglePermission = vi.fn();
const onToggleModule = vi.fn();
const { container } = render(
<RolePermissionTree
tree={buildTree()}
selected={new Set(options.selected ?? [])}
special={new Set(options.special ?? [])}
conflicts={new Set(options.conflicts ?? [])}
readonly={options.readonly ?? false}
onTogglePermission={onTogglePermission}
onToggleModule={onToggleModule}
/>,
);
return { onTogglePermission, onToggleModule, container };
}
afterEach(() => {
cleanup();
mockedNotifyWarning.mockReset();
});
describe('权限点树', () => {
it('按目录渲染 10 个模块与已选计数', () => {
const { container } = renderTree({ selected: ['script:read'] });
const tools = container.querySelector('.lx-role-tree__tools');
expect(tools?.textContent).toContain('已选');
expect(tools?.textContent).toContain('30');
expect(screen.getByText('脚本')).toBeTruthy();
expect(screen.getByText('素材')).toBeTruthy();
expect(screen.getByText('组织与角色')).toBeTruthy();
expect(screen.getByText('审计')).toBeTruthy();
expect(container.querySelector('.lx-role-module__count')?.textContent).toContain('1 / 3');
});
it('默认展开已选模块,点击模块标题展开其余模块', () => {
renderTree({ selected: ['script:read'] });
expect(screen.getByText('查看脚本')).toBeTruthy();
expect(screen.queryByText('查看素材任务')).toBeNull();
fireEvent.click(screen.getByText('素材'));
expect(screen.getByText('查看素材任务')).toBeTruthy();
});
it('点击普通权限点行触发切换', () => {
const { onTogglePermission } = renderTree({ selected: ['script:read'] });
fireEvent.click(screen.getByText('查看脚本'));
expect(onTogglePermission).toHaveBeenCalledWith('script:read');
});
it('点击模块三态复选框触发整模块选中', () => {
const { container, onToggleModule } = renderTree({ selected: ['script:read'] });
const module = container.querySelector('[data-module="script"]');
expect(module).not.toBeNull();
const check = module?.querySelector('.lx-role-module__check');
expect(check?.getAttribute('aria-checked')).toBe('mixed');
if (check !== null && check !== undefined) {
fireEvent.click(check);
}
expect(onToggleModule).toHaveBeenCalledTimes(1);
expect(onToggleModule.mock.calls[0]?.[1]).toBe(true);
});
it('特殊权限行不切换,只提示需单独授予', () => {
const { container, onTogglePermission } = renderTree({});
fireEvent.click(screen.getByText('导出交付'));
const specialRow = container.querySelector('[data-code="delivery:export"]');
expect(specialRow).not.toBeNull();
if (specialRow !== null) {
fireEvent.click(specialRow);
}
expect(onTogglePermission).not.toHaveBeenCalledWith('delivery:export');
expect(mockedNotifyWarning).toHaveBeenCalledTimes(1);
});
it('SoD 冲突行高亮', () => {
const { container } = renderTree({
selected: ['script:write', 'script:review'],
conflicts: ['script:write', 'script:review'],
});
expect(container.querySelector('[data-code="script:write"]')?.className).toContain(
'is-conflict',
);
expect(container.querySelector('[data-code="script:read"]')?.className).not.toContain(
'is-conflict',
);
});
it('只读态禁用交互', () => {
const { onTogglePermission } = renderTree({ selected: ['script:read'], readonly: true });
fireEvent.click(screen.getByText('查看脚本'));
expect(onTogglePermission).not.toHaveBeenCalled();
});
it('搜索权限点后只保留匹配模块并自动展开', () => {
renderTree({});
fireEvent.change(screen.getByPlaceholderText('搜索权限点名称或编码'), {
target: { value: '发起导出' },
});
expect(screen.queryByText('脚本')).toBeNull();
expect(screen.getByText('导出交付')).toBeTruthy();
expect(screen.getByText('发起导出')).toBeTruthy();
});
});
@@ -0,0 +1,381 @@
/* eslint-disable max-lines-per-function -- 权限树包含工具条、模块三态与行渲染,集中在一处便于对齐目录 */
import { Button, Input } from '@douyinfe/semi-ui-19';
import type { PermissionModuleNode, PermissionTree } from '@lingxi/shared';
import { notifyWarning } from '@lingxi/ui';
import { IconChevronDown, IconChevronRight, IconSearch } from '@lingxi/ui/icons';
import type { KeyboardEvent, ReactNode } from 'react';
import { useMemo, useState } from 'react';
export interface RolePermissionTreeProps {
tree: PermissionTree;
/** 普通权限点(特殊权限不得混入) */
selected: ReadonlySet<string>;
/** 特殊权限点(树内只读展示,授予在「特殊权限」区单独确认) */
special: ReadonlySet<string>;
/** SoD 冲突权限点编码,树内高亮 */
conflicts: ReadonlySet<string>;
readonly: boolean;
onTogglePermission: (code: string) => void;
onToggleModule: (module: PermissionModuleNode, selectAll: boolean) => void;
}
function moduleSelectedCount(
module: PermissionModuleNode,
selected: ReadonlySet<string>,
special: ReadonlySet<string>,
): number {
return module.permissions.filter((permission) =>
permission.isSpecial ? special.has(permission.code) : selected.has(permission.code),
).length;
}
function normalPermissionsOf(module: PermissionModuleNode): string[] {
return module.permissions
.filter((permission) => !permission.isSpecial)
.map((permission) => permission.code);
}
function initialExpandedModules(
tree: PermissionTree,
selected: ReadonlySet<string>,
special: ReadonlySet<string>,
): ReadonlySet<string> {
const expanded = new Set<string>();
for (const module of tree.modules) {
if (moduleSelectedCount(module, selected, special) > 0) {
expanded.add(module.module);
}
}
if (expanded.size === 0) {
for (const module of tree.modules.slice(0, 2)) {
expanded.add(module.module);
}
}
return expanded;
}
function matchesKeyword(
module: PermissionModuleNode,
keyword: string,
): PermissionModuleNode | null {
if (keyword === '') {
return module;
}
const permissions = module.permissions.filter((permission) =>
`${permission.name} ${permission.code} ${permission.description}`
.toLowerCase()
.includes(keyword),
);
return permissions.length === 0 ? null : { ...module, permissions };
}
function CheckStateIcon({ state }: { state: 'on' | 'off' | 'half' }): ReactNode {
return <span className={`lx-role-check is-${state}`} aria-hidden="true" />;
}
function PermissionRow({
code,
name,
description,
isSpecial,
checked,
conflict,
readonly,
onToggle,
onSpecialHint,
}: {
code: string;
name: string;
description: string;
isSpecial: boolean;
checked: boolean;
conflict: boolean;
readonly: boolean;
onToggle: () => void;
onSpecialHint: () => void;
}): ReactNode {
const interactive = !readonly && !isSpecial;
const activate = (): void => {
if (readonly) {
return;
}
if (isSpecial) {
onSpecialHint();
return;
}
onToggle();
};
const className = [
'lx-role-perm-row',
checked ? 'is-checked' : '',
isSpecial ? 'is-special' : '',
conflict ? 'is-conflict' : '',
interactive ? 'is-interactive' : '',
]
.filter((item) => item !== '')
.join(' ');
return (
<div
className={className}
role="checkbox"
aria-checked={checked}
aria-disabled={!interactive}
tabIndex={interactive ? 0 : -1}
data-code={code}
onClick={activate}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
activate();
}
}}
>
<span className="lx-role-perm-row__check">
<CheckStateIcon state={checked ? 'on' : 'off'} />
</span>
<span className="lx-role-perm-row__main">
<span className="lx-role-perm-row__name">
{name}
{isSpecial ? <span className="lx-role-tag lx-role-tag--special">★ 特殊</span> : null}
</span>
<span className="lx-role-perm-row__desc">{description}</span>
{isSpecial ? (
<span className="lx-role-perm-row__hint">
特殊权限需在「特殊权限」中单独授予并二次确认
</span>
) : null}
</span>
<code className="lx-role-perm-row__code">{code}</code>
</div>
);
}
function ModuleBlock({
module,
selected,
special,
conflicts,
readonly,
expanded,
onToggleExpanded,
onTogglePermission,
onToggleModule,
}: {
module: PermissionModuleNode;
selected: ReadonlySet<string>;
special: ReadonlySet<string>;
conflicts: ReadonlySet<string>;
readonly: boolean;
expanded: boolean;
onToggleExpanded: () => void;
onTogglePermission: (code: string) => void;
onToggleModule: (module: PermissionModuleNode, selectAll: boolean) => void;
}): ReactNode {
const normalCodes = normalPermissionsOf(module);
const selectedNormal = normalCodes.filter((code) => selected.has(code)).length;
const state: 'on' | 'off' | 'half' =
selectedNormal === 0 ? 'off' : selectedNormal === normalCodes.length ? 'on' : 'half';
const specialCount = module.permissions.filter((permission) => permission.isSpecial).length;
const totalSelected = moduleSelectedCount(module, selected, special);
return (
<div
className={expanded ? 'lx-role-module is-open' : 'lx-role-module'}
data-module={module.module}
>
<div className="lx-role-module__head">
<span
className={[
'lx-role-module__check',
readonly ? 'is-readonly' : '',
normalCodes.length === 0 ? 'is-locked' : '',
]
.filter((item) => item !== '')
.join(' ')}
role="checkbox"
aria-checked={state === 'on' ? true : state === 'half' ? 'mixed' : false}
aria-disabled={readonly || normalCodes.length === 0}
tabIndex={readonly ? -1 : 0}
title={
normalCodes.length === 0
? '该模块权限点均为特殊权限,请在「特殊权限」中授予'
: undefined
}
onClick={(event) => {
event.stopPropagation();
if (readonly || normalCodes.length === 0) {
return;
}
onToggleModule(module, state !== 'on');
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
if (readonly || normalCodes.length === 0) {
return;
}
onToggleModule(module, state !== 'on');
}}
>
<CheckStateIcon state={state} />
</span>
<button
type="button"
className="lx-role-module__toggle"
aria-expanded={expanded}
onClick={onToggleExpanded}
>
<span className="lx-role-module__name">{module.moduleLabel}</span>
{specialCount > 0 ? (
<span className="lx-role-module__star" title="该模块含特殊权限">
★ {specialCount}
</span>
) : null}
<span
className={
totalSelected === 0 ? 'lx-role-module__count' : 'lx-role-module__count is-active'
}
>
{totalSelected} / {module.permissions.length}
</span>
<span className="lx-role-module__caret">
{expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</span>
</button>
</div>
{expanded ? (
<div className="lx-role-module__body">
{module.permissions.map((permission) => (
<PermissionRow
key={permission.code}
code={permission.code}
name={permission.name}
description={permission.description}
isSpecial={permission.isSpecial}
checked={
permission.isSpecial ? special.has(permission.code) : selected.has(permission.code)
}
conflict={conflicts.has(permission.code)}
readonly={readonly}
onToggle={() => {
onTogglePermission(permission.code);
}}
onSpecialHint={() => {
notifyWarning('特殊权限需在「特殊权限」中单独授予并二次确认');
}}
/>
))}
</div>
) : null}
</div>
);
}
/** 权限点树:10 模块 / 30 点,模块三态、搜索、折叠;特殊权限树内只读 */
export function RolePermissionTree({
tree,
selected,
special,
conflicts,
readonly,
onTogglePermission,
onToggleModule,
}: RolePermissionTreeProps): ReactNode {
const [keyword, setKeyword] = useState('');
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() =>
initialExpandedModules(tree, selected, special),
);
const normalizedKeyword = keyword.trim().toLowerCase();
const visibleModules = useMemo(() => {
const matched: PermissionModuleNode[] = [];
for (const module of tree.modules) {
const result = matchesKeyword(module, normalizedKeyword);
if (result !== null) {
matched.push(result);
}
}
return matched;
}, [tree, normalizedKeyword]);
const isExpanded = (module: string): boolean => normalizedKeyword !== '' || expanded.has(module);
const setAllExpanded = (open: boolean): void => {
setExpanded(open ? new Set(tree.modules.map((module) => module.module)) : new Set());
};
const toggleExpanded = (module: string): void => {
setExpanded((current) => {
const next = new Set(current);
if (next.has(module)) {
next.delete(module);
} else {
next.add(module);
}
return next;
});
};
return (
<div className="lx-role-tree">
<div className="lx-role-tree__tools">
<Input
className="lx-role-tree__search"
value={keyword}
prefix={<IconSearch size={15} />}
placeholder="搜索权限点名称或编码"
aria-label="搜索权限点"
showClear
onChange={(value) => {
setKeyword(value);
}}
/>
<span className="lx-role-tree__selected">
已选 <b>{selected.size + special.size}</b> / {tree.totalPermissions}
</span>
<span className="lx-role-tree__links">
<Button
theme="borderless"
size="small"
onClick={() => {
setAllExpanded(true);
}}
>
展开全部
</Button>
<Button
theme="borderless"
size="small"
onClick={() => {
setAllExpanded(false);
}}
>
收起全部
</Button>
</span>
</div>
{visibleModules.length === 0 ? (
<p className="lx-role-tree__empty">没有匹配的权限点,换个名称或编码再试。</p>
) : (
<div className="lx-role-tree__modules">
{visibleModules.map((module) => (
<ModuleBlock
key={module.module}
module={module}
selected={selected}
special={special}
conflicts={conflicts}
readonly={readonly}
expanded={isExpanded(module.module)}
onToggleExpanded={() => {
toggleExpanded(module.module);
}}
onTogglePermission={onTogglePermission}
onToggleModule={onToggleModule}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,86 @@
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
import type { DataScopeType } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { ROLE_SCOPE_OPTIONS, scopeOptionOf } from './role-display';
export interface RoleScopeValue {
type: DataScopeType;
targetIds: readonly string[];
}
export interface RoleScopePickerProps {
value: RoleScopeValue;
readonly: boolean;
onChange: (type: DataScopeType) => void;
}
/**
* 六类数据范围单选卡片。
* A3 前客户组目录为空:选中「指定客户组」时展示空态,不伪造客户组数据。
*/
export function RoleScopePicker({ value, readonly, onChange }: RoleScopePickerProps): ReactNode {
const selected = scopeOptionOf(value.type);
return (
<div className="lx-role-scope">
<div className="lx-role-scope__grid" role="radiogroup" aria-label="数据范围">
{ROLE_SCOPE_OPTIONS.map((option) => {
const active = option.type === value.type;
return (
<button
key={option.type}
type="button"
role="radio"
aria-checked={active}
disabled={readonly}
className={active ? 'lx-role-scope__option is-active' : 'lx-role-scope__option'}
onClick={() => {
if (!readonly && !active) {
onChange(option.type);
}
}}
>
<span className="lx-role-scope__radio" aria-hidden="true" />
<span className="lx-role-scope__copy">
<span className="lx-role-scope__name">{option.label}</span>
<span className="lx-role-scope__desc">{option.description}</span>
</span>
{option.needsTargets ? <span className="lx-role-scope__need">需选客户组</span> : null}
</button>
);
})}
</div>
{value.type === 'customer_groups' ? (
<div className="lx-role-scope__groups">
<div className="lx-role-scope__groups-head">
<b>客户组</b>
<span>
{value.targetIds.length === 0
? '选择客户组后按组批量授权'
: `已指定 ${String(value.targetIds.length)} 个客户组`}
</span>
</div>
{value.targetIds.length === 0 ? (
<div className="lx-role-scope__empty">
<b>暂无可选客户组</b>
<span>
客户组将在客户模块上线后可选择;当前角色先按「指定客户组」保存,后续再补充授权范围。
</span>
</div>
) : (
<p className="lx-role-scope__note">
已指定 {value.targetIds.length} 个客户组。客户组名称与覆盖客户将在客户模块上线后展示。
</p>
)}
</div>
) : null}
<p className="lx-role-scope__foot">
<span className="lx-role-scope__foot-mark" aria-hidden="true" />
当前范围:{selected.label}
。数据范围随用户所属部门或负责客户动态计算,变更后立即对绑定用户生效。
</p>
</div>
);
}
@@ -0,0 +1,77 @@
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
import { Switch } from '@douyinfe/semi-ui-19';
import type { PermissionTree } from '@lingxi/shared';
import { IconAlert, IconStar } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { specialPermissionRiskOf } from './role-display';
export interface RoleSpecialPermissionsProps {
tree: PermissionTree;
granted: ReadonlySet<string>;
readonly: boolean;
/** 当前角色绑定用户数(授予 / 撤销的影响面) */
holderCount: number;
onRequest: (code: string, granted: boolean) => void;
}
/** 特殊权限区:6 个独立开关,授予 / 撤销由父级弹二次确认,不随复制带入 */
export function RoleSpecialPermissions({
tree,
granted,
readonly,
holderCount,
onRequest,
}: RoleSpecialPermissionsProps): ReactNode {
return (
<div className="lx-role-special">
<div className="lx-role-special__banner">
<IconAlert size={16} />
<span>特殊权限默认关闭,不随角色复制带入;授予后记录审计,并立即对绑定用户生效。</span>
</div>
<div className="lx-role-special__list">
{tree.specialPermissions.map((permission) => {
const active = granted.has(permission.code);
return (
<div
key={permission.code}
className={active ? 'lx-role-special__row is-granted' : 'lx-role-special__row'}
>
<div className="lx-role-special__main">
<div className="lx-role-special__name">
{permission.name}
<span className="lx-role-tag lx-role-tag--special">★ 特殊</span>
</div>
<code className="lx-role-special__code">{permission.code}</code>
<p className="lx-role-special__desc">{permission.description}</p>
<p className="lx-role-special__risk">
<IconAlert size={13} />
{specialPermissionRiskOf(permission.code)}
</p>
</div>
<div className="lx-role-special__side">
<Switch
checked={active}
disabled={readonly}
aria-label={permission.name}
onChange={(checked) => {
if (!readonly && checked !== active) {
onRequest(permission.code, checked);
}
}}
/>
<span className="lx-role-special__holder">
{active ? <IconStar size={12} /> : null}
持有者 <b>{active ? holderCount : 0}</b> 名
</span>
</div>
</div>
);
})}
</div>
<p className="lx-role-special__note">
特殊权限与普通权限分开授予:普通权限决定能做什么,特殊权限决定能否让数据离开平台或影响全部客户。
</p>
</div>
);
}
@@ -0,0 +1,31 @@
import type { RoleStatus } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { roleStatusLabel } from './role-display';
export interface RoleStatusTagProps {
status: RoleStatus;
/** 列表行内只显示状态点,详情展示完整标签 */
dotOnly?: boolean;
}
export function RoleStatusTag({ status, dotOnly = false }: RoleStatusTagProps): ReactNode {
const active = status === 'active';
return (
<span
className={
active
? dotOnly
? 'lx-status-dot'
: 'lx-status-tag is-active'
: dotOnly
? 'lx-status-dot is-disabled'
: 'lx-status-tag is-disabled'
}
title={roleStatusLabel(status)}
>
<span className="lx-status-tag__dot" aria-hidden="true" />
{dotOnly ? null : roleStatusLabel(status)}
</span>
);
}
@@ -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>
);
}
@@ -0,0 +1,573 @@
/* eslint-disable max-lines-per-function, complexity -- 角色二次确认弹窗模板固定,集中在一处便于统一文案口径 */
import { Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19';
import type { PermissionNode, RoleDetail, RoleListItem } from '@lingxi/shared';
import { IconAlert, IconShield, IconStar } from '@lingxi/ui/icons';
import type { ComponentType } from 'react';
import type { ReactNode } from 'react';
import { useState } from 'react';
import type { RolePermissionMeta } from './role-display';
import {
permissionNameOf,
roleStatusLabel,
scopeLabelOf,
specialPermissionRiskOf,
} from './role-display';
import type { RoleDraftDiff } from './role-utils';
/** 停用 / 删除 / 改名弹窗所需的最小角色字段(列表项与详情均满足) */
export type RoleDialogTarget = Pick<
RoleListItem,
'id' | 'name' | 'code' | 'status' | 'userCount' | 'dataScope' | 'isSystem'
>;
export interface RoleObjectCardProps {
name: string;
meta: string;
status?: string;
icon?: ComponentType<{ size?: number | string }>;
}
/** 弹窗内的角色对象卡 */
export function RoleObjectCard({
name,
meta,
status,
icon: Icon = IconShield,
}: RoleObjectCardProps): ReactNode {
return (
<div className="lx-role-dialog__object">
<span className="lx-role-dialog__object-icon" aria-hidden="true">
<Icon size={16} />
</span>
<span className="lx-role-dialog__object-copy">
<span className="lx-role-dialog__object-title">{name}</span>
<span className="lx-role-dialog__object-meta">{meta}</span>
</span>
{status === undefined ? null : <span className="lx-role-tag">{status}</span>}
</div>
);
}
function DialogWarn({
children,
tone = 'warning',
}: {
children: ReactNode;
tone?: 'warning' | 'danger' | 'info';
}): ReactNode {
return (
<div className={`lx-role-dialog__warn is-${tone}`}>
<IconAlert size={15} />
<div>{children}</div>
</div>
);
}
// 弹窗内容由父级通过 key 控制重挂载:打开时重置初始值,关闭时卸载,避免 effect 同步状态。
export interface DisableRoleDialogProps {
visible: boolean;
role: RoleDialogTarget | null;
loading: boolean;
onCancel: () => void;
onConfirm: (reason: string) => void;
}
/** 停用角色:影响用户数 + 勾选知晓;停用可随时恢复 */
export function DisableRoleDialog({
visible,
role,
loading,
onCancel,
onConfirm,
}: DisableRoleDialogProps): ReactNode {
const [acknowledged, setAcknowledged] = useState(false);
const [reason, setReason] = useState('');
return (
<Modal
visible={visible}
title="停用角色"
className="lx-role-modal"
width={520}
maskClosable={false}
closeOnEsc={!loading}
onCancel={onCancel}
footer={
<>
<span className="lx-role-dialog__foot-note">
停用可随时恢复,不会删除角色及其权限配置
</span>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button
theme="solid"
type="danger"
disabled={!acknowledged || loading}
loading={loading}
onClick={() => {
onConfirm(reason.trim());
}}
>
停用角色
</Button>
</>
}
>
<div className="lx-role-dialog">
{role === null ? null : (
<RoleObjectCard
name={role.name}
meta={`${role.code} · ${scopeLabelOf(role.dataScope.type)} · ${String(role.userCount)} 名用户`}
status={roleStatusLabel(role.status)}
/>
)}
<DialogWarn>
停用后,{role?.userCount ?? 0}{' '}
名绑定用户将在下一次请求即失去该角色权限;停用不会删除角色配置。
</DialogWarn>
<label className="lx-role-dialog__field">
<span>停用原因(选填,写入审计)</span>
<Input
value={reason}
maxLength={200}
placeholder="例如:岗位调整、角色合并"
onChange={setReason}
/>
</label>
<Checkbox
checked={acknowledged}
onChange={(event) => {
setAcknowledged(event.target.checked === true);
}}
>
我已知晓停用后这些用户下一次请求即失去该角色权限
</Checkbox>
</div>
</Modal>
);
}
export interface DeleteRoleDialogProps {
visible: boolean;
role: RoleDialogTarget | null;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
}
/** 删除角色:仅 0 绑定用户可用,删除不可恢复 */
export function DeleteRoleDialog({
visible,
role,
loading,
onCancel,
onConfirm,
}: DeleteRoleDialogProps): ReactNode {
return (
<Modal
visible={visible}
title="删除角色"
className="lx-role-modal"
width={520}
maskClosable={false}
closeOnEsc={!loading}
onCancel={onCancel}
footer={
<>
<span className="lx-role-dialog__foot-note">删除后不可恢复,审计日志保留授权记录</span>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button
theme="solid"
type="danger"
onClick={onConfirm}
disabled={loading || (role?.userCount ?? 0) > 0}
loading={loading}
>
删除角色
</Button>
</>
}
>
<div className="lx-role-dialog">
{role === null ? null : (
<RoleObjectCard
name={role.name}
meta={`${role.code} · ${String(role.userCount)} 名用户`}
status="自定义"
/>
)}
<DialogWarn tone="danger">
删除后不可恢复,该角色的权限配置会一并删除,授权记录会保留在审计日志中。
</DialogWarn>
<p className="lx-role-dialog__hint">
{role !== null && role.userCount > 0
? '该角色仍有绑定用户,无法删除;请先将用户调整到其他角色,或改为停用。'
: '该角色当前没有绑定用户,可从模板重新创建。'}
</p>
</div>
</Modal>
);
}
export interface RenameRoleDialogProps {
visible: boolean;
role: RoleDialogTarget | null;
loading: boolean;
onCancel: () => void;
onConfirm: (name: string) => void;
}
/** 系统角色改名:仅显示名可改 */
export function RenameRoleDialog({
visible,
role,
loading,
onCancel,
onConfirm,
}: RenameRoleDialogProps): ReactNode {
const [name, setName] = useState(() => role?.name ?? '');
const trimmed = name.trim();
return (
<Modal
visible={visible}
title="修改角色名称"
className="lx-role-modal"
width={520}
onCancel={onCancel}
closeOnEsc={!loading}
footer={
<>
<span className="lx-role-dialog__foot-note">改名会记录审计</span>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button
theme="solid"
type="primary"
disabled={trimmed === '' || loading}
loading={loading}
onClick={() => {
onConfirm(trimmed);
}}
>
保存名称
</Button>
</>
}
>
<div className="lx-role-dialog">
<label className="lx-role-dialog__field">
<span>角色名称</span>
<Input
value={name}
maxLength={64}
onChange={setName}
onEnterPress={() => {
if (trimmed !== '' && !loading) {
onConfirm(trimmed);
}
}}
/>
</label>
<DialogWarn tone="info">
超级管理员为系统内置角色,仅显示名可修改;权限、数据范围与特殊权限由系统保证。
</DialogWarn>
</div>
</Modal>
);
}
export interface CopyRoleDialogProps {
visible: boolean;
source: RoleDetail | null;
sourceLoading: boolean;
loading: boolean;
onCancel: () => void;
onConfirm: (name: string) => void;
}
/** 复制角色:带入普通权限与数据范围,特殊权限不随复制带入 */
export function CopyRoleDialog({
visible,
source,
sourceLoading,
loading,
onCancel,
onConfirm,
}: CopyRoleDialogProps): ReactNode {
const [name, setName] = useState(() => (source === null ? '' : `${source.name} 副本`));
const trimmed = name.trim();
return (
<Modal
visible={visible}
title="复制角色"
className="lx-role-modal"
width={520}
onCancel={onCancel}
closeOnEsc={!loading}
footer={
<>
<span className="lx-role-dialog__foot-note">新角色创建后状态为启用</span>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button
theme="solid"
type="primary"
disabled={trimmed === '' || loading || sourceLoading || source === null}
loading={loading}
onClick={() => {
onConfirm(trimmed);
}}
>
创建副本
</Button>
</>
}
>
<div className="lx-role-dialog">
{source === null ? (
<p className="lx-role-dialog__hint">
{sourceLoading ? '正在读取来源角色…' : '来源角色不可用'}
</p>
) : (
<RoleObjectCard
name={source.name}
meta={`${source.code} · ${scopeLabelOf(source.dataScope.type)} · ${String(source.permissionCount)} 个权限点`}
status={roleStatusLabel(source.status)}
/>
)}
<label className="lx-role-dialog__field">
<span>新角色名称</span>
<Input value={name} maxLength={64} placeholder="请输入新角色名称" onChange={setName} />
</label>
<DialogWarn tone="warning">
将带入 {source?.permissionCount ?? 0} 个普通权限点与「
{source === null ? '—' : scopeLabelOf(source.dataScope.type)}
」数据范围;特殊权限不会随复制带入, 需创建后单独授予。
</DialogWarn>
</div>
</Modal>
);
}
export interface SpecialPermissionDialogProps {
visible: boolean;
permission: PermissionNode | null;
granted: boolean;
holderCount: number;
onCancel: () => void;
onConfirm: () => void;
}
/** 特殊权限授予 / 撤销二次确认(保存前不落库,确认后更新草稿) */
export function SpecialPermissionDialog({
visible,
permission,
granted,
holderCount,
onCancel,
onConfirm,
}: SpecialPermissionDialogProps): ReactNode {
return (
<Modal
visible={visible}
title={granted ? '授予特殊权限' : '撤销特殊权限'}
className="lx-role-modal"
width={520}
maskClosable={false}
onCancel={onCancel}
footer={
<>
<span className="lx-role-dialog__foot-note">
{granted ? '授予后立即生效' : '撤销后立即生效'}
</span>
<Button onClick={onCancel}>取消</Button>
<Button theme="solid" type={granted ? 'primary' : 'danger'} onClick={onConfirm}>
{granted ? '确认授予' : '确认撤销'}
</Button>
</>
}
>
<div className="lx-role-dialog">
<DialogWarn tone={granted ? 'warning' : 'danger'}>
{granted
? `将为 ${String(holderCount)} 名用户授予「${permission?.name ?? ''}」特殊权限。该权限不随角色复制带入,授予后记录审计。`
: `撤销后 ${String(holderCount)} 名用户将立即失去「${permission?.name ?? ''}」权限,撤销会记录审计。`}
</DialogWarn>
{permission === null ? null : (
<RoleObjectCard
name={permission.name}
meta={permission.code}
status="特殊权限"
icon={IconStar}
/>
)}
<p className="lx-role-dialog__hint">
{permission === null
? ''
: `${permission.description}。${specialPermissionRiskOf(permission.code)}。`}
</p>
</div>
</Modal>
);
}
export interface SaveRoleDialogProps {
visible: boolean;
role: RoleDetail | null;
diff: RoleDraftDiff | null;
meta: ReadonlyMap<string, RolePermissionMeta> | null;
baseName: string;
draftName: string;
baseScope: string;
draftScope: string;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
}
function ChangeRow({ kind, text }: { kind: string; text: string }): ReactNode {
return (
<div className="lx-role-dialog__change">
<span className="lx-role-dialog__change-kind">{kind}</span>
<span className="lx-role-dialog__change-text">{text}</span>
</div>
);
}
/** 保存前影响面确认:权限 diff + 基本信息 + 数据范围 + 影响用户数 */
export function SaveRoleDialog({
visible,
role,
diff,
meta,
baseName,
draftName,
baseScope,
draftScope,
loading,
onCancel,
onConfirm,
}: SaveRoleDialogProps): ReactNode {
const names = (codes: readonly string[]): string =>
codes.map((code) => (meta === null ? code : permissionNameOf(meta, code))).join('、');
return (
<Modal
visible={visible}
title="保存角色变更"
className="lx-role-modal"
width={560}
maskClosable={false}
closeOnEsc={!loading}
onCancel={onCancel}
footer={
<>
<span className="lx-role-dialog__foot-note">可在审计日志中按角色检索本次变更</span>
<Button onClick={onCancel} disabled={loading}>
继续编辑
</Button>
<Button theme="solid" type="primary" onClick={onConfirm} loading={loading}>
保存并生效
</Button>
</>
}
>
<div className="lx-role-dialog">
{role === null ? null : (
<RoleObjectCard
name={role.name}
meta={`${role.code} · ${String(role.userCount)} 名用户`}
status={roleStatusLabel(role.status)}
/>
)}
<div className="lx-role-dialog__changes">
<div className="lx-role-dialog__changes-head">
变更摘要
<span>{diff?.totalChanges ?? 0} 项</span>
</div>
<div className="lx-role-dialog__change-list">
{diff === null ? null : (
<>
{diff.addedPermissions.length + diff.addedSpecial.length > 0 ? (
<ChangeRow
kind="新增"
text={names([...diff.addedPermissions, ...diff.addedSpecial])}
/>
) : null}
{diff.removedPermissions.length + diff.removedSpecial.length > 0 ? (
<ChangeRow
kind="移除"
text={names([...diff.removedPermissions, ...diff.removedSpecial])}
/>
) : null}
{diff.nameChanged ? (
<ChangeRow kind="名称" text={`${baseName} → ${draftName}`} />
) : null}
{diff.descriptionChanged ? <ChangeRow kind="说明" text="已更新角色说明" /> : null}
{diff.scopeChanged ? (
<ChangeRow kind="范围" text={`${baseScope} → ${draftScope}`} />
) : null}
{!diff.hasPermissionChanges &&
!diff.hasSpecialChanges &&
!diff.nameChanged &&
!diff.descriptionChanged &&
!diff.scopeChanged ? (
<ChangeRow kind="—" text="本次没有实际变更" />
) : null}
</>
)}
</div>
</div>
<p className="lx-role-dialog__hint">
保存后,{role?.userCount ?? 0} 名用户将在下一次请求生效,变更会记录审计(role.updated)。
</p>
</div>
</Modal>
);
}
export interface LeaveRoleDialogProps {
visible: boolean;
roleName: string;
onCancel: () => void;
onConfirm: () => void;
}
/** 未保存离开提示(路由拦截 / 返回列表 / 切换角色共用) */
export function LeaveRoleDialog({
visible,
roleName,
onCancel,
onConfirm,
}: LeaveRoleDialogProps): ReactNode {
return (
<Modal
visible={visible}
title="放弃未保存的修改?"
className="lx-role-modal"
width={480}
maskClosable={false}
onCancel={onCancel}
footer={
<>
<span className="lx-role-dialog__foot-note">「{roleName}」有未保存的修改</span>
<Button onClick={onCancel}>继续编辑</Button>
<Button theme="solid" type="danger" onClick={onConfirm}>
放弃并离开
</Button>
</>
}
>
<div className="lx-role-dialog">
<DialogWarn tone="warning">
当前角色的权限、数据范围与基本信息修改尚未保存,离开后这些修改将丢失。
</DialogWarn>
</div>
</Modal>
);
}
@@ -0,0 +1,154 @@
import type { PermissionTree, RoleListItem } from '@lingxi/shared';
import { permissionTreeSchema, roleListItemSchema } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import {
buildPermissionMetaMap,
DEFAULT_ROLE_LIST_FILTERS,
filterRoles,
formatRoleDateTime,
hasActiveRoleFilters,
permissionNameOf,
scopeLabelOf,
summarizePermissionModules,
summarizeRoles,
} from './role-display';
let index = 0;
function buildRole(overrides: Record<string, unknown> = {}): RoleListItem {
index += 1;
return roleListItemSchema.parse({
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
code: `role_${String(index)}`,
name: `角色 ${String(index)}`,
description: '角色说明',
isSystem: false,
status: 'active',
permissionCount: 4,
specialPermissionCount: 0,
userCount: 3,
dataScope: { type: 'department' },
createdAt: '2026-10-27T02:40:00.000Z',
updatedAt: '2026-10-27T02:40:00.000Z',
...overrides,
});
}
const tree: PermissionTree = permissionTreeSchema.parse({
modules: [
{
module: 'script',
moduleLabel: '脚本',
sortOrder: 10,
permissions: [
{ code: 'script:read', name: '查看脚本', description: '脚本库', isSpecial: false },
{ code: 'script:write', name: '编辑与生成脚本', description: '编辑', isSpecial: false },
{ code: 'script:review', name: '审核脚本', description: '审核', isSpecial: false },
],
},
{
module: 'delivery',
moduleLabel: '导出交付',
sortOrder: 40,
permissions: [
{ code: 'delivery:read', name: '查看交付记录', description: '交付清单', isSpecial: false },
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
],
},
],
specialPermissions: [
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
],
sodPairs: [
{
id: 'script-write-review',
label: '脚本生成 × 脚本审核',
description: '不得同时拥有',
permissions: ['script:write', 'script:review'],
},
],
totalPermissions: 5,
});
describe('权限点摘要与元数据', () => {
it('按 10 模块口径统计已选 / 总数(含特殊权限)', () => {
const summary = summarizePermissionModules(tree, [
'script:read',
'script:review',
'delivery:export',
]);
expect(summary).toEqual([
{ module: 'script', moduleLabel: '脚本', selected: 2, total: 3 },
{ module: 'delivery', moduleLabel: '导出交付', selected: 1, total: 2 },
]);
});
it('权限点编码映射到名称,缺失时回退编码', () => {
const meta = buildPermissionMetaMap(tree);
expect(permissionNameOf(meta, 'delivery:export')).toBe('发起导出');
expect(meta.get('delivery:export')?.isSpecial).toBe(true);
expect(permissionNameOf(meta, 'ghost:code')).toBe('ghost:code');
});
});
describe('数据范围与时间展示', () => {
it('六类数据范围名称与未知类型兜底', () => {
expect(scopeLabelOf('customer_groups')).toBe('指定客户组');
expect(scopeLabelOf('department_and_children')).toBe('本部门及下级');
expect(scopeLabelOf('created_by_me')).toBe('本人创建');
});
it('ISO 时间转 YYYY-MM-DD HH:mm,空值与非法值显示占位符', () => {
expect(formatRoleDateTime('2026-10-27T02:40:00.000Z')).toMatch(/^2026-10-27 \d{2}:40$/);
expect(formatRoleDateTime(null)).toBe('—');
expect(formatRoleDateTime('not-a-date')).toBe('—');
});
});
describe('角色统计与筛选', () => {
it('汇总角色 / 启用 / 停用 / 绑定用户 / 特殊授予 / 内置数', () => {
const stats = summarizeRoles([
buildRole({ isSystem: true, userCount: 2, specialPermissionCount: 6 }),
buildRole({ status: 'disabled', userCount: 0 }),
buildRole({ userCount: 5, specialPermissionCount: 1 }),
]);
expect(stats).toEqual({
total: 3,
active: 2,
disabled: 1,
boundUsers: 7,
specialGrants: 7,
systemCount: 1,
});
});
it('按关键词 / 状态 / 只看特殊权限筛选', () => {
const roles = [
buildRole({ name: '审核员', code: 'reviewer', description: '脚本与素材审核' }),
buildRole({ name: '设计师', code: 'designer', status: 'disabled' }),
buildRole({ name: '行业配置', specialPermissionCount: 1 }),
];
expect(
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, keyword: '审核' }).map(
(role) => role.code,
),
).toEqual(['reviewer']);
expect(
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, status: 'disabled' }).map(
(role) => role.name,
),
).toEqual(['设计师']);
expect(
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, specialOnly: true }).map(
(role) => role.name,
),
).toEqual(['行业配置']);
});
it('识别是否存在生效筛选', () => {
expect(hasActiveRoleFilters(DEFAULT_ROLE_LIST_FILTERS)).toBe(false);
expect(hasActiveRoleFilters({ ...DEFAULT_ROLE_LIST_FILTERS, keyword: ' ' })).toBe(false);
expect(hasActiveRoleFilters({ ...DEFAULT_ROLE_LIST_FILTERS, specialOnly: true })).toBe(true);
});
});
@@ -0,0 +1,251 @@
import type { DataScopeType, PermissionTree, RoleListItem, RoleStatus } from '@lingxi/shared';
/** 六类数据范围(PRD §5.4.3、技术架构 §6.2)的展示口径 */
export interface RoleScopeOption {
type: DataScopeType;
label: string;
description: string;
/** 是否需要在选中后指定客户组(A3 前客户组无数据) */
needsTargets: boolean;
}
export const ROLE_SCOPE_OPTIONS: readonly RoleScopeOption[] = [
{
type: 'all',
label: '全部客户',
description: '平台内全部客户',
needsTargets: false,
},
{
type: 'department_and_children',
label: '本部门及下级',
description: '用户所属部门及下级部门',
needsTargets: false,
},
{
type: 'department',
label: '本部门',
description: '仅用户所属部门',
needsTargets: false,
},
{
type: 'customer_groups',
label: '指定客户组',
description: '按客户组批量授权',
needsTargets: true,
},
{
type: 'owned_customers',
label: '负责客户',
description: '客户负责人为本人',
needsTargets: false,
},
{
type: 'created_by_me',
label: '本人创建',
description: '仅本人创建的数据',
needsTargets: false,
},
];
const SCOPE_OPTION_BY_TYPE: ReadonlyMap<DataScopeType, RoleScopeOption> = new Map(
ROLE_SCOPE_OPTIONS.map((option) => [option.type, option]),
);
export function scopeOptionOf(type: DataScopeType): RoleScopeOption {
const option = SCOPE_OPTION_BY_TYPE.get(type);
// DataScopeType 是封闭枚举,映射表必然覆盖;兜底仅防御后端返回脏数据
return (
option ?? { type, label: '未知范围', description: '请重新选择数据范围', needsTargets: false }
);
}
export function scopeLabelOf(type: DataScopeType): string {
return scopeOptionOf(type).label;
}
export function scopeDescriptionOf(type: DataScopeType): string {
return scopeOptionOf(type).description;
}
export const ROLE_STATUS_LABEL: Record<RoleStatus, string> = {
active: '启用',
disabled: '停用',
};
export function roleStatusLabel(status: RoleStatus): string {
return ROLE_STATUS_LABEL[status];
}
const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
/** ISO 时间 → `YYYY-MM-DD HH:mm`;空值显示占位符 */
export function formatRoleDateTime(value: string | null | undefined): string {
if (value === null || value === undefined || value === '') {
return '—';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return DATE_TIME_FORMATTER.format(date).replaceAll('/', '-');
}
export interface RoleModuleSummary {
module: string;
moduleLabel: string;
selected: number;
total: number;
}
/** 权限点摘要:按目录模块统计已选 / 总数(含特殊权限,与列表计数口径一致) */
export function summarizePermissionModules(
tree: PermissionTree,
selected: readonly string[],
): RoleModuleSummary[] {
const selectedSet = new Set(selected);
return tree.modules.map((node) => ({
module: node.module,
moduleLabel: node.moduleLabel,
selected: node.permissions.filter((permission) => selectedSet.has(permission.code)).length,
total: node.permissions.length,
}));
}
export interface RolePermissionMeta {
code: string;
name: string;
moduleLabel: string;
description: string;
isSpecial: boolean;
}
/** 权限点编码 → 目录元数据(权限树与 SoD 提示共用,避免各页面重复查找) */
export function buildPermissionMetaMap(
tree: PermissionTree,
): ReadonlyMap<string, RolePermissionMeta> {
const map = new Map<string, RolePermissionMeta>();
for (const node of tree.modules) {
for (const permission of node.permissions) {
map.set(permission.code, {
code: permission.code,
name: permission.name,
moduleLabel: node.moduleLabel,
description: permission.description,
isSpecial: permission.isSpecial,
});
}
}
for (const permission of tree.specialPermissions) {
if (!map.has(permission.code)) {
map.set(permission.code, {
code: permission.code,
name: permission.name,
moduleLabel: '特殊权限',
description: permission.description,
isSpecial: true,
});
}
}
return map;
}
/** 权限点名称;目录缺失时回退编码,保证弹窗与摘要不出现空白 */
export function permissionNameOf(
meta: ReadonlyMap<string, RolePermissionMeta>,
code: string,
): string {
return meta.get(code)?.name ?? code;
}
/** 特殊权限风险说明(授予 / 撤销确认弹窗展示) */
export const SPECIAL_PERMISSION_RISK: Record<string, string> = {
'delivery:export': '持有者可批量导出客户数据,仅授予确需对外交付的人员',
'customer_field:approve': '持有者可批准客户数据结构变更,需与申请权限分离',
'industry:publish': '发布后影响全部客户,请与行业包编辑权限分离授予',
'ai:configure': '持有者可修改模型路由与预算额度,需与业务操作分离',
'audit:read': '审计日志包含全平台操作记录,仅授予审计与合规岗位',
'audit:export': '导出后数据离开平台,每次导出均留痕',
};
export function specialPermissionRiskOf(code: string): string {
return SPECIAL_PERMISSION_RISK[code] ?? '特殊权限默认关闭,授予后立即生效并记录审计';
}
export interface RoleStatistics {
total: number;
active: number;
disabled: number;
boundUsers: number;
specialGrants: number;
systemCount: number;
}
export function summarizeRoles(roles: readonly RoleListItem[]): RoleStatistics {
let active = 0;
let boundUsers = 0;
let specialGrants = 0;
let systemCount = 0;
for (const role of roles) {
if (role.status === 'active') {
active += 1;
}
boundUsers += role.userCount;
specialGrants += role.specialPermissionCount;
if (role.isSystem) {
systemCount += 1;
}
}
return {
total: roles.length,
active,
disabled: roles.length - active,
boundUsers,
specialGrants,
systemCount,
};
}
export interface RoleListFilters {
keyword: string;
status: 'all' | 'active' | 'disabled';
specialOnly: boolean;
}
export const DEFAULT_ROLE_LIST_FILTERS: RoleListFilters = {
keyword: '',
status: 'all',
specialOnly: false,
};
/** 角色列表筛选(角色 ≤ 20,前端本地过滤,避免逐字请求) */
export function filterRoles(
roles: readonly RoleListItem[],
filters: RoleListFilters,
): RoleListItem[] {
const keyword = filters.keyword.trim().toLowerCase();
return roles.filter((role) => {
if (filters.status !== 'all' && role.status !== filters.status) {
return false;
}
if (filters.specialOnly && role.specialPermissionCount === 0) {
return false;
}
if (keyword === '') {
return true;
}
const haystack = `${role.name} ${role.code} ${role.description}`.toLowerCase();
return haystack.includes(keyword);
});
}
export function hasActiveRoleFilters(filters: RoleListFilters): boolean {
return filters.keyword.trim() !== '' || filters.status !== 'all' || filters.specialOnly;
}
@@ -0,0 +1,196 @@
import type { RoleDetail } from '@lingxi/shared';
import { roleDetailSchema } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import {
conflictsFromErrorDetails,
createEmptyDraft,
diffRoleDraft,
draftFromDetail,
findDraftSodConflicts,
type RoleDraft,
saveResultMessage,
validateCreateDraft,
validateUpdateDraft,
} from './role-utils';
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
function buildDetail(overrides: Record<string, unknown> = {}): RoleDetail {
return roleDetailSchema.parse({
id: ROLE_ID,
code: 'account_owner',
name: '客户负责人',
description: '客户资料、脚本审核与交付验收',
isSystem: false,
status: 'active',
permissionCount: 5,
specialPermissionCount: 1,
userCount: 5,
dataScope: { type: 'customer_groups', targetIds: [GROUP_ID] },
permissions: [
'customer:read',
'customer:manage',
'customer:transfer',
'delivery:read',
'script:review',
],
specialPermissions: ['delivery:export'],
boundUsers: [],
createdAt: '2026-10-25T01:30:00.000Z',
updatedAt: '2026-10-25T01:30:00.000Z',
...overrides,
});
}
function draftOf(overrides: Partial<RoleDraft> = {}): RoleDraft {
return {
name: '客户负责人',
description: '客户资料、脚本审核与交付验收',
permissions: [
'customer:read',
'customer:manage',
'customer:transfer',
'delivery:read',
'script:review',
],
specialPermissions: ['delivery:export'],
dataScope: { type: 'customer_groups', targetIds: [GROUP_ID] },
...overrides,
};
}
describe('模板 / 复制预填', () => {
it('默认不带入特殊权限,名称可加副本后缀', () => {
const draft = draftFromDetail(buildDetail(), { nameSuffix: ' 副本' });
expect(draft.name).toBe('客户负责人 副本');
expect(draft.permissions).toHaveLength(5);
expect(draft.specialPermissions).toEqual([]);
expect(draft.dataScope).toEqual({ type: 'customer_groups', targetIds: [GROUP_ID] });
});
it('编辑器初始化时保留特殊权限', () => {
const draft = draftFromDetail(buildDetail(), { includeSpecial: true });
expect(draft.specialPermissions).toEqual(['delivery:export']);
});
it('空白草稿使用最小数据范围', () => {
const draft = createEmptyDraft();
expect(draft.permissions).toEqual([]);
expect(draft.specialPermissions).toEqual([]);
expect(draft.dataScope.type).toBe('created_by_me');
});
});
describe('草稿 diff', () => {
it('完全一致时不是脏状态', () => {
const diff = diffRoleDraft(draftOf(), draftOf());
expect(diff.isDirty).toBe(false);
expect(diff.totalChanges).toBe(0);
});
it('识别权限新增 / 移除、特殊权限与数据范围变化', () => {
const base = draftOf();
const next = draftOf({
permissions: ['customer:read', 'material:read'],
specialPermissions: ['delivery:export', 'audit:read'],
dataScope: { type: 'department', targetIds: [] },
description: '更新说明',
});
const diff = diffRoleDraft(base, next);
expect(diff.addedPermissions).toEqual(['material:read']);
expect(diff.removedPermissions).toEqual([
'customer:manage',
'customer:transfer',
'delivery:read',
'script:review',
]);
expect(diff.addedSpecial).toEqual(['audit:read']);
expect(diff.removedSpecial).toEqual([]);
expect(diff.scopeChanged).toBe(true);
expect(diff.descriptionChanged).toBe(true);
expect(diff.isDirty).toBe(true);
expect(diff.totalAdded).toBe(2);
expect(diff.totalRemoved).toBe(4);
expect(diff.hasSpecialChanges).toBe(true);
});
});
describe('职责互斥(SoD)', () => {
it('命中脚本生成 × 审核', () => {
const conflicts = findDraftSodConflicts(
draftOf({ permissions: ['script:write', 'script:review'] }),
);
expect(conflicts).toHaveLength(1);
expect(conflicts[0]?.pairId).toBe('script-write-review');
});
it('无交集时返回空数组', () => {
expect(findDraftSodConflicts(draftOf())).toEqual([]);
});
it('解析 40304 错误体 details.conflicts', () => {
const conflicts = conflictsFromErrorDetails({
conflicts: [
{
pairId: 'industry-manage-publish',
label: '行业包编辑 × 行业包发布',
permissions: ['industry:manage', 'industry:publish'],
},
{ pairId: 'bad', label: '非法', permissions: ['ghost:one', 'ghost:two'] },
],
});
expect(conflicts).toHaveLength(1);
expect(conflicts[0]?.permissions).toEqual(['industry:manage', 'industry:publish']);
expect(conflictsFromErrorDetails(null)).toEqual([]);
expect(conflictsFromErrorDetails({ conflicts: 'bad' })).toEqual([]);
});
});
describe('保存入参校验', () => {
it('名称为空时返回字段错误', () => {
const result = validateCreateDraft(createEmptyDraft());
expect(result.input).toBeNull();
expect(result.errors.name).toContain('请输入角色名称');
});
it('新建入参把特殊权限放到独立字段,非客户组范围不带 targetIds', () => {
const result = validateCreateDraft(
draftOf({ dataScope: { type: 'department', targetIds: [GROUP_ID] } }),
);
expect(result.input === null).toBe(false);
expect(result.input?.permissions).toHaveLength(5);
expect(result.input?.specialPermissions).toEqual(['delivery:export']);
expect(result.input?.dataScope).toEqual({ type: 'department' });
});
it('更新系统角色只提交显示名', () => {
const detail = buildDetail({ isSystem: true, code: 'super_admin', name: '超级管理员' });
const result = validateUpdateDraft(detail, draftOf({ name: '超级管理员(改名)' }));
expect(result.input).toEqual({ name: '超级管理员(改名)' });
});
it('更新普通角色提交完整权限快照与数据范围', () => {
const detail = buildDetail();
const result = validateUpdateDraft(detail, draftOf({ name: '' }));
expect(result.input).toBeNull();
expect(result.errors.name).toBeTruthy();
const valid = validateUpdateDraft(detail, draftOf());
expect(valid.input?.permissions).toHaveLength(5);
expect(valid.input?.dataScope).toEqual({
type: 'customer_groups',
targetIds: [GROUP_ID],
});
});
it('保存结果文案包含影响用户数', () => {
expect(
saveResultMessage({ role: buildDetail(), affectedUserCount: 3, warnings: [] }),
).toContain('3 名用户');
expect(saveResultMessage({ role: buildDetail(), affectedUserCount: 0, warnings: [] })).toBe(
'角色已保存',
);
});
});
@@ -0,0 +1,259 @@
import {
type CreateRoleInput,
createRoleInputSchema,
type DataScopeType,
findSodConflicts,
isPermissionCode,
type PermissionCode,
type RoleDataScopeInput,
type RoleDetail,
type RoleSaveResult,
type SodConflict,
type UpdateRoleInput,
updateRoleInputSchema,
} from '@lingxi/shared';
/** 编辑器 / 新建页共用的角色草稿(权限与特殊权限分桶,数据范围单选) */
export interface RoleDraft {
name: string;
description: string;
permissions: readonly PermissionCode[];
specialPermissions: readonly PermissionCode[];
dataScope: {
type: DataScopeType;
targetIds: readonly string[];
};
}
export interface DraftFromDetailOptions {
/** 复制 / 模板场景:特殊权限不随角色带入(PRD §5.4.3) */
includeSpecial?: boolean;
/** 名称后缀,例如「 副本」 */
nameSuffix?: string;
}
export function draftFromDetail(
detail: RoleDetail,
options: DraftFromDetailOptions = {},
): RoleDraft {
const suffix = options.nameSuffix ?? '';
return {
name: `${detail.name}${suffix}`,
description: detail.description,
permissions: [...detail.permissions],
specialPermissions: options.includeSpecial === true ? [...detail.specialPermissions] : [],
dataScope: {
type: detail.dataScope.type,
targetIds: [...(detail.dataScope.targetIds ?? [])],
},
};
}
/** 空白草稿:默认最小数据范围(本人创建),特殊权限全关 */
export function createEmptyDraft(): RoleDraft {
return {
name: '',
description: '',
permissions: [],
specialPermissions: [],
dataScope: { type: 'created_by_me', targetIds: [] },
};
}
export interface RoleDraftDiff {
addedPermissions: string[];
removedPermissions: string[];
addedSpecial: string[];
removedSpecial: string[];
nameChanged: boolean;
descriptionChanged: boolean;
scopeChanged: boolean;
totalAdded: number;
totalRemoved: number;
totalChanges: number;
hasPermissionChanges: boolean;
hasSpecialChanges: boolean;
isDirty: boolean;
}
function changedCodes(
base: readonly PermissionCode[],
next: readonly PermissionCode[],
): { added: PermissionCode[]; removed: PermissionCode[] } {
const baseSet = new Set(base);
const nextSet = new Set(next);
return {
added: next.filter((code) => !baseSet.has(code)),
removed: base.filter((code) => !nextSet.has(code)),
};
}
function sameStringSet(left: readonly string[], right: readonly string[]): boolean {
if (left.length !== right.length) {
return false;
}
const rightSet = new Set(right);
return left.every((code) => rightSet.has(code));
}
/** 草稿与基线对比:权限 diff + 基本信息 + 数据范围(保存影响面与脏检查同源) */
export function diffRoleDraft(base: RoleDraft, draft: RoleDraft): RoleDraftDiff {
const permissionDiff = changedCodes(base.permissions, draft.permissions);
const specialDiff = changedCodes(base.specialPermissions, draft.specialPermissions);
const nameChanged = base.name !== draft.name;
const descriptionChanged = base.description !== draft.description;
const scopeChanged =
base.dataScope.type !== draft.dataScope.type ||
!sameStringSet(base.dataScope.targetIds, draft.dataScope.targetIds);
const totalAdded = permissionDiff.added.length + specialDiff.added.length;
const totalRemoved = permissionDiff.removed.length + specialDiff.removed.length;
const hasPermissionChanges = permissionDiff.added.length + permissionDiff.removed.length > 0;
const hasSpecialChanges = specialDiff.added.length + specialDiff.removed.length > 0;
return {
addedPermissions: permissionDiff.added,
removedPermissions: permissionDiff.removed,
addedSpecial: specialDiff.added,
removedSpecial: specialDiff.removed,
nameChanged,
descriptionChanged,
scopeChanged,
totalAdded,
totalRemoved,
totalChanges:
totalAdded +
totalRemoved +
(nameChanged ? 1 : 0) +
(descriptionChanged ? 1 : 0) +
(scopeChanged ? 1 : 0),
hasPermissionChanges,
hasSpecialChanges,
isDirty: totalAdded + totalRemoved > 0 || nameChanged || descriptionChanged || scopeChanged,
};
}
/** 前端实时 SoD 校验(超管豁免由调用方按 isSystem 传入空) */
export function findDraftSodConflicts(draft: RoleDraft): SodConflict[] {
return findSodConflicts([...draft.permissions, ...draft.specialPermissions]);
}
export interface RoleDraftErrors {
name?: string;
description?: string;
}
export interface RoleDraftValidation<TInput> {
input: TInput | null;
errors: RoleDraftErrors;
}
function collectFieldErrors(
issues: readonly { path: readonly PropertyKey[]; message: string }[],
): RoleDraftErrors {
const errors: RoleDraftErrors = {};
for (const issue of issues) {
const field = issue.path[0];
if (field === 'name' && errors.name === undefined) {
errors.name = issue.message;
}
if (field === 'description' && errors.description === undefined) {
errors.description = issue.message;
}
}
return errors;
}
function dataScopePayload(draft: RoleDraft): RoleDataScopeInput {
if (draft.dataScope.type !== 'customer_groups') {
return { type: draft.dataScope.type };
}
return { type: draft.dataScope.type, targetIds: [...draft.dataScope.targetIds] };
}
/** 新建入参:与后端共用 createRoleInputSchema 做同源校验 */
export function validateCreateDraft(draft: RoleDraft): RoleDraftValidation<CreateRoleInput> {
const parsed = createRoleInputSchema.safeParse({
name: draft.name.trim(),
description: draft.description.trim(),
permissions: [...draft.permissions],
specialPermissions: [...draft.specialPermissions],
dataScope: dataScopePayload(draft),
});
if (parsed.success) {
return { input: parsed.data, errors: {} };
}
return { input: null, errors: collectFieldErrors(parsed.error.issues) };
}
/**
* 更新入参:系统角色仅显示名可改;普通角色提交完整权限快照与数据范围。
* 后端按字段级 diff 计算影响面,因此这里始终发送当前草稿(未变更字段也一致)。
*/
export function validateUpdateDraft(
detail: RoleDetail,
draft: RoleDraft,
): RoleDraftValidation<UpdateRoleInput> {
const input: UpdateRoleInput = detail.isSystem
? { name: draft.name.trim() }
: {
name: draft.name.trim(),
description: draft.description.trim(),
permissions: [...draft.permissions],
specialPermissions: [...draft.specialPermissions],
dataScope: dataScopePayload(draft),
};
const parsed = updateRoleInputSchema.safeParse(input);
if (parsed.success) {
return { input: parsed.data, errors: {} };
}
return { input: null, errors: collectFieldErrors(parsed.error.issues) };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isPermissionPair(value: unknown): value is readonly [PermissionCode, PermissionCode] {
return (
Array.isArray(value) &&
value.length === 2 &&
typeof value[0] === 'string' &&
typeof value[1] === 'string' &&
isPermissionCode(value[0]) &&
isPermissionCode(value[1])
);
}
/**
* 解析 40304 错误体的 `details.conflicts`(后端与前端同一契约):
* 用于保存竞态时把服务端判定回填到 SoD 错误条与冲突高亮。
*/
export function conflictsFromErrorDetails(details: unknown): SodConflict[] {
if (!isRecord(details) || !Array.isArray(details.conflicts)) {
return [];
}
const conflicts: SodConflict[] = [];
for (const item of details.conflicts) {
if (!isRecord(item)) {
continue;
}
if (typeof item.pairId !== 'string' || typeof item.label !== 'string') {
continue;
}
if (!isPermissionPair(item.permissions)) {
continue;
}
conflicts.push({
pairId: item.pairId,
label: item.label,
permissions: [item.permissions[0], item.permissions[1]],
});
}
return conflicts;
}
/** 保存成功结果:特殊权限变化时用于 toast 文案 */
export function saveResultMessage(result: RoleSaveResult): string {
const affected = result.affectedUserCount;
const base = '角色已保存';
return affected > 0 ? `${base},${String(affected)} 名用户将在下一次请求生效` : base;
}
@@ -0,0 +1,173 @@
/* eslint-disable max-lines-per-function -- 编辑器状态与动作集中在一个 Hook,便于表单行为单点维护 */
import {
isPermissionCode,
type PermissionCode,
type PermissionModuleNode,
type RoleDetail,
} from '@lingxi/shared';
import { useCallback, useMemo, useState } from 'react';
import {
diffRoleDraft,
draftFromDetail,
findDraftSodConflicts,
type RoleDraft,
type RoleDraftDiff,
} from './role-utils';
export interface RoleEditorState {
base: RoleDraft;
draft: RoleDraft;
diff: RoleDraftDiff;
/** SoD 冲突(系统角色豁免,恒为空) */
conflicts: ReturnType<typeof findDraftSodConflicts>;
conflictCodes: ReadonlySet<string>;
isDirty: boolean;
setName: (name: string) => void;
setDescription: (description: string) => void;
togglePermission: (code: string) => void;
toggleModule: (module: PermissionModuleNode, selectAll: boolean) => void;
setScopeType: (type: RoleDraft['dataScope']['type']) => void;
setTargetIds: (targetIds: readonly string[]) => void;
setSpecialPermission: (code: string, granted: boolean) => void;
reset: () => void;
markSaved: () => void;
}
function cloneDraft(draft: RoleDraft): RoleDraft {
return {
...draft,
permissions: [...draft.permissions],
specialPermissions: [...draft.specialPermissions],
dataScope: {
type: draft.dataScope.type,
targetIds: [...draft.dataScope.targetIds],
},
};
}
/** 角色编辑器状态:草稿 + 基线 + SoD / diff 派生值(纯计算见 role-utils) */
export function useRoleEditor(detail: RoleDetail): RoleEditorState {
const [base, setBase] = useState<RoleDraft>(() =>
draftFromDetail(detail, { includeSpecial: true }),
);
const [draft, setDraft] = useState<RoleDraft>(() =>
draftFromDetail(detail, { includeSpecial: true }),
);
const setName = useCallback((name: string) => {
setDraft((current) => ({ ...current, name }));
}, []);
const setDescription = useCallback((description: string) => {
setDraft((current) => ({ ...current, description }));
}, []);
const togglePermission = useCallback((code: string) => {
if (!isPermissionCode(code)) {
return;
}
setDraft((current) => {
const has = current.permissions.includes(code);
return {
...current,
permissions: has
? current.permissions.filter((item) => item !== code)
: [...current.permissions, code],
};
});
}, []);
const toggleModule = useCallback((module: PermissionModuleNode, selectAll: boolean) => {
const normalCodes: PermissionCode[] = module.permissions
.filter((permission) => !permission.isSpecial && isPermissionCode(permission.code))
.map((permission) => permission.code)
.filter(isPermissionCode);
setDraft((current) => {
const next = new Set(current.permissions);
for (const code of normalCodes) {
if (selectAll) {
next.add(code);
} else {
next.delete(code);
}
}
return { ...current, permissions: [...next] };
});
}, []);
const setScopeType = useCallback((type: RoleDraft['dataScope']['type']) => {
setDraft((current) => ({
...current,
dataScope: {
type,
targetIds: type === 'customer_groups' ? current.dataScope.targetIds : [],
},
}));
}, []);
const setTargetIds = useCallback((targetIds: readonly string[]) => {
setDraft((current) => ({
...current,
dataScope: { type: current.dataScope.type, targetIds: [...targetIds] },
}));
}, []);
const setSpecialPermission = useCallback((code: string, granted: boolean) => {
if (!isPermissionCode(code)) {
return;
}
setDraft((current) => {
const has = current.specialPermissions.includes(code);
if (granted === has) {
return current;
}
return {
...current,
specialPermissions: granted
? [...current.specialPermissions, code]
: current.specialPermissions.filter((item) => item !== code),
};
});
}, []);
const reset = useCallback(() => {
setDraft(cloneDraft(base));
}, [base]);
const markSaved = useCallback(() => {
setBase(cloneDraft(draft));
}, [draft]);
const diff = useMemo(() => diffRoleDraft(base, draft), [base, draft]);
const conflicts = useMemo(
() => (detail.isSystem ? [] : findDraftSodConflicts(draft)),
[detail.isSystem, draft],
);
const conflictCodes = useMemo(() => {
const codes = new Set<string>();
for (const conflict of conflicts) {
codes.add(conflict.permissions[0]);
codes.add(conflict.permissions[1]);
}
return codes;
}, [conflicts]);
return {
base,
draft,
diff,
conflicts,
conflictCodes,
isDirty: diff.isDirty,
setName,
setDescription,
togglePermission,
toggleModule,
setScopeType,
setTargetIds,
setSpecialPermission,
reset,
markSaved,
};
}