diff --git a/.gitignore b/.gitignore index 293035c..8f8e2cc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ deploy.js # misc .env .DS_Store +.claude npm-debug.log* yarn-error.log diff --git a/config/config.ts b/config/config.ts index d00f1d3..62d85d2 100644 --- a/config/config.ts +++ b/config/config.ts @@ -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 diff --git a/example.nginx.conf b/example.nginx.conf index adc7dfa..eab5d17 100644 --- a/example.nginx.conf +++ b/example.nginx.conf @@ -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; + } } diff --git a/src/app.tsx b/src/app.tsx index 6991dec..20dd5b6 100644 --- a/src/app.tsx +++ b/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 ; return ( <> - {currentUser && } + {currentUser && ( + <> + + + + )} {children} diff --git a/src/components/PermissionGuard.tsx b/src/components/PermissionGuard.tsx new file mode 100644 index 0000000..4a45ca2 --- /dev/null +++ b/src/components/PermissionGuard.tsx @@ -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; + 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; +} diff --git a/src/components/SSEController/index.tsx b/src/components/SSEController/index.tsx index 158963d..6f3e4af 100644 --- a/src/components/SSEController/index.tsx +++ b/src/components/SSEController/index.tsx @@ -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(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); diff --git a/src/locales/en_US/menu.ts b/src/locales/en_US/menu.ts index b44b51d..41c519f 100644 --- a/src/locales/en_US/menu.ts +++ b/src/locales/en_US/menu.ts @@ -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', diff --git a/src/locales/en_US/pages.ts b/src/locales/en_US/pages.ts index 6709262..d87314e 100644 --- a/src/locales/en_US/pages.ts +++ b/src/locales/en_US/pages.ts @@ -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', diff --git a/src/locales/zh_CN/menu.ts b/src/locales/zh_CN/menu.ts index 10ae694..980e50d 100644 --- a/src/locales/zh_CN/menu.ts +++ b/src/locales/zh_CN/menu.ts @@ -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)', diff --git a/src/locales/zh_CN/pages.ts b/src/locales/zh_CN/pages.ts index 103e822..7f1c0b8 100644 --- a/src/locales/zh_CN/pages.ts +++ b/src/locales/zh_CN/pages.ts @@ -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': '默认时区', diff --git a/src/pages/alert/index.tsx b/src/pages/alert/index.tsx index acd7154..236fabc 100644 --- a/src/pages/alert/index.tsx +++ b/src/pages/alert/index.tsx @@ -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 = () => { { + items.forEach((item) => { + ids.push(item.id); + if (item.children?.length) walk(item.children); + }); + }; + walk(nodes); + return ids; +} type RolesListItem = Partial; export default (props: { @@ -22,8 +34,16 @@ export default (props: { formValues: Partial; 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: { - ({ - 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', - }, - }} - /> + > + +