From 3c5b7aca4ffd9342fe21a446148e0208c7584812 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 12:39:48 +0800 Subject: [PATCH] feat: add platform admin operations --- README.md | 4 +- apps/taro/README.md | 8 +- .../pages/platform-admin/billing/index.tsx | 230 +++++++++++++++++- .../src/pages/platform-admin/platform.css | 52 ++++ .../platform-admin/question-banks/index.tsx | 111 +++++++++ .../pages/platform-admin/tenants/index.tsx | 156 +++++++++++- apps/taro/src/services/platformAdmin.ts | 123 ++++++++++ docs/refactor/backend-handoff-roadmap.md | 4 +- docs/refactor/frontend-handoff-index.md | 8 +- docs/refactor/legacy-feature-gap-matrix.md | 4 +- docs/refactor/next-development-todo.md | 4 +- docs/refactor/taro-frontend-integration.md | 2 +- 12 files changed, 687 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index a4a4b47c..6c19fffb 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - 题库导出目前完成服务端结构化 payload;PDF/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/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/taro/README.md b/apps/taro/README.md index 2d136fca..2b971630 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -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 为准。后续继续补租户详情页、平台审计报表、自动计费和更细平台权限点。 ## 前端环境变量 diff --git a/apps/taro/src/pages/platform-admin/billing/index.tsx b/apps/taro/src/pages/platform-admin/billing/index.tsx index 69adea43..889e1970 100644 --- a/apps/taro/src/pages/platform-admin/billing/index.tsx +++ b/apps/taro/src/pages/platform-admin/billing/index.tsx @@ -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([]); const [invoices, setInvoices] = useState([]); const [usage, setUsage] = useState([]); + 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 ( @@ -73,6 +242,52 @@ export default function PlatformBillingPage() { 用量记录{String(usage.length)} + + 订阅与账单操作 + + 租户 ID updateSubscriptionForm('tenantId', String(event.detail.value || ''))} /> + 套餐编码 updateSubscriptionForm('planCode', String(event.detail.value || ''))} /> + 订阅状态 updateSubscriptionForm('status', String(event.detail.value || ''))} /> + 订阅金额 updateSubscriptionForm('amountYuan', String(event.detail.value || ''))} /> + + + + + + + 租户 ID updateInvoiceForm('tenantId', String(event.detail.value || ''))} /> + 到期日 updateInvoiceForm('dueDate', String(event.detail.value || ''))} /> + 备注 updateInvoiceForm('note', String(event.detail.value || ''))} /> + + + + + + + + 收款与用量 + + 租户 ID updatePaymentForm('tenantId', String(event.detail.value || ''))} /> + 账单 ID updatePaymentForm('invoiceId', String(event.detail.value || ''))} /> + 收款金额 updatePaymentForm('amountYuan', String(event.detail.value || ''))} /> + 流水号 updatePaymentForm('providerTradeNo', String(event.detail.value || ''))} /> + + + + + + + 租户 ID updateUsageForm('tenantId', String(event.detail.value || ''))} /> + 指标 updateUsageForm('metricKey', String(event.detail.value || ''))} /> + 数值 updateUsageForm('metricValue', String(event.detail.value || ''))} /> + 开始 updateUsageForm('periodStart', String(event.detail.value || ''))} /> + 结束 updateUsageForm('periodEnd', String(event.detail.value || ''))} /> + + + + + + SaaS 套餐 @@ -96,6 +311,19 @@ export default function PlatformBillingPage() { {item.tenantName || item.tenantSlug || item.tenantId} · {item.invoiceType || 'invoice'} · {item.status || '-'} 总额 {money(item.totalCents, item.currency || 'CNY')} · 已收 {money(item.paidCents, item.currency || 'CNY')} · 余额 {money(item.balanceCents, item.currency || 'CNY')} 到期 {item.dueDate ? String(item.dueDate).slice(0, 10) : '-'} + + + ))} diff --git a/apps/taro/src/pages/platform-admin/platform.css b/apps/taro/src/pages/platform-admin/platform.css index 637c396f..8c887e52 100644 --- a/apps/taro/src/pages/platform-admin/platform.css +++ b/apps/taro/src/pages/platform-admin/platform.css @@ -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; diff --git a/apps/taro/src/pages/platform-admin/question-banks/index.tsx b/apps/taro/src/pages/platform-admin/question-banks/index.tsx index 60c756dd..c7a316ee 100644 --- a/apps/taro/src/pages/platform-admin/question-banks/index.tsx +++ b/apps/taro/src/pages/platform-admin/question-banks/index.tsx @@ -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([]); const [grants, setGrants] = useState([]); + 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 ( @@ -67,6 +136,23 @@ export default function PlatformQuestionBanksPage() { 活跃授权{String(grants.filter(item => item.status === 'active').length)} + + 授权编辑 + + 授权 ID updateGrantForm('id', String(event.detail.value || ''))} /> + 题库 ID updateGrantForm('sourceQuestionBankId', String(event.detail.value || ''))} /> + 范围 updateGrantForm('grantScope', String(event.detail.value || ''))} /> + 状态 updateGrantForm('status', String(event.detail.value || ''))} /> + 套餐编码 updateGrantForm('allowedPlanCodes', String(event.detail.value || ''))} /> + 租户 ID updateGrantForm('allowedTenantIds', String(event.detail.value || ''))} /> + 开始 updateGrantForm('startsAt', String(event.detail.value || ''))} /> + 结束 updateGrantForm('expiresAt', String(event.detail.value || ''))} /> + + + + + + 公共题库 @@ -75,6 +161,9 @@ export default function PlatformQuestionBanksPage() { {item.name} {item.regionName || '通用'} · {item.sourceScope || 'tenant'} · {item.status || '-'} · {String(item.questionCount || 0)} 题 来源租户 {item.tenantName || item.tenantSlug || item.tenantId || '-'} + + + ))} @@ -89,6 +178,28 @@ export default function PlatformQuestionBanksPage() { {item.sourceQuestionBankName || item.sourceQuestionBankId || item.id} {item.sourceRegionName || '通用'} · {item.grantScope || 'plans'} · {targetText(item)} · {item.status || '-'} 有效期 {item.startsAt ? String(item.startsAt).slice(0, 10) : '-'} 至 {item.expiresAt ? String(item.expiresAt).slice(0, 10) : '长期'} + + + + ))} diff --git a/apps/taro/src/pages/platform-admin/tenants/index.tsx b/apps/taro/src/pages/platform-admin/tenants/index.tsx index 144076e4..c83a8f63 100644 --- a/apps/taro/src/pages/platform-admin/tenants/index.tsx +++ b/apps/taro/src/pages/platform-admin/tenants/index.tsx @@ -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([]); const [tenants, setTenants] = useState([]); + 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 ( @@ -59,6 +179,36 @@ export default function PlatformTenantsPage() { 暂停租户{String(tenants.filter(item => item.status === 'suspended').length)} + + 创建租户 + + slug updateTenantForm('slug', String(event.detail.value || ''))} /> + 租户名称 updateTenantForm('name', String(event.detail.value || ''))} /> + 公司名称 updateTenantForm('legalName', String(event.detail.value || ''))} /> + 品牌名称 updateTenantForm('brandName', String(event.detail.value || ''))} /> + 主域名 updateTenantForm('primaryHost', String(event.detail.value || ''))} /> + 套餐编码 updateTenantForm('planCode', String(event.detail.value || ''))} /> + 账务状态 updateTenantForm('billingStatus', String(event.detail.value || ''))} /> + 首期金额 updateTenantForm('amountYuan', String(event.detail.value || ''))} /> + + + + + + + + 状态变更 + + 租户 ID updateStatusForm('tenantId', String(event.detail.value || ''))} /> + 租户状态 updateStatusForm('status', String(event.detail.value || ''))} /> + 账务状态 updateStatusForm('billingStatus', String(event.detail.value || ''))} /> + 原因 updateStatusForm('reason', String(event.detail.value || ''))} /> + + + + + + 租户列表 @@ -68,6 +218,10 @@ export default function PlatformTenantsPage() { {item.slug} · {item.legalName || '未填公司'} · {item.status || '-'} · {item.billingStatus || '-'} 套餐 {item.planCode || '未订阅'} · 订阅 {item.subscriptionStatus || '-'} · 到期 {item.subscriptionExpiresAt ? String(item.subscriptionExpiresAt).slice(0, 10) : '-'} 未收余额 {money(item.openBalanceCents)} + + + + ))} diff --git a/apps/taro/src/services/platformAdmin.ts b/apps/taro/src/services/platformAdmin.ts index 9c8ae6a2..99b243a4 100644 --- a/apps/taro/src/services/platformAdmin.ts +++ b/apps/taro/src/services/platformAdmin.ts @@ -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 }>('/api/platform-admin/subscriptions', { + method: 'POST', + body: input, + tenantId: null, + }); +} + +export async function createPlatformInvoiceFromSubscription(input: CreatePlatformInvoiceFromSubscriptionInput) { + return apiRequest<{ item?: Record }>('/api/platform-admin/invoices/from-subscription', { + method: 'POST', + body: input, + tenantId: null, + }); +} + +export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoicePaymentInput) { + return apiRequest<{ item?: Record }>('/api/platform-admin/invoices/payments/manual-confirm', { + method: 'POST', + body: input, + tenantId: null, + }); +} + +export async function recordPlatformUsage(input: RecordPlatformUsageInput) { + return apiRequest<{ item?: Record }>('/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, + }); +} diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index d8b92944..c01dc50c 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -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:商用收费和运营能力 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 6f37dc4f..5817874e 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -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` | -当前平台后台页面以只读联调为主。下一批继续补创建/编辑租户、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑、平台审计报表和高风险操作二次确认。 +当前平台后台已经具备第一批写操作台:创建租户、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限和审计。下一批继续补租户详情页、编辑租户基础资料、平台审计报表、自动计费、账单批量操作和更细平台权限点。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index b19eede6..4fbb988b 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -72,11 +72,11 @@ | 功能 | 新后端状态 | 待补齐 | | --- | --- | --- | -| 创建/管理租户 | 已覆盖 | 平台后台租户列表页已接真实 API;创建/编辑/状态变更表单、详情页和高风险操作确认待补 | +| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户和状态变更已接真实 API;编辑租户基础资料、详情页和平台审计报表待补 | | SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 | | 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 | | 租户用量记录 | 已覆盖 | 自动采集 worker 待补 | -| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、冲突查询 API 和平台后台只读页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺版本通知、冲突处理操作台、批量接受/保留策略和授权编辑 UI | +| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、冲突查询 API 和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺版本通知、冲突处理操作台、批量接受/保留策略 | | 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 | | 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index c3b91851..9e9a0acd 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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 和公共题库版本通知/冲突操作台。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 68e8aad8..a43c23fb 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -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/*` 作为小程序登录适配层。 ## 租户后台前端建议