feat: complete SaaS commercial delivery workflows
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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
2152
Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts
vendored
2152
Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
163
Tiku.PlatformAdmin.Web/src/pages/CommercialWorkbenchPage.tsx
Normal file
163
Tiku.PlatformAdmin.Web/src/pages/CommercialWorkbenchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user