diff --git a/README.md b/README.md index 58feeece..a4a4b47c 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 提交。 更完整的进度看这些文档: @@ -142,6 +142,15 @@ apps/taro/src/pages/tenant-admin/marketing apps/taro/src/pages/tenant-admin/settings ``` +平台后台当前页面: + +```text +apps/taro/src/pages/platform-admin/workbench +apps/taro/src/pages/platform-admin/tenants +apps/taro/src/pages/platform-admin/billing +apps/taro/src/pages/platform-admin/question-banks +``` + 单次运行 CRM worker: ```bash diff --git a/apps/taro/README.md b/apps/taro/README.md index e3873b0b..2d136fca 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -1,6 +1,6 @@ # Taro 前端工程 -这是 SaaS 题库的新跨端前端地基。当前阶段已经提供 H5 多入口、租户解析、统一 API client、Supabase Auth client 初始化、安全环境变量边界、学生端第一批可联调页面,以及租户后台第一批运营页面。 +这是 SaaS 题库的新跨端前端地基。当前阶段已经提供 H5 多入口、租户解析、统一 API client、Supabase Auth client 初始化、安全环境变量边界、学生端第一批可联调页面、租户后台第一批运营页面,以及平台超管第一批 SaaS 运营页面。 ## 三个 H5 入口 @@ -54,6 +54,19 @@ pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色 当前后台页面以只读联调和运营扫描为主,少量写入动作后续会按权限、表单校验、审计结果继续补。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。 +## 当前平台后台页面 + +已接入第一批真实后端 API: + +```text +pages/platform-admin/workbench/index 平台工作台 +pages/platform-admin/tenants/index 租户列表、状态、欠费、订阅到期 +pages/platform-admin/billing/index SaaS 套餐、服务费账单、用量记录 +pages/platform-admin/question-banks/index 平台公共题库、授权规则 +``` + +当前平台后台页面以只读联调和风险扫描为主。创建租户、修改状态、生成账单、确认收款、维护公共题库授权等写操作,后续应在表单校验、二次确认、审计日志和平台权限点完善后继续接入。 + ## 前端环境变量 只允许使用: diff --git a/apps/taro/src/app.config.ts b/apps/taro/src/app.config.ts index bf4b8a73..13e14e8f 100644 --- a/apps/taro/src/app.config.ts +++ b/apps/taro/src/app.config.ts @@ -17,6 +17,9 @@ export default defineAppConfig({ 'pages/tenant-admin/marketing/index', 'pages/tenant-admin/settings/index', 'pages/platform-admin/workbench/index', + 'pages/platform-admin/tenants/index', + 'pages/platform-admin/billing/index', + 'pages/platform-admin/question-banks/index', ], window: { backgroundTextStyle: 'light', diff --git a/apps/taro/src/pages/platform-admin/billing/index.config.ts b/apps/taro/src/pages/platform-admin/billing/index.config.ts new file mode 100644 index 00000000..22d7cee7 --- /dev/null +++ b/apps/taro/src/pages/platform-admin/billing/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '账务中心', +}); diff --git a/apps/taro/src/pages/platform-admin/billing/index.tsx b/apps/taro/src/pages/platform-admin/billing/index.tsx new file mode 100644 index 00000000..69adea43 --- /dev/null +++ b/apps/taro/src/pages/platform-admin/billing/index.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import { Button, Text, View } from '@tarojs/components'; +import { + loadPlatformInvoices, + loadPlatformPlans, + loadPlatformUsage, + type PlatformInvoiceItem, + type PlatformSaasPlan, + type PlatformUsageItem, +} from '@/services/platformAdmin'; +import '../platform.css'; + +function money(cents: unknown, currency = 'CNY') { + const symbol = currency === 'CNY' ? '¥' : `${currency} `; + return `${symbol}${(Number(cents || 0) / 100).toFixed(2)}`; +} + +export default function PlatformBillingPage() { + const [status, setStatus] = useState(''); + const [plans, setPlans] = useState([]); + const [invoices, setInvoices] = useState([]); + const [usage, setUsage] = useState([]); + const [error, setError] = useState(''); + + function reload(nextStatus = status) { + Promise.all([ + loadPlatformPlans(true).catch(() => ({ items: [] })), + loadPlatformInvoices({ status: nextStatus || undefined, limit: 100 }), + loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })), + ]).then(([planPayload, invoicePayload, usagePayload]) => { + setPlans(planPayload.items || []); + setInvoices(invoicePayload.items || []); + setUsage(usagePayload.items || []); + }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败')); + } + + useEffect(() => { + reload(''); + }, []); + + function chooseStatus(nextStatus: string) { + setStatus(nextStatus); + reload(nextStatus); + } + + const unpaidCents = invoices.reduce((sum, item) => sum + Number(item.balanceCents || 0), 0); + const paidCents = invoices.reduce((sum, item) => sum + Number(item.paidCents || 0), 0); + + return ( + + + + Billing + 账务中心 + 平台 SaaS 套餐、租户服务费账单、收款状态和近期待计费用量。 + + + + {[ + { label: '全部', value: '' }, + { label: 'issued', value: 'issued' }, + { label: 'overdue', value: 'overdue' }, + { label: 'paid', value: 'paid' }, + ].map(item => ( + + ))} + + + + 套餐数{String(plans.length)} + 未收余额{money(unpaidCents)} + 已收金额{money(paidCents)} + 用量记录{String(usage.length)} + + + + SaaS 套餐 + + {plans.map(item => ( + + {item.name} + {item.code} · {item.billingCycle || 'cycle'} · {money(item.baseAmountCents, item.currency || 'CNY')} · {item.status || 'active'} + {item.description || '暂无说明'} + + ))} + + {!plans.length ? 暂无套餐配置。 : null} + + + + 服务费账单 + + {invoices.map(item => ( + + {item.invoiceNo || item.id} + {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) : '-'} + + ))} + + {!invoices.length ? 暂无账单。 : null} + + + + 近期待计费用量 + + {usage.slice(0, 12).map(item => ( + + {item.tenantName || item.tenantSlug || item.tenantId} + {item.metricKey || 'metric'} · {String(item.metricValue || 0)} · {String(item.periodStart || '').slice(0, 10)} 至 {String(item.periodEnd || '').slice(0, 10)} + + ))} + + + {error ? {error} : null} + + + ); +} diff --git a/apps/taro/src/pages/platform-admin/platform.css b/apps/taro/src/pages/platform-admin/platform.css new file mode 100644 index 00000000..637c396f --- /dev/null +++ b/apps/taro/src/pages/platform-admin/platform.css @@ -0,0 +1,175 @@ +.platform-page { + min-height: 100vh; + padding: 28px; + background: #f7f9fc; + color: #172033; +} + +.platform-shell { + display: flex; + flex-direction: column; + gap: 26px; +} + +.platform-header { + padding-bottom: 24px; + border-bottom: 1px solid #d8e0ec; +} + +.platform-kicker { + display: block; + color: #5b6b82; + font-size: 22px; + font-weight: 760; +} + +.platform-title { + display: block; + margin-top: 8px; + color: #101827; + font-size: 38px; + font-weight: 820; + line-height: 1.2; +} + +.platform-subtitle { + display: block; + margin-top: 10px; + color: #64748b; + font-size: 24px; + line-height: 1.45; +} + +.platform-tabs, +.platform-actions { + display: flex; + gap: 12px; + overflow-x: auto; + padding-bottom: 6px; +} + +.platform-button { + min-width: 132px; + height: 62px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + color: #1e3a8a; + font-size: 23px; + font-weight: 720; + line-height: 62px; +} + +.platform-button.primary { + border-color: #1d4ed8; + background: #1d4ed8; + color: #fff; +} + +.platform-button.active { + border-color: #1d4ed8; + background: #eff6ff; + color: #1d4ed8; +} + +.platform-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.platform-grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.platform-metric { + min-height: 112px; + padding: 18px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; +} + +.platform-metric-label { + display: block; + color: #64748b; + font-size: 21px; +} + +.platform-metric-value { + display: block; + margin-top: 12px; + color: #0f172a; + font-size: 31px; + font-weight: 820; + line-height: 1.2; +} + +.platform-section { + margin-top: 4px; +} + +.platform-section-title { + display: block; + margin-bottom: 14px; + color: #0f172a; + font-size: 29px; + font-weight: 820; +} + +.platform-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.platform-row { + padding: 20px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; +} + +.platform-row-main { + display: block; + color: #111827; + font-size: 26px; + font-weight: 760; + line-height: 1.35; +} + +.platform-row-meta { + display: block; + margin-top: 8px; + color: #64748b; + font-size: 21px; + line-height: 1.45; +} + +.platform-empty { + padding: 32px 22px; + border: 1px dashed #cbd5e1; + border-radius: 8px; + background: #fff; + color: #64748b; + font-size: 23px; + line-height: 1.45; +} + +.platform-input { + width: 100%; + height: 66px; + padding: 0 18px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + color: #111827; + font-size: 24px; +} + +.platform-error { + display: block; + margin-top: 12px; + color: #be123c; + font-size: 22px; +} diff --git a/apps/taro/src/pages/platform-admin/question-banks/index.config.ts b/apps/taro/src/pages/platform-admin/question-banks/index.config.ts new file mode 100644 index 00000000..dd4f9f9b --- /dev/null +++ b/apps/taro/src/pages/platform-admin/question-banks/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '公共题库', +}); diff --git a/apps/taro/src/pages/platform-admin/question-banks/index.tsx b/apps/taro/src/pages/platform-admin/question-banks/index.tsx new file mode 100644 index 00000000..60c756dd --- /dev/null +++ b/apps/taro/src/pages/platform-admin/question-banks/index.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react'; +import { Button, Input, Text, View } from '@tarojs/components'; +import { + loadPlatformQuestionBankGrants, + loadPlatformQuestionBanks, + type PlatformQuestionBankGrant, + type PlatformQuestionBankItem, +} from '@/services/platformAdmin'; +import '../platform.css'; + +function targetText(item: PlatformQuestionBankGrant) { + if (item.grantScope === 'all_active_tenants') return '全部活跃租户'; + const planCount = item.allowedPlanCodes?.length || 0; + const tenantCount = item.allowedTenantIds?.length || 0; + if (planCount && tenantCount) return `${planCount} 个套餐 + ${tenantCount} 个租户`; + if (planCount) return `${planCount} 个套餐`; + if (tenantCount) return `${tenantCount} 个租户`; + return '未配置目标'; +} + +export default function PlatformQuestionBanksPage() { + const [keyword, setKeyword] = useState(''); + const [includeTenantBanks, setIncludeTenantBanks] = useState(false); + const [banks, setBanks] = useState([]); + const [grants, setGrants] = useState([]); + const [error, setError] = useState(''); + + function reload(nextKeyword = keyword, nextIncludeTenantBanks = includeTenantBanks) { + Promise.all([ + loadPlatformQuestionBanks({ q: nextKeyword || undefined, includeTenantBanks: nextIncludeTenantBanks, limit: 100 }), + loadPlatformQuestionBankGrants({ limit: 160 }), + ]).then(([bankPayload, grantPayload]) => { + setBanks(bankPayload.items || []); + setGrants(grantPayload.items || []); + }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '公共题库加载失败')); + } + + useEffect(() => { + reload('', false); + }, []); + + function toggleTenantBanks() { + const nextValue = !includeTenantBanks; + setIncludeTenantBanks(nextValue); + reload(keyword, nextValue); + } + + return ( + + + + Question Banks + 公共题库授权 + 管理平台公共/地区题库披露范围,支撑租户按套餐或指定授权采纳题库快照。 + + + + setKeyword(String(event.detail.value || ''))} /> + + + + + + 题库{String(banks.length)} + 平台源库{String(banks.filter(item => item.sourceScope === 'platform').length)} + 授权规则{String(grants.length)} + 活跃授权{String(grants.filter(item => item.status === 'active').length)} + + + + 公共题库 + + {banks.map(item => ( + + {item.name} + {item.regionName || '通用'} · {item.sourceScope || 'tenant'} · {item.status || '-'} · {String(item.questionCount || 0)} 题 + 来源租户 {item.tenantName || item.tenantSlug || item.tenantId || '-'} + + ))} + + {!banks.length ? 暂无公共题库,或筛选条件下没有结果。 : null} + + + + 授权规则 + + {grants.map(item => ( + + {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) : '长期'} + + ))} + + {!grants.length ? 暂无授权规则。后续编辑表单会调用 PUT /api/platform-admin/question-bank-grants。 : null} + + {error ? {error} : null} + + + ); +} diff --git a/apps/taro/src/pages/platform-admin/tenants/index.config.ts b/apps/taro/src/pages/platform-admin/tenants/index.config.ts new file mode 100644 index 00000000..35b9770d --- /dev/null +++ b/apps/taro/src/pages/platform-admin/tenants/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '租户管理', +}); diff --git a/apps/taro/src/pages/platform-admin/tenants/index.tsx b/apps/taro/src/pages/platform-admin/tenants/index.tsx new file mode 100644 index 00000000..144076e4 --- /dev/null +++ b/apps/taro/src/pages/platform-admin/tenants/index.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { Button, Input, Text, View } from '@tarojs/components'; +import { loadPlatformTenants, type PlatformTenantItem } from '@/services/platformAdmin'; +import '../platform.css'; + +function money(cents: unknown) { + return `¥${(Number(cents || 0) / 100).toFixed(2)}`; +} + +export default function PlatformTenantsPage() { + const [keyword, setKeyword] = useState(''); + const [status, setStatus] = useState(''); + const [tenants, setTenants] = useState([]); + const [error, setError] = useState(''); + + function reload(nextStatus = status, nextKeyword = keyword) { + loadPlatformTenants({ q: nextKeyword || undefined, status: nextStatus || undefined, limit: 100 }) + .then(payload => setTenants(payload.items || [])) + .catch(nextError => setError(nextError instanceof Error ? nextError.message : '租户列表加载失败')); + } + + useEffect(() => { + reload('', ''); + }, []); + + function chooseStatus(nextStatus: string) { + setStatus(nextStatus); + reload(nextStatus, keyword); + } + + return ( + + + + Tenants + 租户管理 + 查看 SaaS 租户的品牌、订阅、状态和欠费情况;创建和状态变更表单后续接入同一组平台 API。 + + + + setKeyword(String(event.detail.value || ''))} /> + + + + + {[ + { label: '全部', value: '' }, + { label: 'active', value: 'active' }, + { label: 'suspended', value: 'suspended' }, + ].map(item => ( + + ))} + + + + 当前列表{String(tenants.length)} + 欠费租户{String(tenants.filter(item => Number(item.openBalanceCents || 0) > 0).length)} + 试用租户{String(tenants.filter(item => item.billingStatus === 'trial').length)} + 暂停租户{String(tenants.filter(item => item.status === 'suspended').length)} + + + + 租户列表 + + {tenants.map(item => ( + + {item.brandName || item.name} + {item.slug} · {item.legalName || '未填公司'} · {item.status || '-'} · {item.billingStatus || '-'} + 套餐 {item.planCode || '未订阅'} · 订阅 {item.subscriptionStatus || '-'} · 到期 {item.subscriptionExpiresAt ? String(item.subscriptionExpiresAt).slice(0, 10) : '-'} + 未收余额 {money(item.openBalanceCents)} + + ))} + + {!tenants.length ? 暂无租户,或当前平台管理员权限未通过。 : null} + + {error ? {error} : null} + + + ); +} diff --git a/apps/taro/src/pages/platform-admin/workbench/index.css b/apps/taro/src/pages/platform-admin/workbench/index.css deleted file mode 100644 index bab7c866..00000000 --- a/apps/taro/src/pages/platform-admin/workbench/index.css +++ /dev/null @@ -1,53 +0,0 @@ -.platform-page { - min-height: 100vh; - padding: 28px; - background: #f7f9fc; -} - -.platform-header { - padding-bottom: 28px; - border-bottom: 1px solid #d8e0ec; -} - -.platform-title { - display: block; - color: #101827; - font-size: 38px; - font-weight: 800; -} - -.platform-subtitle { - display: block; - margin-top: 10px; - color: #64748b; - font-size: 24px; -} - -.platform-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; - margin-top: 28px; -} - -.platform-tile { - min-height: 128px; - padding: 20px; - border: 1px solid #e2e8f0; - border-radius: 8px; - background: #fff; -} - -.tile-title { - display: block; - color: #172033; - font-size: 28px; - font-weight: 800; -} - -.tile-status { - display: block; - margin-top: 16px; - color: #64748b; - font-size: 22px; -} diff --git a/apps/taro/src/pages/platform-admin/workbench/index.tsx b/apps/taro/src/pages/platform-admin/workbench/index.tsx index b6f48e00..beb94ef2 100644 --- a/apps/taro/src/pages/platform-admin/workbench/index.tsx +++ b/apps/taro/src/pages/platform-admin/workbench/index.tsx @@ -1,28 +1,111 @@ import { useEffect, useState } from 'react'; -import { Text, View } from '@tarojs/components'; -import { apiRequest } from '@/services/api'; -import './index.css'; +import Taro from '@tarojs/taro'; +import { Button, Text, View } from '@tarojs/components'; +import { + loadPlatformInvoices, + loadPlatformOverview, + loadPlatformQuestionBankGrants, + loadPlatformQuestionBanks, + loadPlatformTenants, + type PlatformInvoiceItem, + type PlatformOverview, + type PlatformQuestionBankGrant, + type PlatformQuestionBankItem, + type PlatformTenantItem, +} from '@/services/platformAdmin'; +import '../platform.css'; + +function money(cents: unknown) { + return `¥${(Number(cents || 0) / 100).toFixed(2)}`; +} export default function PlatformWorkbenchPage() { - const [ready, setReady] = useState(false); + const [overview, setOverview] = useState(null); + const [tenants, setTenants] = useState([]); + const [invoices, setInvoices] = useState([]); + const [banks, setBanks] = useState([]); + const [grants, setGrants] = useState([]); + const [error, setError] = useState(''); useEffect(() => { - apiRequest('/api/platform-admin/overview').then(() => setReady(true)).catch(() => setReady(false)); + Promise.all([ + loadPlatformOverview().catch(() => ({ item: null })), + loadPlatformTenants({ limit: 6 }).catch(() => ({ items: [] })), + loadPlatformInvoices({ limit: 6 }).catch(() => ({ items: [] })), + loadPlatformQuestionBanks({ limit: 6 }).catch(() => ({ items: [] })), + loadPlatformQuestionBankGrants({ limit: 6 }).catch(() => ({ items: [] })), + ]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload]) => { + setOverview(overviewPayload.item || null); + setTenants(tenantPayload.items || []); + setInvoices(invoicePayload.items || []); + setBanks(bankPayload.items || []); + setGrants(grantPayload.items || []); + }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台后台加载失败')); }, []); + const modules = [ + { name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期' }, + { name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量' }, + { name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围' }, + ]; + return ( - - SaaS 平台后台 - 租户、套餐、账单、公共题库授权和平台审计入口 - - - {['租户管理', 'SaaS 套餐', '服务费账单', '公共题库', '用量记录', '安全审计'].map(name => ( - - {name} - {ready ? '接口已连接' : '需要平台管理员登录'} + + + Platform Admin + SaaS 平台后台 + 租户、套餐、服务费账单、平台公共题库授权和用量状态的统一工作台。 + + + + 活跃租户{String(overview?.tenants?.active || 0)}/{String(overview?.tenants?.total || 0)} + 待收金额{money(overview?.billing?.unpaidAmountCents)} + 即将到期{String(overview?.subscriptions?.expiringSoon || 0)} + 题目用量{String(overview?.usage?.questions || 0)} + + + + 后台模块 + + {modules.map(item => ( + Taro.navigateTo({ url: item.path })}> + {item.name} + {item.meta} + + ))} - ))} + + + + + + + + + + 近期租户 + + {tenants.slice(0, 4).map(item => ( + + {item.brandName || item.name} + {item.slug} · {item.status || '-'} · {item.planCode || '未订阅'} · 欠款 {money(item.openBalanceCents)} + + ))} + + {!tenants.length ? 暂无租户数据,或当前账号没有平台管理员权限。 : null} + + + + 账单与题库授权 + + 最近账单{String(invoices.length)} + 公共题库{String(banks.length)} + 授权规则{String(grants.length)} + 逾期账单{String(overview?.billing?.overdueInvoices || 0)} + + + {error ? {error} : null} ); diff --git a/apps/taro/src/services/platformAdmin.ts b/apps/taro/src/services/platformAdmin.ts new file mode 100644 index 00000000..9c8ae6a2 --- /dev/null +++ b/apps/taro/src/services/platformAdmin.ts @@ -0,0 +1,160 @@ +import { apiRequest } from './api'; + +export interface PlatformOverview { + tenants?: { + total?: number; + active?: number; + suspended?: number; + trial?: number; + pastDue?: number; + }; + billing?: { + unpaidAmountCents?: number; + paidAmountCents?: number; + overdueInvoices?: number; + }; + subscriptions?: { + active?: number; + expiringSoon?: number; + }; + usage?: { + students?: number; + questions?: number; + storageGb?: number; + }; +} + +export interface PlatformSaasPlan { + id: string; + code: string; + name: string; + description?: string | null; + billingCycle?: string | null; + baseAmountCents?: number | string | null; + currency?: string | null; + includedQuotas?: Record | null; + overagePrices?: Record | null; + featureFlags?: Record | null; + status?: string | null; + sortOrder?: number | null; +} + +export interface PlatformTenantItem { + id: string; + slug: string; + name: string; + legalName?: string | null; + status?: string | null; + mode?: string | null; + billingStatus?: string | null; + brandName?: string | null; + planCode?: string | null; + subscriptionStatus?: string | null; + subscriptionExpiresAt?: string | null; + openBalanceCents?: number | string | null; +} + +export interface PlatformInvoiceItem { + id: string; + tenantId: string; + tenantSlug?: string | null; + tenantName?: string | null; + invoiceNo?: string | null; + invoiceType?: string | null; + status?: string | null; + currency?: string | null; + totalCents?: number | string | null; + paidCents?: number | string | null; + balanceCents?: number | string | null; + dueDate?: string | null; + issuedAt?: string | null; + paidAt?: string | null; + note?: string | null; +} + +export interface PlatformUsageItem { + id: string; + tenantId: string; + tenantSlug?: string | null; + tenantName?: string | null; + metricKey?: string | null; + metricValue?: number | string | null; + periodStart?: string | null; + periodEnd?: string | null; + metadata?: Record | null; +} + +export interface PlatformQuestionBankItem { + id: string; + tenantId?: string | null; + tenantSlug?: string | null; + tenantName?: string | null; + regionId?: string | null; + regionName?: string | null; + name: string; + sourceScope?: string | null; + status?: string | null; + metadata?: Record | null; + questionCount?: number | string | null; +} + +export interface PlatformQuestionBankGrant { + id: string; + sourceQuestionBankId?: string | null; + sourceQuestionBankName?: string | null; + sourceRegionName?: string | null; + grantScope?: string | null; + allowedPlanCodes?: string[] | null; + allowedTenantIds?: string[] | null; + allowedRegionIds?: string[] | null; + allowedSubjectIds?: string[] | null; + status?: string | null; + startsAt?: string | null; + expiresAt?: string | null; +} + +export async function loadPlatformOverview() { + return apiRequest<{ item?: PlatformOverview }>('/api/platform-admin/overview', { tenantId: null }); +} + +export async function loadPlatformPlans(includeArchived = false) { + return apiRequest<{ items?: PlatformSaasPlan[] }>('/api/platform-admin/plans', { + query: { includeArchived }, + tenantId: null, + }); +} + +export async function loadPlatformTenants(query: { q?: string; status?: string; billingStatus?: string; limit?: number } = {}) { + return apiRequest<{ items?: PlatformTenantItem[] }>('/api/platform-admin/tenants', { + query: { ...query, limit: query.limit || 80 }, + tenantId: null, + }); +} + +export async function loadPlatformInvoices(query: { tenantId?: string; status?: string; limit?: number } = {}) { + return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', { + query: { ...query, limit: query.limit || 80 }, + tenantId: null, + }); +} + +export async function loadPlatformUsage(query: { tenantId?: string; limit?: number } = {}) { + return apiRequest<{ items?: PlatformUsageItem[] }>('/api/platform-admin/usage', { + query: { ...query, limit: query.limit || 100 }, + tenantId: null, + }); +} + +export async function loadPlatformQuestionBanks(query: { q?: string; status?: string; includeTenantBanks?: boolean; limit?: number } = {}) { + return apiRequest<{ items?: PlatformQuestionBankItem[] }>('/api/platform-admin/question-banks', { + query: { status: 'active', ...query, limit: query.limit || 80 }, + tenantId: null, + }); +} + +export async function loadPlatformQuestionBankGrants(query: { questionBankId?: string; status?: string; limit?: number } = {}) { + return apiRequest<{ items?: PlatformQuestionBankGrant[] }>('/api/platform-admin/question-bank-grants', { + query: { ...query, limit: query.limit || 120 }, + tenantId: null, + }); +} diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 8e853d8b..d8b92944 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 初始化和基础工作台壳 | 学生端完整页面、租户后台/平台后台业务页面、小程序兼容验证和端到端测试 | +| 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` 地基已建立;下一步补学生端首页、题库、背单词、知识手册、个人中心主链路和小程序兼容验证。 +- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API;下一步补学生端完整刷题体验、后台写操作台和小程序兼容验证。 ### P1:商用收费和运营能力 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index e5250ef6..6f37dc4f 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -27,6 +27,7 @@ - `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、题库、练习、背单词、知识手册、分数线、资料、个人中心。 - 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置。 +- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。 - 可以继续复刻旧题库学生端主要视觉和交互:选地区、刷题细节、视频解析、题目反馈、模考报告、错题复习、商城收银台、订单详情、勋章展示。 - 可以按新后端主模型接入内容导航: - `content_entries` @@ -85,3 +86,14 @@ | 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`role-templates` | 当前页面以运营看板和只读列表为主。下一批需要继续补后台写入表单、导入 preview/import/issue 操作台、公共题库采纳/同步按钮、学生批量导入、角色模板配置 UI 和权限驱动菜单。 + +## 已落地的 Taro 平台后台页面 + +| 页面 | 文件 | 已接接口 | +| --- | --- | --- | +| 工作台 | `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` | + +当前平台后台页面以只读联调为主。下一批继续补创建/编辑租户、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑、平台审计报表和高风险操作二次确认。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 4d7907a8..b19eede6 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -72,11 +72,11 @@ | 功能 | 新后端状态 | 待补齐 | | --- | --- | --- | -| 创建/管理租户 | 已覆盖 | 平台后台页面待做 | +| 创建/管理租户 | 已覆盖 | 平台后台租户列表页已接真实 API;创建/编辑/状态变更表单、详情页和高风险操作确认待补 | | SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 | | 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 | | 租户用量记录 | 已覆盖 | 自动采集 worker 待补 | -| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker 和冲突查询 API;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺版本通知、冲突处理操作台、批量接受/保留策略和更完整运营 UI | +| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、冲突查询 API 和平台后台只读页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺版本通知、冲突处理操作台、批量接受/保留策略和授权编辑 UI | | 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 | | 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index d695e8a1..c3b91851 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;下一步补完整页面、状态管理和小程序兼容验证。 +- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;下一步补完整写操作、状态管理和小程序兼容验证。 ### 第一批页面 @@ -210,9 +210,10 @@ ## 推荐下一步顺序 -1. 先把当前后端代码推到 Gitea。 -2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 -3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。 -4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。 -5. 并行补对象存储、真实登录、完整资金流水对账、题库导出 PDF/Word worker 和公共题库版本通知/冲突操作台。 -6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 +1. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、订单收银台。 +2. 补租户后台写操作台:导入 preview/import/issues、字段映射、公共题库采纳/同步、角色模板、CRM/分佣。 +3. 补平台后台写操作台:租户创建/编辑、订阅、账单、收款确认、用量录入、公共题库授权编辑和平台审计。 +4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 +5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。 +6. 并行补对象存储、真实登录、完整资金流水对账、题库导出 PDF/Word worker 和公共题库版本通知/冲突操作台。 +7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 4aea79d6..68e8aad8 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -1375,6 +1375,7 @@ src/services/learning.ts 练习 session、答题、收藏、单词复习 src/services/commerce.ts 套餐、订单、权益、激活码 src/services/profile.ts 个人中心、签到、勋章、倒计时 src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设置 +src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权 ``` 验证命令: @@ -1383,6 +1384,7 @@ src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设 npm run check:taro npm run build:taro:h5:student npm run build:taro:h5:tenant +npm run build:taro:h5:platform ``` 已通过。构建仍有 Taro H5 入口体积 warning,属于当前 Taro 工程既有警告,不阻断联调。 @@ -1391,7 +1393,7 @@ npm run build:taro:h5:tenant - 学生端:选地区、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、订单收银台和订单详情。 - 租户后台:写入表单、字段映射 UI、导入 preview/import/issues 操作台、公共题库采纳/同步、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作。 -- 平台后台:租户、SaaS 套餐、订阅账单、公共题库授权、平台审计。 +- 平台后台:租户创建/编辑/状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑、平台审计。 - 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。 ## 租户后台前端建议