feat: add permission-based access control and improve alert table UI
- Implement PermissionGuard component for route-level permission enforcement - Add tree-based permission checkbox group in role management popup - Gate SSE connection, unread count fetch, and rule edit actions on user permissions - Add skip401Refresh option and auto token refresh on 401 response - Improve alert table with fixed columns, explicit widths, and text ellipsis - Add i18n entries for permission guide and rule menu descriptions (en/zh) - Add expo-api nginx proxy configuration - Add .claude to .gitignore Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ deploy.js
|
||||
# misc
|
||||
.env
|
||||
.DS_Store
|
||||
.claude
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
|
||||
|
||||
@@ -91,7 +91,9 @@ const config: ConfigType = defineConfig({
|
||||
* @description Can be used to store global data such as user info or global state. The global initial state is created at the very start of the Umi project.
|
||||
* @doc https://umijs.org/docs/max/data-flow#%E5%85%A8%E5%B1%80%E5%88%9D%E5%A7%8B%E7%8A%B6%E6%80%81
|
||||
*/
|
||||
initialState: {},
|
||||
initialState: {
|
||||
loading: '@/loading',
|
||||
},
|
||||
/**
|
||||
* @name layout 插件
|
||||
* @doc https://umijs.org/docs/max/layout-menu
|
||||
|
||||
@@ -91,7 +91,20 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
|
||||
|
||||
}
|
||||
|
||||
location /expo-api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Cache-Control "no-transform";
|
||||
proxy_set_header X-Accel-Buffering "no";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 86400s;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
}
|
||||
}
|
||||
|
||||
14
src/app.tsx
14
src/app.tsx
@@ -1,7 +1,7 @@
|
||||
import type { Settings as LayoutSettings } from '@ant-design/pro-components';
|
||||
import { PageLoading } from '@ant-design/pro-components';
|
||||
import type { RequestConfig, RunTimeLayoutConfig } from '@umijs/max';
|
||||
import { history, Link, useModel } from '@umijs/max';
|
||||
import { history, Link, useAccess, useModel } from '@umijs/max';
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import { App } from 'antd';
|
||||
import { AvatarDropdown, AvatarName, SelectLang } from '@/components';
|
||||
@@ -18,6 +18,7 @@ import { errorConfig } from './requestErrorConfig';
|
||||
import { getDeviceId } from './utils/fp';
|
||||
import './utils/sentry';
|
||||
import GlobalErrorBoundary from './components/GlobalErrorBoundary';
|
||||
import PermissionGuard from './components/PermissionGuard';
|
||||
import SSEController from './components/SSEController';
|
||||
import VoiceCheck from './components/VoiceCheck';
|
||||
|
||||
@@ -106,6 +107,7 @@ export function rootContainer(container: React.ReactNode) {
|
||||
// ProLayout support API https://procomponents.ant.design/components/layout
|
||||
export const layout: RunTimeLayoutConfig = ({ initialState }) => {
|
||||
const { fetchUnread, initialized } = useModel('useMenuNoticeNum');
|
||||
const { hasPerms } = useAccess() ?? {};
|
||||
|
||||
const isDark = initialState?.settings?.navTheme === 'realDark';
|
||||
const currentUser = initialState?.currentUser;
|
||||
@@ -123,8 +125,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
|
||||
bgLayout: '#f8fafc',
|
||||
};
|
||||
|
||||
if (currentUser && !initialized) {
|
||||
console.log('fetchUnread');
|
||||
if (currentUser && !initialized && hasPerms?.('alert:list')) {
|
||||
fetchUnread();
|
||||
}
|
||||
return {
|
||||
@@ -198,7 +199,12 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
|
||||
if (initialState?.loading) return <PageLoading />;
|
||||
return (
|
||||
<>
|
||||
{currentUser && <SSEController />}
|
||||
{currentUser && (
|
||||
<>
|
||||
<SSEController />
|
||||
<PermissionGuard />
|
||||
</>
|
||||
)}
|
||||
|
||||
<GlobalErrorBoundary>{children}</GlobalErrorBoundary>
|
||||
</>
|
||||
|
||||
48
src/components/PermissionGuard.tsx
Normal file
48
src/components/PermissionGuard.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { history, useAccess } from '@umijs/max';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import useUserInfo from '@/hooks/useUserInfo';
|
||||
import { currentUser as queryCurrentUser } from '@/services/api';
|
||||
import { getRequiredPermissions } from '@/utils/routePermissions';
|
||||
|
||||
export default function PermissionGuard() {
|
||||
const { setUserInfo } = useUserInfo();
|
||||
const access = useAccess();
|
||||
const [checkVersion, setCheckVersion] = useState(0);
|
||||
const pathnameRef = useRef('');
|
||||
const refreshing = useRef(false);
|
||||
|
||||
const onAuthRefresh = useCallback(async () => {
|
||||
if (refreshing.current) return;
|
||||
refreshing.current = true;
|
||||
pathnameRef.current = history.location.pathname;
|
||||
|
||||
const res = (await queryCurrentUser()) as API.BaseResponse<API.CurrentUser>;
|
||||
if (res?.code === 200 && res?.data?.data) {
|
||||
await setUserInfo(res.data.data);
|
||||
setCheckVersion((v) => v + 1);
|
||||
}
|
||||
|
||||
refreshing.current = false;
|
||||
}, [setUserInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkVersion === 0) return;
|
||||
|
||||
const required = getRequiredPermissions(pathnameRef.current);
|
||||
if (required?.length) {
|
||||
const hasAccess = required.some(
|
||||
(perm) => access?.hasPerms?.(perm) ?? false,
|
||||
);
|
||||
if (!hasAccess) {
|
||||
history.replace('/404', { from: pathnameRef.current });
|
||||
}
|
||||
}
|
||||
}, [checkVersion, access]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('auth:refresh', onAuthRefresh);
|
||||
return () => window.removeEventListener('auth:refresh', onAuthRefresh);
|
||||
}, [onAuthRefresh]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useModel } from '@umijs/max';
|
||||
import { useAccess, useModel } from '@umijs/max';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useMessageBuffer } from '@/hooks/useMessage';
|
||||
import useUserInfo from '@/hooks/useUserInfo';
|
||||
@@ -11,11 +11,13 @@ const SSEController: React.FC = () => {
|
||||
const { disconnectSSE } = useModel('useSSE');
|
||||
const { startConnection } = useMessageBuffer();
|
||||
const { userInfo } = useUserInfo();
|
||||
const { hasPerms } = useAccess();
|
||||
const userConfig = userInfo?.userConfig;
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { setUnreadCountWithSign } = useModel('useMenuNoticeNum');
|
||||
|
||||
const refreshCount = async () => {
|
||||
if (!hasPerms('alert:list')) return;
|
||||
try {
|
||||
const { data } = await getAlertPages({
|
||||
pageNum: 1,
|
||||
@@ -30,6 +32,7 @@ const SSEController: React.FC = () => {
|
||||
};
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
if (!hasPerms('alert:list')) return;
|
||||
timerRef.current = setInterval(() => {
|
||||
refreshCount();
|
||||
}, TIMER_INTERVAL);
|
||||
|
||||
@@ -7,6 +7,14 @@ export default {
|
||||
'menu.rules': 'Rules',
|
||||
'menu.rule': 'Rule',
|
||||
'menu.rules.desc': 'Manage Rules',
|
||||
'menu.rules.desc2':
|
||||
'Add, edit, enable/disable, delete, and clone rules. View Rules will be selected automatically.',
|
||||
'menu.rules.edit.badge': 'Write access',
|
||||
'menu.rules.edit': 'Manage Rules',
|
||||
'menu.rules.view.badge': 'Read only',
|
||||
'menu.rules.view': 'View Rules',
|
||||
'menu.rules.view.desc':
|
||||
'Enter Rule Management and view rule settings. No create, edit, clone, or delete actions.',
|
||||
'menu.rules.largeTradeLots': 'Large Trade (Lots)',
|
||||
'menu.rules.largeTradeLots.desc':
|
||||
'Monitor trades with lots exceeding threshold',
|
||||
|
||||
@@ -329,6 +329,8 @@ export default {
|
||||
'form.role.placeholder2': 'System roles cannot modify permission levels',
|
||||
'form.role.badgeColor': 'Badge Color',
|
||||
'form.role.menuIds': 'Bound Permissions (Menus)',
|
||||
'form.role.permissionGuide': 'Permission Guide',
|
||||
'form.role.otherPermissions': 'Other Menu Permissions',
|
||||
|
||||
'form.config.timezone': 'Timezone Settings',
|
||||
'form.config.timezone.title': 'Default Timezone',
|
||||
|
||||
@@ -7,6 +7,14 @@ export default {
|
||||
'menu.rules': '规则管理',
|
||||
'menu.rule': '规则',
|
||||
'menu.rules.desc': '管理规则',
|
||||
'menu.rules.desc2':
|
||||
'新增、编辑、启停、删除、克隆规则;系统会自动包含“查看规则”。',
|
||||
'menu.rules.edit.badge': '写权限',
|
||||
'menu.rules.edit': '管理规则',
|
||||
'menu.rules.view.badge': '只读',
|
||||
'menu.rules.view': '查看规则',
|
||||
'menu.rules.view.desc':
|
||||
'进入规则管理并查看配置;不能新增、编辑、克隆、删除。',
|
||||
'menu.rules.largeTradeLots': '大额交易(手数)',
|
||||
'menu.rules.largeTradeLots.desc': '监控单笔开仓手数超过设定阈值的大额交易',
|
||||
'menu.rules.largeTradeUSD': '大额交易(USD)',
|
||||
|
||||
@@ -321,6 +321,8 @@ export default {
|
||||
'form.role.placeholder2': '系统角色权限级别不可修改',
|
||||
'form.role.badgeColor': '徽章颜色',
|
||||
'form.role.menuIds': '绑定权限(菜单)',
|
||||
'form.role.permissionGuide': '权限怎么选',
|
||||
'form.role.otherPermissions': '其他菜单权限',
|
||||
|
||||
'form.config.timezone': '时区设置',
|
||||
'form.config.timezone.title': '默认时区',
|
||||
|
||||
@@ -146,6 +146,8 @@ const Alert: React.FC = () => {
|
||||
{
|
||||
title: $t('table.alertId'),
|
||||
dataIndex: 'id',
|
||||
width: 100,
|
||||
fixed: 'left',
|
||||
fieldProps: {
|
||||
placeholder: $t('table.alertId'),
|
||||
},
|
||||
@@ -158,6 +160,7 @@ const Alert: React.FC = () => {
|
||||
dataIndex: 'companyId',
|
||||
valueType: 'select',
|
||||
sorter: true,
|
||||
width: 140,
|
||||
fieldProps: {
|
||||
defaultValue: '',
|
||||
popupMatchSelectWidth: false,
|
||||
@@ -182,6 +185,7 @@ const Alert: React.FC = () => {
|
||||
dataIndex: 'dataSourceId',
|
||||
valueType: 'select',
|
||||
sorter: true,
|
||||
width: 160,
|
||||
fieldProps: {
|
||||
defaultValue: '',
|
||||
popupMatchSelectWidth: false,
|
||||
@@ -215,6 +219,7 @@ const Alert: React.FC = () => {
|
||||
dataIndex: 'ruleType',
|
||||
valueType: 'select',
|
||||
sorter: true,
|
||||
width: 'auto',
|
||||
fieldProps: {
|
||||
defaultValue: '',
|
||||
popupMatchSelectWidth: false,
|
||||
@@ -234,6 +239,7 @@ const Alert: React.FC = () => {
|
||||
{
|
||||
title: $t('pages.rules.ruleName'),
|
||||
dataIndex: 'ruleName',
|
||||
ellipsis: true,
|
||||
// sorter: true,
|
||||
fieldProps: {
|
||||
placeholder: $t('pages.rules.ruleName'),
|
||||
@@ -300,16 +306,14 @@ const Alert: React.FC = () => {
|
||||
{
|
||||
title: $t('table.trigger'),
|
||||
dataIndex: 'trigger',
|
||||
width: 'auto',
|
||||
ellipsis: true,
|
||||
className: 'text-bold',
|
||||
fieldProps: {
|
||||
ellipsis: true,
|
||||
},
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: $t('table.summary'),
|
||||
dataIndex: 'summary',
|
||||
ellipsis: true,
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
@@ -318,6 +322,7 @@ const Alert: React.FC = () => {
|
||||
valueType: 'dateTime',
|
||||
hideInSearch: true,
|
||||
sorter: true,
|
||||
width: 170,
|
||||
// renderText: (_, { triggerTime }) => {
|
||||
// return formatToUserTimezone(triggerTime);
|
||||
// },
|
||||
@@ -326,6 +331,7 @@ const Alert: React.FC = () => {
|
||||
title: $t('table.dataSourceTime'),
|
||||
dataIndex: 'dataSourceServerTime',
|
||||
hideInSearch: true,
|
||||
width: 180,
|
||||
render: (_, { dataSourceServerTime, timeZone }) => {
|
||||
if (!dataSourceServerTime) return '-';
|
||||
return (
|
||||
@@ -344,6 +350,8 @@ const Alert: React.FC = () => {
|
||||
title: $t('table.status'),
|
||||
dataIndex: 'statusId',
|
||||
valueType: 'select',
|
||||
fixed: 'right',
|
||||
width: 'auto',
|
||||
fieldProps: {
|
||||
defaultValue: '',
|
||||
popupMatchSelectWidth: false,
|
||||
@@ -521,7 +529,7 @@ const Alert: React.FC = () => {
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<ProTable
|
||||
scroll={{ x: true }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
columns={columns}
|
||||
form={{
|
||||
initialValues: {
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import {
|
||||
ModalForm,
|
||||
ProForm,
|
||||
ProFormCheckbox,
|
||||
ProFormDependency,
|
||||
// ProFormDigit,
|
||||
ProFormText,
|
||||
} from '@ant-design/pro-components';
|
||||
// import { useAccess } from '@umijs/max';
|
||||
import { ColorPicker, Form, Space } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
import MyTag from '@/components/MyTag';
|
||||
import useUserInfo from '@/hooks/useUserInfo';
|
||||
import { getRoleDetail } from '@/services/api';
|
||||
import { $t } from '@/utils/i18n';
|
||||
// import { preventScientificNotation } from '@/utils';
|
||||
import useCardList from './cardList';
|
||||
import PermissionCheckboxGroup from './components/PermissionCheckboxGroup';
|
||||
|
||||
function flattenAllIds(nodes: API.MenuListItem[]): (string | number)[] {
|
||||
const ids: (string | number)[] = [];
|
||||
const walk = (items: API.MenuListItem[]) => {
|
||||
items.forEach((item) => {
|
||||
ids.push(item.id);
|
||||
if (item.children?.length) walk(item.children);
|
||||
});
|
||||
};
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
|
||||
type RolesListItem = Partial<API.RolesListItem>;
|
||||
export default (props: {
|
||||
@@ -22,8 +34,16 @@ export default (props: {
|
||||
formValues: Partial<RolesListItem | undefined>;
|
||||
onSubmit: (values: RolesListItem) => void;
|
||||
onDone: () => void;
|
||||
menuTree?: API.MenuListItem[];
|
||||
}) => {
|
||||
const { visible, formValues, onSubmit, onDone, action } = props;
|
||||
const {
|
||||
visible,
|
||||
formValues,
|
||||
onSubmit,
|
||||
onDone,
|
||||
action,
|
||||
menuTree: propMenuTree,
|
||||
} = props;
|
||||
|
||||
const { userInfo } = useUserInfo();
|
||||
// const { isGlobalCompany } = useAccess();
|
||||
@@ -31,9 +51,14 @@ export default (props: {
|
||||
// const level = userRoleInfo?.level || (isGlobalCompany ? 100 : 80);
|
||||
|
||||
const { getMenuMetaByPermissions } = useCardList();
|
||||
const menuList = getMenuMetaByPermissions(
|
||||
userInfo?.menuList || ([] as API.MenuListItem[]),
|
||||
);
|
||||
|
||||
const menuTree = useMemo(() => {
|
||||
const source = propMenuTree?.length
|
||||
? propMenuTree
|
||||
: getMenuMetaByPermissions(userInfo?.menuList || []);
|
||||
return source as API.MenuListItem[];
|
||||
}, [propMenuTree, userInfo?.menuList]);
|
||||
|
||||
const actionMap = {
|
||||
add: $t('table.action.add'),
|
||||
view: $t('table.action.view'),
|
||||
@@ -46,7 +71,7 @@ export default (props: {
|
||||
initialValues={formValues?.id ? formValues : { color: '#3b82f6' }}
|
||||
open={visible}
|
||||
onFinish={async (values: any) => {
|
||||
values.menuIds = values.menuIds.join(',') || '';
|
||||
values.menuIds = (values.menuIds || []).join(',') || '';
|
||||
return onSubmit(values);
|
||||
}}
|
||||
modalProps={{
|
||||
@@ -61,12 +86,10 @@ export default (props: {
|
||||
} = await getRoleDetail({
|
||||
id: formValues.id,
|
||||
});
|
||||
const menuIds = (data?.menuList || []).map(
|
||||
(item: API.MenuListItem) => item.id,
|
||||
);
|
||||
const allSelectedIds = flattenAllIds(data?.menuList || []);
|
||||
return {
|
||||
...formValues,
|
||||
menuIds,
|
||||
menuIds: allSelectedIds,
|
||||
};
|
||||
}
|
||||
return formValues || {};
|
||||
@@ -114,23 +137,16 @@ export default (props: {
|
||||
</ProFormDependency>
|
||||
</Space>
|
||||
</ProForm.Item>
|
||||
<ProFormCheckbox.Group
|
||||
<ProForm.Item
|
||||
name="menuIds"
|
||||
label={$t('form.role.menuIds')}
|
||||
rules={[{ required: true, message: $t('form.required') }]}
|
||||
options={menuList.map((item: API.MenuListItem) => ({
|
||||
label: `${item.meta?.icon || ''} ${item.meta?.title}(${item?.meta?.describe})`,
|
||||
value: item.id,
|
||||
}))}
|
||||
fieldProps={{
|
||||
style: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '8px 16px',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<PermissionCheckboxGroup
|
||||
menuTree={menuTree}
|
||||
disabled={action === 'view'}
|
||||
/>
|
||||
</ProForm.Item>
|
||||
<ProFormText hidden name="id" label="ID" />
|
||||
</ModalForm>
|
||||
);
|
||||
|
||||
@@ -104,5 +104,38 @@ export default function useRoleCardList() {
|
||||
return item;
|
||||
});
|
||||
}
|
||||
return { roleCardList, getMenuMetaByPermissions };
|
||||
|
||||
/**
|
||||
* Build a permission tree from a flat list using id/parentId.
|
||||
* The backend returns a flat list where children reference their parent via parentId.
|
||||
*/
|
||||
function buildMenuTree(flatList: API.MenuListItem[]): API.MenuListItem[] {
|
||||
const nodeMap = new Map<string | number, API.MenuListItem>();
|
||||
const roots: API.MenuListItem[] = [];
|
||||
|
||||
flatList.forEach((item) => {
|
||||
nodeMap.set(item.id, { ...item, children: [] });
|
||||
});
|
||||
|
||||
flatList.forEach((item) => {
|
||||
const node = nodeMap.get(item.id);
|
||||
if (!node) return;
|
||||
const pid = Number(item.parentId);
|
||||
if (pid === 0 || !nodeMap.has(item.parentId)) {
|
||||
roots.push(node);
|
||||
} else {
|
||||
const parent = nodeMap.get(item.parentId);
|
||||
if (parent) {
|
||||
if (!parent.children) parent.children = [];
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
return { roleCardList, getMenuMetaByPermissions, buildMenuTree };
|
||||
}
|
||||
|
||||
301
src/pages/role/components/PermissionCheckboxGroup.tsx
Normal file
301
src/pages/role/components/PermissionCheckboxGroup.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { Checkbox, Tag, Typography } from 'antd';
|
||||
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { $t } from '@/utils/i18n';
|
||||
import './style.less';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* When true, rules permissions are consolidated into 2 summary cards
|
||||
* (View Rules / Manage Rules). Flip to false to show each child node
|
||||
* as an individual checkbox for fine-grained control.
|
||||
*/
|
||||
const CONSOLIDATE_RULES = true;
|
||||
|
||||
interface PermissionCheckboxGroupProps {
|
||||
value?: (string | number)[];
|
||||
onChange?: (checkedIds: (string | number)[]) => void;
|
||||
menuTree: API.MenuListItem[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function getAllDescendantIds(node: API.MenuListItem): (string | number)[] {
|
||||
const ids: (string | number)[] = [node.id];
|
||||
const walk = (children: API.MenuListItem[]) => {
|
||||
children.forEach((child) => {
|
||||
ids.push(child.id);
|
||||
if (child.children?.length) walk(child.children);
|
||||
});
|
||||
};
|
||||
if (node.children?.length) walk(node.children);
|
||||
return ids;
|
||||
}
|
||||
|
||||
const isRulesMenu = (node: API.MenuListItem) =>
|
||||
node.path === 'rule' || node.permission === 'rule:list';
|
||||
|
||||
const PermissionCheckboxGroup: React.FC<PermissionCheckboxGroupProps> = ({
|
||||
value = [],
|
||||
onChange,
|
||||
menuTree,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const checkedSet = useMemo(() => new Set(value), [value]);
|
||||
|
||||
// Split: rules menus (consolidated into cards) vs everything else (preserves original order)
|
||||
const { rulesMenus, belowMenus } = useMemo(() => {
|
||||
const rules: API.MenuListItem[] = [];
|
||||
const below: API.MenuListItem[] = [];
|
||||
menuTree.forEach((node) => {
|
||||
if (CONSOLIDATE_RULES && isRulesMenu(node)) {
|
||||
rules.push(node);
|
||||
} else {
|
||||
below.push(node);
|
||||
}
|
||||
});
|
||||
return { rulesMenus: rules, belowMenus: below };
|
||||
}, [menuTree]);
|
||||
|
||||
// --- Rules: consolidate all descendants into 2 logical cards ---
|
||||
|
||||
const rulesCardData = useMemo(() => {
|
||||
if (!rulesMenus.length) return null;
|
||||
const parent = rulesMenus[0];
|
||||
const viewIds: (string | number)[] = [];
|
||||
const allIds: (string | number)[] = [];
|
||||
const walk = (children: API.MenuListItem[]) => {
|
||||
children.forEach((child) => {
|
||||
allIds.push(child.id);
|
||||
if (child.permission === 'rule:list') viewIds.push(child.id);
|
||||
if (child.children?.length) walk(child.children);
|
||||
});
|
||||
};
|
||||
if (parent.children?.length) walk(parent.children);
|
||||
const manageOnlyIds = allIds.filter((id) => !viewIds.includes(id));
|
||||
return { parentId: parent.id, viewIds, allIds, manageOnlyIds };
|
||||
}, [rulesMenus]);
|
||||
|
||||
const handleRulesViewChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!rulesCardData) return;
|
||||
const { viewIds, manageOnlyIds } = rulesCardData;
|
||||
const newSet = new Set(value);
|
||||
if (checked) {
|
||||
for (const id of viewIds) newSet.add(id);
|
||||
} else {
|
||||
newSet.delete(rulesCardData.parentId);
|
||||
for (const id of viewIds) newSet.delete(id);
|
||||
for (const id of manageOnlyIds) newSet.delete(id);
|
||||
}
|
||||
onChange?.([...newSet]);
|
||||
},
|
||||
[value, onChange, rulesCardData],
|
||||
);
|
||||
|
||||
const handleRulesManageChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!rulesCardData) return;
|
||||
const { parentId, allIds, manageOnlyIds } = rulesCardData;
|
||||
const newSet = new Set(value);
|
||||
if (checked) {
|
||||
newSet.add(parentId);
|
||||
for (const id of allIds) newSet.add(id);
|
||||
} else {
|
||||
newSet.delete(parentId);
|
||||
for (const id of manageOnlyIds) newSet.delete(id);
|
||||
}
|
||||
onChange?.([...newSet]);
|
||||
},
|
||||
[value, onChange, rulesCardData],
|
||||
);
|
||||
|
||||
// --- Other grouped (parent checkbox, children hidden) ---
|
||||
|
||||
const handleGroupedParentChange = useCallback(
|
||||
(parentNode: API.MenuListItem, checked: boolean) => {
|
||||
const newSet = new Set(value);
|
||||
const ids = getAllDescendantIds(parentNode);
|
||||
if (checked) {
|
||||
for (const id of ids) newSet.add(id);
|
||||
} else {
|
||||
for (const id of ids) newSet.delete(id);
|
||||
}
|
||||
onChange?.([...newSet]);
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
// --- Flat checkbox ---
|
||||
|
||||
const handleFlatChange = useCallback(
|
||||
(node: API.MenuListItem, checked: boolean) => {
|
||||
const newSet = new Set(value);
|
||||
if (checked) {
|
||||
newSet.add(node.id);
|
||||
} else {
|
||||
newSet.delete(node.id);
|
||||
}
|
||||
onChange?.([...newSet]);
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
const hasRules = rulesCardData !== null;
|
||||
const hasBelow = belowMenus.length > 0;
|
||||
const showDivider = hasRules && hasBelow;
|
||||
|
||||
// Derived checked state for the 2 rules cards
|
||||
const rulesViewChecked = rulesCardData
|
||||
? rulesCardData.viewIds.some((id) => checkedSet.has(id)) ||
|
||||
checkedSet.has(rulesCardData.parentId)
|
||||
: false;
|
||||
const rulesManageChecked = rulesCardData
|
||||
? checkedSet.has(rulesCardData.parentId)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hasRules && (
|
||||
<>
|
||||
<div className="perm-help-banner">
|
||||
<div className="perm-help-banner__title">
|
||||
<span>💡</span>
|
||||
<strong>
|
||||
{$t('menu.rules')}
|
||||
{$t('form.role.permissionGuide')}
|
||||
</strong>
|
||||
</div>
|
||||
{$t('menu.rules.desc2')}
|
||||
</div>
|
||||
|
||||
<div className="perm-rules-grid">
|
||||
{/* View card */}
|
||||
<div
|
||||
className="perm-rules-card perm-rules-card--view"
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
>
|
||||
<Checkbox
|
||||
checked={rulesViewChecked}
|
||||
disabled={disabled}
|
||||
onChange={(e: CheckboxChangeEvent) =>
|
||||
handleRulesViewChange(e.target.checked)
|
||||
}
|
||||
>
|
||||
<span className="perm-rules-card__inner">
|
||||
<span className="perm-rules-card__icon">👁️</span>
|
||||
<span className="perm-rules-card__body">
|
||||
<span className="perm-rules-card__header">
|
||||
<span className="perm-rules-card__title">
|
||||
{$t('menu.rules.view')}
|
||||
</span>
|
||||
<Tag color="blue" style={{ fontSize: 11, lineHeight: '16px', margin: 0 }}>
|
||||
{$t('menu.rules.view.badge')}
|
||||
</Tag>
|
||||
</span>
|
||||
<Text type="secondary" className="perm-rules-card__desc">
|
||||
{$t('menu.rules.view.desc')}
|
||||
</Text>
|
||||
</span>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
{/* Manage card */}
|
||||
<div
|
||||
className="perm-rules-card perm-rules-card--manage"
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
>
|
||||
<Checkbox
|
||||
checked={rulesManageChecked}
|
||||
disabled={disabled}
|
||||
onChange={(e: CheckboxChangeEvent) =>
|
||||
handleRulesManageChange(e.target.checked)
|
||||
}
|
||||
>
|
||||
<span className="perm-rules-card__inner">
|
||||
<span className="perm-rules-card__icon">⚙️</span>
|
||||
<span className="perm-rules-card__body">
|
||||
<span className="perm-rules-card__header">
|
||||
<span className="perm-rules-card__title">
|
||||
{$t('menu.rules.edit')}
|
||||
</span>
|
||||
<Tag color="orange" style={{ fontSize: 11, lineHeight: '16px', margin: 0 }}>
|
||||
{$t('menu.rules.edit.badge')}
|
||||
</Tag>
|
||||
</span>
|
||||
<Text type="secondary" className="perm-rules-card__desc">
|
||||
{$t('menu.rules.desc2')}
|
||||
</Text>
|
||||
</span>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showDivider && (
|
||||
<div className="perm-divider">
|
||||
<span className="perm-divider__line" />
|
||||
<span>{$t('form.role.otherPermissions')}</span>
|
||||
<span className="perm-divider__line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasBelow && (
|
||||
<div className="perm-below-grid">
|
||||
{belowMenus.map((node) => {
|
||||
if (node.children?.length) {
|
||||
const childIds = getAllDescendantIds(node).filter(
|
||||
(id) => id !== node.id,
|
||||
);
|
||||
const checkedCount = childIds.filter((id) =>
|
||||
checkedSet.has(id),
|
||||
).length;
|
||||
return (
|
||||
<Checkbox
|
||||
key={node.id}
|
||||
className="perm-checkbox--grouped"
|
||||
checked={childIds.length > 0 && checkedCount === childIds.length}
|
||||
indeterminate={checkedCount > 0 && checkedCount < childIds.length}
|
||||
disabled={disabled}
|
||||
onChange={(e: CheckboxChangeEvent) =>
|
||||
handleGroupedParentChange(node, e.target.checked)
|
||||
}
|
||||
>
|
||||
{node.meta?.icon || ''} {node.meta?.title || node.menuName}
|
||||
{node.meta?.describe && (
|
||||
<Text type="secondary" className="perm-checkbox-desc">
|
||||
({node.meta.describe})
|
||||
</Text>
|
||||
)}
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Checkbox
|
||||
key={node.id}
|
||||
className="perm-checkbox--flat"
|
||||
checked={checkedSet.has(node.id)}
|
||||
disabled={disabled}
|
||||
onChange={(e: CheckboxChangeEvent) =>
|
||||
handleFlatChange(node, e.target.checked)
|
||||
}
|
||||
>
|
||||
{node.meta?.icon || ''} {node.meta?.title || node.menuName}
|
||||
{node.meta?.describe && (
|
||||
<Text type="secondary" className="perm-checkbox-desc">
|
||||
({node.meta.describe})
|
||||
</Text>
|
||||
)}
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionCheckboxGroup;
|
||||
152
src/pages/role/components/style.less
Normal file
152
src/pages/role/components/style.less
Normal file
@@ -0,0 +1,152 @@
|
||||
// --- Rules help banner ---
|
||||
.perm-help-banner {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(79, 70, 229, 0.08),
|
||||
rgba(14, 165, 233, 0.06)
|
||||
);
|
||||
border: 1px solid rgba(99, 102, 241, 0.18);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
strong {
|
||||
color: var(--text-primary, #1e293b);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rules cards (2-column) ---
|
||||
.perm-rules-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.perm-rules-card {
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
min-height: 112px;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
|
||||
|
||||
// full-width checkbox so entire card is clickable
|
||||
.ant-checkbox-wrapper {
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
// Card layout: [checkbox-indicator] [icon] [text]
|
||||
&__inner {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
line-height: 1.35;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary, #1e293b);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// View card (blue)
|
||||
&--view {
|
||||
border: 1px solid rgba(59, 130, 246, 0.46);
|
||||
background: rgba(239, 246, 255, 0.92);
|
||||
|
||||
.perm-rules-card__icon {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
// Manage card (orange)
|
||||
&--manage {
|
||||
border: 1px solid rgba(245, 158, 11, 0.48);
|
||||
background: rgba(255, 251, 235, 0.92);
|
||||
|
||||
.perm-rules-card__icon {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Divider between rules and other permissions ---
|
||||
.perm-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0 8px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&__line {
|
||||
height: 1px;
|
||||
background: var(--border-color, #e2e8f0);
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Below section: other grouped + flat checkboxes ---
|
||||
.perm-below-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.perm-checkbox--grouped {
|
||||
padding: 6px 10px;
|
||||
// font-weight: 600;
|
||||
}
|
||||
|
||||
.perm-checkbox--flat {
|
||||
padding: 6px 10px;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.perm-checkbox-desc {
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
@@ -15,7 +15,12 @@ import HeadStatisticCard, {
|
||||
} from '@/components/HeadStatisticCard';
|
||||
import MyTag from '@/components/MyTag';
|
||||
import useUserInfo from '@/hooks/useUserInfo';
|
||||
import { getCompanyList, getRoleList, roleHandle } from '@/services/api';
|
||||
import {
|
||||
getCompanyList,
|
||||
getRoleDetail,
|
||||
getRoleList,
|
||||
roleHandle,
|
||||
} from '@/services/api';
|
||||
import { $t } from '@/utils/i18n';
|
||||
import useRoleCardList from './cardList';
|
||||
import Popup from './Popup';
|
||||
@@ -33,7 +38,24 @@ const Role: React.FC = () => {
|
||||
const { isGlobalCompany, isSuperAdmin, isCompanyAdmin } = useAccess();
|
||||
const globalCompanyId = isGlobalCompany ? userInfo?.companyId : 0;
|
||||
const tableRef = useRef<ActionType>(null);
|
||||
const { roleCardList } = useRoleCardList();
|
||||
const { roleCardList, getMenuMetaByPermissions, buildMenuTree } =
|
||||
useRoleCardList();
|
||||
|
||||
const [completeMenuTree, setCompleteMenuTree] = useState<API.MenuListItem[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const roleId = userInfo?.roleList?.[0]?.id;
|
||||
if (!roleId) return;
|
||||
getRoleDetail({ id: roleId }).then(({ data: { data } }) => {
|
||||
const flatList = data?.menuList || [];
|
||||
if (!flatList.length) return;
|
||||
const tree = buildMenuTree(flatList);
|
||||
const treeWithMeta = getMenuMetaByPermissions(tree);
|
||||
setCompleteMenuTree(treeWithMeta);
|
||||
});
|
||||
}, [userInfo?.roleList?.[0]?.id]);
|
||||
|
||||
const [companyType, setCompanyType] = useState<number | ''>(
|
||||
userInfo?.companyId ?? '',
|
||||
@@ -225,7 +247,7 @@ const Role: React.FC = () => {
|
||||
📋 {$t('pages.availablePermissionList')}
|
||||
</Title>
|
||||
</Col> */}
|
||||
<Row wrap gutter={[16, 16]}>
|
||||
<Row wrap gutter={[16, 16]} className="w-100">
|
||||
{roleCardList
|
||||
.filter((item) => item.curUserShow)
|
||||
.map((item) => (
|
||||
@@ -357,6 +379,7 @@ const Role: React.FC = () => {
|
||||
formValues={formValues}
|
||||
onDone={handleDone}
|
||||
onSubmit={handleSubmit}
|
||||
menuTree={completeMenuTree}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ const RuleCommon: React.FC = () => {
|
||||
const { ruleType: CURRENT_RULE_TYPE } = useRouteProps();
|
||||
const { getAllMetaByType } = useRuleMeta();
|
||||
const { userInfo } = useUserInfo();
|
||||
const { isGlobalCompany } = useAccess();
|
||||
const { isGlobalCompany, hasPerms } = useAccess();
|
||||
const [rules, setRules] = useState<API.RuleListItem[]>([]);
|
||||
const [alerts, setAlerts] = useState<API.AlertListItem[]>([]);
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -253,7 +253,7 @@ const RuleCommon: React.FC = () => {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{!isGlobalCompany && (
|
||||
{!isGlobalCompany && hasPerms('rule:edit') && (
|
||||
<>
|
||||
<Divider style={{ margin: '14px 0' }} />
|
||||
<Flex justify="end">
|
||||
@@ -438,7 +438,7 @@ const RuleCommon: React.FC = () => {
|
||||
icon={<RedoOutlined />}
|
||||
onClick={() => refreshRules(1)}
|
||||
/>
|
||||
{!isGlobalCompany && (
|
||||
{!isGlobalCompany && hasPerms('rule:edit') && (
|
||||
<>
|
||||
<Button
|
||||
key="1"
|
||||
|
||||
@@ -12,7 +12,6 @@ export const errorConfig: RequestConfig = {
|
||||
errorConfig: {
|
||||
// Error reception and processing
|
||||
errorHandler: async (error: any) => {
|
||||
console.log('error', error);
|
||||
const response = error?.response;
|
||||
const config = error?.config;
|
||||
if (response?.status === 401) {
|
||||
@@ -58,15 +57,20 @@ export const errorConfig: RequestConfig = {
|
||||
const { data } = response as { data: API.BaseResponse };
|
||||
const config = response?.config as RequestConfig & {
|
||||
skip417Error?: boolean;
|
||||
skip401Refresh?: boolean;
|
||||
};
|
||||
const skip417Error = config?.skip417Error;
|
||||
if (skip417Error && data?.code === 417) {
|
||||
if (config?.skip417Error && data?.code === 417) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (data?.success === false) {
|
||||
antdMessage.error(data?.message || 'Error');
|
||||
}
|
||||
|
||||
if (data?.code === 401 && !config?.skip401Refresh) {
|
||||
window.dispatchEvent(new CustomEvent('auth:refresh'));
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
],
|
||||
|
||||
@@ -254,6 +254,7 @@ export async function getAlertPages(
|
||||
method: "GET",
|
||||
params,
|
||||
skip417Error: true,
|
||||
skip401Refresh: true,
|
||||
...(options || {}),
|
||||
});
|
||||
}
|
||||
@@ -261,6 +262,7 @@ export async function getAlertDetail(id: number | string, options?: ObjType) {
|
||||
return request(`/alert-record/${id}`, {
|
||||
method: "GET",
|
||||
skip417Error: true,
|
||||
skip401Refresh: true,
|
||||
...(options || {}),
|
||||
});
|
||||
}
|
||||
@@ -376,6 +378,7 @@ export async function getRulePages(
|
||||
export async function getRuleDetail(id: number | string, options?: ObjType) {
|
||||
return request(`/rule/${id}`, {
|
||||
method: "GET",
|
||||
skip401Refresh: true,
|
||||
...(options || {}),
|
||||
});
|
||||
}
|
||||
|
||||
61
src/utils/routePermissions.ts
Normal file
61
src/utils/routePermissions.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { commonRoutes, roleRoutes } from '../../config/routes';
|
||||
|
||||
interface RouteItem {
|
||||
path?: string;
|
||||
access?: string;
|
||||
meta?: { permissions?: string[] };
|
||||
routes?: RouteItem[];
|
||||
}
|
||||
|
||||
function flattenPermissions(
|
||||
routes: RouteItem[],
|
||||
parentPath = '',
|
||||
inheritedPermissions?: string[],
|
||||
): Map<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const route of routes) {
|
||||
const fullPath = parentPath + (route.path || '');
|
||||
const ownPermissions =
|
||||
route.access === 'normalRouteFilter' && route.meta?.permissions?.length
|
||||
? route.meta.permissions
|
||||
: undefined;
|
||||
const effectivePermissions = ownPermissions ?? inheritedPermissions;
|
||||
|
||||
if (effectivePermissions?.length) {
|
||||
map.set(fullPath, effectivePermissions);
|
||||
}
|
||||
|
||||
if (route.routes) {
|
||||
const children = flattenPermissions(
|
||||
route.routes,
|
||||
`${fullPath}/`,
|
||||
effectivePermissions,
|
||||
);
|
||||
for (const [path, perms] of children) {
|
||||
map.set(path, perms);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export const routePermissions = flattenPermissions([
|
||||
...commonRoutes,
|
||||
...roleRoutes,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Look up the required permissions for a pathname.
|
||||
* Falls back to parent paths if the exact path isn't found (e.g. /rules/liquidity-trade → /rules).
|
||||
*/
|
||||
export function getRequiredPermissions(pathname: string): string[] | undefined {
|
||||
let path = pathname.replace(/\/$/, '') || '/';
|
||||
while (path) {
|
||||
const perms = routePermissions.get(path);
|
||||
if (perms) return perms;
|
||||
const i = path.lastIndexOf('/');
|
||||
if (i <= 0) break;
|
||||
path = path.slice(0, i);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user