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>
This commit is contained in:
2026-07-29 16:15:58 +08:00
parent 43d6e9207a
commit fc55965555
10 changed files with 165 additions and 6 deletions

View File

@@ -15,7 +15,8 @@ export default {
'/api': { '/api': {
// The address to proxy // The address to proxy
// target: 'http://2333z061l7.51mypc.cn', // 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, changeOrigin: true,
pathRewrite: { pathRewrite: {
'^/api': '', '^/api': '',

View File

@@ -17,6 +17,7 @@ import ServerErrorPage from './pages/500';
import { errorConfig } from './requestErrorConfig'; 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 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';
@@ -197,6 +198,12 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
// Add a loading state // Add a loading state
childrenRender: (children) => { childrenRender: (children) => {
if (initialState?.loading) return <PageLoading />; if (initialState?.loading) return <PageLoading />;
const { company } = currentUser ?? {};
if (company?.special !== 0 && company?.validityStatus === 2) {
return <CompanyExpireGuard />;
}
return ( return (
<> <>
{currentUser && ( {currentUser && (

View File

@@ -0,0 +1,50 @@
import { Button, Result, Space } from 'antd';
import { logoutAndToLoginPage } from '@/access';
import useUserInfo from '@/hooks/useUserInfo';
import { $t } from '@/utils/i18n';
const CompanyExpireGuard: React.FC = () => {
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',
}}
>
<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

@@ -46,6 +46,8 @@ export default {
'table.contactEmail': 'Contact Email', 'table.contactEmail': 'Contact Email',
'table.dataSourceCount': 'Data Sources', 'table.dataSourceCount': 'Data Sources',
'table.userCount': 'Users', 'table.userCount': 'Users',
'table.validityEndTime': 'Expire At (UTC+0)',
'table.validityEndTime.tips': 'Company subscription expiry date ',
'table.createTime': 'Create Time', 'table.createTime': 'Create Time',
'table.name': 'Name', 'table.name': 'Name',
'table.ipAddress': 'IP Address', 'table.ipAddress': 'IP Address',
@@ -87,6 +89,13 @@ export default {
'pages.totalAlertCount': 'Total Alerts', 'pages.totalAlertCount': 'Total Alerts',
'pages.totalCompanyCount': 'Total Companies', 'pages.totalCompanyCount': 'Total Companies',
'pages.activeCompanyCount': 'Active 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 {days} days',
'pages.company.expireToday': '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',
@@ -329,6 +338,9 @@ export default {
'form.role.placeholder2': 'System roles cannot modify permission levels', 'form.role.placeholder2': 'System roles cannot modify permission levels',
'form.role.badgeColor': 'Badge Color', 'form.role.badgeColor': 'Badge Color',
'form.role.menuIds': 'Bound Permissions (Menus)', '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.role.permissionGuide': 'Permission Guide', 'form.role.permissionGuide': 'Permission Guide',
'form.role.otherPermissions': 'Other Menu Permissions', 'form.role.otherPermissions': 'Other Menu Permissions',

View File

@@ -46,6 +46,8 @@ export default {
'table.contactEmail': '联系邮箱', 'table.contactEmail': '联系邮箱',
'table.dataSourceCount': '数据源', 'table.dataSourceCount': '数据源',
'table.userCount': '用户数', 'table.userCount': '用户数',
'table.validityEndTime': '订阅到期时间(UTC+0)',
'table.validityEndTime.tips': '公司订阅服务到期时间',
'table.createTime': '创建时间', 'table.createTime': '创建时间',
'table.name': '名称', 'table.name': '名称',
'table.ipAddress': 'IP地址', 'table.ipAddress': 'IP地址',
@@ -87,6 +89,13 @@ export default {
'pages.totalAlertCount': '总告警数', 'pages.totalAlertCount': '总告警数',
'pages.totalCompanyCount': '总公司数', 'pages.totalCompanyCount': '总公司数',
'pages.activeCompanyCount': '活跃公司数', 'pages.activeCompanyCount': '活跃公司数',
'pages.company.expired': '已过期',
'pages.company.permanent': '永久有效',
'pages.company.expireTitle': '公司订阅已过期',
'pages.company.expireSubTitle':
'您的公司在该系统的使用授权已到期,各项功能已暂时锁定。请联系商务代表进行续费操作以恢复访问。',
'pages.company.expireInDays': '订阅即将到期,剩余 {days}天',
'pages.company.expireToday': '今天到期',
'pages.totalDataSourceCount': '数据源总数', 'pages.totalDataSourceCount': '数据源总数',
'pages.activeDataSourceCount': '活跃数据源', 'pages.activeDataSourceCount': '活跃数据源',
'pages.mt4DataSourceCount': 'MT4 数据源', 'pages.mt4DataSourceCount': 'MT4 数据源',
@@ -321,6 +330,9 @@ export default {
'form.role.placeholder2': '系统角色权限级别不可修改', 'form.role.placeholder2': '系统角色权限级别不可修改',
'form.role.badgeColor': '徽章颜色', 'form.role.badgeColor': '徽章颜色',
'form.role.menuIds': '绑定权限(菜单)', 'form.role.menuIds': '绑定权限(菜单)',
'form.company.validityEndTime': '到期时间(UTC+0)',
'form.company.validityEndTime.placeholder': '请选择到期时间',
'form.company.validityEndTime.tips': '留空为永久有效',
'form.role.permissionGuide': '权限怎么选', 'form.role.permissionGuide': '权限怎么选',
'form.role.otherPermissions': '其他菜单权限', 'form.role.otherPermissions': '其他菜单权限',

View File

@@ -1,5 +1,6 @@
import { import {
ModalForm, ModalForm,
ProFormDatePicker,
ProFormDependency, ProFormDependency,
type ProFormInstance, type ProFormInstance,
ProFormSelect, ProFormSelect,
@@ -8,6 +9,7 @@ import {
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { useAccess } from '@umijs/max'; import { useAccess } from '@umijs/max';
import { Divider, Space, Typography } from 'antd'; import { Divider, Space, Typography } from 'antd';
import type { Dayjs } from 'dayjs';
import { useRef } from 'react'; import { useRef } from 'react';
import useUserInfo from '@/hooks/useUserInfo'; import useUserInfo from '@/hooks/useUserInfo';
import { import {
@@ -16,6 +18,7 @@ import {
getEmailConfigList, getEmailConfigList,
} from '@/services/api'; } from '@/services/api';
import { $t } from '@/utils/i18n'; import { $t } from '@/utils/i18n';
import { utcInstance as dayjs } from '@/utils/timeFormat';
const { Text } = Typography; const { Text } = Typography;
type CompanyListItem = Partial<API.CompanyListItem>; type CompanyListItem = Partial<API.CompanyListItem>;
@@ -25,7 +28,7 @@ export default (props: {
onSubmit: (values: CompanyListItem) => void; onSubmit: (values: CompanyListItem) => void;
onDone: () => void; onDone: () => void;
}) => { }) => {
const { isGlobalCompany } = useAccess(); const { isGlobalCompany, isSuperAdmin } = useAccess();
const { userInfo } = useUserInfo(); const { userInfo } = useUserInfo();
const { modalVisit, formValues, onSubmit, onDone } = props; const { modalVisit, formValues, onSubmit, onDone } = props;
@@ -43,6 +46,13 @@ export default (props: {
if (!isGlobalCompany) { if (!isGlobalCompany) {
values.companyId = userInfo?.companyId; values.companyId = userInfo?.companyId;
} }
if (isSuperAdmin) {
values.validityEndTime = values.validityEndTime
? dayjs(values.validityEndTime).endOf('day').format('YYYY-MM-DD')
: '';
values.validityStartTime = values.validityEndTime ? '2026-01-01' : '';
}
return onSubmit(values); return onSubmit(values);
}} }}
modalProps={{ modalProps={{
@@ -70,6 +80,7 @@ export default (props: {
return { return {
...values, ...values,
...companyMailHostConfig, ...companyMailHostConfig,
validityEndTime: values.validityEndTime || undefined,
}; };
} }
return values || {}; return values || {};
@@ -106,6 +117,23 @@ export default (props: {
}, },
]} ]}
/> />
{isSuperAdmin && (
<ProFormDatePicker
hidden={formValues?.id === void 0}
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 ? value : undefined
}
fieldProps={{
style: { width: '100%' },
disabledDate: (current: Dayjs) =>
current?.isBefore(dayjs().startOf('day')),
}}
// extra={<Text type="danger">{$t('pages.company.expired')}</Text>}
/>
)}
{formValues?.id !== void 0 && ( {formValues?.id !== void 0 && (
<> <>

View File

@@ -5,7 +5,8 @@ import {
type ProColumns, type ProColumns,
ProTable, ProTable,
} from '@ant-design/pro-components'; } 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 React, { useRef, useState } from 'react';
import HeadStatisticCard, { import HeadStatisticCard, {
type CardInfo, type CardInfo,
@@ -13,9 +14,13 @@ import HeadStatisticCard, {
import MyTag from '@/components/MyTag'; import MyTag from '@/components/MyTag';
import { companyHandle, getCompanyPages } from '@/services/api'; import { companyHandle, getCompanyPages } from '@/services/api';
import { $t } from '@/utils/i18n'; import { $t } from '@/utils/i18n';
import { utcInstance as dayjs } from '@/utils/timeFormat';
import PopUp from './Popup'; import PopUp from './Popup';
const { Text } = Typography;
const Company: React.FC = () => { const Company: React.FC = () => {
const { isSuperAdmin } = useAccess();
const [modalVisit, setModalVisit] = useState(false); const [modalVisit, setModalVisit] = useState(false);
const [cardInfo, setCardInfo] = useState({ const [cardInfo, setCardInfo] = useState({
@@ -92,6 +97,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'), title: $t('table.createTime'),
dataIndex: 'createTime', dataIndex: 'createTime',
@@ -122,7 +149,10 @@ const Company: React.FC = () => {
size="small" size="small"
key="edit" key="edit"
onClick={() => { onClick={() => {
setFormValues(item); setFormValues({
...item,
validityEndTime: item.validityEndTime || undefined,
});
setModalVisit(true); setModalVisit(true);
}} }}
> >

View File

@@ -3,7 +3,7 @@ import {
ProForm, ProForm,
ProFormText, ProFormText,
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { history } from '@umijs/max'; import { history, useAccess } from '@umijs/max';
import { import {
App, App,
Button, Button,
@@ -15,7 +15,7 @@ import {
Space, Space,
Tag, Tag,
} from 'antd'; } from 'antd';
import React from 'react'; import React, { useEffect } from 'react';
import { clearAccessToken } from '@/access'; import { clearAccessToken } from '@/access';
import PasswordLevel from '@/components/PasswordLevel'; import PasswordLevel from '@/components/PasswordLevel';
import useUserInfo from '@/hooks/useUserInfo'; import useUserInfo from '@/hooks/useUserInfo';
@@ -24,6 +24,7 @@ import { $t } from '@/utils/i18n';
const Profile: React.FC = () => { const Profile: React.FC = () => {
const { userInfo, setUserInfo } = useUserInfo(); const { userInfo, setUserInfo } = useUserInfo();
const { isGlobalCompany } = useAccess();
const userCompany = userInfo?.company; const userCompany = userInfo?.company;
const { message: antdMessage, modal } = App.useApp(); const { message: antdMessage, modal } = App.useApp();
@@ -71,6 +72,19 @@ const Profile: React.FC = () => {
}, },
]; ];
useEffect(() => {
if (!isGlobalCompany) {
items.push({
key: '6',
span: 'filled',
label: $t('pages.company.permanent'),
children: userCompany?.validityEndTime
? userCompany.validityEndTime
: $t('pages.company.permanent'),
});
}
}, [userInfo]);
const changePasswordSuccessHadle = async () => { const changePasswordSuccessHadle = async () => {
let countdown = 5; let countdown = 5;
let timer: NodeJS.Timeout | null = null; let timer: NodeJS.Timeout | null = null;

View File

@@ -83,6 +83,8 @@ export async function companyHandle(
name?: string; name?: string;
email?: string; email?: string;
status?: number; status?: number;
validityStartTime?: string | null;
validityEndTime?: string | null;
}, },
options?: ObjType, options?: ObjType,
) { ) {

View File

@@ -113,6 +113,9 @@ declare namespace API {
fromName: string | null; fromName: string | null;
mailHostConfigId: number | string | null; mailHostConfigId: number | string | null;
mailStatus: number | null; mailStatus: number | null;
validityStartTime?: string | null;
validityEndTime?: string | null;
validityStatus?: number | null;
}; };
// "DataSource" Type // "DataSource" Type