diff --git a/admin/src/components/NavBar/index.tsx b/admin/src/components/NavBar/index.tsx index 3735124..c638ede 100644 --- a/admin/src/components/NavBar/index.tsx +++ b/admin/src/components/NavBar/index.tsx @@ -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 (
@@ -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 = ( - - - - - {role === 'admin' - ? t['menu.user.role.admin'] - : t['menu.user.role.user']} - - + { + if (key === 'logout') { + logout(); } - > - - - {t['menu.user.switchRoles']} - - - - - {t['menu.user.setting']} - - - - {t['message.seeMore']} -
- } - > - - - {t['menu.dashboard.workplace']} - - - - {t['menu.list.cardList']} - - - - + }} + > +
+
+ {userInfo?.name || 'Administrator'} +
+
+ {roleTitle} · {roleScope} +
+
{t['navbar.logout']} @@ -148,6 +88,8 @@ function Navbar({ show }: { show: boolean }) { ); + const avatarText = userInfo?.name?.trim()?.slice(0, 1) || 'A'; + return (
@@ -184,11 +126,6 @@ function Navbar({ show }: { show: boolean }) { }} /> -
  • - - } /> - -
  • {userInfo && ( -
  • +
  • + + + {roleTitle} + {userLoading ? ( + ) : userInfo.avatar ? ( + ) : ( - avatar + avatarText )} diff --git a/admin/src/layout.tsx b/admin/src/layout.tsx index 4908346..20ddcd4 100644 --- a/admin/src/layout.tsx +++ b/admin/src/layout.tsx @@ -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 ; case 'settings-biz': return ; - case 'list': - return ; - case 'form': - return ; - case 'profile': - return ; - case 'visualization': - return ; - case 'result': - return ; + case 'platform': + return ; case 'exception': return ; - case 'user': - return ; default: return
    ; } } +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() { import('./pages/exception/403'))} + component={lazyload(() => import('./pages/exception/404'))} /> diff --git a/admin/src/locale/index.ts b/admin/src/locale/index.ts index 0f1cc52..cd6982e 100644 --- a/admin/src/locale/index.ts +++ b/admin/src/locale/index.ts @@ -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': '新账与财务统计', diff --git a/admin/src/pages/platform/renewals/index.tsx b/admin/src/pages/platform/renewals/index.tsx new file mode 100644 index 0000000..d458a45 --- /dev/null +++ b/admin/src/pages/platform/renewals/index.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [size, setSize] = useState(10); + const [keyword, setKeyword] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + + // 会员下拉数据 (代续费选择) + const [subscribers, setSubscribers] = useState([]); + + // 弹窗状态 + const [manualModalVisible, setManualModalVisible] = useState(false); + const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); + const [selectedOrder, setSelectedOrder] = useState(null); + const [form] = Form.useForm(); + + 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 = { + 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 Stripe 国际支付; + case 'wire_transfer': + return 银行公对公电汇; + case 'manual_admin': + return 超管后台代续; + default: + return {channel}; + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'paid': + return ; + case 'pending': + return ; + case 'refunded': + return ; + default: + return ; + } + }; + + const columns = [ + { + title: '订单流水号', + dataIndex: 'orderNo', + render: (no: string) => ( + {no} + ), + }, + { + title: '会员企业 (租户)', + dataIndex: 'tenantName', + render: (name: string, record: RenewalOrderItem) => ( +
    +
    {name}
    +
    + 租户 ID: #{record.tenantId} +
    +
    + ), + }, + { + title: '续费套餐 / 周期', + render: (_: unknown, record: RenewalOrderItem) => ( +
    + + {record.planType.toUpperCase()} + + + {record.billingCycle === 'yearly' ? '年付结算' : '月付结算'} + +
    + ), + }, + { + title: '实付金额', + render: (_: unknown, record: RenewalOrderItem) => ( + + ${Number(record.amount || 0).toLocaleString()} {record.currency} + + ), + }, + { + 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) => ( + + ), + }, + ]; + + // 统计指标汇总 + const totalCollected = data + .filter(d => d.status === 'paid') + .reduce((sum, d) => sum + Number(d.amount || 0), 0); + + return ( +
    + {/* 顶部统计卡片 */} + + + + } + suffix="笔" + /> + + + + + + + + + + d.billingCycle === 'yearly').length / data.length) * 100 + ) + : 80 + } + suffix="%" + valueStyle={{ color: '#0B5CFF' }} + /> + + + + + d.channel === 'manual_admin').length} + suffix="笔" + valueStyle={{ color: '#722ED1' }} + /> + + + + + {/* 主列表卡片 */} + + + 全网会员续费订单与账单台账 + 最高权限管控 +
    + } + extra={ + + + + + } + > + {/* 工具条 */} + + + + } + placeholder="搜索订单流水号、租户企业名..." + value={keyword} + onChange={setKeyword} + onPressEnter={handleSearch} + style={{ width: 280 }} + allowClear + /> + + + + + + + {/* 表格 */} + { + setPage(p); + setSize(s); + fetchData(p, s); + }, + }} + /> + + + {/* 超管手动代续费 Modal */} + + + 超级管理员 - 手动代续费与补录订单 + + } + visible={manualModalVisible} + onOk={handleConfirmManualRenew} + onCancel={() => setManualModalVisible(false)} + width={500} + > +
    + + + + + + + + + 按月续费 (顺延 30 天) + 按年续费 (顺延 365 天) + + + +
    + + + + + + + + + + + + + + + + + + + + {/* 发票凭据 Drawer */} + setDetailDrawerVisible(false)} + footer={null} + width={440} + > + {selectedOrder && ( +
    + +
    实收金额
    +
    + ${Number(selectedOrder.amount || 0).toLocaleString()} {selectedOrder.currency} +
    +
    {getStatusBadge(selectedOrder.status)}
    +
    + +
    + )} +
    + + ); +} diff --git a/admin/src/pages/platform/subscribers/index.tsx b/admin/src/pages/platform/subscribers/index.tsx new file mode 100644 index 0000000..9b1a57c --- /dev/null +++ b/admin/src/pages/platform/subscribers/index.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [size, setSize] = useState(10); + const [keyword, setKeyword] = useState(''); + const [planFilter, setPlanFilter] = useState('all'); + const [statusFilter, setStatusFilter] = useState('all'); + const [overview, setOverview] = useState(null); + + // 弹窗表单状态 + const [editDrawerVisible, setEditDrawerVisible] = useState(false); + const [selectedSub, setSelectedSub] = useState(null); + const [extendModalVisible, setExtendModalVisible] = useState(false); + const [extendDays, setExtendDays] = useState(30); + const [form] = Form.useForm(); + + 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 = { + 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 Enterprise 商业企业版; + case 'pro': + return Pro 专业商务版; + default: + return Free 基础版; + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'active': + return ; + case 'trialing': + return ; + case 'past_due': + return ; + case 'canceled': + return ; + default: + return ; + } + }; + + const columns = [ + { + title: '租户 ID / 会员名称', + dataIndex: 'tenantName', + render: (name: string, record: SubscriberItem) => ( +
    +
    {name}
    +
    + ID: #{record.tenantId} · 开通时间: {record.startDate} +
    +
    + ), + }, + { + title: '主账号 / 负责人', + dataIndex: 'adminName', + render: (adminName: string, record: SubscriberItem) => ( +
    +
    {adminName}
    +
    {record.adminEmail}
    +
    + ), + }, + { + title: '当前套餐', + dataIndex: 'planType', + render: (plan: TenantPlanType) => getPlanTag(plan), + }, + { + title: '服务状态', + dataIndex: 'status', + render: (status: string) => getStatusBadge(status), + }, + { + title: '名片 / 坐席用量', + render: (_: unknown, record: SubscriberItem) => ( +
    +
    名片: {record.cardsUsed} / {record.cardsLimit}
    +
    + 坐席: {record.seatsUsed} / {record.seatsLimit} +
    +
    + ), + }, + { + title: '服务到期日', + dataIndex: 'expireDate', + render: (date: string, record: SubscriberItem) => { + const isExpiring = record.status === 'past_due'; + return ( + + {date} + + ); + }, + }, + { + title: '累计实付', + dataIndex: 'totalPaidAmount', + render: (amount: number, record: SubscriberItem) => ( + ${Number(amount || 0).toLocaleString()} {record.currency} + ), + }, + { + title: '最高权限操作', + render: (_: unknown, record: SubscriberItem) => ( + + + + handleToggleStatus(record)} + > + + + + ), + }, + ]; + + return ( +
    + {/* 顶部总控概览卡片 */} + +
    + + } + suffix="家" + /> + + + + + + + + + + + + + + + + + + + + {/* 主列表卡片 */} + + + 平台会员生命周期总控列表 + 超级管理员权限 + + } + extra={ + + + + } + > + {/* 筛选过滤工具条 */} + + + + } + placeholder="搜索会员企业名、主账号邮箱、ID..." + value={keyword} + onChange={setKeyword} + onPressEnter={handleSearch} + style={{ width: 280 }} + allowClear + /> + + + + + + + + {/* 表格 */} +
    { + setPage(p); + setSize(s); + fetchData(p, s); + }, + }} + /> + + + {/* 调整会员抽屉 */} + setEditDrawerVisible(false)} + width={420} + > +
    + + + + + + + + + + + + + 正常有效 + 到期催收 + 冻结停用 + + + +
    + + {/* 延长有效期 Modal */} + setExtendModalVisible(false)} + > +
    + 当前会员:{selectedSub?.tenantName} +
    + 当前到期日:{selectedSub?.expireDate} +
    +
    选择顺延天数:
    + + +30 天 (1 个月) + +90 天 (1 季度) + +365 天 (1 年) + +
    + setExtendDays(Number(val) || 30)} + min={1} + max={3650} + suffix="天" + style={{ width: 160 }} + /> +
    +
    + + ); +} diff --git a/admin/src/pages/platform/subscriptions/index.tsx b/admin/src/pages/platform/subscriptions/index.tsx new file mode 100644 index 0000000..7eb0f75 --- /dev/null +++ b/admin/src/pages/platform/subscriptions/index.tsx @@ -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([]); + const [editModalVisible, setEditModalVisible] = useState(false); + const [currentPlan, setCurrentPlan] = useState(null); + const [form] = Form.useForm(); + + 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 ( +
    + {/* 顶部公告 */} + } + content="超级管理员总后台:此处配置的 SaaS 商业化订阅规格与定价将直接生效于全平台企业租户的升级与计费逻辑。" + style={{ marginBottom: 16 }} + /> + + {/* 顶部总览卡片 */} + +
    + + + + + + + + + + + + + + + + + {/* 套餐方案卡片矩阵 */} + + SaaS 订阅套餐规格与出海权益矩阵 + 平台超管可调控 + + } + extra={ + + } + > + + {plans.map((plan) => { + const color = getPlanColor(plan.planType); + return ( + + + {plan.isPopular && ( +
    + }> + 最受欢迎推荐 + +
    + )} + +
    +
    + {plan.name} +
    +
    + {plan.description} +
    +
    + + + +
    +
    + + ${plan.monthlyPrice} + + / 月 + + (年付 ${plan.yearlyPrice}/年) + +
    +
    + + {/* 核心权益列表 */} + 支持 + ) : ( + 仅单向写入 + ), + }, + { + label: '自定义企业独立域名', + value: plan.supportCustomDomain ? ( + 支持通配 + ) : ( + 平台二级域名 + ), + }, + { + label: 'CRM 商机线索上限', + value: `${plan.crmLeadLimit.toLocaleString()} 条`, + }, + ]} + /> + + +
    + + ); + })} + + + + {/* 编辑套餐规格 Modal */} + setEditModalVisible(false)} + width={520} + > +
    + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/admin/src/routes.ts b/admin/src/routes.ts index c31a8a9..581ffa3 100644 --- a/admin/src/routes.ts +++ b/admin/src/routes.ts @@ -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) => { diff --git a/admin/src/types/omni.ts b/admin/src/types/omni.ts index d56c275..0c2de87 100644 --- a/admin/src/types/omni.ts +++ b/admin/src/types/omni.ts @@ -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; +} + + diff --git a/admin/tests/platform-management.test.ts b/admin/tests/platform-management.test.ts new file mode 100644 index 0000000..0bdb4cb --- /dev/null +++ b/admin/tests/platform-management.test.ts @@ -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'); +}); diff --git a/backend/src/comm/types/omni.ts b/backend/src/comm/types/omni.ts index d7a5944..92f4045 100644 --- a/backend/src/comm/types/omni.ts +++ b/backend/src/comm/types/omni.ts @@ -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; +} + + diff --git a/backend/src/modules/team/controller/admin/platform.ts b/backend/src/modules/team/controller/admin/platform.ts new file mode 100644 index 0000000..9984509 --- /dev/null +++ b/backend/src/modules/team/controller/admin/platform.ts @@ -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; + } + ) { + const result = await this.platformSubscriptionService.updatePlan( + body.planType, + body.params + ); + return this.ok(result); + } +} diff --git a/backend/src/modules/team/service/currency-tax.ts b/backend/src/modules/team/service/currency-tax.ts index 3d11fb1..8e30c65 100644 --- a/backend/src/modules/team/service/currency-tax.ts +++ b/backend/src/modules/team/service/currency-tax.ts @@ -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; diff --git a/backend/src/modules/team/service/invoice.ts b/backend/src/modules/team/service/invoice.ts index 8ae0527..5f3ae03 100644 --- a/backend/src/modules/team/service/invoice.ts +++ b/backend/src/modules/team/service/invoice.ts @@ -19,6 +19,8 @@ const CURRENCY_SYMBOLS: Record = { EUR: '€', GBP: '£', CNY: '¥', + AED: 'AED ', + JPY: '¥', }; /** diff --git a/backend/src/modules/team/service/platform-subscription.ts b/backend/src/modules/team/service/platform-subscription.ts new file mode 100644 index 0000000..3eb08aa --- /dev/null +++ b/backend/src/modules/team/service/platform-subscription.ts @@ -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; + + @InjectEntityModel(TeamSubscriptionEntity) + teamSubscriptionEntity?: Repository; + + // 内存化种子会员数据池 (保障无缝离线测试与即席演示) + 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 { + 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 { + 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 { + return [...this.planConfigs]; + } + + /** + * 7. 更新指定套餐定价与配额参数 + */ + async updatePlan( + planType: TenantPlanType, + params: Partial + ): Promise { + const plan = this.planConfigs.find(p => p.planType === planType); + if (!plan) { + throw new Error(`套餐类型 ${planType} 不存在`); + } + + Object.assign(plan, params); + return plan; + } +} diff --git a/backend/test/modules/team/currency-tax.test.ts b/backend/test/modules/team/currency-tax.test.ts index 2eef195..1f418aa 100644 --- a/backend/test/modules/team/currency-tax.test.ts +++ b/backend/test/modules/team/currency-tax.test.ts @@ -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; diff --git a/backend/test/modules/team/platform-subscription.test.ts b/backend/test/modules/team/platform-subscription.test.ts new file mode 100644 index 0000000..1b62ac7 --- /dev/null +++ b/backend/test/modules/team/platform-subscription.test.ts @@ -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'); + }); + }); +});