feat: add platform admin operations

This commit is contained in:
Codex
2026-06-29 12:39:48 +08:00
parent 43c63f5c39
commit 3c5b7aca4f
12 changed files with 687 additions and 19 deletions

View File

@@ -33,7 +33,7 @@
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
- 题库导出目前完成服务端结构化 payloadPDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置;平台后台第一批页面已接入工作台、租户管理、账务中心公共题库授权。
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置;平台后台已接入工作台、租户管理、账务中心公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
更完整的进度看这些文档:
@@ -333,7 +333,7 @@ git diff --check
优先继续补:
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI平台后台完整业务页面,小程序兼容验证。
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI平台后台租户详情/审计/自动计费增强,小程序兼容验证。
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
4. 题库导出 PDF/Word worker、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
5. 真实 OAuth/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。

View File

@@ -60,12 +60,12 @@ pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色
```text
pages/platform-admin/workbench/index 平台工作台
pages/platform-admin/tenants/index 租户列表、状态、欠费、订阅到期
pages/platform-admin/billing/index SaaS 套餐、服务费账单、用量记录
pages/platform-admin/question-banks/index 平台公共题库、授权规则
pages/platform-admin/tenants/index 租户列表、创建租户、状态变更
pages/platform-admin/billing/index SaaS 套餐、订阅、服务费账单、收款、用量
pages/platform-admin/question-banks/index 平台公共题库、授权规则编辑
```
当前平台后台页面以只读联调和风险扫描为主。创建租户、修改状态、生成账单、确认收款、维护公共题库授权等写操作,后续应在表单校验二次确认、审计日志和平台权限点完善后继续接入
当前平台后台已接入创建租户、修改状态、开通订阅、生成订阅账单、确认线下收款、记录用量和公共题库授权编辑。页面会做基础表单校验二次确认,真正权限、租户/套餐校验、幂等和审计以后端 `platform-admin` API 为准。后续继续补租户详情页、平台审计报表、自动计费和更细平台权限点
## 前端环境变量

View File

@@ -1,9 +1,15 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { Input } from '@tarojs/components';
import {
confirmPlatformInvoicePayment,
createPlatformInvoiceFromSubscription,
createPlatformSubscription,
loadPlatformInvoices,
loadPlatformPlans,
loadPlatformUsage,
recordPlatformUsage,
type PlatformInvoiceItem,
type PlatformSaasPlan,
type PlatformUsageItem,
@@ -15,11 +21,46 @@ function money(cents: unknown, currency = 'CNY') {
return `${symbol}${(Number(cents || 0) / 100).toFixed(2)}`;
}
function todayText() {
return new Date().toISOString().slice(0, 10);
}
function centsFromYuan(value: string) {
const amount = Number(value || 0);
if (!Number.isFinite(amount) || amount <= 0) return 0;
return Math.round(amount * 100);
}
export default function PlatformBillingPage() {
const [status, setStatus] = useState('');
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
const [subscriptionForm, setSubscriptionForm] = useState({
tenantId: '',
planCode: '',
status: 'active',
amountYuan: '',
});
const [invoiceForm, setInvoiceForm] = useState({
tenantId: '',
dueDate: '',
note: '',
});
const [paymentForm, setPaymentForm] = useState({
tenantId: '',
invoiceId: '',
amountYuan: '',
providerTradeNo: '',
});
const [usageForm, setUsageForm] = useState({
tenantId: '',
metricKey: 'students',
metricValue: '',
periodStart: todayText(),
periodEnd: todayText(),
});
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
function reload(nextStatus = status) {
@@ -28,7 +69,9 @@ export default function PlatformBillingPage() {
loadPlatformInvoices({ status: nextStatus || undefined, limit: 100 }),
loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })),
]).then(([planPayload, invoicePayload, usagePayload]) => {
setPlans(planPayload.items || []);
const nextPlans = planPayload.items || [];
setPlans(nextPlans);
setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' }));
setInvoices(invoicePayload.items || []);
setUsage(usagePayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败'));
@@ -46,6 +89,132 @@ export default function PlatformBillingPage() {
const unpaidCents = invoices.reduce((sum, item) => sum + Number(item.balanceCents || 0), 0);
const paidCents = invoices.reduce((sum, item) => sum + Number(item.paidCents || 0), 0);
function updateSubscriptionForm(key: keyof typeof subscriptionForm, value: string) {
setSubscriptionForm(current => ({ ...current, [key]: value }));
}
function updateInvoiceForm(key: keyof typeof invoiceForm, value: string) {
setInvoiceForm(current => ({ ...current, [key]: value }));
}
function updatePaymentForm(key: keyof typeof paymentForm, value: string) {
setPaymentForm(current => ({ ...current, [key]: value }));
}
function updateUsageForm(key: keyof typeof usageForm, value: string) {
setUsageForm(current => ({ ...current, [key]: value }));
}
async function confirm(title: string, content: string) {
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
return result.confirm;
}
async function submitSubscription() {
setError('');
if (!subscriptionForm.tenantId || !subscriptionForm.planCode) {
setError('开通订阅需要填写租户 ID 和套餐编码。');
return;
}
const ok = await confirm('开通订阅', `确认给租户 ${subscriptionForm.tenantId} 开通 ${subscriptionForm.planCode}`);
if (!ok) return;
setBusy('subscription');
try {
await createPlatformSubscription({
tenantId: subscriptionForm.tenantId,
planCode: subscriptionForm.planCode,
status: subscriptionForm.status || 'active',
amountCents: subscriptionForm.amountYuan ? centsFromYuan(subscriptionForm.amountYuan) : undefined,
});
Taro.showToast({ title: '已开通', icon: 'success' });
reload(status);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '订阅开通失败');
} finally {
setBusy('');
}
}
async function submitInvoiceFromSubscription() {
setError('');
if (!invoiceForm.tenantId) {
setError('生成账单需要填写租户 ID。');
return;
}
const ok = await confirm('生成账单', `确认按租户 ${invoiceForm.tenantId} 最新订阅生成服务费账单?`);
if (!ok) return;
setBusy('invoice');
try {
await createPlatformInvoiceFromSubscription({
tenantId: invoiceForm.tenantId,
status: 'issued',
dueDate: invoiceForm.dueDate || undefined,
note: invoiceForm.note.trim() || undefined,
});
Taro.showToast({ title: '已生成', icon: 'success' });
reload(status);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '账单生成失败');
} finally {
setBusy('');
}
}
async function submitPaymentConfirm() {
setError('');
const amountCents = centsFromYuan(paymentForm.amountYuan);
if (!paymentForm.tenantId || !paymentForm.invoiceId || amountCents <= 0) {
setError('确认收款需要填写租户 ID、账单 ID 和正数金额。');
return;
}
const ok = await confirm('确认收款', `确认登记线下收款 ${money(amountCents)}?该动作会影响租户账务状态。`);
if (!ok) return;
setBusy('payment');
try {
await confirmPlatformInvoicePayment({
tenantId: paymentForm.tenantId,
invoiceId: paymentForm.invoiceId,
amountCents,
provider: 'manual',
method: 'manual',
providerTradeNo: paymentForm.providerTradeNo.trim() || undefined,
});
Taro.showToast({ title: '已确认', icon: 'success' });
reload(status);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '收款确认失败');
} finally {
setBusy('');
}
}
async function submitUsageRecord() {
setError('');
const metricValue = Number(usageForm.metricValue || 0);
if (!usageForm.tenantId || !usageForm.metricKey || !usageForm.periodStart || !usageForm.periodEnd || metricValue < 0) {
setError('记录用量需要填写租户、指标、数值和账期。');
return;
}
const ok = await confirm('记录用量', `确认写入 ${usageForm.metricKey} = ${metricValue} 的账期用量?`);
if (!ok) return;
setBusy('usage');
try {
await recordPlatformUsage({
tenantId: usageForm.tenantId,
metricKey: usageForm.metricKey,
metricValue,
periodStart: usageForm.periodStart,
periodEnd: usageForm.periodEnd,
});
Taro.showToast({ title: '已记录', icon: 'success' });
reload(status);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '用量记录失败');
} finally {
setBusy('');
}
}
return (
<View className='platform-page'>
<View className='platform-shell'>
@@ -73,6 +242,52 @@ export default function PlatformBillingPage() {
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(usage.length)}</Text></View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form compact'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='tenantId' value={subscriptionForm.tenantId} onInput={event => updateSubscriptionForm('tenantId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder={plans[0]?.code || 'plan_code'} value={subscriptionForm.planCode} onInput={event => updateSubscriptionForm('planCode', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='active / trial' value={subscriptionForm.status} onInput={event => updateSubscriptionForm('status', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='元,可留空用套餐价' type='digit' value={subscriptionForm.amountYuan} onInput={event => updateSubscriptionForm('amountYuan', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'subscription'} onClick={submitSubscription}></Button>
</View>
<View className='platform-form compact'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='tenantId' value={invoiceForm.tenantId} onInput={event => updateInvoiceForm('tenantId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='YYYY-MM-DD' value={invoiceForm.dueDate} onInput={event => updateInvoiceForm('dueDate', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='账单说明,可选' value={invoiceForm.note} onInput={event => updateInvoiceForm('note', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'invoice'} onClick={submitInvoiceFromSubscription}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form compact'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='tenantId' value={paymentForm.tenantId} onInput={event => updatePaymentForm('tenantId', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='invoiceId' value={paymentForm.invoiceId} onInput={event => updatePaymentForm('invoiceId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='元' type='digit' value={paymentForm.amountYuan} onInput={event => updatePaymentForm('amountYuan', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='可选' value={paymentForm.providerTradeNo} onInput={event => updatePaymentForm('providerTradeNo', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'payment'} onClick={submitPaymentConfirm}></Button>
</View>
<View className='platform-form compact'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='tenantId' value={usageForm.tenantId} onInput={event => updateUsageForm('tenantId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='students / questions / storage_gb' value={usageForm.metricKey} onInput={event => updateUsageForm('metricKey', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='0' type='digit' value={usageForm.metricValue} onInput={event => updateUsageForm('metricValue', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='YYYY-MM-DD' value={usageForm.periodStart} onInput={event => updateUsageForm('periodStart', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='YYYY-MM-DD' value={usageForm.periodEnd} onInput={event => updateUsageForm('periodEnd', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'usage'} onClick={submitUsageRecord}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'>SaaS </Text>
<View className='platform-list'>
@@ -96,6 +311,19 @@ export default function PlatformBillingPage() {
<Text className='platform-row-meta'>{item.tenantName || item.tenantSlug || item.tenantId} · {item.invoiceType || 'invoice'} · {item.status || '-'}</Text>
<Text className='platform-row-meta'> {money(item.totalCents, item.currency || 'CNY')} · {money(item.paidCents, item.currency || 'CNY')} · {money(item.balanceCents, item.currency || 'CNY')}</Text>
<Text className='platform-row-meta'> {item.dueDate ? String(item.dueDate).slice(0, 10) : '-'}</Text>
<View className='platform-row-actions'>
<Button className='platform-mini-button' onClick={() => {
setPaymentForm(current => ({
...current,
tenantId: item.tenantId,
invoiceId: item.id,
amountYuan: Number(item.balanceCents || 0) > 0 ? String(Number(item.balanceCents || 0) / 100) : current.amountYuan,
}));
setInvoiceForm(current => ({ ...current, tenantId: item.tenantId }));
setSubscriptionForm(current => ({ ...current, tenantId: item.tenantId }));
setUsageForm(current => ({ ...current, tenantId: item.tenantId }));
}}></Button>
</View>
</View>
))}
</View>

View File

@@ -167,6 +167,58 @@
font-size: 24px;
}
.platform-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 14px;
}
.platform-form.compact {
margin-top: 12px;
}
.platform-field {
min-width: 0;
}
.platform-field.wide {
grid-column: 1 / -1;
}
.platform-field-label {
display: block;
margin-bottom: 8px;
color: #475569;
font-size: 20px;
font-weight: 720;
}
.platform-row-actions {
display: flex;
gap: 10px;
margin-top: 14px;
overflow-x: auto;
}
.platform-mini-button {
min-width: 116px;
height: 54px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #1e3a8a;
font-size: 21px;
font-weight: 720;
line-height: 54px;
}
.platform-mini-button.danger {
border-color: #fecdd3;
background: #fff1f2;
color: #be123c;
}
.platform-error {
display: block;
margin-top: 12px;

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import {
loadPlatformQuestionBankGrants,
loadPlatformQuestionBanks,
upsertPlatformQuestionBankGrant,
type PlatformQuestionBankGrant,
type PlatformQuestionBankItem,
} from '@/services/platformAdmin';
@@ -23,6 +25,17 @@ export default function PlatformQuestionBanksPage() {
const [includeTenantBanks, setIncludeTenantBanks] = useState(false);
const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]);
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
const [grantForm, setGrantForm] = useState({
id: '',
sourceQuestionBankId: '',
grantScope: 'plans',
allowedPlanCodes: '',
allowedTenantIds: '',
status: 'active',
startsAt: '',
expiresAt: '',
});
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
function reload(nextKeyword = keyword, nextIncludeTenantBanks = includeTenantBanks) {
@@ -45,6 +58,62 @@ export default function PlatformQuestionBanksPage() {
reload(keyword, nextValue);
}
function updateGrantForm(key: keyof typeof grantForm, value: string) {
setGrantForm(current => ({ ...current, [key]: value }));
}
function commaList(value: string) {
return value.split(',').map(item => item.trim()).filter(Boolean);
}
async function confirm(title: string, content: string) {
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
return result.confirm;
}
async function submitGrant() {
setError('');
if (!grantForm.sourceQuestionBankId) {
setError('请先选择平台公共题库。');
return;
}
const allowedPlanCodes = commaList(grantForm.allowedPlanCodes);
const allowedTenantIds = commaList(grantForm.allowedTenantIds);
if (grantForm.grantScope === 'plans' && !allowedPlanCodes.length) {
setError('按套餐授权时必须填写套餐编码。');
return;
}
if (grantForm.grantScope === 'tenants' && !allowedTenantIds.length) {
setError('按租户授权时必须填写租户 ID。');
return;
}
if (grantForm.grantScope === 'mixed' && !allowedPlanCodes.length && !allowedTenantIds.length) {
setError('混合授权至少需要填写套餐编码或租户 ID。');
return;
}
const ok = await confirm('更新题库授权', '确认更新公共题库披露规则?租户后台可见范围会受该规则影响。');
if (!ok) return;
setBusy('grant');
try {
await upsertPlatformQuestionBankGrant({
id: grantForm.id || undefined,
sourceQuestionBankId: grantForm.sourceQuestionBankId,
grantScope: grantForm.grantScope,
allowedPlanCodes,
allowedTenantIds,
status: grantForm.status || 'active',
startsAt: grantForm.startsAt || undefined,
expiresAt: grantForm.expiresAt || undefined,
});
Taro.showToast({ title: '已保存', icon: 'success' });
reload(keyword, includeTenantBanks);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '授权保存失败');
} finally {
setBusy('');
}
}
return (
<View className='platform-page'>
<View className='platform-shell'>
@@ -67,6 +136,23 @@ export default function PlatformQuestionBanksPage() {
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(grants.filter(item => item.status === 'active').length)}</Text></View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form compact'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='留空则新增' value={grantForm.id} onInput={event => updateGrantForm('id', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='sourceQuestionBankId' value={grantForm.sourceQuestionBankId} onInput={event => updateGrantForm('sourceQuestionBankId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='plans / tenants / mixed / all_active_tenants' value={grantForm.grantScope} onInput={event => updateGrantForm('grantScope', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='active / disabled' value={grantForm.status} onInput={event => updateGrantForm('status', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='starter_yearly,regional_yearly' value={grantForm.allowedPlanCodes} onInput={event => updateGrantForm('allowedPlanCodes', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='多个用英文逗号分隔' value={grantForm.allowedTenantIds} onInput={event => updateGrantForm('allowedTenantIds', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='YYYY-MM-DD可选' value={grantForm.startsAt} onInput={event => updateGrantForm('startsAt', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='YYYY-MM-DD可选' value={grantForm.expiresAt} onInput={event => updateGrantForm('expiresAt', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'grant'} onClick={submitGrant}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
@@ -75,6 +161,9 @@ export default function PlatformQuestionBanksPage() {
<Text className='platform-row-main'>{item.name}</Text>
<Text className='platform-row-meta'>{item.regionName || '通用'} · {item.sourceScope || 'tenant'} · {item.status || '-'} · {String(item.questionCount || 0)} </Text>
<Text className='platform-row-meta'> {item.tenantName || item.tenantSlug || item.tenantId || '-'}</Text>
<View className='platform-row-actions'>
<Button className='platform-mini-button' onClick={() => setGrantForm(current => ({ ...current, sourceQuestionBankId: item.id }))}></Button>
</View>
</View>
))}
</View>
@@ -89,6 +178,28 @@ export default function PlatformQuestionBanksPage() {
<Text className='platform-row-main'>{item.sourceQuestionBankName || item.sourceQuestionBankId || item.id}</Text>
<Text className='platform-row-meta'>{item.sourceRegionName || '通用'} · {item.grantScope || 'plans'} · {targetText(item)} · {item.status || '-'}</Text>
<Text className='platform-row-meta'> {item.startsAt ? String(item.startsAt).slice(0, 10) : '-'} {item.expiresAt ? String(item.expiresAt).slice(0, 10) : '长期'}</Text>
<View className='platform-row-actions'>
<Button className='platform-mini-button' onClick={() => setGrantForm({
id: item.id,
sourceQuestionBankId: item.sourceQuestionBankId || '',
grantScope: item.grantScope || 'plans',
allowedPlanCodes: (item.allowedPlanCodes || []).join(','),
allowedTenantIds: (item.allowedTenantIds || []).join(','),
status: item.status || 'active',
startsAt: item.startsAt ? String(item.startsAt).slice(0, 10) : '',
expiresAt: item.expiresAt ? String(item.expiresAt).slice(0, 10) : '',
})}></Button>
<Button className='platform-mini-button danger' onClick={() => setGrantForm({
id: item.id,
sourceQuestionBankId: item.sourceQuestionBankId || '',
grantScope: item.grantScope || 'plans',
allowedPlanCodes: (item.allowedPlanCodes || []).join(','),
allowedTenantIds: (item.allowedTenantIds || []).join(','),
status: 'disabled',
startsAt: item.startsAt ? String(item.startsAt).slice(0, 10) : '',
expiresAt: item.expiresAt ? String(item.expiresAt).slice(0, 10) : '',
})}></Button>
</View>
</View>
))}
</View>

View File

@@ -1,16 +1,48 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import { loadPlatformTenants, type PlatformTenantItem } from '@/services/platformAdmin';
import {
createPlatformTenant,
loadPlatformPlans,
loadPlatformTenants,
updatePlatformTenantStatus,
type PlatformSaasPlan,
type PlatformTenantItem,
} from '@/services/platformAdmin';
import '../platform.css';
function money(cents: unknown) {
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
}
function centsFromYuan(value: string) {
const amount = Number(value || 0);
if (!Number.isFinite(amount) || amount < 0) return 0;
return Math.round(amount * 100);
}
export default function PlatformTenantsPage() {
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('');
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
const [tenants, setTenants] = useState<PlatformTenantItem[]>([]);
const [tenantForm, setTenantForm] = useState({
slug: '',
name: '',
legalName: '',
brandName: '',
primaryHost: '',
planCode: '',
billingStatus: 'trial',
amountYuan: '',
});
const [statusForm, setStatusForm] = useState({
tenantId: '',
status: 'active',
billingStatus: '',
reason: '',
});
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
function reload(nextStatus = status, nextKeyword = keyword) {
@@ -21,6 +53,11 @@ export default function PlatformTenantsPage() {
useEffect(() => {
reload('', '');
loadPlatformPlans().then(payload => {
const nextPlans = payload.items || [];
setPlans(nextPlans);
setTenantForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || 'starter_yearly' }));
}).catch(() => setPlans([]));
}, []);
function chooseStatus(nextStatus: string) {
@@ -28,6 +65,89 @@ export default function PlatformTenantsPage() {
reload(nextStatus, keyword);
}
function updateTenantForm(key: keyof typeof tenantForm, value: string) {
setTenantForm(current => ({ ...current, [key]: value }));
}
function updateStatusForm(key: keyof typeof statusForm, value: string) {
setStatusForm(current => ({ ...current, [key]: value }));
}
async function confirm(title: string, content: string) {
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
return result.confirm;
}
async function submitCreateTenant() {
setError('');
const slug = tenantForm.slug.trim();
const name = tenantForm.name.trim();
if (!slug || !name) {
setError('创建租户需要填写 slug 和租户名称。');
return;
}
const ok = await confirm('创建租户', `确认创建租户 ${name},并绑定套餐 ${tenantForm.planCode || 'starter_yearly'}`);
if (!ok) return;
setBusy('create');
try {
await createPlatformTenant({
slug,
name,
legalName: tenantForm.legalName.trim() || undefined,
brandName: tenantForm.brandName.trim() || name,
shortName: tenantForm.brandName.trim() || name,
primaryHost: tenantForm.primaryHost.trim() || undefined,
planCode: tenantForm.planCode || plans[0]?.code || 'starter_yearly',
billingStatus: tenantForm.billingStatus || 'trial',
amountCents: tenantForm.amountYuan ? centsFromYuan(tenantForm.amountYuan) : undefined,
});
Taro.showToast({ title: '已创建', icon: 'success' });
setTenantForm(current => ({
...current,
slug: '',
name: '',
legalName: '',
brandName: '',
primaryHost: '',
amountYuan: '',
}));
reload(status, keyword);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '创建租户失败');
} finally {
setBusy('');
}
}
async function submitStatusChange() {
setError('');
if (!statusForm.tenantId) {
setError('请先从租户列表选择要操作的租户。');
return;
}
if (!statusForm.status && !statusForm.billingStatus) {
setError('至少需要选择一个租户状态或账务状态。');
return;
}
const ok = await confirm('变更租户状态', '该操作会影响租户后台和学生端访问,请确认已完成线下沟通或风控检查。');
if (!ok) return;
setBusy('status');
try {
await updatePlatformTenantStatus({
tenantId: statusForm.tenantId,
status: statusForm.status || undefined,
billingStatus: statusForm.billingStatus || undefined,
reason: statusForm.reason.trim() || undefined,
});
Taro.showToast({ title: '已更新', icon: 'success' });
reload(status, keyword);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '状态更新失败');
} finally {
setBusy('');
}
}
return (
<View className='platform-page'>
<View className='platform-shell'>
@@ -59,6 +179,36 @@ export default function PlatformTenantsPage() {
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.filter(item => item.status === 'suspended').length)}</Text></View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form'>
<View className='platform-field'><Text className='platform-field-label'>slug</Text><Input className='platform-input' placeholder='tenant-slug' value={tenantForm.slug} onInput={event => updateTenantForm('slug', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='合作商名称' value={tenantForm.name} onInput={event => updateTenantForm('name', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='开票主体,可选' value={tenantForm.legalName} onInput={event => updateTenantForm('legalName', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='前台展示品牌,可选' value={tenantForm.brandName} onInput={event => updateTenantForm('brandName', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='tiku.example.com可选' value={tenantForm.primaryHost} onInput={event => updateTenantForm('primaryHost', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder={plans[0]?.code || 'starter_yearly'} value={tenantForm.planCode} onInput={event => updateTenantForm('planCode', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='trial / active' value={tenantForm.billingStatus} onInput={event => updateTenantForm('billingStatus', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='元,可留空用套餐默认价' type='digit' value={tenantForm.amountYuan} onInput={event => updateTenantForm('amountYuan', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'create'} onClick={submitCreateTenant}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form compact'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='从列表选择或粘贴 tenantId' value={statusForm.tenantId} onInput={event => updateStatusForm('tenantId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='active / suspended' value={statusForm.status} onInput={event => updateStatusForm('status', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='trial / active / past_due' value={statusForm.billingStatus} onInput={event => updateStatusForm('billingStatus', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='内部审计备注' value={statusForm.reason} onInput={event => updateStatusForm('reason', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'status'} onClick={submitStatusChange}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
@@ -68,6 +218,10 @@ export default function PlatformTenantsPage() {
<Text className='platform-row-meta'>{item.slug} · {item.legalName || '未填公司'} · {item.status || '-'} · {item.billingStatus || '-'}</Text>
<Text className='platform-row-meta'> {item.planCode || '未订阅'} · {item.subscriptionStatus || '-'} · {item.subscriptionExpiresAt ? String(item.subscriptionExpiresAt).slice(0, 10) : '-'}</Text>
<Text className='platform-row-meta'> {money(item.openBalanceCents)}</Text>
<View className='platform-row-actions'>
<Button className='platform-mini-button' onClick={() => setStatusForm(current => ({ ...current, tenantId: item.id, status: item.status || 'active', billingStatus: item.billingStatus || '' }))}></Button>
<Button className='platform-mini-button danger' onClick={() => setStatusForm({ tenantId: item.id, status: 'suspended', billingStatus: item.billingStatus || '', reason: 'platform manual suspend' })}></Button>
</View>
</View>
))}
</View>

View File

@@ -113,6 +113,73 @@ export interface PlatformQuestionBankGrant {
expiresAt?: string | null;
}
export interface CreatePlatformTenantInput {
slug: string;
name: string;
legalName?: string;
brandName?: string;
shortName?: string;
primaryHost?: string;
planCode?: string;
status?: string;
billingStatus?: string;
amountCents?: number;
}
export interface UpdatePlatformTenantStatusInput {
tenantId: string;
status?: string;
billingStatus?: string;
reason?: string;
}
export interface CreatePlatformSubscriptionInput {
tenantId: string;
planCode: string;
status?: string;
startsAt?: string;
expiresAt?: string;
amountCents?: number;
}
export interface CreatePlatformInvoiceFromSubscriptionInput {
tenantId: string;
subscriptionId?: string;
status?: string;
dueDate?: string;
note?: string;
}
export interface ConfirmPlatformInvoicePaymentInput {
tenantId: string;
invoiceId: string;
amountCents: number;
provider?: string;
method?: string;
providerTradeNo?: string;
}
export interface RecordPlatformUsageInput {
tenantId: string;
metricKey: string;
metricValue: number;
periodStart: string;
periodEnd: string;
}
export interface UpsertPlatformQuestionBankGrantInput {
id?: string;
sourceQuestionBankId: string;
grantScope: string;
allowedPlanCodes?: string[];
allowedTenantIds?: string[];
allowedRegionIds?: string[];
allowedSubjectIds?: string[];
status?: string;
startsAt?: string;
expiresAt?: string;
}
export async function loadPlatformOverview() {
return apiRequest<{ item?: PlatformOverview }>('/api/platform-admin/overview', { tenantId: null });
}
@@ -158,3 +225,59 @@ export async function loadPlatformQuestionBankGrants(query: { questionBankId?: s
tenantId: null,
});
}
export async function createPlatformTenant(input: CreatePlatformTenantInput) {
return apiRequest<{ item?: PlatformTenantItem }>('/api/platform-admin/tenants', {
method: 'POST',
body: input,
tenantId: null,
});
}
export async function updatePlatformTenantStatus(input: UpdatePlatformTenantStatusInput) {
return apiRequest<{ item?: PlatformTenantItem }>('/api/platform-admin/tenants/status', {
method: 'PATCH',
body: input,
tenantId: null,
});
}
export async function createPlatformSubscription(input: CreatePlatformSubscriptionInput) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/subscriptions', {
method: 'POST',
body: input,
tenantId: null,
});
}
export async function createPlatformInvoiceFromSubscription(input: CreatePlatformInvoiceFromSubscriptionInput) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/invoices/from-subscription', {
method: 'POST',
body: input,
tenantId: null,
});
}
export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoicePaymentInput) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/invoices/payments/manual-confirm', {
method: 'POST',
body: input,
tenantId: null,
});
}
export async function recordPlatformUsage(input: RecordPlatformUsageInput) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/usage', {
method: 'POST',
body: input,
tenantId: null,
});
}
export async function upsertPlatformQuestionBankGrant(input: UpsertPlatformQuestionBankGrantInput) {
return apiRequest<{ item?: PlatformQuestionBankGrant }>('/api/platform-admin/question-bank-grants', {
method: 'PUT',
body: input,
tenantId: null,
});
}

View File

@@ -35,7 +35,7 @@
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、PocketBase JSON dry-run 报告 | 字段映射 UI、真实数据 dry-run 执行验收和导入性能压测 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面 | 学生端完整体验、租户后台/平台后台写操作页面、小程序兼容验证和端到端测试 |
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;平台后台已接关键写操作第一版 | 学生端完整体验、租户后台写操作页面、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
## 前端接入建议
@@ -79,7 +79,7 @@
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
- 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
- 生产环境配置:`.env.example``npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
- Taro scaffold`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API下一步补学生端完整刷题体验、后台写操作台和小程序兼容验证。
- Taro scaffold`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,平台后台关键写操作第一版已接入;下一步补学生端完整刷题体验、租户后台写操作台、平台后台审计增强和小程序兼容验证。
### P1商用收费和运营能力

View File

@@ -92,8 +92,8 @@
| 页面 | 文件 | 已接接口 |
| --- | --- | --- |
| 工作台 | `apps/taro/src/pages/platform-admin/workbench/index.tsx` | `platform-admin/overview``tenants``invoices``question-banks``question-bank-grants` |
| 租户管理 | `apps/taro/src/pages/platform-admin/tenants/index.tsx` | `platform-admin/tenants` |
| 账务中心 | `apps/taro/src/pages/platform-admin/billing/index.tsx` | `platform-admin/plans``invoices``usage` |
| 公共题库 | `apps/taro/src/pages/platform-admin/question-banks/index.tsx` | `platform-admin/question-banks``question-bank-grants` |
| 租户管理 | `apps/taro/src/pages/platform-admin/tenants/index.tsx` | `platform-admin/tenants``POST tenants``PATCH tenants/status` |
| 账务中心 | `apps/taro/src/pages/platform-admin/billing/index.tsx` | `platform-admin/plans``invoices``usage``subscriptions``invoices/from-subscription``invoices/payments/manual-confirm``POST usage` |
| 公共题库 | `apps/taro/src/pages/platform-admin/question-banks/index.tsx` | `platform-admin/question-banks``question-bank-grants``PUT question-bank-grants` |
当前平台后台页面以只读联调为主。下一批继续补创建/编辑租户、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑、平台审计报表和高风险操作二次确认
当前平台后台已经具备第一批写操作台:创建租户、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限和审计。下一批继续补租户详情页、编辑租户基础资料、平台审计报表、自动计费、账单批量操作和更细平台权限点

View File

@@ -72,11 +72,11 @@
| 功能 | 新后端状态 | 待补齐 |
| --- | --- | --- |
| 创建/管理租户 | 已覆盖 | 平台后台租户列表页已接真实 API创建/编辑/状态变更表单、详情页和高风险操作确认待补 |
| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户和状态变更已接真实 API编辑租户基础资料、详情页和平台审计报表待补 |
| SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 |
| 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 |
| 租户用量记录 | 已覆盖 | 自动采集 worker 待补 |
| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、冲突查询 API 和平台后台只读页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺版本通知、冲突处理操作台、批量接受/保留策略和授权编辑 UI |
| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、冲突查询 API 和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺版本通知、冲突处理操作台、批量接受/保留策略 |
| 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 |
| 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 |

View File

@@ -162,7 +162,7 @@
- H5 和小程序共用同一套业务 API client。
- 租户通过域名、小程序配置或启动参数解析。
- 页面主题、品牌、功能开关都从后端租户配置读取。
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client学生端、租户后台、平台后台都有第一批真实 API 页面;下一步补完整写操作、状态管理和小程序兼容验证。
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client学生端、租户后台、平台后台都有第一批真实 API 页面;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;下一步补租户后台写操作、学生端完整体验、状态管理和小程序兼容验证。
### 第一批页面
@@ -212,7 +212,7 @@
1. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、订单收银台。
2. 补租户后台写操作台:导入 preview/import/issues、字段映射、公共题库采纳/同步、角色模板、CRM/分佣。
3. 补平台后台写操作台:租户创建/编辑、订阅、账单、收款确认、用量录入、公共题库授权编辑和平台审计
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
6. 并行补对象存储、真实登录、完整资金流水对账、题库导出 PDF/Word worker 和公共题库版本通知/冲突操作台。

View File

@@ -1393,7 +1393,7 @@ npm run build:taro:h5:platform
- 学生端:选地区、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、订单收银台和订单详情。
- 租户后台:写入表单、字段映射 UI、导入 preview/import/issues 操作台、公共题库采纳/同步、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作。
- 平台后台:租户创建/编辑/状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑、平台审计
- 平台后台:租户创建状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
## 租户后台前端建议