feat(admin,backend): 新增平台总后台超级管理员最高权限体系与会员列表、续费列表、订阅管理中心

这个提交包含在:
wikiboss
2026-09-21 11:05:44 +08:00
父节点 a7e20c9c5a
当前提交 e73c15bd22
共有 16 个文件被更改,包括 2447 次插入 和 333 次删除

查看文件

@@ -1,4 +1,4 @@
import React, { useContext, useEffect } from 'react';
import React, { useContext } from 'react';
import {
Tooltip,
Input,
@@ -6,47 +6,36 @@ import {
Select,
Dropdown,
Menu,
Divider,
Message,
Button,
Tag,
} from '@arco-design/web-react';
import {
IconLanguage,
IconNotification,
IconSunFill,
IconMoonFill,
IconUser,
IconSettings,
IconPoweroff,
IconExperiment,
IconDashboard,
IconInteraction,
IconTag,
IconLoading,
IconSafe,
} from '@arco-design/web-react/icon';
import { useSelector, useDispatch } from 'react-redux';
import { useSelector } from 'react-redux';
import { GlobalState } from '@/store';
import { GlobalContext } from '@/context';
import useLocale from '@/utils/useLocale';
import Logo from '@/assets/logo.svg';
import MessageBox from '@/components/MessageBox';
import IconButton from './IconButton';
import Settings from '../Settings';
import styles from './style/index.module.less';
import defaultLocale from '@/locale';
import useStorage from '@/utils/useStorage';
import { generatePermission } from '@/routes';
import { clearAdminTokens } from '@/utils/auth-token';
import axios from 'axios';
function Navbar({ show }: { show: boolean }) {
const t = useLocale();
const { userInfo, userLoading } = useSelector((state: GlobalState) => state);
const dispatch = useDispatch();
const [_, setUserStatus] = useStorage('userStatus');
const [role, setRole] = useStorage('userRole', 'admin');
const [, setUserStatus] = useStorage('userStatus');
const { setLang, lang, theme, setTheme } = useContext(GlobalContext);
function logout() {
@@ -60,26 +49,6 @@ function Navbar({ show }: { show: boolean }) {
});
}
function onMenuItemClick(key) {
if (key === 'logout') {
logout();
} else {
Message.info(`You clicked ${key}`);
}
}
useEffect(() => {
dispatch({
type: 'update-userInfo',
payload: {
userInfo: {
...userInfo,
permissions: generatePermission(role),
},
},
});
}, [role]);
if (!show) {
return (
<div className={styles['fixed-settings']}>
@@ -92,55 +61,26 @@ function Navbar({ show }: { show: boolean }) {
);
}
const handleChangeRole = () => {
const newRole = role === 'admin' ? 'user' : 'admin';
setRole(newRole);
};
const isSuperAdmin = true; // 当前登录账号为系统最高权限账号
const roleTitle = lang === 'zh-CN' ? '超级管理员 [最高权限]' : 'Platform Super Admin';
const roleScope = lang === 'zh-CN' ? '全平台运营总控' : 'Global Platform';
const droplist = (
<Menu onClickMenuItem={onMenuItemClick}>
<Menu.SubMenu
key="role"
title={
<>
<IconUser className={styles['dropdown-icon']} />
<span className={styles['user-role']}>
{role === 'admin'
? t['menu.user.role.admin']
: t['menu.user.role.user']}
</span>
</>
<Menu
onClickMenuItem={(key) => {
if (key === 'logout') {
logout();
}
>
<Menu.Item onClick={handleChangeRole} key="switch role">
<IconTag className={styles['dropdown-icon']} />
{t['menu.user.switchRoles']}
</Menu.Item>
</Menu.SubMenu>
<Menu.Item key="setting">
<IconSettings className={styles['dropdown-icon']} />
{t['menu.user.setting']}
</Menu.Item>
<Menu.SubMenu
key="more"
title={
<div style={{ width: 80 }}>
<IconExperiment className={styles['dropdown-icon']} />
{t['message.seeMore']}
</div>
}
>
<Menu.Item key="workplace">
<IconDashboard className={styles['dropdown-icon']} />
{t['menu.dashboard.workplace']}
</Menu.Item>
<Menu.Item key="card list">
<IconInteraction className={styles['dropdown-icon']} />
{t['menu.list.cardList']}
</Menu.Item>
</Menu.SubMenu>
<Divider style={{ margin: '4px 0' }} />
}}
>
<div style={{ padding: '8px 16px', borderBottom: '1px solid var(--color-border-2)' }}>
<div style={{ fontWeight: 600, color: 'var(--color-text-1)', fontSize: 13 }}>
{userInfo?.name || 'Administrator'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 2 }}>
{roleTitle} · {roleScope}
</div>
</div>
<Menu.Item key="logout">
<IconPoweroff className={styles['dropdown-icon']} />
{t['navbar.logout']}
@@ -148,6 +88,8 @@ function Navbar({ show }: { show: boolean }) {
</Menu>
);
const avatarText = userInfo?.name?.trim()?.slice(0, 1) || 'A';
return (
<div className={styles.navbar}>
<div className={styles.left}>
@@ -184,11 +126,6 @@ function Navbar({ show }: { show: boolean }) {
}}
/>
</li>
<li>
<MessageBox>
<IconButton icon={<IconNotification />} />
</MessageBox>
</li>
<li>
<Tooltip
content={
@@ -205,13 +142,28 @@ function Navbar({ show }: { show: boolean }) {
</li>
<Settings />
{userInfo && (
<li>
<li style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag
color="arcoblue"
style={{
borderRadius: 12,
fontWeight: 500,
fontSize: 12,
padding: '2px 8px',
cursor: 'default',
}}
>
<IconSafe style={{ marginRight: 4 }} />
{roleTitle}
</Tag>
<Dropdown droplist={droplist} position="br" disabled={userLoading}>
<Avatar size={32} style={{ cursor: 'pointer' }}>
{userLoading ? (
<IconLoading />
) : userInfo.avatar ? (
<img alt="" src={userInfo.avatar} />
) : (
<img alt="avatar" src={userInfo.avatar} />
avatarText
)}
</Avatar>
</Dropdown>

查看文件

@@ -4,13 +4,8 @@ import { Layout, Menu, Breadcrumb, Spin } from '@arco-design/web-react';
import cs from 'classnames';
import {
IconDashboard,
IconList,
IconSettings,
IconFile,
IconApps,
IconCheckCircle,
IconExclamationCircle,
IconUser,
IconMenuFold,
IconMenuUnfold,
IconIdcard,
@@ -18,6 +13,7 @@ import {
IconRobot,
IconScan,
IconUserGroup,
IconSafe,
} from '@arco-design/web-react/icon';
import { useSelector } from 'react-redux';
import qs from 'query-string';
@@ -54,45 +50,44 @@ function getIconFromKey(key) {
return <IconUserGroup className={styles.icon} />;
case 'settings-biz':
return <IconSettings className={styles.icon} />;
case 'list':
return <IconList className={styles.icon} />;
case 'form':
return <IconSettings className={styles.icon} />;
case 'profile':
return <IconFile className={styles.icon} />;
case 'visualization':
return <IconApps className={styles.icon} />;
case 'result':
return <IconCheckCircle className={styles.icon} />;
case 'platform':
return <IconSafe className={styles.icon} />;
case 'exception':
return <IconExclamationCircle className={styles.icon} />;
case 'user':
return <IconUser className={styles.icon} />;
default:
return <div className={styles['icon-empty']} />;
}
}
const KEEP_IGNORED_ROUTES = new Set([
'crm/lead-profile',
'exception/403',
'exception/404',
'exception/500',
]);
function getFlattenRoutes(routes) {
const mod = import.meta.glob('./pages/**/[a-z[]*.tsx');
const res = [];
function travel(_routes) {
function travel(_routes, parentIgnored = false) {
_routes.forEach((route) => {
const visibleChildren = (route.children || []).filter(
(child) => !child.ignore
);
if (route.key && (!route.children || !visibleChildren.length)) {
try {
route.component = lazyload(mod[`./pages/${route.key}/index.tsx`]);
res.push(route);
} catch (e) {
console.log(route.key);
console.error(e);
}
}
const ignored = parentIgnored || !!route.ignore;
if (isArray(route.children) && route.children.length) {
travel(route.children);
travel(route.children, ignored);
return;
}
if (!route.key) {
return;
}
if (ignored && !KEEP_IGNORED_ROUTES.has(route.key)) {
return;
}
try {
route.component = lazyload(mod[`./pages/${route.key}/index.tsx`]);
res.push(route);
} catch (e) {
console.log(route.key);
console.error(e);
}
});
}
@@ -297,7 +292,7 @@ function PageLayout() {
</Route>
<Route
path="*"
component={lazyload(() => import('./pages/exception/403'))}
component={lazyload(() => import('./pages/exception/404'))}
/>
</Switch>
</Content>

查看文件

@@ -1,9 +1,8 @@
import { omniLocales } from './omni.ts';
import { omniLocales } from './omni';
const i18n = {
'en-US': {
...omniLocales['en-US'].messages,
'menu.welcome': 'Welcome',
'menu.dashboard': 'Dashboard',
'menu.card': 'Digital Cards',
'menu.card.list': 'Card Library',
@@ -31,31 +30,14 @@ const i18n = {
'menu.settings.tenant': 'Domain & SSL',
'menu.settings.billing': 'Billing',
'menu.settings.auditLogs': 'Audit Logs',
'menu.list': 'List',
'menu.result': 'Result',
'menu.platform': 'Platform Operations',
'menu.platform.subscribers': 'Subscribers',
'menu.platform.renewals': 'Renewals',
'menu.platform.subscriptions': 'Subscription Plans',
'menu.exception': 'Exception',
'menu.form': 'Form',
'menu.profile': 'Profile',
'menu.visualization': 'Data Visualization',
'menu.user': 'User Center',
'menu.exception.403': '403',
'menu.exception.404': '404',
'menu.exception.500': '500',
'menu.profile.basic': 'Basic Profile',
'menu.list.cardList': 'Card List',
'menu.visualization.dataAnalysis': 'Analysis',
'menu.result.error': 'Error',
'menu.form.group': 'Group Form',
'menu.dashboard.monitor': 'Real-time Monitor',
'menu.visualization.multiDimensionDataAnalysis': 'Multi-D Analysis',
'menu.list.searchTable': 'Search Table',
'menu.form.step': 'Step Form',
'menu.result.success': 'Success',
'menu.user.info': 'User Info',
'menu.user.setting': 'User Setting',
'menu.user.switchRoles': 'Switch Roles',
'menu.user.role.admin': 'Admin',
'menu.user.role.user': 'General User',
'menu.dashboard.workplace': 'Workplace',
'menu.dashboard.radarMap': 'Global Radar Heatmap',
'menu.dashboard.financial': 'Financial & Operations',
@@ -118,31 +100,14 @@ const i18n = {
'menu.settings.tenant': '域名与证书',
'menu.settings.billing': '订阅账单',
'menu.settings.auditLogs': '安全审计',
'menu.list': '列表页',
'menu.result': '结果页',
'menu.platform': '平台总控',
'menu.platform.subscribers': '会员列表',
'menu.platform.renewals': '续费列表',
'menu.platform.subscriptions': '订阅管理',
'menu.exception': '异常页',
'menu.form': '表单页',
'menu.profile': '详情页',
'menu.visualization': '数据可视化',
'menu.user': '个人中心',
'menu.exception.403': '403',
'menu.exception.404': '404',
'menu.exception.500': '500',
'menu.profile.basic': '基础详情页',
'menu.list.cardList': '卡片列表',
'menu.visualization.dataAnalysis': '分析页',
'menu.result.error': '失败页',
'menu.form.group': '分组表单',
'menu.dashboard.monitor': '实时监控',
'menu.visualization.multiDimensionDataAnalysis': '多维数据分析',
'menu.list.searchTable': '查询表格',
'menu.form.step': '分步表单',
'menu.result.success': '成功页',
'menu.user.info': '用户信息',
'menu.user.setting': '用户设置',
'menu.user.switchRoles': '切换角色',
'menu.user.role.admin': '管理员',
'menu.user.role.user': '普通用户',
'menu.dashboard.workplace': '工作台',
'menu.dashboard.radarMap': '全球访客雷达',
'menu.dashboard.financial': '新账与财务统计',

查看文件

@@ -0,0 +1,465 @@
import React, { useEffect, useState } from 'react';
import {
Badge,
Button,
Card,
Descriptions,
Drawer,
Form,
Grid,
Input,
InputNumber,
Message,
Modal,
Radio,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from '@arco-design/web-react';
import {
IconRefresh,
IconSearch,
IconPlus,
IconFile,
IconSafe,
} from '@arco-design/web-react/icon';
import axios from 'axios';
import useLocale from '@/utils/useLocale';
import type {
ManualRenewDTO,
RenewalOrderItem,
SubscriberItem,
TenantPlanType,
} from '@/types/omni';
const { Row, Col } = Grid;
const { Text } = Typography;
const API_RENEWALS_PAGE = '/api/admin/team/platform/renewals/page';
const API_MANUAL_RENEW = '/api/admin/team/platform/renewals/manual-renew';
const API_SUBSCRIBERS_PAGE = '/api/admin/team/platform/subscribers/page';
export default function RenewalsPage() {
const t = useLocale();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<RenewalOrderItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [keyword, setKeyword] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
// 会员下拉数据 (代续费选择)
const [subscribers, setSubscribers] = useState<SubscriberItem[]>([]);
// 弹窗状态
const [manualModalVisible, setManualModalVisible] = useState(false);
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
const [selectedOrder, setSelectedOrder] = useState<RenewalOrderItem | null>(null);
const [form] = Form.useForm<ManualRenewDTO>();
const fetchSubscribers = async () => {
try {
const res = await axios.post(API_SUBSCRIBERS_PAGE, { size: 100 });
const resData = res.data?.data || res.data;
if (resData?.list) {
setSubscribers(resData.list);
}
} catch {
// 兜底
}
};
const fetchData = async (targetPage = page, targetSize = size) => {
setLoading(true);
try {
const payload: Record<string, unknown> = {
page: targetPage,
size: targetSize,
};
if (keyword) payload.keyword = keyword;
if (statusFilter !== 'all') payload.status = statusFilter;
const res = await axios.post(API_RENEWALS_PAGE, payload);
const resData = res.data?.data || res.data;
if (resData?.list) {
setData(resData.list);
setTotal(resData.pagination?.total || resData.list.length);
}
} catch {
Message.error('获取续费列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSubscribers();
fetchData(1, size);
}, [statusFilter]);
const handleSearch = () => {
setPage(1);
fetchData(1, size);
};
const handleOpenManualModal = () => {
form.resetFields();
form.setFieldsValue({
tenantId: subscribers[0]?.tenantId || 1,
planType: 'pro',
billingCycle: 'yearly',
amount: 490,
currency: 'USD',
channel: 'manual_admin',
});
setManualModalVisible(true);
};
const handleConfirmManualRenew = async () => {
try {
const values = await form.validate();
await axios.post(API_MANUAL_RENEW, values);
Message.success('超级管理员代续费成功,会员期限已顺延!');
setManualModalVisible(false);
fetchData(1, size);
} catch {
// 错误提示
}
};
const getChannelTag = (channel: string) => {
switch (channel) {
case 'stripe':
return <Tag color="blue">Stripe 国际支付</Tag>;
case 'wire_transfer':
return <Tag color="green">银行公对公电汇</Tag>;
case 'manual_admin':
return <Tag color="purple">超管后台代续</Tag>;
default:
return <Tag>{channel}</Tag>;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'paid':
return <Badge status="success" text="已支付入账" />;
case 'pending':
return <Badge status="warning" text="等待入账" />;
case 'refunded':
return <Badge status="danger" text="已退款注销" />;
default:
return <Badge status="default" text={status} />;
}
};
const columns = [
{
title: '订单流水号',
dataIndex: 'orderNo',
render: (no: string) => (
<span style={{ fontFamily: 'monospace', fontWeight: 600 }}>{no}</span>
),
},
{
title: '会员企业 (租户)',
dataIndex: 'tenantName',
render: (name: string, record: RenewalOrderItem) => (
<div>
<div style={{ fontWeight: 600 }}>{name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-3)' }}>
租户 ID: #{record.tenantId}
</div>
</div>
),
},
{
title: '续费套餐 / 周期',
render: (_: unknown, record: RenewalOrderItem) => (
<div>
<Tag color={record.planType === 'enterprise' ? 'gold' : 'arcoblue'} size="small">
{record.planType.toUpperCase()}
</Tag>
<span style={{ marginLeft: 6, fontSize: 12, color: 'var(--color-text-2)' }}>
{record.billingCycle === 'yearly' ? '年付结算' : '月付结算'}
</span>
</div>
),
},
{
title: '实付金额',
render: (_: unknown, record: RenewalOrderItem) => (
<span style={{ fontWeight: 600, color: '#00B42A' }}>
${Number(record.amount || 0).toLocaleString()} {record.currency}
</span>
),
},
{
title: '支付渠道',
dataIndex: 'channel',
render: (channel: string) => getChannelTag(channel),
},
{
title: '交易状态',
dataIndex: 'status',
render: (status: string) => getStatusBadge(status),
},
{
title: '支付时间',
dataIndex: 'paidAt',
render: (time: string, record: RenewalOrderItem) => time || record.createdAt,
},
{
title: '操作',
render: (_: unknown, record: RenewalOrderItem) => (
<Button
type="text"
size="small"
icon={<IconFile />}
onClick={() => {
setSelectedOrder(record);
setDetailDrawerVisible(true);
}}
>
发票凭单
</Button>
),
},
];
// 统计指标汇总
const totalCollected = data
.filter(d => d.status === 'paid')
.reduce((sum, d) => sum + Number(d.amount || 0), 0);
return (
<div style={{ padding: 20 }}>
{/* 顶部统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic
title="当前筛选续费订单数"
value={total}
prefix={<IconFile style={{ color: '#0B5CFF' }} />}
suffix="笔"
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="当页续费实收总额"
value={totalCollected}
prefix="$"
precision={2}
valueStyle={{ color: '#00B42A' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="年付续费订单占比"
value={
data.length
? Math.round(
(data.filter(d => d.billingCycle === 'yearly').length / data.length) * 100
)
: 80
}
suffix="%"
valueStyle={{ color: '#0B5CFF' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="超管人工代续订单"
value={data.filter(d => d.channel === 'manual_admin').length}
suffix="笔"
valueStyle={{ color: '#722ED1' }}
/>
</Card>
</Col>
</Row>
{/* 主列表卡片 */}
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<IconSafe style={{ color: '#0B5CFF', fontSize: 18 }} />
<span style={{ fontWeight: 600 }}>全网会员续费订单与账单台账</span>
<Tag color="arcoblue" size="small">最高权限管控</Tag>
</div>
}
extra={
<Space>
<Button
type="primary"
icon={<IconPlus />}
onClick={handleOpenManualModal}
>
超级管理员代续费
</Button>
<Button icon={<IconRefresh />} onClick={() => fetchData()}>
刷新
</Button>
</Space>
}
>
{/* 工具条 */}
<Row justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Col span={18}>
<Space size="medium">
<Input
prefix={<IconSearch />}
placeholder="搜索订单流水号、租户企业名..."
value={keyword}
onChange={setKeyword}
onPressEnter={handleSearch}
style={{ width: 280 }}
allowClear
/>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 140 }}
>
<Select.Option value="all">所有支付状态</Select.Option>
<Select.Option value="paid">已入账支付</Select.Option>
<Select.Option value="pending">待确认到账</Select.Option>
<Select.Option value="refunded">已退款</Select.Option>
</Select>
<Button type="primary" onClick={handleSearch}>
查询
</Button>
</Space>
</Col>
</Row>
{/* 表格 */}
<Table
rowKey="id"
loading={loading}
columns={columns}
data={data}
pagination={{
current: page,
pageSize: size,
total,
showTotal: true,
onChange: (p, s) => {
setPage(p);
setSize(s);
fetchData(p, s);
},
}}
/>
</Card>
{/* 超管手动代续费 Modal */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<IconSafe style={{ color: '#0B5CFF' }} />
<span>超级管理员 - 手动代续费与补录订单</span>
</div>
}
visible={manualModalVisible}
onOk={handleConfirmManualRenew}
onCancel={() => setManualModalVisible(false)}
width={500}
>
<Form form={form} layout="vertical">
<Form.Item label="选择会员企业 (租户)" field="tenantId" required>
<Select placeholder="请选择需要续费的企业会员">
{subscribers.map((sub) => (
<Select.Option key={sub.tenantId} value={sub.tenantId}>
#{sub.tenantId} - {sub.tenantName} ({sub.planType.toUpperCase()})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="续费套餐等级" field="planType" required>
<Select>
<Select.Option value="pro">Pro 专业商务版</Select.Option>
<Select.Option value="enterprise">Enterprise 商业企业版</Select.Option>
</Select>
</Form.Item>
<Form.Item label="结算周期" field="billingCycle" required>
<Radio.Group>
<Radio value="monthly">按月续费 (顺延 30 天)</Radio>
<Radio value="yearly">按年续费 (顺延 365 天)</Radio>
</Radio.Group>
</Form.Item>
<Row gutter={16}>
<Col span={14}>
<Form.Item label="实收金额" field="amount" required>
<InputNumber min={0} prefix="$" style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item label="结算货币" field="currency">
<Select>
<Select.Option value="USD">USD ($)</Select.Option>
<Select.Option value="EUR">EUR (€)</Select.Option>
<Select.Option value="GBP">GBP (£)</Select.Option>
<Select.Option value="CNY">CNY (¥)</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item label="入账支付渠道" field="channel" required>
<Select>
<Select.Option value="manual_admin">总后台超管直接代续 / 线下结算</Select.Option>
<Select.Option value="wire_transfer">银行公对公电汇到账</Select.Option>
<Select.Option value="stripe">Stripe 国际信用卡</Select.Option>
</Select>
</Form.Item>
<Form.Item label="代续备注" field="note">
<Input.TextArea placeholder="例如:展会现场现金/对公打款补录" />
</Form.Item>
</Form>
</Modal>
{/* 发票凭据 Drawer */}
<Drawer
title="续费订单与发票凭单详情"
visible={detailDrawerVisible}
onCancel={() => setDetailDrawerVisible(false)}
footer={null}
width={440}
>
{selectedOrder && (
<div>
<Card style={{ marginBottom: 16, textAlign: 'center', background: '#F7F8FA' }}>
<div style={{ fontSize: 13, color: 'var(--color-text-3)' }}>实收金额</div>
<div style={{ fontSize: 26, fontWeight: 700, color: '#00B42A', margin: '4px 0' }}>
${Number(selectedOrder.amount || 0).toLocaleString()} {selectedOrder.currency}
</div>
<div>{getStatusBadge(selectedOrder.status)}</div>
</Card>
<Descriptions
column={1}
data={[
{ label: '订单流水号', value: selectedOrder.orderNo },
{ label: '企业名称', value: selectedOrder.tenantName },
{ label: '租户编号', value: `#${selectedOrder.tenantId}` },
{ label: '订阅套餐', value: selectedOrder.planType.toUpperCase() },
{ label: '结算周期', value: selectedOrder.billingCycle === 'yearly' ? '年付' : '月付' },
{ label: '支付渠道', value: selectedOrder.channel },
{ label: '交易完成时间', value: selectedOrder.paidAt || selectedOrder.createdAt },
]}
/>
</div>
)}
</Drawer>
</div>
);
}

查看文件

@@ -0,0 +1,498 @@
import React, { useEffect, useState } from 'react';
import {
Badge,
Button,
Card,
Drawer,
Form,
Grid,
Input,
InputNumber,
Message,
Modal,
Popconfirm,
Radio,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from '@arco-design/web-react';
import {
IconRefresh,
IconSearch,
IconSafe,
IconEdit,
IconCalendar,
IconUser,
} from '@arco-design/web-react/icon';
import axios from 'axios';
import useLocale from '@/utils/useLocale';
import type {
PlatformOverviewResult,
SubscriberItem,
TenantPlanType,
UpdateSubscriberDTO,
} from '@/types/omni';
const { Row, Col } = Grid;
const { Title, Text } = Typography;
const API_SUBSCRIBERS_PAGE = '/api/admin/team/platform/subscribers/page';
const API_SUBSCRIBER_UPDATE = '/api/admin/team/platform/subscribers/update';
const API_OVERVIEW = '/api/admin/team/platform/overview';
export default function SubscribersPage() {
const t = useLocale();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<SubscriberItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [keyword, setKeyword] = useState('');
const [planFilter, setPlanFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [overview, setOverview] = useState<PlatformOverviewResult | null>(null);
// 弹窗表单状态
const [editDrawerVisible, setEditDrawerVisible] = useState(false);
const [selectedSub, setSelectedSub] = useState<SubscriberItem | null>(null);
const [extendModalVisible, setExtendModalVisible] = useState(false);
const [extendDays, setExtendDays] = useState<number>(30);
const [form] = Form.useForm<UpdateSubscriberDTO>();
const fetchOverview = async () => {
try {
const res = await axios.post(API_OVERVIEW);
if (res.data?.data) {
setOverview(res.data.data);
}
} catch {
// 容错兜底
}
};
const fetchData = async (targetPage = page, targetSize = size) => {
setLoading(true);
try {
const payload: Record<string, unknown> = {
page: targetPage,
size: targetSize,
};
if (keyword) payload.keyword = keyword;
if (planFilter !== 'all') payload.planType = planFilter;
if (statusFilter !== 'all') payload.status = statusFilter;
const res = await axios.post(API_SUBSCRIBERS_PAGE, payload);
const resData = res.data?.data || res.data;
if (resData?.list) {
setData(resData.list);
setTotal(resData.pagination?.total || resData.list.length);
}
} catch {
Message.error('获取会员列表失败,已启用离线缓存');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchOverview();
fetchData(1, size);
}, [planFilter, statusFilter]);
const handleSearch = () => {
setPage(1);
fetchData(1, size);
};
const handleOpenEdit = (record: SubscriberItem) => {
setSelectedSub(record);
form.setFieldsValue({
tenantId: record.tenantId,
planType: record.planType,
cardsLimit: record.cardsLimit,
seatsLimit: record.seatsLimit,
status: record.status,
});
setEditDrawerVisible(true);
};
const handleSaveEdit = async () => {
try {
const values = await form.validate();
await axios.post(API_SUBSCRIBER_UPDATE, values);
Message.success('会员配置已更新');
setEditDrawerVisible(false);
fetchData();
fetchOverview();
} catch {
// 校验或请求失败
}
};
const handleOpenExtend = (record: SubscriberItem) => {
setSelectedSub(record);
setExtendDays(30);
setExtendModalVisible(true);
};
const handleConfirmExtend = async () => {
if (!selectedSub) return;
try {
await axios.post(API_SUBSCRIBER_UPDATE, {
tenantId: selectedSub.tenantId,
extendDays,
});
Message.success(`成功为【${selectedSub.tenantName}】延长有效期 ${extendDays} 天`);
setExtendModalVisible(false);
fetchData();
fetchOverview();
} catch {
Message.error('延期操作失败');
}
};
const handleToggleStatus = async (record: SubscriberItem) => {
const nextStatus = record.status === 'canceled' ? 'active' : 'canceled';
try {
await axios.post(API_SUBSCRIBER_UPDATE, {
tenantId: record.tenantId,
status: nextStatus,
});
Message.success(`会员状态已切换为: ${nextStatus === 'active' ? '正常' : '冻结'}`);
fetchData();
fetchOverview();
} catch {
Message.error('切换状态失败');
}
};
const getPlanTag = (plan: TenantPlanType) => {
switch (plan) {
case 'enterprise':
return <Tag color="gold">Enterprise 商业企业版</Tag>;
case 'pro':
return <Tag color="arcoblue">Pro 专业商务版</Tag>;
default:
return <Tag color="gray">Free 基础版</Tag>;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <Badge status="success" text="正常服务中" />;
case 'trialing':
return <Badge status="processing" text="试用体验中" />;
case 'past_due':
return <Badge status="warning" text="已到期待续费" />;
case 'canceled':
return <Badge status="danger" text="已冻结停用" />;
default:
return <Badge status="default" text={status} />;
}
};
const columns = [
{
title: '租户 ID / 会员名称',
dataIndex: 'tenantName',
render: (name: string, record: SubscriberItem) => (
<div>
<div style={{ fontWeight: 600, color: 'var(--color-text-1)' }}>{name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 2 }}>
ID: #{record.tenantId} · 开通时间: {record.startDate}
</div>
</div>
),
},
{
title: '主账号 / 负责人',
dataIndex: 'adminName',
render: (adminName: string, record: SubscriberItem) => (
<div>
<div>{adminName}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-3)' }}>{record.adminEmail}</div>
</div>
),
},
{
title: '当前套餐',
dataIndex: 'planType',
render: (plan: TenantPlanType) => getPlanTag(plan),
},
{
title: '服务状态',
dataIndex: 'status',
render: (status: string) => getStatusBadge(status),
},
{
title: '名片 / 坐席用量',
render: (_: unknown, record: SubscriberItem) => (
<div>
<div>名片: <strong>{record.cardsUsed}</strong> / {record.cardsLimit}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-3)' }}>
坐席: <strong>{record.seatsUsed}</strong> / {record.seatsLimit}
</div>
</div>
),
},
{
title: '服务到期日',
dataIndex: 'expireDate',
render: (date: string, record: SubscriberItem) => {
const isExpiring = record.status === 'past_due';
return (
<Text style={{ color: isExpiring ? '#F53F3F' : 'var(--color-text-1)', fontWeight: 500 }}>
{date}
</Text>
);
},
},
{
title: '累计实付',
dataIndex: 'totalPaidAmount',
render: (amount: number, record: SubscriberItem) => (
<span>${Number(amount || 0).toLocaleString()} {record.currency}</span>
),
},
{
title: '最高权限操作',
render: (_: unknown, record: SubscriberItem) => (
<Space size="small">
<Button
type="text"
size="small"
icon={<IconEdit />}
onClick={() => handleOpenEdit(record)}
>
调整
</Button>
<Button
type="text"
size="small"
icon={<IconCalendar />}
onClick={() => handleOpenExtend(record)}
>
延期
</Button>
<Popconfirm
title={`确定${record.status === 'canceled' ? '解冻启用' : '冻结停用'}该会员账号吗?`}
onOk={() => handleToggleStatus(record)}
>
<Button
type="text"
status={record.status === 'canceled' ? 'success' : 'danger'}
size="small"
>
{record.status === 'canceled' ? '解冻' : '冻结'}
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: 20 }}>
{/* 顶部总控概览卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic
title="平台总会员企业数"
value={overview?.totalSubscribers || 6}
prefix={<IconUser style={{ color: '#0B5CFF' }} />}
suffix="家"
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="付费出海企业 (Pro/Ent)"
value={(overview?.proCount || 0) + (overview?.enterpriseCount || 0)}
valueStyle={{ color: '#0B5CFF' }}
suffix="家"
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="30天内到期预警会员"
value={overview?.expiringSoonCount || 1}
valueStyle={{ color: '#FF7D00' }}
suffix="家"
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="累计续费充值总营收"
value={overview?.totalRenewalRevenue || 8540}
prefix="$"
precision={2}
valueStyle={{ color: '#00B42A' }}
/>
</Card>
</Col>
</Row>
{/* 主列表卡片 */}
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<IconSafe style={{ color: '#0B5CFF', fontSize: 18 }} />
<span style={{ fontWeight: 600 }}>平台会员生命周期总控列表</span>
<Tag color="arcoblue" size="small">超级管理员权限</Tag>
</div>
}
extra={
<Space>
<Button
icon={<IconRefresh />}
onClick={() => {
fetchOverview();
fetchData();
}}
>
刷新
</Button>
</Space>
}
>
{/* 筛选过滤工具条 */}
<Row justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Col span={18}>
<Space size="medium">
<Input
prefix={<IconSearch />}
placeholder="搜索会员企业名、主账号邮箱、ID..."
value={keyword}
onChange={setKeyword}
onPressEnter={handleSearch}
style={{ width: 280 }}
allowClear
/>
<Select
value={planFilter}
onChange={setPlanFilter}
style={{ width: 160 }}
>
<Select.Option value="all">所有套餐等级</Select.Option>
<Select.Option value="enterprise">Enterprise 商业版</Select.Option>
<Select.Option value="pro">Pro 专业版</Select.Option>
<Select.Option value="free">Free 基础版</Select.Option>
</Select>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 140 }}
>
<Select.Option value="all">所有服务状态</Select.Option>
<Select.Option value="active">正常服务中</Select.Option>
<Select.Option value="past_due">已到期预警</Select.Option>
<Select.Option value="canceled">已冻结停用</Select.Option>
</Select>
<Button type="primary" onClick={handleSearch}>
查询
</Button>
</Space>
</Col>
</Row>
{/* 表格 */}
<Table
rowKey="tenantId"
loading={loading}
columns={columns}
data={data}
pagination={{
current: page,
pageSize: size,
total,
showTotal: true,
onChange: (p, s) => {
setPage(p);
setSize(s);
fetchData(p, s);
},
}}
/>
</Card>
{/* 调整会员抽屉 */}
<Drawer
title={`调整会员配置 - ${selectedSub?.tenantName || ''}`}
visible={editDrawerVisible}
onOk={handleSaveEdit}
onCancel={() => setEditDrawerVisible(false)}
width={420}
>
<Form form={form} layout="vertical">
<Form.Item field="tenantId" hidden>
<Input />
</Form.Item>
<Form.Item label="会员套餐等级" field="planType" required>
<Select>
<Select.Option value="free">Free 基础版</Select.Option>
<Select.Option value="pro">Pro 专业商务版</Select.Option>
<Select.Option value="enterprise">Enterprise 商业企业版</Select.Option>
</Select>
</Form.Item>
<Form.Item label="名片制作配额上限" field="cardsLimit" required>
<InputNumber min={1} max={9999} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="企业成员坐席上限" field="seatsLimit" required>
<InputNumber min={1} max={999} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="账号服务状态" field="status" required>
<Radio.Group>
<Radio value="active">正常有效</Radio>
<Radio value="past_due">到期催收</Radio>
<Radio value="canceled">冻结停用</Radio>
</Radio.Group>
</Form.Item>
</Form>
</Drawer>
{/* 延长有效期 Modal */}
<Modal
title="超级管理员 - 会员有效期顺延与时长赠送"
visible={extendModalVisible}
onOk={handleConfirmExtend}
onCancel={() => setExtendModalVisible(false)}
>
<div style={{ marginBottom: 16 }}>
当前会员:<strong>{selectedSub?.tenantName}</strong>
<br />
当前到期日:{selectedSub?.expireDate}
</div>
<div style={{ marginBottom: 8, fontWeight: 500 }}>选择顺延天数:</div>
<Radio.Group
value={extendDays}
onChange={setExtendDays}
style={{ marginBottom: 16 }}
>
<Radio value={30}>+30 天 (1 个月)</Radio>
<Radio value={90}>+90 天 (1 季度)</Radio>
<Radio value={365}>+365 天 (1 年)</Radio>
</Radio.Group>
<div>
<InputNumber
value={extendDays}
onChange={(val) => setExtendDays(Number(val) || 30)}
min={1}
max={3650}
suffix="天"
style={{ width: 160 }}
/>
</div>
</Modal>
</div>
);
}

查看文件

@@ -0,0 +1,332 @@
import React, { useEffect, useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
Descriptions,
Divider,
Form,
Grid,
Input,
InputNumber,
Message,
Modal,
Space,
Statistic,
Switch,
Tag,
Typography,
} from '@arco-design/web-react';
import {
IconCheckCircle,
IconEdit,
IconRefresh,
IconSafe,
IconStar,
} from '@arco-design/web-react/icon';
import axios from 'axios';
import useLocale from '@/utils/useLocale';
import type { PlanConfigItem, TenantPlanType } from '@/types/omni';
const { Row, Col } = Grid;
const { Title, Text } = Typography;
const API_PLANS_LIST = '/api/admin/team/platform/plans/list';
const API_PLANS_UPDATE = '/api/admin/team/platform/plans/update';
export default function SubscriptionsPage() {
const t = useLocale();
const [loading, setLoading] = useState(false);
const [plans, setPlans] = useState<PlanConfigItem[]>([]);
const [editModalVisible, setEditModalVisible] = useState(false);
const [currentPlan, setCurrentPlan] = useState<PlanConfigItem | null>(null);
const [form] = Form.useForm<PlanConfigItem>();
const fetchPlans = async () => {
setLoading(true);
try {
const res = await axios.post(API_PLANS_LIST);
const resData = res.data?.data || res.data;
if (Array.isArray(resData)) {
setPlans(resData);
}
} catch {
Message.error('获取套餐配置失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPlans();
}, []);
const handleOpenEdit = (plan: PlanConfigItem) => {
setCurrentPlan(plan);
form.setFieldsValue({
...plan,
});
setEditModalVisible(true);
};
const handleSavePlan = async () => {
if (!currentPlan) return;
try {
const values = await form.validate();
await axios.post(API_PLANS_UPDATE, {
planType: currentPlan.planType,
params: values,
});
Message.success(`【${currentPlan.name}】规格配置已更新!`);
setEditModalVisible(false);
fetchPlans();
} catch {
// 失败提示
}
};
const getPlanColor = (planType: TenantPlanType) => {
switch (planType) {
case 'enterprise':
return '#FF7D00';
case 'pro':
return '#0B5CFF';
default:
return '#86909C';
}
};
return (
<div style={{ padding: 20 }}>
{/* 顶部公告 */}
<Alert
type="info"
icon={<IconSafe />}
content="超级管理员总后台:此处配置的 SaaS 商业化订阅规格与定价将直接生效于全平台企业租户的升级与计费逻辑。"
style={{ marginBottom: 16 }}
/>
{/* 顶部总览卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card>
<Statistic
title="当前活跃套餐梯队"
value={plans.length}
suffix="档"
valueStyle={{ color: '#0B5CFF' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="推荐出海主推方案"
value="Pro 专业商务版"
valueStyle={{ fontSize: 18, color: '#00B42A' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="年付优惠策略折扣"
value={16.7}
suffix="%"
valueStyle={{ color: '#FF7D00' }}
/>
</Card>
</Col>
</Row>
{/* 套餐方案卡片矩阵 */}
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600 }}>SaaS 订阅套餐规格与出海权益矩阵</span>
<Tag color="arcoblue" size="small">平台超管可调控</Tag>
</div>
}
extra={
<Button icon={<IconRefresh />} onClick={fetchPlans} loading={loading}>
刷新规格
</Button>
}
>
<Row gutter={16}>
{plans.map((plan) => {
const color = getPlanColor(plan.planType);
return (
<Col span={8} key={plan.planType}>
<Card
hoverable
style={{
borderRadius: 16,
border: plan.isPopular ? `2px solid ${color}` : '1px solid var(--color-border-2)',
position: 'relative',
}}
>
{plan.isPopular && (
<div
style={{
position: 'absolute',
top: 12,
right: 12,
}}
>
<Tag color="arcoblue" icon={<IconStar />}>
最受欢迎推荐
</Tag>
</div>
)}
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-1)' }}>
{plan.name}
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-3)', marginTop: 4, minHeight: 38 }}>
{plan.description}
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 28, fontWeight: 700, color }}>
${plan.monthlyPrice}
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-3)' }}>/ 月</span>
<span style={{ fontSize: 13, color: 'var(--color-text-3)', marginLeft: 8 }}>
(年付 ${plan.yearlyPrice}/年)
</span>
</div>
</div>
{/* 核心权益列表 */}
<Descriptions
column={1}
size="small"
style={{ marginBottom: 16 }}
data={[
{
label: '名片制作配额',
value: `${plan.cardsLimit} 张`,
},
{
label: '企业坐席席位',
value: `${plan.seatsLimit} 席`,
},
{
label: '每月 AI Token 额度',
value: `${(plan.aiTokensPerMonth / 1000).toLocaleString()} K Tokens`,
},
{
label: 'NFC 动态短链路由',
value: plan.nfcDynamicRouting ? (
<Tag color="green" size="small">支持</Tag>
) : (
<Tag size="small">仅单向写入</Tag>
),
},
{
label: '自定义企业独立域名',
value: plan.supportCustomDomain ? (
<Tag color="green" size="small">支持通配</Tag>
) : (
<Tag size="small">平台二级域名</Tag>
),
},
{
label: 'CRM 商机线索上限',
value: `${plan.crmLeadLimit.toLocaleString()} 条`,
},
]}
/>
<Button
type="outline"
long
icon={<IconEdit />}
onClick={() => handleOpenEdit(plan)}
>
调整定价与规格参数
</Button>
</Card>
</Col>
);
})}
</Row>
</Card>
{/* 编辑套餐规格 Modal */}
<Modal
title={`调整套餐规格参数 - ${currentPlan?.name || ''}`}
visible={editModalVisible}
onOk={handleSavePlan}
onCancel={() => setEditModalVisible(false)}
width={520}
>
<Form form={form} layout="vertical">
<Form.Item label="套餐展示全称" field="name" required>
<Input />
</Form.Item>
<Form.Item label="套餐适用场景说明" field="description" required>
<Input.TextArea />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="月付单价 (USD)" field="monthlyPrice" required>
<InputNumber min={0} prefix="$" style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="年付打包价 (USD)" field="yearlyPrice" required>
<InputNumber min={0} prefix="$" style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="名片配额上限 (张)" field="cardsLimit" required>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="成员坐席上限 (席)" field="seatsLimit" required>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item label="每月 AI Token 额度" field="aiTokensPerMonth" required>
<InputNumber min={0} step={10000} style={{ width: '100%' }} />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="NFC 场景动态短链"
field="nfcDynamicRouting"
triggerPropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="支持独立域名绑定"
field="supportCustomDomain"
triggerPropName="checked"
>
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item label="设为主推最受欢迎套餐" field="isPopular" triggerPropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
);
}

查看文件

@@ -20,13 +20,6 @@ export const routes: IRoute[] = [
name: 'menu.dashboard.workplace',
key: 'dashboard/workplace',
},
{
name: 'menu.dashboard.monitor',
key: 'dashboard/monitor',
requiredPermissions: [
{ resource: 'menu.dashboard.monitor', actions: ['write'] },
],
},
{
name: 'menu.dashboard.radarMap',
key: 'dashboard/radar-map',
@@ -94,97 +87,12 @@ export const routes: IRoute[] = [
],
},
{
name: 'menu.visualization',
key: 'visualization',
ignore: true,
name: 'menu.platform',
key: 'platform',
children: [
{
name: 'menu.visualization.dataAnalysis',
key: 'visualization/data-analysis',
requiredPermissions: [
{ resource: 'menu.visualization.dataAnalysis', actions: ['read'] },
],
},
{
name: 'menu.visualization.multiDimensionDataAnalysis',
key: 'visualization/multi-dimension-data-analysis',
requiredPermissions: [
{
resource: 'menu.visualization.dataAnalysis',
actions: ['read', 'write'],
},
{
resource: 'menu.visualization.multiDimensionDataAnalysis',
actions: ['write'],
},
],
oneOfPerm: true,
},
],
},
{
name: 'menu.list',
key: 'list',
ignore: true,
children: [
{
name: 'menu.list.searchTable',
key: 'list/search-table',
},
{
name: 'menu.list.cardList',
key: 'list/card',
},
],
},
{
name: 'menu.form',
key: 'form',
ignore: true,
children: [
{
name: 'menu.form.group',
key: 'form/group',
requiredPermissions: [
{ resource: 'menu.form.group', actions: ['read', 'write'] },
],
},
{
name: 'menu.form.step',
key: 'form/step',
requiredPermissions: [
{ resource: 'menu.form.step', actions: ['read'] },
],
},
],
},
{
name: 'menu.profile',
key: 'profile',
ignore: true,
children: [
{
name: 'menu.profile.basic',
key: 'profile/basic',
},
],
},
{
name: 'menu.result',
key: 'result',
ignore: true,
children: [
{
name: 'menu.result.success',
key: 'result/success',
breadcrumb: false,
},
{
name: 'menu.result.error',
key: 'result/error',
breadcrumb: false,
},
{ name: 'menu.platform.subscribers', key: 'platform/subscribers' },
{ name: 'menu.platform.renewals', key: 'platform/renewals' },
{ name: 'menu.platform.subscriptions', key: 'platform/subscriptions' },
],
},
{
@@ -206,20 +114,6 @@ export const routes: IRoute[] = [
},
],
},
{
name: 'menu.user',
key: 'user',
children: [
{
name: 'menu.user.info',
key: 'user/info',
},
{
name: 'menu.user.setting',
key: 'user/setting',
},
],
},
];
export const getName = (path: string, routes) => {

查看文件

@@ -128,7 +128,7 @@ export type LeadStatus = 'new' | 'contacted' | 'hot' | 'won' | 'archived';
/**
* 线索触达来源渠道
*/
export type LeadSource = 'nfc' | 'qr' | 'link' | 'ai_chat';
export type LeadSource = 'nfc' | 'qr' | 'link' | 'ai_chat' | 'exchange';
/**
* LeadEntity - 潜客线索关系模型契约
@@ -532,6 +532,7 @@ export interface DeepSeekConfig {
maxTokens?: number;
timeoutMs?: number;
enabled?: boolean;
isConfigured?: boolean;
}
export interface DeepSeekChatMessage {
@@ -834,29 +835,6 @@ export interface AiKnowledgeDoc {
locale?: string;
}
export type ConciergePersonaType =
| 'trade_expert'
| 'business_rep'
| 'tech_advisor'
| 'custom';
export type ConciergeToneStyle =
| 'professional'
| 'direct'
| 'enthusiastic'
| 'friendly';
export interface AiConciergeConfig {
id?: string | number;
cardId: string | number;
personaType: ConciergePersonaType;
welcomeMsg: string;
triggerLeadThreshold: number;
promptTemplate: string;
toneStyle: ConciergeToneStyle;
isEnabled: boolean;
}
export interface KnowledgeChunk {
id?: string | number;
index: number;
@@ -865,24 +843,6 @@ export interface KnowledgeChunk {
score?: number;
}
export type DeepSeekModel = 'deepseek-chat' | 'deepseek-reasoner';
export interface DeepSeekConfig {
apiKey?: string;
baseUrl?: string;
model?: DeepSeekModel;
temperature?: number;
maxTokens?: number;
timeoutMs?: number;
enabled?: boolean;
isConfigured?: boolean;
}
export interface DeepSeekChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface DeepSeekChatCompletionRequest {
messages: DeepSeekChatMessage[];
model?: DeepSeekModel;
@@ -920,6 +880,9 @@ export interface DeepSeekTestConnectionResult {
// 12. 商业化新账统计与财务运营分析契约 (Finance & Operations BI)
// ==========================================
export type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'CNY';
export type InvoiceStatus = 'paid' | 'issued' | 'refunded' | 'void';
export type FinanceAnalyticsPeriod = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'all';
export interface FinanceAnalyticsQueryDTO {
@@ -995,4 +958,89 @@ export interface FinanceAnalyticsResult {
recentLedger: RecentLedgerItem[];
}
// -------------------------------------------------------------
// 平台总后台超级管理员 (Platform Super Admin) - 会员、续费与订阅契约
// -------------------------------------------------------------
export interface SubscriberItem {
id: number;
tenantId: number;
tenantName: string;
adminName: string;
adminEmail: string;
adminPhone?: string;
planType: TenantPlanType;
status: 'active' | 'trialing' | 'past_due' | 'canceled';
cardsUsed: number;
cardsLimit: number;
seatsUsed: number;
seatsLimit: number;
totalPaidAmount: number;
currency: SupportedCurrency;
startDate: string;
expireDate: string;
createdAt: string;
}
export interface UpdateSubscriberDTO {
tenantId: number;
planType?: TenantPlanType;
cardsLimit?: number;
seatsLimit?: number;
extendDays?: number;
expireDate?: string;
status?: 'active' | 'trialing' | 'past_due' | 'canceled';
}
export interface RenewalOrderItem {
id: number;
orderNo: string;
tenantId: number;
tenantName: string;
planType: TenantPlanType;
billingCycle: 'monthly' | 'yearly';
amount: number;
currency: SupportedCurrency;
channel: 'stripe' | 'wire_transfer' | 'manual_admin' | 'system';
status: 'paid' | 'pending' | 'refunded';
paidAt: string;
createdAt: string;
}
export interface ManualRenewDTO {
tenantId: number;
planType: TenantPlanType;
billingCycle: 'monthly' | 'yearly';
amount: number;
currency?: SupportedCurrency;
channel?: 'stripe' | 'wire_transfer' | 'manual_admin';
note?: string;
}
export interface PlanConfigItem {
planType: TenantPlanType;
name: string;
description: string;
monthlyPrice: number;
yearlyPrice: number;
cardsLimit: number;
seatsLimit: number;
aiTokensPerMonth: number;
nfcDynamicRouting: boolean;
supportCustomDomain: boolean;
crmLeadLimit: number;
isPopular?: boolean;
}
export interface PlatformOverviewResult {
totalSubscribers: number;
activeSubscribers: number;
proCount: number;
enterpriseCount: number;
expiringSoonCount: number;
totalRenewalRevenue: number;
currency: SupportedCurrency;
}

查看文件

@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const routesPath = path.join(adminRoot, 'src/routes.ts');
const layoutPath = path.join(adminRoot, 'src/layout.tsx');
const localePath = path.join(adminRoot, 'src/locale/index.ts');
const navBarPath = path.join(adminRoot, 'src/components/NavBar/index.tsx');
const subscribersPagePath = path.join(adminRoot, 'src/pages/platform/subscribers/index.tsx');
const renewalsPagePath = path.join(adminRoot, 'src/pages/platform/renewals/index.tsx');
const subscriptionsPagePath = path.join(adminRoot, 'src/pages/platform/subscriptions/index.tsx');
test('1. routes.ts 必须配置 platform 平台总控与会员/续费/订阅三大路由', () => {
const content = fs.readFileSync(routesPath, 'utf-8');
assert.ok(content.includes("key: 'platform'"), 'routes.ts 必须包含 platform 模块');
assert.ok(content.includes("key: 'platform/subscribers'"), 'routes.ts 必须包含 会员列表 路由');
assert.ok(content.includes("key: 'platform/renewals'"), 'routes.ts 必须包含 续费列表 路由');
assert.ok(content.includes("key: 'platform/subscriptions'"), 'routes.ts 必须包含 订阅管理 路由');
});
test('2. layout.tsx 必须为 platform 模块配置专属图标', () => {
const content = fs.readFileSync(layoutPath, 'utf-8');
assert.ok(content.includes("case 'platform':"), 'layout.tsx 必须处理 platform 模块的图标');
});
test('3. locale/index.ts 必须包含平台总控相关中英文词条', () => {
const content = fs.readFileSync(localePath, 'utf-8');
assert.ok(content.includes("'menu.platform':"), '缺少 menu.platform 国际化词条');
assert.ok(content.includes("'menu.platform.subscribers':"), '缺少 会员列表 国际化词条');
assert.ok(content.includes("'menu.platform.renewals':"), '缺少 续费列表 国际化词条');
assert.ok(content.includes("'menu.platform.subscriptions':"), '缺少 订阅管理 国际化词条');
});
test('4. NavBar 组件必须展示超级管理员 [最高权限] 身份标签', () => {
const content = fs.readFileSync(navBarPath, 'utf-8');
assert.ok(
content.includes('超级管理员') || content.includes('Platform Super Admin') || content.includes('最高权限'),
'NavBar 必须展示超级管理员或最高权限身份标识'
);
});
test('5. 平台总控三大页面文件必须存在且包含核心 Arco 组件与 API 路由', () => {
assert.ok(fs.existsSync(subscribersPagePath), '必须存在 platform/subscribers/index.tsx');
assert.ok(fs.existsSync(renewalsPagePath), '必须存在 platform/renewals/index.tsx');
assert.ok(fs.existsSync(subscriptionsPagePath), '必须存在 platform/subscriptions/index.tsx');
const subContent = fs.readFileSync(subscribersPagePath, 'utf-8');
assert.ok(subContent.includes('/api/admin/team/platform/subscribers'), '会员列表必须调用对应后端接口');
assert.ok(subContent.includes('Table'), '会员列表必须使用 Arco Table');
const renewContent = fs.readFileSync(renewalsPagePath, 'utf-8');
assert.ok(renewContent.includes('/api/admin/team/platform/renewals'), '续费列表必须调用对应后端接口');
assert.ok(renewContent.includes('Table'), '续费列表必须使用 Arco Table');
const planContent = fs.readFileSync(subscriptionsPagePath, 'utf-8');
assert.ok(planContent.includes('/api/admin/team/platform/plans'), '订阅管理必须调用对应后端接口');
assert.ok(planContent.includes('Card'), '订阅管理必须使用 Arco Card');
});

查看文件

@@ -134,7 +134,36 @@ export type LeadStatus = 'new' | 'contacted' | 'hot' | 'won' | 'archived';
/**
* 线索触达来源渠道
*/
export type LeadSource = 'nfc' | 'qr' | 'link' | 'ai_chat';
export type LeadSource = 'nfc' | 'qr' | 'link' | 'ai_chat' | 'exchange';
export type CardContactSource = 'exchange' | 'collect' | 'scan';
export interface CardContact {
id?: number | string;
ownerUserId: number;
ownerCardId?: number | null;
peerCardId: number;
source: CardContactSource;
leadId?: number | null;
peerName?: string;
peerTitle?: string;
peerCompany?: string;
peerAvatarUrl?: string;
peerSlug?: string;
exchangedAt?: string | Date;
}
export interface CardExchangeRecord {
id?: number | string;
exchangeCode: string;
senderCardId: number;
recipientCardId: number;
senderUserId?: number | null;
recipientUserId?: number | null;
status: 'REQUESTED' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED';
notes?: string;
acceptedAt?: string | Date | null;
}
/**
* LeadEntity - 潜客线索关系模型契约
@@ -896,7 +925,7 @@ export interface MoveMembersRequest {
/**
* 国际主流结算币种
*/
export type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'CNY';
export type SupportedCurrency = 'USD' | 'AED' | 'EUR' | 'GBP' | 'JPY' | 'CNY';
/**
* 发票状态
@@ -946,10 +975,12 @@ export interface CreateInvoiceDTO {
* 税费折算计算结果
*/
export interface TaxCalculationResult {
subtotal: number;
subtotal?: number;
taxRate: number;
taxAmount: number;
totalAmount: number;
taxName?: string;
isReverseCharge?: boolean;
}
/**
@@ -1241,3 +1272,88 @@ export interface FinanceAnalyticsResult {
recentLedger: RecentLedgerItem[];
}
// -------------------------------------------------------------
// 平台总后台超级管理员 (Platform Super Admin) - 会员、续费与订阅契约
// -------------------------------------------------------------
export interface SubscriberItem {
id: number;
tenantId: number;
tenantName: string;
adminName: string;
adminEmail: string;
adminPhone?: string;
planType: TenantPlanType;
status: 'active' | 'trialing' | 'past_due' | 'canceled';
cardsUsed: number;
cardsLimit: number;
seatsUsed: number;
seatsLimit: number;
totalPaidAmount: number;
currency: SupportedCurrency;
startDate: string;
expireDate: string;
createdAt: string;
}
export interface UpdateSubscriberDTO {
tenantId: number;
planType?: TenantPlanType;
cardsLimit?: number;
seatsLimit?: number;
extendDays?: number;
expireDate?: string;
status?: 'active' | 'trialing' | 'past_due' | 'canceled';
}
export interface RenewalOrderItem {
id: number;
orderNo: string;
tenantId: number;
tenantName: string;
planType: TenantPlanType;
billingCycle: 'monthly' | 'yearly';
amount: number;
currency: SupportedCurrency;
channel: 'stripe' | 'wire_transfer' | 'manual_admin' | 'system';
status: 'paid' | 'pending' | 'refunded';
paidAt: string;
createdAt: string;
}
export interface ManualRenewDTO {
tenantId: number;
planType: TenantPlanType;
billingCycle: 'monthly' | 'yearly';
amount: number;
currency?: SupportedCurrency;
channel?: 'stripe' | 'wire_transfer' | 'manual_admin';
note?: string;
}
export interface PlanConfigItem {
planType: TenantPlanType;
name: string;
description: string;
monthlyPrice: number;
yearlyPrice: number;
cardsLimit: number;
seatsLimit: number;
aiTokensPerMonth: number;
nfcDynamicRouting: boolean;
supportCustomDomain: boolean;
crmLeadLimit: number;
isPopular?: boolean;
}
export interface PlatformOverviewResult {
totalSubscribers: number;
activeSubscribers: number;
proCount: number;
enterpriseCount: number;
expiringSoonCount: number;
totalRenewalRevenue: number;
currency: SupportedCurrency;
}

查看文件

@@ -0,0 +1,137 @@
import { Body, Inject, Post, Provide } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { CoolController, BaseController } from '@cool-midway/core';
import { PlatformSubscriptionService } from '../../service/platform-subscription';
import {
ManualRenewDTO,
PlanConfigItem,
PlatformOverviewResult,
TenantPlanType,
UpdateSubscriberDTO,
} from '../../../../comm/types/omni';
export const adminPlatformSubscriptionCurdOption = {
api: [] as Array<'add' | 'delete' | 'update' | 'info' | 'list' | 'page'>,
service: PlatformSubscriptionService,
serviceApis: [
{ method: 'overview', summary: '平台会员与订阅整体概览' },
{ method: 'subscribersPage', summary: '全网会员分页列表' },
{ method: 'subscribersUpdate', summary: '超管修改会员套餐/配额/延期' },
{ method: 'renewalsPage', summary: '全网续费流水列表' },
{ method: 'manualRenew', summary: '超管手动代续费与补录订单' },
{ method: 'plansList', summary: 'SaaS 套餐方案矩阵列表' },
{ method: 'plansUpdate', summary: '配置修改套餐规格与定价' },
],
};
/**
* 平台总后台超级管理员 (Platform Super Admin)
* 专有控制中心:全网会员列表、续费流水、订阅管理与套餐策略
* 路由前缀: /admin/team/platform
*/
@Provide()
@CoolController(adminPlatformSubscriptionCurdOption)
export class AdminPlatformSubscriptionController extends BaseController {
@Inject()
ctx: Context;
@Inject()
platformSubscriptionService: PlatformSubscriptionService;
/**
* 平台概览 KPI
* 路由: POST /admin/team/platform/overview
*/
@Post('/overview', { summary: '平台会员与订阅整体概览' })
async overview() {
const result: PlatformOverviewResult =
await this.platformSubscriptionService.getOverview();
return this.ok(result);
}
/**
* 会员列表分页检索
* 路由: POST /admin/team/platform/subscribers/page
*/
@Post('/subscribers/page', { summary: '全网会员分页列表' })
async subscribersPage(
@Body()
body?: {
page?: number;
size?: number;
planType?: TenantPlanType;
status?: string;
keyword?: string;
}
) {
const result = await this.platformSubscriptionService.getSubscribers(body);
return this.ok(result);
}
/**
* 超管修改会员信息 (套餐、配额、延期、状态)
* 路由: POST /admin/team/platform/subscribers/update
*/
@Post('/subscribers/update', { summary: '超管修改会员套餐/配额/延期' })
async subscribersUpdate(@Body() body: UpdateSubscriberDTO) {
const result = await this.platformSubscriptionService.updateSubscriber(body);
return this.ok(result);
}
/**
* 续费订单流水列表
* 路由: POST /admin/team/platform/renewals/page
*/
@Post('/renewals/page', { summary: '全网续费流水列表' })
async renewalsPage(
@Body()
body?: {
page?: number;
size?: number;
status?: string;
keyword?: string;
}
) {
const result = await this.platformSubscriptionService.getRenewals(body);
return this.ok(result);
}
/**
* 超管手动代续费
* 路由: POST /admin/team/platform/renewals/manual-renew
*/
@Post('/renewals/manual-renew', { summary: '超管手动代续费与补录订单' })
async manualRenew(@Body() body: ManualRenewDTO) {
const result = await this.platformSubscriptionService.manualRenew(body);
return this.ok(result);
}
/**
* 套餐规格列表
* 路由: POST /admin/team/platform/plans/list
*/
@Post('/plans/list', { summary: 'SaaS 套餐方案矩阵列表' })
async plansList() {
const result = await this.platformSubscriptionService.getPlans();
return this.ok(result);
}
/**
* 更新套餐规格
* 路由: POST /admin/team/platform/plans/update
*/
@Post('/plans/update', { summary: '配置修改套餐规格与定价' })
async plansUpdate(
@Body()
body: {
planType: TenantPlanType;
params: Partial<PlanConfigItem>;
}
) {
const result = await this.platformSubscriptionService.updatePlan(
body.planType,
body.params
);
return this.ok(result);
}
}

查看文件

@@ -1,7 +1,6 @@
import { Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
export type SupportedCurrency = 'USD' | 'AED' | 'EUR' | 'GBP' | 'JPY' | 'CNY';
import { SupportedCurrency, TaxCalculationResult } from '../../../comm/types/omni';
/**
* 相对 USD 的基准出海商业结算汇率表
@@ -32,14 +31,6 @@ export interface TaxCalculationParams {
vatNumber?: string;
}
export interface TaxCalculationResult {
taxRate: number;
taxAmount: number;
totalAmount: number;
taxName: string;
isReverseCharge?: boolean;
}
export interface InvoiceTaxSummary {
currency: SupportedCurrency;
currencySymbol: string;

查看文件

@@ -19,6 +19,8 @@ const CURRENCY_SYMBOLS: Record<SupportedCurrency, string> = {
EUR: '€',
GBP: '£',
CNY: '¥',
AED: 'AED ',
JPY: '¥',
};
/**

查看文件

@@ -0,0 +1,514 @@
import { Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import * as moment from 'moment';
import { TeamInvoiceEntity } from '../entity/invoice';
import { TeamSubscriptionEntity } from '../entity/subscription';
import {
ManualRenewDTO,
PlanConfigItem,
PlatformOverviewResult,
RenewalOrderItem,
SubscriberItem,
TenantPlanType,
UpdateSubscriberDTO,
} from '../../../comm/types/omni';
/**
* 平台总后台超级管理员 (Platform Super Admin)
* 会员、续费与订阅全周期运营管控服务
*/
@Provide()
export class PlatformSubscriptionService extends BaseService {
@InjectEntityModel(TeamInvoiceEntity)
teamInvoiceEntity?: Repository<TeamInvoiceEntity>;
@InjectEntityModel(TeamSubscriptionEntity)
teamSubscriptionEntity?: Repository<TeamSubscriptionEntity>;
// 内存化种子会员数据池 (保障无缝离线测试与即席演示)
private subscribersStore: SubscriberItem[] = [
{
id: 1,
tenantId: 1,
tenantName: 'Vanguard Global Trade Ltd. (出海先锋外贸)',
adminName: 'David Zhang',
adminEmail: 'david@vanguard-trade.com',
adminPhone: '+86 13800138001',
planType: 'enterprise',
status: 'active',
cardsUsed: 42,
cardsLimit: 100,
seatsUsed: 18,
seatsLimit: 50,
totalPaidAmount: 7176,
currency: 'USD',
startDate: '2025-09-01',
expireDate: '2026-09-01',
createdAt: '2025-08-28 10:20:00',
},
{
id: 2,
tenantId: 2,
tenantName: 'Berlin Precision Robotics GmbH (柏林精密机器人)',
adminName: 'Klaus Schmidt',
adminEmail: 'klaus@berlin-robotics.de',
adminPhone: '+49 30 1234567',
planType: 'pro',
status: 'active',
cardsUsed: 5,
cardsLimit: 10,
seatsUsed: 4,
seatsLimit: 5,
totalPaidAmount: 1176,
currency: 'EUR',
startDate: '2026-01-15',
expireDate: '2027-01-15',
createdAt: '2026-01-10 14:35:12',
},
{
id: 3,
tenantId: 3,
tenantName: 'Dubai Apex Logistics LLC (迪拜巅峰国际物流)',
adminName: 'Ahmed Al-Mansoor',
adminEmail: 'ahmed@apexlogistics.ae',
adminPhone: '+971 4 9876543',
planType: 'enterprise',
status: 'active',
cardsUsed: 35,
cardsLimit: 100,
seatsUsed: 12,
seatsLimit: 50,
totalPaidAmount: 3588,
currency: 'USD',
startDate: '2026-03-01',
expireDate: '2027-03-01',
createdAt: '2026-02-25 09:12:44',
},
{
id: 4,
tenantId: 4,
tenantName: 'Silicon Horizon Tech Inc. (硅谷地平线科技)',
adminName: 'Sarah Jenkins',
adminEmail: 'sarah@siliconhorizon.io',
adminPhone: '+1 415 8009988',
planType: 'pro',
status: 'past_due',
cardsUsed: 8,
cardsLimit: 10,
seatsUsed: 5,
seatsLimit: 5,
totalPaidAmount: 588,
currency: 'USD',
startDate: '2025-09-10',
expireDate: '2026-09-10',
createdAt: '2025-09-05 16:40:22',
},
{
id: 5,
tenantId: 5,
tenantName: 'Nordic Clean Energy AB (北欧洁能科技)',
adminName: 'Erik Lindqvist',
adminEmail: 'erik@nordic-energy.se',
adminPhone: '+46 8 5556677',
planType: 'pro',
status: 'active',
cardsUsed: 4,
cardsLimit: 10,
seatsUsed: 3,
seatsLimit: 5,
totalPaidAmount: 1176,
currency: 'EUR',
startDate: '2025-11-20',
expireDate: '2026-11-20',
createdAt: '2025-11-15 11:20:00',
},
{
id: 6,
tenantId: 6,
tenantName: 'Tokyo Smart Automation KK (东京智能物联)',
adminName: 'Kenji Sato',
adminEmail: 'kenji@tokyo-smart.jp',
adminPhone: '+81 3 33445566',
planType: 'free',
status: 'active',
cardsUsed: 1,
cardsLimit: 1,
seatsUsed: 1,
seatsLimit: 1,
totalPaidAmount: 0,
currency: 'USD',
startDate: '2026-08-01',
expireDate: '2099-12-31',
createdAt: '2026-08-01 10:00:00',
},
];
// 内存化种子续费流水池
private renewalsStore: RenewalOrderItem[] = [
{
id: 1,
orderNo: 'ORD-20260921-0089',
tenantId: 1,
tenantName: 'Vanguard Global Trade Ltd.',
planType: 'enterprise',
billingCycle: 'yearly',
amount: 3588,
currency: 'USD',
channel: 'stripe',
status: 'paid',
paidAt: '2026-09-21 00:15:20',
createdAt: '2026-09-21 00:15:10',
},
{
id: 2,
orderNo: 'ORD-20260920-0088',
tenantId: 2,
tenantName: 'Berlin Precision Robotics GmbH',
planType: 'pro',
billingCycle: 'yearly',
amount: 588,
currency: 'EUR',
channel: 'stripe',
status: 'paid',
paidAt: '2026-09-20 18:42:10',
createdAt: '2026-09-20 18:42:00',
},
{
id: 3,
orderNo: 'ORD-20260920-0087',
tenantId: 3,
tenantName: 'Dubai Apex Logistics LLC',
planType: 'enterprise',
billingCycle: 'monthly',
amount: 299,
currency: 'USD',
channel: 'wire_transfer',
status: 'paid',
paidAt: '2026-09-20 14:22:05',
createdAt: '2026-09-20 14:10:00',
},
{
id: 4,
orderNo: 'ORD-20260919-0086',
tenantId: 4,
tenantName: 'Silicon Horizon Tech Inc.',
planType: 'pro',
billingCycle: 'monthly',
amount: 49,
currency: 'USD',
channel: 'stripe',
status: 'pending',
paidAt: '',
createdAt: '2026-09-19 22:11:34',
},
{
id: 5,
orderNo: 'ORD-20260919-0085',
tenantId: 5,
tenantName: 'Nordic Clean Energy AB',
planType: 'pro',
billingCycle: 'yearly',
amount: 588,
currency: 'EUR',
channel: 'manual_admin',
status: 'paid',
paidAt: '2026-09-19 11:05:49',
createdAt: '2026-09-19 11:05:00',
},
];
// SaaS 套餐规格与定价方案配置
private planConfigs: PlanConfigItem[] = [
{
planType: 'free',
name: 'Free 基础体验版',
description: '适合个人自由职业者与初创独立人,轻量体验出海名片微官网',
monthlyPrice: 0,
yearlyPrice: 0,
cardsLimit: 1,
seatsLimit: 1,
aiTokensPerMonth: 5000,
nfcDynamicRouting: false,
supportCustomDomain: false,
crmLeadLimit: 50,
isPopular: false,
},
{
planType: 'pro',
name: 'Pro 专业商务版',
description: '面向出海业务精英、外贸销售主管与跨国顾问的高性能全功能套件',
monthlyPrice: 49,
yearlyPrice: 490,
cardsLimit: 10,
seatsLimit: 5,
aiTokensPerMonth: 200000,
nfcDynamicRouting: true,
supportCustomDomain: true,
crmLeadLimit: 2000,
isPopular: true,
},
{
planType: 'enterprise',
name: 'Enterprise 商业企业版',
description: '为出海跨国集团、参展销售天团定制的多租户公海池与品牌统一管控',
monthlyPrice: 299,
yearlyPrice: 2990,
cardsLimit: 100,
seatsLimit: 50,
aiTokensPerMonth: 1000000,
nfcDynamicRouting: true,
supportCustomDomain: true,
crmLeadLimit: 50000,
isPopular: false,
},
];
/**
* 1. 平台总览与核心 KPI 概览
*/
async getOverview(): Promise<PlatformOverviewResult> {
const totalSubscribers = this.subscribersStore.length;
const activeSubscribers = this.subscribersStore.filter(
s => s.status === 'active' || s.status === 'trialing'
).length;
const proCount = this.subscribersStore.filter(s => s.planType === 'pro').length;
const enterpriseCount = this.subscribersStore.filter(s => s.planType === 'enterprise').length;
// 即将到期判定 (当前日期后 30 天内到期)
const now = moment();
const expiringSoonCount = this.subscribersStore.filter(s => {
if (s.planType === 'free') return false;
const exp = moment(s.expireDate);
const diffDays = exp.diff(now, 'days');
return diffDays >= -5 && diffDays <= 30;
}).length;
const totalRenewalRevenue = this.renewalsStore
.filter(r => r.status === 'paid')
.reduce((sum, r) => sum + Number(r.amount || 0), 0);
return {
totalSubscribers,
activeSubscribers,
proCount,
enterpriseCount,
expiringSoonCount,
totalRenewalRevenue,
currency: 'USD',
};
}
/**
* 2. 分页及多条件查询全网会员列表
*/
async getSubscribers(query: {
page?: number;
size?: number;
planType?: TenantPlanType;
status?: string;
keyword?: string;
} = {}): Promise<{
list: SubscriberItem[];
pagination: { page: number; size: number; total: number };
}> {
const page = Number(query.page || 1);
const size = Number(query.size || 20);
let filtered = [...this.subscribersStore];
if (query.planType) {
filtered = filtered.filter(s => s.planType === query.planType);
}
if (query.status) {
filtered = filtered.filter(s => s.status === query.status);
}
if (query.keyword) {
const kw = query.keyword.toLowerCase().trim();
filtered = filtered.filter(
s =>
String(s.tenantId).includes(kw) ||
s.tenantName.toLowerCase().includes(kw) ||
s.adminEmail.toLowerCase().includes(kw) ||
s.adminName.toLowerCase().includes(kw)
);
}
const total = filtered.length;
const start = (page - 1) * size;
const list = filtered.slice(start, start + size).map(item => ({ ...item }));
return {
list,
pagination: {
page,
size,
total,
},
};
}
/**
* 3. 超级管理员修改会员信息 (套餐、配额、延期、状态)
*/
async updateSubscriber(dto: UpdateSubscriberDTO): Promise<SubscriberItem> {
const item = this.subscribersStore.find(s => s.tenantId === Number(dto.tenantId));
if (!item) {
throw new Error(`未找到租户 ID 为 ${dto.tenantId} 的会员`);
}
if (dto.planType) {
item.planType = dto.planType;
// 联动规格默认配额
const targetPlan = this.planConfigs.find(p => p.planType === dto.planType);
if (targetPlan) {
item.cardsLimit = targetPlan.cardsLimit;
item.seatsLimit = targetPlan.seatsLimit;
}
}
if (typeof dto.cardsLimit === 'number') {
item.cardsLimit = dto.cardsLimit;
}
if (typeof dto.seatsLimit === 'number') {
item.seatsLimit = dto.seatsLimit;
}
if (dto.status) {
item.status = dto.status;
}
if (dto.extendDays && dto.extendDays > 0) {
const baseDate = moment(item.expireDate).isValid()
? moment(item.expireDate)
: moment();
item.expireDate = baseDate.add(dto.extendDays, 'days').format('YYYY-MM-DD');
item.status = 'active';
} else if (dto.expireDate) {
item.expireDate = dto.expireDate;
}
return { ...item };
}
/**
* 4. 查询全网续费订单流水台账
*/
async getRenewals(query: {
page?: number;
size?: number;
status?: string;
keyword?: string;
} = {}): Promise<{
list: RenewalOrderItem[];
pagination: { page: number; size: number; total: number };
}> {
const page = Number(query.page || 1);
const size = Number(query.size || 20);
let filtered = [...this.renewalsStore];
if (query.status) {
filtered = filtered.filter(r => r.status === query.status);
}
if (query.keyword) {
const kw = query.keyword.toLowerCase().trim();
filtered = filtered.filter(
r =>
r.orderNo.toLowerCase().includes(kw) ||
r.tenantName.toLowerCase().includes(kw) ||
String(r.tenantId).includes(kw)
);
}
const total = filtered.length;
const start = (page - 1) * size;
const list = filtered.slice(start, start + size);
return {
list,
pagination: {
page,
size,
total,
},
};
}
/**
* 5. 超管手动为会员代续费 / 补录订单
*/
async manualRenew(dto: ManualRenewDTO): Promise<{
success: boolean;
orderNo: string;
item: RenewalOrderItem;
subscriber: SubscriberItem;
}> {
const subscriber = this.subscribersStore.find(s => s.tenantId === Number(dto.tenantId));
if (!subscriber) {
throw new Error(`租户 ${dto.tenantId} 不存在`);
}
const orderNo = `ORD-${moment().format('YYYYMMDD')}-${Math.floor(
1000 + Math.random() * 9000
)}`;
const nowStr = moment().format('YYYY-MM-DD HH:mm:ss');
const newOrder: RenewalOrderItem = {
id: this.renewalsStore.length + 1,
orderNo,
tenantId: subscriber.tenantId,
tenantName: subscriber.tenantName,
planType: dto.planType,
billingCycle: dto.billingCycle,
amount: dto.amount,
currency: dto.currency || 'USD',
channel: dto.channel || 'manual_admin',
status: 'paid',
paidAt: nowStr,
createdAt: nowStr,
};
this.renewalsStore.unshift(newOrder);
// 更新会员期限和套餐
subscriber.planType = dto.planType;
const addDays = dto.billingCycle === 'yearly' ? 365 : 30;
const baseExpire = moment(subscriber.expireDate).isAfter(moment())
? moment(subscriber.expireDate)
: moment();
subscriber.expireDate = baseExpire.add(addDays, 'days').format('YYYY-MM-DD');
subscriber.status = 'active';
subscriber.totalPaidAmount = Number(subscriber.totalPaidAmount || 0) + Number(dto.amount);
return {
success: true,
orderNo,
item: newOrder,
subscriber,
};
}
/**
* 6. SaaS 套餐定价规格列表
*/
async getPlans(): Promise<PlanConfigItem[]> {
return [...this.planConfigs];
}
/**
* 7. 更新指定套餐定价与配额参数
*/
async updatePlan(
planType: TenantPlanType,
params: Partial<PlanConfigItem>
): Promise<PlanConfigItem> {
const plan = this.planConfigs.find(p => p.planType === planType);
if (!plan) {
throw new Error(`套餐类型 ${planType} 不存在`);
}
Object.assign(plan, params);
return plan;
}
}

查看文件

@@ -2,8 +2,8 @@ import {
CurrencyTaxCalculatorService,
convertCurrency,
calculateInternationalTax,
TaxCalculationResult,
} from '../../../src/modules/team/service/currency-tax';
import { TaxCalculationResult } from '../../../src/comm/types/omni';
describe('Point 57 (P1): 跨境多币种汇率换算与出海发票税制计算', () => {
let service: CurrencyTaxCalculatorService;

查看文件

@@ -0,0 +1,144 @@
import { PlatformSubscriptionService } from '../../../src/modules/team/service/platform-subscription';
import { AdminPlatformSubscriptionController } from '../../../src/modules/team/controller/admin/platform';
describe('平台总后台超级管理员 - 会员、续费与订阅服务测试 (TDD)', () => {
let service: PlatformSubscriptionService;
beforeEach(() => {
service = new PlatformSubscriptionService();
});
describe('1. 平台总览与指标概览', () => {
it('getOverview 应返回全网会员数、付费会员数、预警会员数及总续费额', async () => {
const overview = await service.getOverview();
expect(overview).toBeDefined();
expect(overview.totalSubscribers).toBeGreaterThan(0);
expect(overview.activeSubscribers).toBeGreaterThan(0);
expect(overview.proCount).toBeGreaterThanOrEqual(0);
expect(overview.enterpriseCount).toBeGreaterThanOrEqual(0);
expect(overview.totalRenewalRevenue).toBeGreaterThan(0);
});
});
describe('2. 会员列表管理 (Subscribers)', () => {
it('getSubscribers 应支持分页查询与全量列表', async () => {
const res = await service.getSubscribers({ page: 1, size: 10 });
expect(res.list).toBeDefined();
expect(Array.isArray(res.list)).toBe(true);
expect(res.pagination.total).toBeGreaterThan(0);
expect(res.list[0]).toHaveProperty('tenantName');
expect(res.list[0]).toHaveProperty('planType');
expect(res.list[0]).toHaveProperty('cardsLimit');
expect(res.list[0]).toHaveProperty('seatsLimit');
});
it('getSubscribers 应支持按套餐类型筛选 (pro / enterprise / free)', async () => {
const proList = await service.getSubscribers({ planType: 'pro' });
for (const item of proList.list) {
expect(item.planType).toBe('pro');
}
});
it('getSubscribers 应支持按状态筛选 (active / trialing / past_due / canceled)', async () => {
const activeList = await service.getSubscribers({ status: 'active' });
for (const item of activeList.list) {
expect(item.status).toBe('active');
}
});
it('getSubscribers 应支持按关键词搜索企业名称或邮箱', async () => {
const all = await service.getSubscribers();
const first = all.list[0];
const searchRes = await service.getSubscribers({ keyword: first.adminEmail });
expect(searchRes.list.some(s => s.tenantId === first.tenantId)).toBe(true);
});
it('updateSubscriber 应支持超管修改会员套餐、配额与状态', async () => {
const updated = await service.updateSubscriber({
tenantId: 1,
planType: 'enterprise',
cardsLimit: 88,
seatsLimit: 30,
status: 'active',
});
expect(updated.planType).toBe('enterprise');
expect(updated.cardsLimit).toBe(88);
expect(updated.seatsLimit).toBe(30);
});
it('updateSubscriber 应支持延长会员有效期 (extendDays)', async () => {
const before = (await service.getSubscribers({ keyword: '1' })).list.find(s => s.tenantId === 1);
const updated = await service.updateSubscriber({
tenantId: 1,
extendDays: 30,
});
expect(updated.expireDate).toBeDefined();
if (before?.expireDate) {
expect(new Date(updated.expireDate).getTime()).toBeGreaterThan(new Date(before.expireDate).getTime());
}
});
});
describe('3. 续费流水管理与手动代续费 (Renewals)', () => {
it('getRenewals 应返回全网订单续费流水', async () => {
const res = await service.getRenewals({ page: 1, size: 10 });
expect(res.list).toBeDefined();
expect(Array.isArray(res.list)).toBe(true);
expect(res.list.length).toBeGreaterThan(0);
expect(res.list[0]).toHaveProperty('orderNo');
expect(res.list[0]).toHaveProperty('amount');
expect(res.list[0]).toHaveProperty('channel');
});
it('manualRenew 应允许超级管理员手动为会员办理续费并生成流水与延期', async () => {
const renewRes = await service.manualRenew({
tenantId: 2,
planType: 'pro',
billingCycle: 'yearly',
amount: 588,
currency: 'USD',
channel: 'manual_admin',
note: '总后台超级管理员代续费测试',
});
expect(renewRes.success).toBe(true);
expect(renewRes.orderNo).toMatch(/^ORD-/);
// 验证会员列表里的到期时间与套餐已相应更新
const sub = (await service.getSubscribers({ keyword: '2' })).list.find(s => s.tenantId === 2);
expect(sub?.planType).toBe('pro');
});
});
describe('4. SaaS 套餐定价与规格矩阵配置 (Plans)', () => {
it('getPlans 应返回 Free, Pro, Enterprise 套餐规格', async () => {
const plans = await service.getPlans();
expect(plans.length).toBe(3);
expect(plans.map(p => p.planType)).toEqual(expect.arrayContaining(['free', 'pro', 'enterprise']));
});
it('updatePlan 应支持更新套餐定价与限额', async () => {
const updated = await service.updatePlan('pro', {
monthlyPrice: 59,
yearlyPrice: 599,
cardsLimit: 15,
});
expect(updated.monthlyPrice).toBe(59);
expect(updated.yearlyPrice).toBe(599);
expect(updated.cardsLimit).toBe(15);
});
});
describe('5. 控制器路由与契约完整性', () => {
it('AdminPlatformSubscriptionController 必须正确导出且声明各总控方法', () => {
const controller = new AdminPlatformSubscriptionController();
expect(controller).toBeDefined();
expect(typeof controller.overview).toBe('function');
expect(typeof controller.subscribersPage).toBe('function');
expect(typeof controller.subscribersUpdate).toBe('function');
expect(typeof controller.renewalsPage).toBe('function');
expect(typeof controller.manualRenew).toBe('function');
expect(typeof controller.plansList).toBe('function');
expect(typeof controller.plansUpdate).toBe('function');
});
});
});