Compare commits

...

13 Commits

Author SHA1 Message Date
ef605de159 fix(user): default to current company in user edit popup
Pass currentCompanyId to Popup so the form defaults to the
currently active company instead of always picking the first
item from companyList.
2026-07-31 18:01:25 +08:00
08e695b9ad feat(company): enhance expiry warning with rich i18n and day ranges
Add red rich-text style to useRichI18n. Update CompanyExpireWarning to
show info/warning/error states based on remaining days and render rich
formatted countdown text.
2026-07-31 17:01:23 +08:00
795962e61f feat(company): add quick expiry date presets and i18n translations
Add date picker presets for validityEndTime: 7/30/60/90 days later.
Extract preset labels into i18n files for zh-CN and en-US.
2026-07-31 17:00:06 +08:00
318728b121 refactor(company): extract ExpireOrApp guard component from app.tsx
- Move ExpireOrApp logic into standalone component under components/ExpireOrApp
- Remove unused React hooks and CompanyExpireGuard import from app.tsx
- Use dayjs.utc for validity date to prevent timezone offset issues
- Remove stale console.log from CompanyExpireWarning
2026-07-30 21:33:48 +08:00
2a8d213c0f feat(profile): fix expired text show 2026-07-29 20:59:47 +08:00
2589205efd Merge branches 'feat/company-expire' and 'feat/platform-limit' into dev/2.0
Resolved conflicts:
- typings.d.ts: Keep both validity fields and platform quota fields in CompanyListItem
- company/Popup.tsx: Merge imports (Card, Col, Row from platform-limit + Dayjs from company-expire)
- company/index.tsx: Keep useAccess and Typography imports needed by company-expire
- rules/index.tsx: Keep validityStatus check from company-expire, adopt simplified clone button from platform-limit

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 20:38:13 +08:00
4085393db1 feat(company): add expiry warning banner, route wrapper, and conditional UI for expired companies
- Add CompanyExpireWarning component to layout header for expiring subscription alerts
- Add CompanyExpire wrapper to guard rules and data-source routes
- Refactor CompanyExpireGuard to support optional fullscreen mode via prop
- Add responsive Popover support to VoiceCheck for mobile breakpoints
- Hide rule CRUD action buttons when company validity is expired
- Update profile page to display expiry status alongside validity date
- Update i18n strings for clearer expiry messaging

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 20:28:06 +08:00
fc55965555 feat(company): add subscription expiry management and expired company guard
Add validity period fields to company CRUD, display expiry info in
table and profile, and block access for expired companies via a
full-screen guard component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-29 16:15:58 +08:00
3c636bcaa2 refactor(copy-rule): extract context and improve batch clone modal
- Extract MultiCopyPopupContext to standalone context.ts file
- Add Task type and improve type safety in BatchCloneModal
- Use useRef/useCallback to stabilize batch clone handlers
- Add cleanup effect for selectRules in RuleCheckCard on unmount

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 23:05:56 +08:00
16999ca583 feat(clone): allow same-source cloning and disable cross-sourceType targets
- Remove the restriction that prevents cloning to the same data source
- Disable (instead of hide) target options with different sourceType
- Add sourceType label (MT4/MT5) to data source select options
- Preserve groupFilter/param1/param2 when cloning to same source
- Auto-reset target when source changes to incompatible sourceType
- Remove parent-level check requiring at least 2 data sources

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 21:39:01 +08:00
dfcba94cf5 feat(platform): add platform access control with quota management
- Add userPlatform hook to derive available platforms from company config
- Add platform access section in company popup with MT4/MT5 switch and quota limits
- Add platform access column in company list showing quota usage
- Replace hardcoded platform options with dynamic userPlatform in account/alert/data-source pages
- Add getPlatformList API and extend CompanyListItem typings
- Add i18n translations for platform access related keys
- Update dev proxy target port to 8082

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 17:57:25 +08:00
43d6e9207a 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>
2026-05-29 18:04:07 +08:00
710da86521 fix(oper-log): fix json.parse error again 2026-05-24 16:22:42 +08:00
48 changed files with 1644 additions and 332 deletions

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ deploy.js
# misc
.env
.DS_Store
.claude
npm-debug.log*
yarn-error.log

View File

@@ -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

View File

@@ -15,7 +15,8 @@ export default {
'/api': {
// The address to proxy
// target: 'http://2333z061l7.51mypc.cn',
target: 'http://172.20.177.121:8080',
// target: 'http://172.20.177.121:8080',
target: 'http://172.20.177.121:8082',
changeOrigin: true,
pathRewrite: {
'^/api': '',

View File

@@ -52,7 +52,7 @@ export const roleRoutes = [
meta: {
permissions: ['rule:list'],
},
wrappers: ['@/wrappers/noBindDataSource'],
wrappers: ['@/wrappers/CompanyExpire', '@/wrappers/noBindDataSource'],
component: './rules',
routes: [
{
@@ -166,6 +166,7 @@ export const roleRoutes = [
meta: {
permissions: ['data-source:list'],
},
wrappers: ['@/wrappers/CompanyExpire'],
component: './data-source/index',
},
{

View File

@@ -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;
}
}

View File

@@ -1,8 +1,9 @@
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 React from 'react';
import { App } from 'antd';
import { AvatarDropdown, AvatarName, SelectLang } from '@/components';
import { currentUser as queryCurrentUser } from '@/services/api';
@@ -17,7 +18,10 @@ import ServerErrorPage from './pages/500';
import { errorConfig } from './requestErrorConfig';
import { getDeviceId } from './utils/fp';
import './utils/sentry';
import ExpireOrApp from './components/ExpireOrApp';
import CompanyExpireWarning from './components/CompanyExpireWarning';
import GlobalErrorBoundary from './components/GlobalErrorBoundary';
import PermissionGuard from './components/PermissionGuard';
import SSEController from './components/SSEController';
import VoiceCheck from './components/VoiceCheck';
@@ -103,9 +107,14 @@ export function rootContainer(container: React.ReactNode) {
);
}
export function innerProvider(container: React.ReactNode) {
return <ExpireOrApp>{container}</ExpireOrApp>;
}
// 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 +132,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
bgLayout: '#f8fafc',
};
if (currentUser && !initialized) {
console.log('fetchUnread');
if (currentUser && !initialized && hasPerms?.('alert:list')) {
fetchUnread();
}
return {
@@ -138,6 +146,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
},
actionsRender: (_layoutProps) => {
return [
<CompanyExpireWarning key="CompanyExpireWarning" />,
<VoiceCheck key="VoiceCheck" />,
needChangePass && <ChangePassTips key="ChangePassTips" />,
// <ThemeSwitch
@@ -196,9 +205,15 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
// Add a loading state
childrenRender: (children) => {
if (initialState?.loading) return <PageLoading />;
return (
<>
{currentUser && <SSEController />}
{currentUser && (
<>
<SSEController />
<PermissionGuard />
</>
)}
<GlobalErrorBoundary>{children}</GlobalErrorBoundary>
</>

View File

@@ -0,0 +1,41 @@
import { Button, Result, Space } from 'antd';
import { logoutAndToLoginPage } from '@/access';
import useUserInfo from '@/hooks/useUserInfo';
import { $t } from '@/utils/i18n';
const fullscreenClass =
'fixed-all-center flex align-center justify-center bg-white';
const CompanyExpireGuard: React.FC<{ fullscreen?: boolean }> = ({
fullscreen = true,
}) => {
const { userInfo } = useUserInfo();
const isExpired = userInfo?.company?.validityStatus === 2;
if (!isExpired) return null;
return (
<div className={fullscreen ? fullscreenClass : ''}>
<Result
status="warning"
title={$t('pages.company.expireTitle')}
subTitle={$t('pages.company.expireSubTitle')}
extra={
<Space>
<Button type="primary" onClick={() => window.location.reload()}>
{$t('pages.refresh')}
</Button>
<Button
onClick={() => {
logoutAndToLoginPage();
}}
>
{$t('pages.logout')}
</Button>
</Space>
}
/>
</div>
);
};
export default CompanyExpireGuard;

View File

@@ -0,0 +1,64 @@
import { ClockCircleOutlined } from '@ant-design/icons';
import { useBreakpoint } from '@ant-design/pro-components';
import { history as umiHistory, useAccess } from '@umijs/max';
import { Alert, Popover, Space } from 'antd';
import useRichI18n from '@/hooks/useRichI18n';
import useUserInfo from '@/hooks/useUserInfo';
import { $t } from '@/utils/i18n';
import { utcInstance as dayjs } from '@/utils/timeFormat';
const CompanyExpireWarning: React.FC = () => {
const { richFormat } = useRichI18n();
const { isGlobalCompany } = useAccess();
const { userInfo } = useUserInfo();
const screens = useBreakpoint();
const { validityEndTime, validityStatus } = userInfo?.company || {};
if (isGlobalCompany || !validityEndTime) {
return null;
}
const expireInDays = dayjs(`${validityEndTime}T23:59:59`).diff(
dayjs.utc(),
'day',
);
const tipsNode = (
<Alert
message={
validityStatus === 2
? $t('pages.company.expireTitle')
: expireInDays >= 30
? `${$t('pages.company.expireInfo')}${validityEndTime} (UTC+0)`
: richFormat('pages.company.expireInDays', {
days: Math.abs(expireInDays + 1),
})
}
type={
validityStatus === 2 ? 'error' : expireInDays >= 30 ? 'info' : 'warning'
}
showIcon
/>
);
return (
<Space align="center" onClick={() => umiHistory.push('/profile')}>
{['xs', 'sm'].includes(screens || '') ? (
<Popover
content={tipsNode}
styles={{
body: { padding: 0 },
}}
zIndex={999}
defaultOpen={true}
>
<ClockCircleOutlined
className="flex align-center"
style={{ color: '#f59e0b', fontSize: 20 }}
/>
</Popover>
) : (
tipsNode
)}
</Space>
);
};
export default CompanyExpireWarning;

View File

@@ -0,0 +1,58 @@
import { PageLoading } from '@ant-design/pro-components';
import { history, useModel } from '@umijs/max';
import React, { useEffect, useRef, useState } from 'react';
import { LOGIN_URL } from '@/access';
import CompanyExpireGuard from '@/components/CompanyExpireGuard';
const ExpireOrApp: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { initialState, loading } = useModel('@@initialState');
const [location, setLocation] = useState(history.location);
const company = initialState?.currentUser?.company;
const guardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
return history.listen((update) => {
setLocation(update.location);
});
}, []);
const isExpired = company?.special !== 0 && company?.validityStatus === 2;
const isLoginPage = location.pathname === LOGIN_URL;
const shouldBlock = isExpired && !isLoginPage;
const shouldBlockRef = useRef(shouldBlock);
shouldBlockRef.current = shouldBlock;
useEffect(() => {
if (!shouldBlock) return;
const el = guardRef.current;
if (!el) return;
const observer = new MutationObserver((mutations) => {
if (!shouldBlockRef.current) return;
for (const mutation of mutations) {
for (const node of Array.from(mutation.removedNodes)) {
if (node === el) {
window.location.reload();
break;
}
}
}
});
observer.observe(document.body, { childList: true });
return () => observer.disconnect();
}, [shouldBlock]);
if (loading) return <PageLoading />;
if (shouldBlock) {
return (
<div ref={guardRef} id="company-expire-guard-root">
<CompanyExpireGuard />
</div>
);
}
return <>{children}</>;
};
export default ExpireOrApp;

View 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;
}

View File

@@ -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);

View File

@@ -1,6 +1,17 @@
import { ChromeOutlined, SoundOutlined } from '@ant-design/icons';
import { useBreakpoint } from '@ant-design/pro-components';
import { getLocale, Icon } from '@umijs/max';
import { Alert, Divider, Image, Modal, Space, Tabs, Typography } from 'antd';
import {
Alert,
Divider,
Flex,
Image,
Modal,
Popover,
Space,
Tabs,
Typography,
} from 'antd';
import { useCallback, useEffect, useState } from 'react';
import useRichI18n from '@/hooks/useRichI18n';
import { checkAudioPermission } from '@/utils';
@@ -8,9 +19,9 @@ import bus from '@/utils/eventBus';
import { $t } from '@/utils/i18n';
const { Paragraph } = Typography;
const ChangePassTips: React.FC = () => {
const VoiceCheck: React.FC = () => {
const { richFormat } = useRichI18n();
const screens = useBreakpoint();
const [visible, setVisible] = useState(false);
const [hasAudioPermission, setHasAudioPermission] = useState(true);
@@ -69,25 +80,46 @@ const ChangePassTips: React.FC = () => {
),
},
];
const tipsNode = (
<>
<Alert
message={$t('pages.voiceCheck.alertMessage')}
type="warning"
showIcon
/>
</>
);
return (
<>
<Space align="center" onClick={() => setVisible(true)}>
{!hasAudioPermission && (
<>
<Alert
message={$t('pages.voiceCheck.alertMessage')}
type="warning"
showIcon
/>
<Icon
icon="local:silence"
height="20px"
fill="#f59e0b"
className="flex align-center"
/>
</>
)}
<Flex align="center" onClick={() => setVisible(true)}>
{!hasAudioPermission &&
(['xs', 'sm'].includes(screens || '') ? (
<Popover
content={tipsNode}
styles={{
body: { padding: 0 },
}}
zIndex={999}
defaultOpen={true}
>
<Icon
icon="local:silence"
height="20px"
fill="#f59e0b"
className="flex align-center"
/>
</Popover>
) : (
<>
{tipsNode}
<Icon
icon="local:silence"
height="20px"
fill="#f59e0b"
className="flex align-center"
/>
</>
))}
{/* {hasAudioPermission ? (
<Icon
icon="local:voice"
@@ -103,7 +135,7 @@ const ChangePassTips: React.FC = () => {
className="flex align-center"
/>
)} */}
</Space>
</Flex>
<Modal
title={
@@ -154,4 +186,4 @@ const ChangePassTips: React.FC = () => {
</>
);
};
export default ChangePassTips;
export default VoiceCheck;

View File

@@ -135,6 +135,16 @@ ol {
padding-inline: 15px;
}
.fixed-all-center {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background-color: #fff;
}
@media (max-width: 768px) {
.ant-table {
width: 100%;

View File

@@ -39,6 +39,12 @@ const richTextMap: Record<string, RichTextInfo> = {
color: '#818cf8',
},
},
red: {
tag: 'b',
style: {
color: '#f56565',
},
},
};
const mapKeys = Object.keys(richTextMap);
const collection = {} as Record<string, (chunks: Node) => Node>;

View File

@@ -1,4 +1,5 @@
import { useModel } from '@umijs/max';
import { useMemo } from 'react';
export default function useUserInfo() {
const { initialState, setInitialState } = useModel('@@initialState');
@@ -14,5 +15,24 @@ export default function useUserInfo() {
setUserInfo(info);
};
return { userInfo, refreshUserInfo, setUserInfo };
const userPlatform = useMemo(() => {
const company = userInfo?.company;
const originalPlatform = [
{
label: 'MT5',
value: 5,
isOpen: company?.mt5Open === 1,
},
{
label: 'MT4',
value: 4,
isOpen: company?.mt4Open === 1,
},
];
// return originalPlatform.filter((item) => item.isOpen);
return originalPlatform;
}, [userInfo]);
return { userInfo, refreshUserInfo, setUserInfo, userPlatform };
}

View File

@@ -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',

View File

@@ -2,6 +2,7 @@ export default {
'common.loading': 'Loading...',
'common.content.noRules': 'No rules available',
'common.content.noData': 'No data available',
'table.platformAccess': 'Platform Quota',
'table.group': 'Group',
'table.operatorRecords': 'Operator Records',
'table.operatorAction': 'Operator Action',
@@ -46,6 +47,8 @@ export default {
'table.contactEmail': 'Contact Email',
'table.dataSourceCount': 'Data Sources',
'table.userCount': 'Users',
'table.validityEndTime': 'Expire At (UTC+0)',
'table.validityEndTime.tips': 'Company subscription expiry date ',
'table.createTime': 'Create Time',
'table.name': 'Name',
'table.ipAddress': 'IP Address',
@@ -87,6 +90,15 @@ export default {
'pages.totalAlertCount': 'Total Alerts',
'pages.totalCompanyCount': 'Total Companies',
'pages.activeCompanyCount': 'Active Companies',
'pages.company.expired': 'Expired',
'pages.company.permanent': 'Permanent',
'pages.company.expireTitle': 'Company Subscription Expired',
'pages.company.expireSubTitle':
"Your company's license has expired. All features are temporarily locked. Please contact your sales representative to renew.",
'pages.company.expireInDays':
'Subscription expires in <red>{days}</red> days',
'pages.company.expireToday': 'Subscription expiring soon; it expires today.',
'pages.company.expireInfo': 'Subscription expires on: ',
'pages.totalDataSourceCount': 'Total Data Sources',
'pages.activeDataSourceCount': 'Active Data Sources',
'pages.mt4DataSourceCount': 'MT4 Data Sources',
@@ -329,6 +341,15 @@ export default {
'form.role.placeholder2': 'System roles cannot modify permission levels',
'form.role.badgeColor': 'Badge Color',
'form.role.menuIds': 'Bound Permissions (Menus)',
'form.company.validityEndTime': 'Expiry At (UTC+0)',
'form.company.validityEndTime.placeholder': 'Select expiry date',
'form.company.validityEndTime.tips': 'Blank for Permanent',
'form.company.validityEndTime.preset.7days': '7 days later',
'form.company.validityEndTime.preset.30days': '30 days later',
'form.company.validityEndTime.preset.60days': '60 days later',
'form.company.validityEndTime.preset.90days': '90 days later',
'form.role.permissionGuide': 'Permission Guide',
'form.role.otherPermissions': 'Other Menu Permissions',
'form.config.timezone': 'Timezone Settings',
'form.config.timezone.title': 'Default Timezone',
@@ -601,6 +622,10 @@ export default {
'Enter verified sender emails (comma separated)',
'pages.email.status.normal': 'Active',
'pages.email.status.disabled': 'Inactive',
'pages.email.platformAccess': ' Platform Access',
'pages.email.platformAccessEnable': ' Access',
'pages.email.platformMax': 'Quota Limit',
'pages.email.platformAssigned': 'Assigned',
'pages.email.notification': 'Email Notification Config',
'pages.email.notificationEmail': 'Notification Email',
'pages.email.notificationEmail.tips':

View File

@@ -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)',

View File

@@ -2,6 +2,7 @@ export default {
'common.loading': '加载中...',
'common.content.noRules': '暂无规则',
'common.content.noData': '暂无数据',
'table.platformAccess': '平台配额',
'table.group': '所属组',
'table.operatorRecords': '操作记录',
'table.operatorAction': '操作动作',
@@ -46,6 +47,8 @@ export default {
'table.contactEmail': '联系邮箱',
'table.dataSourceCount': '数据源',
'table.userCount': '用户数',
'table.validityEndTime': '订阅到期时间(UTC+0)',
'table.validityEndTime.tips': '公司订阅服务到期时间',
'table.createTime': '创建时间',
'table.name': '名称',
'table.ipAddress': 'IP地址',
@@ -87,6 +90,14 @@ export default {
'pages.totalAlertCount': '总告警数',
'pages.totalCompanyCount': '总公司数',
'pages.activeCompanyCount': '活跃公司数',
'pages.company.expired': '已过期',
'pages.company.permanent': '永久有效',
'pages.company.expireTitle': '公司订阅已过期',
'pages.company.expireSubTitle':
'您的公司在该系统的使用授权已到期,各项功能已暂时锁定。请联系商务代表进行续费操作以恢复访问。',
'pages.company.expireInDays': '订阅即将到期,剩余 <red>{days}</red> 天',
'pages.company.expireToday': '订阅即将到期,今天到期',
'pages.company.expireInfo': '订阅到:',
'pages.totalDataSourceCount': '数据源总数',
'pages.activeDataSourceCount': '活跃数据源',
'pages.mt4DataSourceCount': 'MT4 数据源',
@@ -321,6 +332,15 @@ export default {
'form.role.placeholder2': '系统角色权限级别不可修改',
'form.role.badgeColor': '徽章颜色',
'form.role.menuIds': '绑定权限(菜单)',
'form.company.validityEndTime': '到期时间(UTC+0)',
'form.company.validityEndTime.placeholder': '请选择到期时间',
'form.company.validityEndTime.tips': '留空为永久有效',
'form.company.validityEndTime.preset.7days': '7天后',
'form.company.validityEndTime.preset.30days': '30天后',
'form.company.validityEndTime.preset.60days': '60天后',
'form.company.validityEndTime.preset.90days': '90天后',
'form.role.permissionGuide': '权限怎么选',
'form.role.otherPermissions': '其他菜单权限',
'form.config.timezone': '时区设置',
'form.config.timezone.title': '默认时区',
@@ -567,6 +587,10 @@ export default {
'pages.email.availableEmails.tips': '在服务商已验证的邮箱,多个用逗号分隔',
'pages.email.status.normal': '正常',
'pages.email.status.disabled': '停用',
'pages.email.platformAccess': '平台接入权限',
'pages.email.platformAccessEnable': '接入',
'pages.email.platformMax': '最大数量上限',
'pages.email.platformAssigned': '已配',
'pages.email.notification': '邮件通知配置',
'pages.email.notificationEmail': '通知邮箱',
'pages.email.notificationEmail.tips':

View File

@@ -21,7 +21,7 @@ const maxAlertHideCount = 9999;
const Account: React.FC = () => {
const { isGlobalCompany } = useAccess();
const { userInfo } = useUserInfo();
const { userInfo, userPlatform } = useUserInfo();
const companyInfo = userInfo?.company;
const [visible, setVisible] = useState(false);
@@ -122,14 +122,7 @@ const Account: React.FC = () => {
label: $t('table.all'),
value: '',
},
// {
// label: 'MT4',
// value: '4',
// },
{
label: 'MT5',
value: '5',
},
...userPlatform,
],
},
},

View File

@@ -64,7 +64,7 @@ const Alert: React.FC = () => {
const [dataSourceList, setDataSourceList] = useState<
API.DataSourceListItem[]
>([]);
const { userInfo } = useUserInfo();
const { userInfo, userPlatform } = useUserInfo();
const userDataSourceList = userInfo?.userDataSourceList;
const companyInfo = userInfo?.company;
@@ -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'),
@@ -251,14 +257,7 @@ const Alert: React.FC = () => {
label: $t('table.all'),
value: '',
},
// {
// label: 'MT4',
// value: 4,
// },
{
label: 'MT5',
value: 5,
},
...userPlatform,
],
},
render: (_, { platform }) => {
@@ -300,16 +299,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 +315,7 @@ const Alert: React.FC = () => {
valueType: 'dateTime',
hideInSearch: true,
sorter: true,
width: 170,
// renderText: (_, { triggerTime }) => {
// return formatToUserTimezone(triggerTime);
// },
@@ -326,6 +324,7 @@ const Alert: React.FC = () => {
title: $t('table.dataSourceTime'),
dataIndex: 'dataSourceServerTime',
hideInSearch: true,
width: 180,
render: (_, { dataSourceServerTime, timeZone }) => {
if (!dataSourceServerTime) return '-';
return (
@@ -344,6 +343,8 @@ const Alert: React.FC = () => {
title: $t('table.status'),
dataIndex: 'statusId',
valueType: 'select',
fixed: 'right',
width: 'auto',
fieldProps: {
defaultValue: '',
popupMatchSelectWidth: false,
@@ -521,7 +522,7 @@ const Alert: React.FC = () => {
<Row gutter={[16, 16]}>
<Col span={24}>
<ProTable
scroll={{ x: true }}
scroll={{ x: 'max-content' }}
columns={columns}
form={{
initialValues: {

View File

@@ -1,13 +1,16 @@
import {
ModalForm,
ProFormDatePicker,
ProFormDependency,
ProFormDigit,
type ProFormInstance,
ProFormSelect,
ProFormSwitch,
ProFormText,
} from '@ant-design/pro-components';
import { useAccess } from '@umijs/max';
import { Divider, Space, Typography } from 'antd';
import { Card, Col, Divider, Row, Space, Typography } from 'antd';
import type { Dayjs } from 'dayjs';
import { useRef } from 'react';
import useUserInfo from '@/hooks/useUserInfo';
import {
@@ -16,6 +19,7 @@ import {
getEmailConfigList,
} from '@/services/api';
import { $t } from '@/utils/i18n';
import { utcInstance as dayjs } from '@/utils/timeFormat';
const { Text } = Typography;
type CompanyListItem = Partial<API.CompanyListItem>;
@@ -25,7 +29,7 @@ export default (props: {
onSubmit: (values: CompanyListItem) => void;
onDone: () => void;
}) => {
const { isGlobalCompany } = useAccess();
const { isGlobalCompany, isSuperAdmin } = useAccess();
const { userInfo } = useUserInfo();
const { modalVisit, formValues, onSubmit, onDone } = props;
@@ -43,6 +47,13 @@ export default (props: {
if (!isGlobalCompany) {
values.companyId = userInfo?.companyId;
}
if (isSuperAdmin) {
values.validityEndTime = values.validityEndTime
? dayjs.utc(values.validityEndTime).format('YYYY-MM-DD')
: '';
values.validityStartTime = values.validityEndTime ? '2026-01-01' : '';
}
return onSubmit(values);
}}
modalProps={{
@@ -70,6 +81,7 @@ export default (props: {
return {
...values,
...companyMailHostConfig,
validityEndTime: values.validityEndTime || undefined,
};
}
return values || {};
@@ -109,7 +121,113 @@ export default (props: {
{formValues?.id !== void 0 && (
<>
{isSuperAdmin && (
<ProFormDatePicker
name="validityEndTime"
label={`${$t('form.company.validityEndTime')} (${$t('form.company.validityEndTime.tips')})`}
placeholder={$t('form.company.validityEndTime.placeholder')}
convertValue={(value: string | Dayjs | null | undefined) =>
value ? dayjs.utc(value) : undefined
}
fieldProps={{
style: { width: '100%' },
disabledDate: (current: Dayjs) => {
return current?.isBefore(
dayjs.utc().subtract(2, 'day').startOf('day'),
);
// return current < dayjs.utc();
},
presets: [
{
label: $t('form.company.validityEndTime.preset.7days'),
value: dayjs.utc().add(7, 'day'),
},
{
label: $t('form.company.validityEndTime.preset.30days'),
value: dayjs.utc().add(30, 'day'),
},
{
label: $t('form.company.validityEndTime.preset.60days'),
value: dayjs.utc().add(60, 'day'),
},
{
label: $t('form.company.validityEndTime.preset.90days'),
value: dayjs.utc().add(90, 'day'),
},
],
}}
/>
)}
<Divider size="small" />
<Text strong style={{ lineHeight: '40px', fontSize: 16 }}>
{$t('pages.email.platformAccess')}
</Text>
<Row gutter={16}>
<Col span={12}>
<Card
title={`MT4 ${$t('pages.email.platformAccessEnable')}`}
extra={
<ProFormSwitch
name="mt4Open"
noStyle
transform={(value) => +value}
/>
}
styles={{
header: { fontSize: 14, minHeight: 40, paddingInline: 14 },
body: { paddingInline: 14 },
}}
>
<ProFormDependency name={['mt4Open']}>
{({ mt4Open }) => (
<ProFormDigit
name="mt4Count"
label={`${$t('pages.email.platformMax')}`}
min={0}
step={1}
disabled={!mt4Open}
formItemProps={{
style: { marginBottom: 0 },
}}
/>
)}
</ProFormDependency>
</Card>
</Col>
<Col span={12}>
<Card
title={`MT5 ${$t('pages.email.platformAccessEnable')}`}
extra={
<ProFormSwitch
name="mt5Open"
noStyle
transform={(value) => +value}
/>
}
styles={{
header: { fontSize: 14, minHeight: 40, paddingInline: 14 },
body: { paddingInline: 14 },
}}
>
<ProFormDependency name={['mt5Open']}>
{({ mt5Open }) => (
<ProFormDigit
name="mt5Count"
label={`${$t('pages.email.platformMax')}`}
min={0}
step={1}
disabled={!mt5Open}
formItemProps={{
style: { marginBottom: 0 },
}}
/>
)}
</ProFormDependency>
</Card>
</Col>
</Row>
{/* <Divider size="small" /> */}
<Text strong style={{ lineHeight: '40px', fontSize: 16 }}>
📧 {$t('pages.email.notification')}
</Text>

View File

@@ -5,7 +5,8 @@ import {
type ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { Badge, Button, Col, Row } from 'antd';
import { useAccess } from '@umijs/max';
import { Badge, Button, Col, Row, Space, Typography } from 'antd';
import React, { useRef, useState } from 'react';
import HeadStatisticCard, {
type CardInfo,
@@ -13,9 +14,13 @@ import HeadStatisticCard, {
import MyTag from '@/components/MyTag';
import { companyHandle, getCompanyPages } from '@/services/api';
import { $t } from '@/utils/i18n';
import { utcInstance as dayjs } from '@/utils/timeFormat';
import PopUp from './Popup';
const { Text } = Typography;
const Company: React.FC = () => {
const { isSuperAdmin } = useAccess();
const [modalVisit, setModalVisit] = useState(false);
const [cardInfo, setCardInfo] = useState({
@@ -69,6 +74,46 @@ const Company: React.FC = () => {
);
},
},
{
title: $t('table.platformAccess'),
dataIndex: 'platformAccess',
render: (
_,
{
mt4Count,
mt4Open,
mt5Count,
mt5Open,
sourceType4Count,
sourceType5Count,
},
) => {
return (
<Space direction="vertical" size={4}>
<MyTag color={mt4Open === 1 ? '#818cf8' : '#94a3b8'}>
MT4:{' '}
{mt4Open === 1 ? (
<span>
{sourceType4Count ?? '-'} / {mt4Count ?? '-'}
</span>
) : (
$t('pages.rules.disable')
)}
</MyTag>
<MyTag color={mt5Open === 1 ? '#3b82f6' : '#94a3b8'}>
MT5:{' '}
{mt5Open === 1 ? (
<span>
{sourceType5Count ?? '-'} / {mt5Count ?? '-'}
</span>
) : (
$t('pages.rules.disable')
)}
</MyTag>
</Space>
);
},
},
{
title: $t('table.userCount'),
dataIndex: 'userCount',
@@ -92,6 +137,28 @@ const Company: React.FC = () => {
);
},
},
{
title: $t('table.validityEndTime'),
dataIndex: 'validityEndTime',
tooltip: $t('table.validityEndTime.tips'),
hideInTable: !isSuperAdmin,
render: (_, { validityEndTime, validityStatus }) => {
return (
<Space>
{!validityEndTime ? (
<Text type="secondary">{$t('pages.company.permanent')}</Text>
) : (
<span>{dayjs(validityEndTime).format('YYYY-MM-DD')}</span>
)}
{validityStatus === 2 && (
<Text type="danger" style={{ fontSize: 11 }}>
{$t('pages.company.expired')}
</Text>
)}
</Space>
);
},
},
{
title: $t('table.createTime'),
dataIndex: 'createTime',
@@ -122,7 +189,10 @@ const Company: React.FC = () => {
size="small"
key="edit"
onClick={() => {
setFormValues(item);
setFormValues({
...item,
validityEndTime: item.validityEndTime || undefined,
});
setModalVisit(true);
}}
>

View File

@@ -6,7 +6,7 @@ import {
} from '@ant-design/pro-components';
import { useAccess } from '@umijs/max';
import { Divider, type FormInstance, Typography } from 'antd';
import { useRef } from 'react';
import { useMemo, useRef } from 'react';
import useUserInfo from '@/hooks/useUserInfo';
import { preventScientificNotation } from '@/utils';
import { $t } from '@/utils/i18n';
@@ -22,7 +22,7 @@ export default (props: {
}) => {
const { visible, formValues, onSubmit, onDone, companyList } = props;
const { userInfo } = useUserInfo();
const { userInfo, userPlatform } = useUserInfo();
const { isGlobalCompany } = useAccess();
const globalCompanyId = isGlobalCompany ? userInfo?.companyId : 0;
const formRef = useRef<FormInstance>(null);
@@ -41,6 +41,12 @@ export default (props: {
message: $t('form.placeholder.ipAddress'),
},
];
const filteredUserPlatform = useMemo(
() => userPlatform.filter((v) => v?.isOpen),
[userPlatform],
);
return (
<ModalForm
width="600px"
@@ -50,7 +56,10 @@ export default (props: {
formValues?.id
? formValues
: {
sourceType: 5,
sourceType:
filteredUserPlatform.length > 0
? filteredUserPlatform[0].value
: null,
}
}
open={visible}
@@ -78,16 +87,7 @@ export default (props: {
label={$t('form.dataSource.type')}
allowClear={false}
rules={[{ required: true, message: $t('form.required') }]}
options={[
// {
// value: 4,
// label: 'MT4',
// },
{
value: 5,
label: 'MT5',
},
]}
options={filteredUserPlatform}
/>
{isGlobalCompany && (

View File

@@ -18,6 +18,7 @@ import {
dataSourceHandle,
getCompanyList,
getDataSourcePages,
getPlatformList,
} from '@/services/api';
import { $t } from '@/utils/i18n';
import Popup from './Popup';
@@ -49,6 +50,7 @@ const DataSource: React.FC = () => {
setCompanyList(data || []);
});
}
getPlatformList();
}, []);
const [cardInfo, setCardInfo] = useState({
// mt4Count: 0,

View File

@@ -50,11 +50,11 @@ const OperLog: React.FC = () => {
},
'rule insert': (item) => {
const operParam = parseOperParam(item);
return `Add Rule: '${ruleBaseMeta[operParam.type].title || '-'}'`;
return `Add Rule: '${ruleBaseMeta[operParam?.type]?.title || '-'}'`;
},
'rule update': (item) => {
const operParam = parseOperParam(item);
return `Update Rule: '${ruleBaseMeta[operParam.type].title || '-'}'`;
return `Update Rule: '${ruleBaseMeta[operParam?.type]?.title || '-'}'`;
},
'rule update status': (item) => {
const matched = item.operParam.match(/status=(?<status>\d+)/);
@@ -124,7 +124,7 @@ const OperLog: React.FC = () => {
hideInSearch: true,
ellipsis: true,
width: 'auto',
renderText: (_, item) => actionMap[item.title]?.(item) || '-',
renderText: (_, item) => actionMap[item?.title]?.(item) || '-',
},
{
title: $t('table.ipAddress'),

View File

@@ -3,7 +3,7 @@ import {
ProForm,
ProFormText,
} from '@ant-design/pro-components';
import { history } from '@umijs/max';
import { history, useAccess } from '@umijs/max';
import {
App,
Button,
@@ -14,16 +14,20 @@ import {
Row,
Space,
Tag,
Typography,
} from 'antd';
import React from 'react';
import React, { useEffect } from 'react';
import { clearAccessToken } from '@/access';
import PasswordLevel from '@/components/PasswordLevel';
import useUserInfo from '@/hooks/useUserInfo';
import { changeUserPass } from '@/services/api';
import { $t } from '@/utils/i18n';
const { Text } = Typography;
const Profile: React.FC = () => {
const { userInfo, setUserInfo } = useUserInfo();
const { isGlobalCompany } = useAccess();
const userCompany = userInfo?.company;
const { message: antdMessage, modal } = App.useApp();
@@ -71,6 +75,28 @@ const Profile: React.FC = () => {
},
];
useEffect(() => {
if (!isGlobalCompany) {
items.push({
key: '6',
span: 'filled',
label: $t('table.validityEndTime'),
children: userCompany?.validityEndTime ? (
<Space>
{userCompany.validityEndTime}
{userCompany?.validityStatus === 2 && (
<Text type="danger" style={{ fontSize: '12px' }}>
{$t('pages.company.expired')}
</Text>
)}
</Space>
) : (
$t('pages.company.permanent')
),
});
}
}, [userCompany, isGlobalCompany]);
const changePasswordSuccessHadle = async () => {
let countdown = 5;
let timer: NodeJS.Timeout | null = null;

View File

@@ -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>
);

View File

@@ -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 };
}

View 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;

View 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;
}

View File

@@ -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>
);

View File

@@ -4,7 +4,7 @@ import {
useBreakpoint,
} from '@ant-design/pro-components';
import { Button, Divider, Flex, Input, Modal, Space, Typography } from 'antd';
import { createContext, useMemo, useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import useRichFormat from '@/hooks/useRichI18n';
import { $t } from '@/utils/i18n';
import '../style.less';
@@ -14,26 +14,9 @@ import { handleEmptyValuesFunc } from '../../utils';
import BatchCloneModal from './comp/BatchCloneModal';
import Step1 from './comp/Step1';
import Step2 from './comp/Step2';
import { MultiCopyPopupContext } from './context';
const { Text, Title } = Typography;
type MultiCopyPopupContextValue = {
ruleMeta: Record<string, any>;
dataSourceList: API.DataSourceListItem[];
currentRuleType: number;
targetDataSourceId: string | number | null;
selectRules: API.RuleListItem[];
setSelectRules: (rules: API.RuleListItem[]) => void;
currentSchemaMap: Record<string, any>;
};
export const MultiCopyPopupContext = createContext<MultiCopyPopupContextValue>({
ruleMeta: {},
dataSourceList: [],
currentRuleType: 0,
targetDataSourceId: null,
selectRules: [],
setSelectRules: () => {},
currentSchemaMap: {},
});
type MultiCopyPopupProps = {
visible: boolean;
ruleMeta: Record<string, any>;
@@ -232,22 +215,22 @@ const MultiCopyPopup = ({
onValuesChange={({ sourceDataSourceId }) => {
if (sourceDataSourceId) {
const curFormRef = formMapRef?.current?.[0]?.current;
const curTargetDataSourceId =
curFormRef?.getFieldValue('targetDataSourceId');
if (curTargetDataSourceId === sourceDataSourceId) {
let targetDataSourceId: string | number | null = null;
for (const item of dataSourceList) {
if (item.id !== sourceDataSourceId) {
targetDataSourceId = item.id;
break;
}
}
if (targetDataSourceId) {
curFormRef?.setFieldsValue({
targetDataSourceId,
});
setTargetDataSourceId(targetDataSourceId);
}
const curTargetId = curFormRef?.getFieldValue('targetDataSourceId');
const sourceItem = dataSourceList.find(
(item) => item.id === sourceDataSourceId,
);
const targetItem = dataSourceList.find(
(item) => item.id === curTargetId,
);
if (targetItem && sourceItem && targetItem.sourceType !== sourceItem.sourceType) {
const fallback =
dataSourceList.find(
(item) =>
item.id !== sourceDataSourceId &&
item.sourceType === sourceItem.sourceType,
)?.id || sourceDataSourceId;
curFormRef?.setFieldsValue({ targetDataSourceId: fallback });
setTargetDataSourceId(fallback);
}
}
}}

View File

@@ -17,7 +17,7 @@ import {
Row,
Typography,
} from 'antd';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import useRichFormat from '@/hooks/useRichI18n';
import { $t } from '@/utils/i18n';
import { type SchemaType, schemaMap } from '../../ruleFormSchema';
@@ -68,49 +68,83 @@ const SingleCopyPopup = ({
} =
schemaMap[currentRuleType as unknown as SchemaType]?.({ richFormat }) || {};
const rightDataSourceList = dataSourceList.filter(
(item) => item.id !== formValues?.dataSourceId,
);
const sourceSourceType = useMemo(() => {
return dataSourceList.find((item) => item.id === formValues?.dataSourceId)
?.sourceType;
}, [dataSourceList, formValues?.dataSourceId]);
const dataSourceOptions = useMemo(() => {
return dataSourceList.map((item) => ({
...item,
disabled: item.sourceType !== sourceSourceType,
label: (
<Flex justify="space-between" align="center">
<span>{item.sourceName}</span>
<Typography.Text
type="secondary"
style={{ fontSize: 12, marginLeft: 8 }}
>
MT{item.sourceType}
</Typography.Text>
</Flex>
),
}));
}, [dataSourceList, sourceSourceType]);
const initialValues = transformInitialValue?.(formValues) || formValues;
const rightFirstDataSource = rightDataSourceList[0];
const rightFirstDataSource =
dataSourceOptions.find(
(item) => item.id !== formValues?.dataSourceId && !item.disabled,
) || dataSourceOptions[0];
const isSameSource = rightFirstDataSource?.id === formValues?.dataSourceId;
const copyFormValues = {
...initialValues,
name: initialValues?.name
? `${initialValues.name} - ${rightFirstDataSource?.sourceName}`
: `${Date.now()} - ${rightFirstDataSource?.sourceName}`,
groupFilter: '',
id: void 0,
dataSourceId: rightFirstDataSource?.id || '',
...(isSameSource
? {}
: {
groupFilter: '',
...(symbolFormShow ? { param2: '' } : {}),
...(assetFormShow ? { param1: '' } : {}),
}),
};
if (symbolFormShow) {
copyFormValues.param2 = '';
}
if (assetFormShow) {
copyFormValues.param1 = '';
}
const formContent = ({
dataSourceList,
dataSourceList: options,
formAttr,
onDataSourceChange,
}: {
dataSourceList: API.DataSourceListItem[];
dataSourceList: any[];
formAttr: Record<string, any>;
onDataSourceChange?: (dataSourceId: string | number) => void;
}) => {
return (
<ProForm {...formAttr} submitter={false}>
<ProForm
{...formAttr}
submitter={false}
onValuesChange={(changedValues) => {
if (changedValues.dataSourceId && onDataSourceChange) {
onDataSourceChange(changedValues.dataSourceId);
}
}}
>
<ProFormSelect
name="dataSourceId"
label={$t('table.dataSource')}
options={dataSourceList}
options={options}
rules={[{ required: true, message: $t('form.required') }]}
fieldProps={{
allowClear: false,
fieldNames: {
label: 'sourceName',
label: 'label',
value: 'id',
},
optionLabelProp: 'label',
}}
/>
<BetaSchemaForm layoutType="Embed" columns={leftForm} />
@@ -194,53 +228,64 @@ const SingleCopyPopup = ({
},
}}
>
<Row>
<Col xs={24} sm={11}>
<div style={{ marginBottom: 12 }}>
<Text strong>📋 {$t('pages.rules.originalRule')}</Text>
</div>
{formContent({
dataSourceList,
formAttr: { initialValues, disabled: true },
})}
</Col>
{visible && (
<Row>
<Col xs={24} sm={11}>
<div style={{ marginBottom: 12 }}>
<Text strong>📋 {$t('pages.rules.originalRule')}</Text>
</div>
{formContent({
dataSourceList: dataSourceOptions,
formAttr: { initialValues, disabled: true },
})}
</Col>
<Col xs={0} sm={1}>
<Flex vertical align="center" style={{ height: '100%' }}>
<ArrowRightOutlined style={{ marginTop: 2, fontSize: 18 }} />
<Divider type="vertical" style={{ flex: 1, marginTop: 16 }} />
</Flex>
</Col>
<Col xs={0} sm={1}>
<Flex vertical align="center" style={{ height: '100%' }}>
<ArrowRightOutlined style={{ marginTop: 2, fontSize: 18 }} />
<Divider type="vertical" style={{ flex: 1, marginTop: 16 }} />
</Flex>
</Col>
<Col xs={24} sm={0}>
<div className="text-center" style={{ marginBottom: 16 }}>
<ArrowDownOutlined />
</div>
</Col>
<Col xs={24} sm={0}>
<div className="text-center" style={{ marginBottom: 16 }}>
<ArrowDownOutlined />
</div>
</Col>
<Col xs={24} sm={12}>
<div style={{ marginBottom: 12 }}>
<Text strong> {$t('pages.rules.cloneRulePreview')}</Text>
</div>
<Col xs={24} sm={12}>
<div style={{ marginBottom: 12 }}>
<Text strong> {$t('pages.rules.cloneRulePreview')}</Text>
</div>
{formContent({
dataSourceList: rightDataSourceList,
formAttr: {
initialValues: copyFormValues,
formRef: formRef,
disabled: confirmLoading,
},
})}
{errMsg && (
<Alert
message={errMsg}
type="error"
showIcon
style={{ marginBottom: 16 }}
/>
)}
</Col>
</Row>
{formContent({
dataSourceList: dataSourceOptions,
formAttr: {
initialValues: copyFormValues,
formRef: formRef,
disabled: confirmLoading,
},
onDataSourceChange: (dataSourceId) => {
const isSame = dataSourceId === formValues?.dataSourceId;
if (!isSame) {
const fields: Record<string, any> = { groupFilter: '' };
if (symbolFormShow) fields.param2 = '';
if (assetFormShow) fields.param1 = '';
formRef.current?.setFieldsValue(fields);
}
},
})}
{errMsg && (
<Alert
message={errMsg}
type="error"
showIcon
style={{ marginBottom: 16 }}
/>
)}
</Col>
</Row>
)}
</Modal>
);
};

View File

@@ -17,10 +17,13 @@ import {
Tag,
Tooltip,
} from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { processSingleRule } from '@/pages/rules/utils';
import { $t } from '@/utils/i18n';
type TaskStatus = 'waiting' | 'processing' | 'success' | 'warning' | 'error';
type Task = { name: string; status: TaskStatus; errorMsg: string };
type BatchCloneModalProps = {
visible: boolean;
selectedRows: Partial<API.RuleListItem>[];
@@ -33,26 +36,33 @@ const BatchCloneModal = ({
onClose,
onFinish,
}: BatchCloneModalProps) => {
const [tasks, setTasks] = useState<any[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [isRunning, setIsRunning] = useState(false);
const [currentRunning, setCurrentRunning] = useState(0);
const selectedRowsRef = useRef(selectedRows);
selectedRowsRef.current = selectedRows;
useEffect(() => {
const initialTasks = selectedRows.map((row) => ({
name: row.name,
status: 'waiting', // waiting, processing, success, error
errorMsg: '',
}));
setTasks(initialTasks);
}, [visible, selectedRows]);
if (visible) {
setTasks(
selectedRows.map((row) => ({
name: row.name || '',
status: 'waiting',
errorMsg: '',
})),
);
setIsRunning(false);
setCurrentRunning(0);
}
}, [visible]);
const finishedTasks = useMemo(
() => tasks.filter((t) => ['success', 'warning'].includes(t.status)),
() => tasks.filter((t) => t.status === 'success' || t.status === 'warning'),
[tasks],
);
const finishedMessage = useMemo(() => {
const hasWarning = finishedTasks.find((t) => t.status === 'warning');
const hasWarning = finishedTasks.some((t) => t.status === 'warning');
const type: AlertProps['type'] = hasWarning
? 'warning'
: finishedTasks.length === tasks.length
@@ -69,9 +79,10 @@ const BatchCloneModal = ({
};
}, [finishedTasks, tasks]);
const startBatch = async () => {
const startBatch = useCallback(async () => {
const rows = selectedRowsRef.current;
setIsRunning(true);
for (let i = 0; i < selectedRows.length; i++) {
for (let i = 0; i < rows.length; i++) {
setCurrentRunning(i + 1);
setTasks((prev) => {
const next = [...prev];
@@ -79,8 +90,7 @@ const BatchCloneModal = ({
return next;
});
try {
await processSingleRule({ payload: selectedRows[i] });
await processSingleRule({ payload: rows[i] });
setTasks((prev) => {
const next = [...prev];
next[i].status = 'success';
@@ -96,13 +106,13 @@ const BatchCloneModal = ({
}
}
setIsRunning(false);
};
}, []);
const handleReset = () => {
const handleReset = useCallback(() => {
setTasks([]);
setIsRunning(false);
setCurrentRunning(0);
};
}, []);
return (
<Modal

View File

@@ -1,22 +1,20 @@
import { CheckCard, ProCard } from '@ant-design/pro-components';
import { useRequest } from '@umijs/max';
import { Col, Row, Spin } from 'antd';
import { useContext, useState } from 'react';
import { useContext, useEffect, useState } from 'react';
import { getRuleList } from '@/services/api';
import { $t } from '@/utils/i18n';
import { RuleCardBodyInfo } from '../../RuleCardBody';
import { MultiCopyPopupContext } from '../MultiCopyPopup';
import { MultiCopyPopupContext } from '../context';
type RuleCheckCardProps = {
dataSourceId: string | number;
onChange?: (id: string | number) => void;
};
const RuleCheckCard = ({ dataSourceId }: RuleCheckCardProps) => {
const [ruleList, setRuleList] = useState<API.RuleListItem[]>([]);
const { ruleMeta, currentRuleType, setSelectRules } = useContext(
MultiCopyPopupContext,
);
const [ruleList, setRuleList] = useState<API.RuleListItem[]>([]);
const { loading } = useRequest(
() =>
@@ -25,7 +23,6 @@ const RuleCheckCard = ({ dataSourceId }: RuleCheckCardProps) => {
dataSourceId,
}),
{
// manual: true,
refreshDeps: [dataSourceId, currentRuleType],
onSuccess: ({ data = [] }: { data: API.RuleListItem[] }) => {
setRuleList(data);
@@ -37,15 +34,17 @@ const RuleCheckCard = ({ dataSourceId }: RuleCheckCardProps) => {
},
);
useEffect(() => {
return () => setSelectRules([]);
}, []);
if (!dataSourceId) return <div>{$t('pages.clones.step1.tips')}</div>;
if (loading) return <Spin />;
// const
return (
<ProCard
title={$t('pages.clones.step1.sourceRules', {
count: ruleList?.length,
count: ruleList.length,
ruleType: ruleMeta?.title || '-',
})}
collapsible

View File

@@ -1,9 +1,9 @@
import { ArrowDownOutlined } from '@ant-design/icons';
import { ProFormDependency, ProFormSelect } from '@ant-design/pro-components';
import { Divider, Tag } from 'antd';
import { useContext, useEffect } from 'react';
import { Divider, Flex, Tag, Typography } from 'antd';
import { useContext, useEffect, useMemo } from 'react';
import { $t } from '@/utils/i18n';
import { MultiCopyPopupContext } from '../MultiCopyPopup';
import { MultiCopyPopupContext } from '../context';
import RuleCheckCard from './RuleCheckCard';
type Step1Props = {
@@ -13,11 +13,52 @@ type Step1Props = {
const Step1 = ({ formMapRef, setTargetDataSourceId }: Step1Props) => {
const { dataSourceList } = useContext(MultiCopyPopupContext);
const buildOptions = (sourceId?: string | number) => {
const sourceType = dataSourceList.find((item) => item.id === sourceId)
?.sourceType;
return dataSourceList.map((item) => ({
...item,
disabled: item.sourceType !== sourceType,
label: (
<Flex justify="space-between" align="center">
<span>{item.sourceName}</span>
<Typography.Text
type="secondary"
style={{ fontSize: 12, marginLeft: 8 }}
>
MT{item.sourceType}
</Typography.Text>
</Flex>
),
}));
};
const sourceOptions = useMemo(
() => dataSourceList.map((item) => ({
...item,
label: (
<Flex justify="space-between" align="center">
<span>{item.sourceName}</span>
<Typography.Text
type="secondary"
style={{ fontSize: 12, marginLeft: 8 }}
>
MT{item.sourceType}
</Typography.Text>
</Flex>
),
})),
[dataSourceList],
);
useEffect(() => {
if (dataSourceList?.length) {
// waitTime(1000).then(() => {
const sourceDataSourceId = dataSourceList[0]?.id || '';
const targetDataSourceId = dataSourceList[1]?.id || '';
const sourceItem = dataSourceList[0];
const sourceDataSourceId = sourceItem?.id || '';
const sameTypeItem = dataSourceList.find(
(item) => item.id !== sourceDataSourceId && item.sourceType === sourceItem?.sourceType,
);
const targetDataSourceId = sameTypeItem?.id || sourceDataSourceId;
const curFormRef = formMapRef?.current?.[0]?.current;
if (curFormRef) {
curFormRef?.setFieldsValue({
@@ -26,7 +67,6 @@ const Step1 = ({ formMapRef, setTargetDataSourceId }: Step1Props) => {
});
setTargetDataSourceId(targetDataSourceId);
}
// });
}
}, [dataSourceList]);
@@ -35,14 +75,15 @@ const Step1 = ({ formMapRef, setTargetDataSourceId }: Step1Props) => {
<ProFormSelect
name="sourceDataSourceId"
label={$t('pages.clones.step1.source')}
options={dataSourceList}
options={sourceOptions}
rules={[{ required: true, message: $t('form.required') }]}
fieldProps={{
allowClear: false,
fieldNames: {
label: 'sourceName',
label: 'label',
value: 'id',
},
optionLabelProp: 'label',
}}
/>
<ProFormDependency name={['sourceDataSourceId']}>
@@ -61,21 +102,20 @@ const Step1 = ({ formMapRef, setTargetDataSourceId }: Step1Props) => {
<ProFormDependency name={['sourceDataSourceId']}>
{({ sourceDataSourceId }) => {
if (!sourceDataSourceId) return null;
const dataSourceOptions = dataSourceList.filter(
(item) => item.id !== sourceDataSourceId,
);
const targetOptions = buildOptions(sourceDataSourceId);
return (
<ProFormSelect
name="targetDataSourceId"
label={$t('pages.clones.step1.target')}
options={dataSourceOptions}
options={targetOptions}
rules={[{ required: true, message: $t('form.required') }]}
fieldProps={{
allowClear: false,
fieldNames: {
label: 'sourceName',
label: 'label',
value: 'id',
},
optionLabelProp: 'label',
}}
onChange={(value: string | number | null) => {
setTargetDataSourceId(value || null);

View File

@@ -16,7 +16,7 @@ import {
import { useContext, useEffect, useMemo, useState } from 'react';
import { $t } from '@/utils/i18n';
import { RuleCardBodyInfo } from '../../RuleCardBody';
import { MultiCopyPopupContext } from '../MultiCopyPopup';
import { MultiCopyPopupContext } from '../context';
const { Text } = Typography;

View File

@@ -0,0 +1,21 @@
import { createContext } from 'react';
type MultiCopyPopupContextValue = {
ruleMeta: Record<string, any>;
dataSourceList: API.DataSourceListItem[];
currentRuleType: number;
targetDataSourceId: string | number | null;
selectRules: API.RuleListItem[];
setSelectRules: (rules: API.RuleListItem[]) => void;
currentSchemaMap: Record<string, any>;
};
export const MultiCopyPopupContext = createContext<MultiCopyPopupContextValue>({
ruleMeta: {},
dataSourceList: [],
currentRuleType: 0,
targetDataSourceId: null,
selectRules: [],
setSelectRules: () => {},
currentSchemaMap: {},
});

View File

@@ -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,62 +253,60 @@ const RuleCommon: React.FC = () => {
</Space>
</div>
{!isGlobalCompany && (
<>
<Divider style={{ margin: '14px 0' }} />
<Flex justify="end">
<Space wrap style={{ justifyContent: 'flex-end' }}>
<Button
variant="solid"
color={rule.status === 2 ? 'cyan' : 'orange'}
disabled={
![1, 2].includes(rule.status) || ruleStatusHandleLoading
}
onClick={() => handleToggleStatus(rule)}
>
{rule.status === 1
? $t('pages.rules.disable')
: $t('pages.rules.enable')}
</Button>
<Button
disabled={
![1, 2].includes(rule.status) || ruleStatusHandleLoading
}
onClick={() => {
setAction('edit');
setFormValues(rule);
setVisible(true);
}}
>
{$t('table.action.edit')}
</Button>
<Button
color="danger"
variant="solid"
disabled={
![1, 2].includes(rule.status) || ruleStatusHandleLoading
}
onClick={() => handleDelete(rule)}
>
{$t('table.action.delete')}
</Button>
<Button
icon={<CopyOutlined />}
onClick={() => {
if (dataSourceList.length < 2) {
message.warning($t('pages.rules.cloneRuleWarning'));
return;
{!isGlobalCompany &&
hasPerms('rule:edit') &&
companyInfo?.validityStatus !== 2 && (
<>
<Divider style={{ margin: '14px 0' }} />
<Flex justify="end">
<Space wrap style={{ justifyContent: 'flex-end' }}>
<Button
variant="solid"
color={rule.status === 2 ? 'cyan' : 'orange'}
disabled={
![1, 2].includes(rule.status) || ruleStatusHandleLoading
}
setFormValues(rule);
setCopyVisible(true);
}}
>
{$t('pages.rules.clone')}
</Button>
</Space>
</Flex>
</>
)}
onClick={() => handleToggleStatus(rule)}
>
{rule.status === 1
? $t('pages.rules.disable')
: $t('pages.rules.enable')}
</Button>
<Button
disabled={
![1, 2].includes(rule.status) || ruleStatusHandleLoading
}
onClick={() => {
setAction('edit');
setFormValues(rule);
setVisible(true);
}}
>
{$t('table.action.edit')}
</Button>
<Button
color="danger"
variant="solid"
disabled={
![1, 2].includes(rule.status) || ruleStatusHandleLoading
}
onClick={() => handleDelete(rule)}
>
{$t('table.action.delete')}
</Button>
<Button
icon={<CopyOutlined />}
onClick={() => {
setFormValues(rule);
setCopyVisible(true);
}}
>
{$t('pages.rules.clone')}
</Button>
</Space>
</Flex>
</>
)}
</ProCard>
);
@@ -438,34 +436,32 @@ const RuleCommon: React.FC = () => {
icon={<RedoOutlined />}
onClick={() => refreshRules(1)}
/>
{!isGlobalCompany && (
<>
<Button
key="1"
type="primary"
onClick={() => {
setAction('add');
setFormValues({});
setVisible(true);
}}
>
<PlusOutlined />
{`${$t('table.action.add')}${$t('menu.rule')}`}
</Button>
<Button
icon={<CopyOutlined />}
onClick={() => {
if (dataSourceList.length < 2) {
message.warning($t('pages.rules.cloneRuleWarning'));
return;
}
setMultiCopyVisible(true);
}}
>
{$t('pages.clones.title')}
</Button>
</>
)}
{!isGlobalCompany &&
hasPerms('rule:edit') &&
companyInfo?.validityStatus !== 2 && (
<>
<Button
key="1"
type="primary"
onClick={() => {
setAction('add');
setFormValues({});
setVisible(true);
}}
>
<PlusOutlined />
{`${$t('table.action.add')}${$t('menu.rule')}`}
</Button>
<Button
icon={<CopyOutlined />}
onClick={() => {
setMultiCopyVisible(true);
}}
>
{$t('pages.clones.title')}
</Button>
</>
)}
</Space>
</Space>

View File

@@ -29,6 +29,7 @@ export default (props: {
action: NewAction;
visible: boolean;
roleList: API.RolesListItem[];
currentCompanyId: number | string;
companyList: API.CompanyListItem[];
dataSourceList: API.DataSourceListItem[];
formValues: Partial<UserListItem | undefined>;
@@ -48,6 +49,7 @@ export default (props: {
roleList,
companyList,
dataSourceList,
currentCompanyId,
} = props;
const dataSourceForm = (
@@ -165,7 +167,7 @@ export default (props: {
}
: {
enabled: true,
companyId: companyList[0]?.id,
companyId: currentCompanyId || companyList[0]?.id,
}
}
open={visible}

View File

@@ -412,6 +412,7 @@ const User: React.FC = () => {
visible={visible}
formValues={formValues}
roleList={roleList}
currentCompanyId={companyType}
companyList={companyList}
dataSourceList={dataSourceList}
onDone={handleDone}

View File

@@ -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;
},
],

View File

@@ -83,6 +83,8 @@ export async function companyHandle(
name?: string;
email?: string;
status?: number;
validityStartTime?: string | null;
validityEndTime?: string | null;
},
options?: ObjType,
) {
@@ -254,6 +256,7 @@ export async function getAlertPages(
method: "GET",
params,
skip417Error: true,
skip401Refresh: true,
...(options || {}),
});
}
@@ -261,6 +264,7 @@ export async function getAlertDetail(id: number | string, options?: ObjType) {
return request(`/alert-record/${id}`, {
method: "GET",
skip417Error: true,
skip401Refresh: true,
...(options || {}),
});
}
@@ -376,6 +380,7 @@ export async function getRulePages(
export async function getRuleDetail(id: number | string, options?: ObjType) {
return request(`/rule/${id}`, {
method: "GET",
skip401Refresh: true,
...(options || {}),
});
}
@@ -609,6 +614,12 @@ export async function closeSSEWithApi(options?: ObjType) {
});
}
export async function getPlatformList(options?: ObjType) {
return request("/system/platform-list", {
method: "GET",
...(options || {}),
});
}
export async function getDashboardTotal(options?: ObjType) {
return request("/dashboard/total", {
method: "GET",

View File

@@ -113,6 +113,16 @@ declare namespace API {
fromName: string | null;
mailHostConfigId: number | string | null;
mailStatus: number | null;
validityStartTime?: string | null;
validityEndTime?: string | null;
validityStatus?: number | null;
mt4Count: number;
mt4Open: number;
mt5Count: number;
mt5Open: number;
sourceType4Count: number;
sourceType5Count: number;
};
// "DataSource" Type

View 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;
}

View File

@@ -0,0 +1,13 @@
import { Outlet, useAccess } from '@umijs/max';
import CompanyExpireGuard from '@/components/CompanyExpireGuard';
import useUserInfo from '@/hooks/useUserInfo';
export default () => {
const { isGlobalCompany } = useAccess();
const { userInfo } = useUserInfo();
const { company } = userInfo || {};
if (!isGlobalCompany && company?.validityStatus === 2) {
return <CompanyExpireGuard fullscreen={false} />;
}
return <Outlet />;
};

View File

@@ -1,5 +1,6 @@
{
"compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": "./",
"target": "esnext",
"moduleResolution": "node",