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>
This commit is contained in:
2026-07-29 20:28:06 +08:00
parent fc55965555
commit 4085393db1
11 changed files with 254 additions and 128 deletions

View File

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

View File

@@ -18,6 +18,7 @@ import { errorConfig } from './requestErrorConfig';
import { getDeviceId } from './utils/fp'; import { getDeviceId } from './utils/fp';
import './utils/sentry'; import './utils/sentry';
import CompanyExpireGuard from './components/CompanyExpireGuard'; import CompanyExpireGuard from './components/CompanyExpireGuard';
import CompanyExpireWarning from './components/CompanyExpireWarning';
import GlobalErrorBoundary from './components/GlobalErrorBoundary'; import GlobalErrorBoundary from './components/GlobalErrorBoundary';
import PermissionGuard from './components/PermissionGuard'; import PermissionGuard from './components/PermissionGuard';
import SSEController from './components/SSEController'; import SSEController from './components/SSEController';
@@ -140,6 +141,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
}, },
actionsRender: (_layoutProps) => { actionsRender: (_layoutProps) => {
return [ return [
<CompanyExpireWarning key="CompanyExpireWarning" />,
<VoiceCheck key="VoiceCheck" />, <VoiceCheck key="VoiceCheck" />,
needChangePass && <ChangePassTips key="ChangePassTips" />, needChangePass && <ChangePassTips key="ChangePassTips" />,
// <ThemeSwitch // <ThemeSwitch

View File

@@ -3,27 +3,18 @@ import { logoutAndToLoginPage } from '@/access';
import useUserInfo from '@/hooks/useUserInfo'; import useUserInfo from '@/hooks/useUserInfo';
import { $t } from '@/utils/i18n'; import { $t } from '@/utils/i18n';
const CompanyExpireGuard: React.FC = () => { const fullscreenClass =
'fixed-all-center flex align-center justify-center bg-white';
const CompanyExpireGuard: React.FC<{ fullscreen?: boolean }> = ({
fullscreen = true,
}) => {
const { userInfo } = useUserInfo(); const { userInfo } = useUserInfo();
const isExpired = userInfo?.company?.validityStatus === 2; const isExpired = userInfo?.company?.validityStatus === 2;
if (!isExpired) return null; if (!isExpired) return null;
return ( return (
<div <div className={fullscreen ? fullscreenClass : ''}>
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Result <Result
status="warning" status="warning"
title={$t('pages.company.expireTitle')} title={$t('pages.company.expireTitle')}

View File

@@ -0,0 +1,63 @@
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 useUserInfo from '@/hooks/useUserInfo';
import { $t } from '@/utils/i18n';
import { utcInstance as dayjs } from '@/utils/timeFormat';
const CompanyExpireWarning: React.FC = () => {
const { isGlobalCompany } = useAccess();
const { userInfo } = useUserInfo();
const screens = useBreakpoint();
const { validityEndTime, validityStatus } = userInfo?.company || {};
if (isGlobalCompany) {
return null;
}
const expireInDays = dayjs(`${validityEndTime}T23:59:59`).diff(
dayjs().utc(),
'day',
);
console.log(
expireInDays,
validityEndTime,
dayjs().utc().format('YYYY-MM-DD HH:mm:ss'),
);
const tipsNode = (
<Alert
message={
validityStatus === 2
? $t('pages.company.expireTitle')
: expireInDays <= 0
? $t('pages.company.expireToday')
: `${$t('pages.company.expireInDays', { days: Math.abs(expireInDays) })}`
}
type={validityStatus === 2 ? 'error' : '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

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

View File

@@ -95,7 +95,7 @@ export default {
'pages.company.expireSubTitle': 'pages.company.expireSubTitle':
"Your company's license has expired. All features are temporarily locked. Please contact your sales representative to renew.", "Your company's license has expired. All features are temporarily locked. Please contact your sales representative to renew.",
'pages.company.expireInDays': 'Subscription expires in {days} days', 'pages.company.expireInDays': 'Subscription expires in {days} days',
'pages.company.expireToday': 'Expires Today', 'pages.company.expireToday': 'Subscription expiring soon; it expires today.',
'pages.totalDataSourceCount': 'Total Data Sources', 'pages.totalDataSourceCount': 'Total Data Sources',
'pages.activeDataSourceCount': 'Active Data Sources', 'pages.activeDataSourceCount': 'Active Data Sources',
'pages.mt4DataSourceCount': 'MT4 Data Sources', 'pages.mt4DataSourceCount': 'MT4 Data Sources',

View File

@@ -95,7 +95,7 @@ export default {
'pages.company.expireSubTitle': 'pages.company.expireSubTitle':
'您的公司在该系统的使用授权已到期,各项功能已暂时锁定。请联系商务代表进行续费操作以恢复访问。', '您的公司在该系统的使用授权已到期,各项功能已暂时锁定。请联系商务代表进行续费操作以恢复访问。',
'pages.company.expireInDays': '订阅即将到期,剩余 {days}天', 'pages.company.expireInDays': '订阅即将到期,剩余 {days}天',
'pages.company.expireToday': '今天到期', 'pages.company.expireToday': '订阅即将到期,今天到期',
'pages.totalDataSourceCount': '数据源总数', 'pages.totalDataSourceCount': '数据源总数',
'pages.activeDataSourceCount': '活跃数据源', 'pages.activeDataSourceCount': '活跃数据源',
'pages.mt4DataSourceCount': 'MT4 数据源', 'pages.mt4DataSourceCount': 'MT4 数据源',

View File

@@ -14,6 +14,7 @@ import {
Row, Row,
Space, Space,
Tag, Tag,
Typography,
} from 'antd'; } from 'antd';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { clearAccessToken } from '@/access'; import { clearAccessToken } from '@/access';
@@ -22,6 +23,8 @@ import useUserInfo from '@/hooks/useUserInfo';
import { changeUserPass } from '@/services/api'; import { changeUserPass } from '@/services/api';
import { $t } from '@/utils/i18n'; import { $t } from '@/utils/i18n';
const { Text } = Typography;
const Profile: React.FC = () => { const Profile: React.FC = () => {
const { userInfo, setUserInfo } = useUserInfo(); const { userInfo, setUserInfo } = useUserInfo();
const { isGlobalCompany } = useAccess(); const { isGlobalCompany } = useAccess();
@@ -77,13 +80,20 @@ const Profile: React.FC = () => {
items.push({ items.push({
key: '6', key: '6',
span: 'filled', span: 'filled',
label: $t('pages.company.permanent'), label: $t('table.validityEndTime'),
children: userCompany?.validityEndTime children: userCompany?.validityEndTime ? (
? userCompany.validityEndTime <Space>
: $t('pages.company.permanent'), {userCompany.validityEndTime}
<Text type="danger" style={{ fontSize: '12px' }}>
{$t('pages.company.expired')}
</Text>
</Space>
) : (
$t('pages.company.permanent')
),
}); });
} }
}, [userInfo]); }, [userCompany, isGlobalCompany]);
const changePasswordSuccessHadle = async () => { const changePasswordSuccessHadle = async () => {
let countdown = 5; let countdown = 5;

View File

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

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