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: {
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

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

View File

@@ -3,27 +3,18 @@ import { logoutAndToLoginPage } from '@/access';
import useUserInfo from '@/hooks/useUserInfo';
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 isExpired = userInfo?.company?.validityStatus === 2;
if (!isExpired) return null;
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div className={fullscreen ? fullscreenClass : ''}>
<Result
status="warning"
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 { 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,17 +80,38 @@ const ChangePassTips: React.FC = () => {
),
},
];
return (
<>
<Space align="center" onClick={() => setVisible(true)}>
{!hasAudioPermission && (
const tipsNode = (
<>
<Alert
message={$t('pages.voiceCheck.alertMessage')}
type="warning"
showIcon
/>
</>
);
return (
<>
<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"
@@ -87,7 +119,7 @@ const ChangePassTips: React.FC = () => {
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

@@ -95,7 +95,7 @@ export default {
'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 {days} days',
'pages.company.expireToday': 'Expires Today',
'pages.company.expireToday': 'Subscription expiring soon; it expires today.',
'pages.totalDataSourceCount': 'Total Data Sources',
'pages.activeDataSourceCount': 'Active Data Sources',
'pages.mt4DataSourceCount': 'MT4 Data Sources',

View File

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

View File

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

View File

@@ -253,7 +253,9 @@ const RuleCommon: React.FC = () => {
</Space>
</div>
{!isGlobalCompany && hasPerms('rule:edit') && (
{!isGlobalCompany &&
hasPerms('rule:edit') &&
companyInfo?.validityStatus !== 2 && (
<>
<Divider style={{ margin: '14px 0' }} />
<Flex justify="end">
@@ -438,7 +440,9 @@ const RuleCommon: React.FC = () => {
icon={<RedoOutlined />}
onClick={() => refreshRules(1)}
/>
{!isGlobalCompany && hasPerms('rule:edit') && (
{!isGlobalCompany &&
hasPerms('rule:edit') &&
companyInfo?.validityStatus !== 2 && (
<>
<Button
key="1"

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