feat: complete SaaS commercial delivery workflows

This commit is contained in:
2026-08-01 15:26:21 +08:00
parent 46abf4d62f
commit d58bcd97e9
56 changed files with 27505 additions and 181 deletions

View File

@@ -2,6 +2,7 @@ import type { PlatformOperation } from './types';
export const platformGroups = [
{ key: 'overview', label: '经营概览', description: '平台经营指标与实时概览' },
{ key: 'operations', label: '运行中心', description: 'Worker、后台任务与依赖健康状态' },
{ key: 'tenants', label: '租户管理', description: '租户、详情、域名与账务资料' },
{ key: 'catalog', label: 'SaaS 商品', description: '功能、限额、套餐与版本生命周期' },
{ key: 'billing', label: '订阅与账务', description: '订单、订阅、支付、退款、发票、用量与催缴' },
@@ -18,6 +19,7 @@ export type PlatformGroupKey = (typeof platformGroups)[number]['key'];
export function groupForOperation(operation: PlatformOperation): PlatformGroupKey {
const route = operation.path;
if (route === '/api/platform-admin/overview') return 'overview';
if (route.startsWith('/api/platform-admin/operations')) return 'operations';
if (route.startsWith('/api/platform-admin/tenants') || route.startsWith('/api/platform-admin/domains')) return 'tenants';
if (route.startsWith('/api/platform-admin/question-banks')) return 'question-banks';
if (route.startsWith('/api/platform-admin/tenant-capabilities/crm')) return 'crm';

View File

@@ -73,6 +73,9 @@ export async function apiRequest<T = unknown>(
appendQuery(url, ((input.query || {}) as Record<string, unknown>));
route = `${url.pathname}${url.search}`;
const headers: Record<string, string> = {};
for (const [name, value] of Object.entries((input.headers || {}) as Record<string, unknown>)) {
if (value !== undefined && value !== null && value !== '') headers[name] = String(value);
}
const init: RequestInit = { method: operation.method, headers, signal };
if (input.body !== undefined && operation.method !== 'GET') {
headers['Content-Type'] = 'application/json';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ export function normalizeOperationInput(operation: PlatformOperation, values: Op
export function OperationForm({ operation }: { operation: PlatformOperation }) {
const pathParameters = operation.parameters.filter((item) => item.in === 'path');
const queryParameters = operation.parameters.filter((item) => item.in === 'query');
const headerParameters = operation.parameters.filter((item) => item.in === 'header');
const bodySchema = resolveSchema(operation.requestSchema);
return (
<>
@@ -53,6 +54,17 @@ export function OperationForm({ operation }: { operation: PlatformOperation }) {
}}
/>
)}
{headerParameters.map((parameter) => (
<Form.Item
key={`header-${parameter.name}`}
name={['headers', parameter.name]}
label={parameter.description || parameter.name}
rules={[{ required: parameter.required, message: `请填写${parameter.description || parameter.name}` }]}
initialValue={parameter.name.toLowerCase() === 'idempotency-key' ? crypto.randomUUID() : undefined}
>
<Input />
</Form.Item>
))}
{operation.requestSchema && schemaType(bodySchema) === 'object' && (
<SchemaFields schema={bodySchema} prefix="body" required={bodySchema.required} />
)}

View File

@@ -26,10 +26,10 @@ const items = [
{ key: '/', icon: <DashboardOutlined />, label: '经营概览' },
{ type: 'group' as const, label: '业务运营', children: [
{ key: '/tenants', icon: <ApartmentOutlined />, label: '租户管理' },
{ key: '/subscriptions', icon: <ShopOutlined />, label: '套餐与订阅' },
{ key: '/billing', icon: <BankOutlined />, label: '订单与发票' },
{ key: '/subscriptions', icon: <ShopOutlined />, label: '订阅与应收' },
{ key: '/usage', icon: <PieChartOutlined />, label: '用量计费' },
{ key: '/dunning', icon: <FileTextOutlined />, label: '收款与催缴' },
{ key: '/refunds', icon: <BankOutlined />, label: '退款处理' },
] },
{ type: 'group' as const, label: '内容资产', children: [
{ key: '/question-banks', icon: <BookOutlined />, label: '公共题库' },
@@ -43,13 +43,14 @@ const items = [
{ key: '/staff', icon: <UserSwitchOutlined />, label: '员工与角色' },
{ key: '/audit', icon: <AuditOutlined />, label: '审计日志' },
{ key: '/alerts', icon: <AlertOutlined />, label: '审计告警' },
{ key: '/operations', icon: <DashboardOutlined />, label: '运行中心' },
] },
];
const pageTitles: Record<string, string> = {
'/': '经营概览', '/tenants': '租户管理', '/subscriptions': '套餐与订阅', '/billing': '订单与发票',
'/usage': '用量计费', '/dunning': '收款与催缴', '/question-banks': '公共题库', '/crm': 'CRM 服务',
'/sms': '短信服务', '/payments': '支付服务', '/staff': '员工与角色', '/audit': '审计日志', '/alerts': '审计告警',
'/': '经营概览', '/tenants': '租户开通', '/subscriptions': '订阅与应收', '/billing': '订阅与应收',
'/usage': '用量计费', '/dunning': '收款与催缴', '/refunds': '退款处理', '/question-banks': '公共题库', '/crm': 'CRM 服务',
'/sms': '短信服务', '/payments': '支付服务', '/staff': '员工与角色', '/audit': '审计日志', '/alerts': '审计告警', '/operations': '运行中心',
};
export function AppLayout() {

View File

@@ -0,0 +1,163 @@
import { AuditOutlined, ExclamationCircleOutlined, ReloadOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Col, Descriptions, Drawer, Empty, Flex, Form, Row, Space, Statistic, Tabs, Tag, Timeline, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { apiRequest } from '../api/http';
import { platformOperations } from '../api/platform-operations.generated';
import type { OperationInput, PlatformOperation } from '../api/types';
import { BusinessTable, rowsFromPayload } from '../components/BusinessTable';
import { normalizeOperationInput, OperationForm } from '../components/OperationForm';
type WorkbenchKey = 'onboarding' | 'receivables' | 'dunning' | 'refunds';
const workbenches: Record<WorkbenchKey, {
title: string;
description: string;
matches(operation: PlatformOperation): boolean;
preferredRead: string;
}> = {
onboarding: {
title: '租户开通工作台',
description: '在一个工作流中完成租户、Owner 激活、试用套餐和收款策略配置。激活令牌只显示一次,请通过安全渠道交付。',
preferredRead: 'GET /api/platform-admin/tenants',
matches: (operation) => operation.path.startsWith('/api/platform-admin/tenants') || operation.path === '/api/platform-admin/saas/subscriptions/trial',
},
receivables: {
title: '订阅与应收工作台',
description: '统一查看商业指标、订阅、订单和应收,并执行暂停、恢复、取消、延长及人工收款。',
preferredRead: 'GET /api/platform-admin/saas/metrics',
matches: (operation) => operation.path === '/api/platform-admin/saas/metrics' || operation.path.startsWith('/api/platform-admin/saas/subscriptions') || operation.path.startsWith('/api/platform-admin/saas/orders') || operation.path === '/api/platform-admin/saas/invoices' || operation.path.startsWith('/api/platform-admin/saas/payments'),
},
dunning: {
title: '催缴工作台',
description: '跟踪提醒、外部投递、失败原因和重试状态;高风险渠道变更和人工重试均需要二次确认。',
preferredRead: 'GET /api/platform-admin/saas/dunning/events',
matches: (operation) => operation.path.startsWith('/api/platform-admin/saas/dunning') || operation.path.startsWith('/api/platform-admin/saas/invoices/reminders'),
},
refunds: {
title: '退款工作台',
description: '按申请、审核、执行和失败重试处理 SaaS 退款;每次申请必须明确服务保持、期末取消或立即终止。',
preferredRead: 'GET /api/platform-admin/saas/refunds',
matches: (operation) => operation.path.startsWith('/api/platform-admin/saas/refunds'),
},
};
function prefill(operation: PlatformOperation, record: Record<string, unknown> | null): OperationInput {
if (!record) return {};
const path = Object.fromEntries(operation.parameters
.filter((parameter) => parameter.in === 'path')
.map((parameter) => [parameter.name, record[parameter.name] ?? record.id]));
return { path };
}
function isDangerous(operation: PlatformOperation) {
return /cancel|suspend|refund|reject|retry|disable|terminate/i.test(`${operation.path} ${operation.summary}`);
}
function statusTimeline(record: Record<string, unknown> | null) {
if (!record) return [];
const items = [
['创建', record.createdAt],
['最近更新', record.updatedAt],
['最近尝试', record.lastAttemptAt],
['完成', record.completedAt ?? record.sentAt ?? record.paidAt],
].filter((item) => item[1]);
return items.map(([label, value]) => ({ children: `${label}${String(value)}` }));
}
export function CommercialWorkbenchPage({ workbenchKey }: { workbenchKey: WorkbenchKey }) {
const definition = workbenches[workbenchKey];
const operations = useMemo(() => platformOperations.filter(definition.matches), [definition]);
const reads = operations.filter((operation) => operation.method === 'GET');
const writes = operations.filter((operation) => operation.method !== 'GET');
const [activeReadId, setActiveReadId] = useState(reads.find((item) => item.id === definition.preferredRead)?.id || reads[0]?.id || '');
const [payload, setPayload] = useState<unknown>();
const [selected, setSelected] = useState<Record<string, unknown> | null>(null);
const [action, setAction] = useState<PlatformOperation | null>(null);
const [loading, setLoading] = useState(false);
const [filterForm] = Form.useForm<OperationInput>();
const [actionForm] = Form.useForm<OperationInput>();
const { message, modal } = App.useApp();
const activeRead = reads.find((operation) => operation.id === activeReadId) || reads[0];
const load = async () => {
if (!activeRead) return;
setLoading(true);
try {
setPayload(await apiRequest(activeRead, normalizeOperationInput(activeRead, filterForm.getFieldsValue(true))));
} catch (error) {
message.error(error instanceof Error ? error.message : '工作台数据加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
filterForm.resetFields();
setSelected(null);
void load();
}, [activeReadId]);
const open = (operation: PlatformOperation) => {
setAction(operation);
actionForm.resetFields();
queueMicrotask(() => actionForm.setFieldsValue(prefill(operation, selected)));
};
const submit = async () => {
if (!action) return;
const input = normalizeOperationInput(action, await actionForm.validateFields());
const danger = isDangerous(action);
modal.confirm({
title: `确认${action.summary}`,
icon: danger ? <ExclamationCircleOutlined /> : <SafetyCertificateOutlined />,
content: danger ? '这是商业状态高风险操作。请再次核对租户、金额、订阅影响和原因;执行结果会进入审计日志。' : '提交后会写入平台审计日志,请确认业务信息无误。',
okText: danger ? '确认并执行' : '确认执行',
okButtonProps: { danger },
onOk: async () => {
setLoading(true);
try {
const result = await apiRequest(action, input);
if (workbenchKey === 'onboarding' && result && typeof result === 'object' && 'activationToken' in result) {
const token = (result as Record<string, unknown>).activationToken;
modal.success({ title: '租户开通成功', content: token ? 'Owner 激活令牌已签发且只显示本次,请立即通过安全渠道交付。' : '这是幂等重放,系统不会再次返回 Owner 激活令牌。' });
}
message.success(`${action.summary}完成`);
setAction(null);
await load();
} catch (error) {
message.error(error instanceof Error ? error.message : '操作失败');
} finally {
setLoading(false);
}
},
});
};
const metric = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload as Record<string, unknown> : null;
const rows = rowsFromPayload(payload);
const failedReason = selected?.lastError ?? selected?.reviewReason;
return (
<div className="business-page commercial-workbench">
<Flex justify="space-between" align="end" gap={16} wrap>
<div><Typography.Text type="secondary"></Typography.Text><Typography.Title level={2}>{definition.title}</Typography.Title><Typography.Paragraph type="secondary">{definition.description}</Typography.Paragraph></div>
<Space wrap>{writes.map((operation) => <Button key={operation.id} danger={isDangerous(operation)} type={isDangerous(operation) ? 'default' : 'primary'} onClick={() => open(operation)}>{operation.summary}</Button>)}</Space>
</Flex>
{workbenchKey === 'receivables' && metric && activeRead?.path.endsWith('/metrics') && (
<Row gutter={[16, 16]}>{['mrrCents', 'arrCents', 'collectedCents', 'outstandingCents', 'overdueCents', 'refundedCents'].map((key) => <Col xs={12} lg={8} xl={4} key={key}><Card><Statistic title={key} value={Number(metric[key] || 0) / 100} prefix="¥" precision={2} /></Card></Col>)}</Row>
)}
<Card className="business-card">
<Tabs activeKey={activeRead?.id} onChange={setActiveReadId} items={reads.map((operation) => ({ key: operation.id, label: operation.summary }))} />
{activeRead ? <><Form form={filterForm} layout="inline" className="business-filter"><OperationForm operation={activeRead} /><Button icon={<ReloadOutlined />} onClick={() => void load()}></Button></Form><BusinessTable payload={payload} loading={loading} onSelect={setSelected} /></> : <Empty description="暂无查询接口" />}
</Card>
{selected && <Card title="业务状态与审计" extra={<Link to={`/audit?targetId=${String(selected.id ?? selected.tenantId ?? '')}`}><AuditOutlined /> </Link>}>
{Boolean(failedReason) && <Alert type="error" showIcon message="最近失败原因" description={String(failedReason)} />}
<Row gutter={24}><Col xs={24} lg={14}><Descriptions column={2} size="small" items={Object.entries(selected).filter(([, value]) => value == null || ['string', 'number', 'boolean'].includes(typeof value)).slice(0, 10).map(([key, value]) => ({ key, label: key, children: /status/i.test(key) ? <Tag>{String(value ?? '-')}</Tag> : String(value ?? '-') }))} /></Col><Col xs={24} lg={10}><Timeline items={statusTimeline(selected)} /></Col></Row>
</Card>}
<Drawer title={action?.summary} width={640} open={Boolean(action)} onClose={() => setAction(null)} footer={<Flex justify="end" gap={8}><Button onClick={() => setAction(null)}></Button><Button type="primary" danger={action ? isDangerous(action) : false} loading={loading} onClick={() => void submit()}></Button></Flex>}>
{action && <Form form={actionForm} layout="vertical" requiredMark="optional"><OperationForm operation={action} /></Form>}
</Drawer>
</div>
);
}

View File

@@ -13,7 +13,7 @@ const starts = (operation: PlatformOperation, prefix: string) => operation.path.
export const businessPages: readonly BusinessPageDefinition[] = [
{ key: 'tenants', eyebrow: '租户管理', title: '租户列表与生命周期', description: '管理租户主体、域名、业务状态、账务状态与开票资料。', matches: (op) => starts(op, '/api/platform-admin/tenants') || starts(op, '/api/platform-admin/domains') },
{ key: 'subscriptions', eyebrow: 'SaaS 账务', title: '套餐与订阅', description: '维护 SaaS 功能、限额、商品、版本与租户订阅。', matches: (op) => starts(op, '/api/platform-admin/saas/catalog') || starts(op, '/api/platform-admin/saas/features') || starts(op, '/api/platform-admin/saas/feature-limits') || starts(op, '/api/platform-admin/saas/offerings') || starts(op, '/api/platform-admin/saas/offering-versions') || starts(op, '/api/platform-admin/saas/tenant-feature-overrides') || starts(op, '/api/platform-admin/saas/subscriptions') },
{ key: 'billing', eyebrow: 'SaaS 账务', title: '服务费账单与交易', description: '集中查看平台订单、退款和服务费账单。', matches: (op) => starts(op, '/api/platform-admin/saas/orders') || starts(op, '/api/platform-admin/saas/refunds') || op.path === '/api/platform-admin/saas/invoices' },
{ key: 'billing', eyebrow: 'SaaS 账务', title: '服务费账单与交易', description: '集中查看平台订单、退款和服务费账单。', matches: (op) => starts(op, '/api/platform-admin/saas/orders') || starts(op, '/api/platform-admin/saas/refunds') || op.path === '/api/platform-admin/saas/invoices' || op.path === '/api/platform-admin/saas/metrics' },
{ key: 'usage', eyebrow: 'SaaS 账务', title: '用量与超额计费', description: '按租户和账期查看席位、题量、存储等 SaaS 用量。', matches: (op) => starts(op, '/api/platform-admin/saas/usage') },
{ key: 'dunning', eyebrow: 'SaaS 账务', title: '收款、逾期与催缴', description: '处理人工收款、账单提醒、催缴渠道与失败事件重试。', matches: (op) => starts(op, '/api/platform-admin/saas/payments') || starts(op, '/api/platform-admin/saas/dunning') || starts(op, '/api/platform-admin/saas/invoices/reminders') },
{ key: 'question-banks', eyebrow: '平台资产', title: '公共题库工作台', description: '维护题库、内容结构、题目版本、批量导入与资源上传。', matches: (op) => starts(op, '/api/platform-admin/question-banks') },
@@ -23,6 +23,7 @@ export const businessPages: readonly BusinessPageDefinition[] = [
{ key: 'staff', eyebrow: '安全治理', title: '平台员工与权限', description: '管理平台员工、角色、权限绑定与账号状态。', matches: (op) => starts(op, '/api/platform-admin/staff') || starts(op, '/api/backoffice/platform') },
{ key: 'audit', eyebrow: '安全治理', title: '平台审计日志', description: '检索跨租户敏感操作、账务变更与权限事件。', matches: (op) => starts(op, '/api/platform-admin/audit-logs') },
{ key: 'alerts', eyebrow: '安全治理', title: '审计告警与处理', description: '按开放、确认、解决或忽略状态处理平台安全告警。', matches: (op) => starts(op, '/api/platform-admin/audit-alerts') },
{ key: 'operations', eyebrow: '运行治理', title: '任务与 Worker 运行中心', description: '查看依赖健康、Worker 心跳、后台任务积压,并处理失败任务。', matches: (op) => starts(op, '/api/platform-admin/operations') },
];
export function businessPageForOperation(operation: PlatformOperation) {

View File

@@ -4,6 +4,7 @@ import { DashboardPage } from './pages/DashboardPage';
import { LoginPage } from './pages/LoginPage';
import { BusinessPage } from './pages/BusinessPage';
import { QuestionBankPage } from './pages/QuestionBankPage';
import { CommercialWorkbenchPage } from './pages/CommercialWorkbenchPage';
export const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
@@ -12,11 +13,12 @@ export const router = createBrowserRouter([
element: <AppLayout />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'tenants', element: <BusinessPage pageKey="tenants" /> },
{ path: 'subscriptions', element: <BusinessPage pageKey="subscriptions" /> },
{ path: 'billing', element: <BusinessPage pageKey="billing" /> },
{ path: 'tenants', element: <CommercialWorkbenchPage workbenchKey="onboarding" /> },
{ path: 'subscriptions', element: <CommercialWorkbenchPage workbenchKey="receivables" /> },
{ path: 'billing', element: <Navigate to="/subscriptions" replace /> },
{ path: 'usage', element: <BusinessPage pageKey="usage" /> },
{ path: 'dunning', element: <BusinessPage pageKey="dunning" /> },
{ path: 'dunning', element: <CommercialWorkbenchPage workbenchKey="dunning" /> },
{ path: 'refunds', element: <CommercialWorkbenchPage workbenchKey="refunds" /> },
{ path: 'question-banks', element: <QuestionBankPage /> },
{ path: 'crm', element: <BusinessPage pageKey="crm" /> },
{ path: 'sms', element: <BusinessPage pageKey="sms" /> },
@@ -24,6 +26,7 @@ export const router = createBrowserRouter([
{ path: 'staff', element: <BusinessPage pageKey="staff" /> },
{ path: 'audit', element: <BusinessPage pageKey="audit" /> },
{ path: 'alerts', element: <BusinessPage pageKey="alerts" /> },
{ path: 'operations', element: <BusinessPage pageKey="operations" /> },
],
},
{ path: '*', element: <Navigate to="/" replace /> },