feat: add taro platform admin pages

This commit is contained in:
Codex
2026-06-29 12:31:17 +08:00
parent 9bd608b27e
commit 43c63f5c39
18 changed files with 797 additions and 82 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 提交。
更完整的进度看这些文档:
@@ -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

View File

@@ -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 平台公共题库、授权规则
```
当前平台后台页面以只读联调和风险扫描为主。创建租户、修改状态、生成账单、确认收款、维护公共题库授权等写操作,后续应在表单校验、二次确认、审计日志和平台权限点完善后继续接入。
## 前端环境变量
只允许使用:

View File

@@ -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',

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '账务中心',
});

View File

@@ -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<PlatformSaasPlan[]>([]);
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
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 (
<View className='platform-page'>
<View className='platform-shell'>
<View className='platform-header'>
<Text className='platform-kicker'>Billing</Text>
<Text className='platform-title'></Text>
<Text className='platform-subtitle'> SaaS </Text>
</View>
<View className='platform-tabs'>
{[
{ label: '全部', value: '' },
{ label: 'issued', value: 'issued' },
{ label: 'overdue', value: 'overdue' },
{ label: 'paid', value: 'paid' },
].map(item => (
<Button key={item.label} className={`platform-button ${status === item.value ? 'active' : ''}`} onClick={() => chooseStatus(item.value)}>{item.label}</Button>
))}
</View>
<View className='platform-grid'>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(plans.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{money(unpaidCents)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{money(paidCents)}</Text></View>
<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'>SaaS </Text>
<View className='platform-list'>
{plans.map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.name}</Text>
<Text className='platform-row-meta'>{item.code} · {item.billingCycle || 'cycle'} · {money(item.baseAmountCents, item.currency || 'CNY')} · {item.status || 'active'}</Text>
<Text className='platform-row-meta'>{item.description || '暂无说明'}</Text>
</View>
))}
</View>
{!plans.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{invoices.map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.invoiceNo || item.id}</Text>
<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>
))}
</View>
{!invoices.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{usage.slice(0, 12).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.tenantName || item.tenantSlug || item.tenantId}</Text>
<Text className='platform-row-meta'>{item.metricKey || 'metric'} · {String(item.metricValue || 0)} · {String(item.periodStart || '').slice(0, 10)} {String(item.periodEnd || '').slice(0, 10)}</Text>
</View>
))}
</View>
</View>
{error ? <Text className='platform-error'>{error}</Text> : null}
</View>
</View>
);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '公共题库',
});

View File

@@ -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<PlatformQuestionBankItem[]>([]);
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
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 (
<View className='platform-page'>
<View className='platform-shell'>
<View className='platform-header'>
<Text className='platform-kicker'>Question Banks</Text>
<Text className='platform-title'></Text>
<Text className='platform-subtitle'>/</Text>
</View>
<View className='platform-actions'>
<Input className='platform-input' placeholder='题库名或地区' value={keyword} onInput={event => setKeyword(String(event.detail.value || ''))} />
<Button className='platform-button primary' onClick={() => reload(keyword, includeTenantBanks)}></Button>
<Button className={`platform-button ${includeTenantBanks ? 'active' : ''}`} onClick={toggleTenantBanks}></Button>
</View>
<View className='platform-grid'>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(banks.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(banks.filter(item => item.sourceScope === 'platform').length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(grants.length)}</Text></View>
<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-list'>
{banks.map(item => (
<View className='platform-row' key={item.id}>
<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>
))}
</View>
{!banks.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{grants.map(item => (
<View className='platform-row' key={item.id}>
<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>
))}
</View>
{!grants.length ? <View className='platform-empty'> PUT /api/platform-admin/question-bank-grants</View> : null}
</View>
{error ? <Text className='platform-error'>{error}</Text> : null}
</View>
</View>
);
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '租户管理',
});

View File

@@ -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<PlatformTenantItem[]>([]);
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 (
<View className='platform-page'>
<View className='platform-shell'>
<View className='platform-header'>
<Text className='platform-kicker'>Tenants</Text>
<Text className='platform-title'></Text>
<Text className='platform-subtitle'> SaaS API</Text>
</View>
<View className='platform-actions'>
<Input className='platform-input' placeholder='租户名、slug、公司名' value={keyword} onInput={event => setKeyword(String(event.detail.value || ''))} />
<Button className='platform-button primary' onClick={() => reload(status, keyword)}></Button>
</View>
<View className='platform-tabs'>
{[
{ label: '全部', value: '' },
{ label: 'active', value: 'active' },
{ label: 'suspended', value: 'suspended' },
].map(item => (
<Button key={item.label} className={`platform-button ${status === item.value ? 'active' : ''}`} onClick={() => chooseStatus(item.value)}>{item.label}</Button>
))}
</View>
<View className='platform-grid'>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.filter(item => Number(item.openBalanceCents || 0) > 0).length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.filter(item => item.billingStatus === 'trial').length)}</Text></View>
<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-list'>
{tenants.map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.brandName || item.name}</Text>
<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>
))}
</View>
{!tenants.length ? <View className='platform-empty'></View> : null}
</View>
{error ? <Text className='platform-error'>{error}</Text> : null}
</View>
</View>
);
}

View File

@@ -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;
}

View File

@@ -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<PlatformOverview | null>(null);
const [tenants, setTenants] = useState<PlatformTenantItem[]>([]);
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]);
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
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 (
<View className='platform-page'>
<View className='platform-header'>
<Text className='platform-title'>SaaS </Text>
<Text className='platform-subtitle'></Text>
</View>
<View className='platform-grid'>
{['租户管理', 'SaaS 套餐', '服务费账单', '公共题库', '用量记录', '安全审计'].map(name => (
<View className='platform-tile' key={name}>
<Text className='tile-title'>{name}</Text>
<Text className='tile-status'>{ready ? '接口已连接' : '需要平台管理员登录'}</Text>
<View className='platform-shell'>
<View className='platform-header'>
<Text className='platform-kicker'>Platform Admin</Text>
<Text className='platform-title'>SaaS </Text>
<Text className='platform-subtitle'></Text>
</View>
<View className='platform-grid'>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(overview?.tenants?.active || 0)}/{String(overview?.tenants?.total || 0)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{money(overview?.billing?.unpaidAmountCents)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(overview?.subscriptions?.expiringSoon || 0)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(overview?.usage?.questions || 0)}</Text></View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{modules.map(item => (
<View className='platform-row' key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
<Text className='platform-row-main'>{item.name}</Text>
<Text className='platform-row-meta'>{item.meta}</Text>
</View>
))}
</View>
))}
</View>
<View className='platform-actions'>
<Button className='platform-button primary' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/tenants/index' })}></Button>
<Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/billing/index' })}></Button>
<Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/question-banks/index' })}></Button>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{tenants.slice(0, 4).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.brandName || item.name}</Text>
<Text className='platform-row-meta'>{item.slug} · {item.status || '-'} · {item.planCode || '未订阅'} · {money(item.openBalanceCents)}</Text>
</View>
))}
</View>
{!tenants.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-grid'>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(invoices.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(banks.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(grants.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(overview?.billing?.overdueInvoices || 0)}</Text></View>
</View>
</View>
{error ? <Text className='platform-error'>{error}</Text> : null}
</View>
</View>
);

View File

@@ -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<string, unknown> | null;
overagePrices?: Record<string, unknown> | null;
featureFlags?: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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,
});
}

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 初始化和基础工作台壳 | 学生端完整页面、租户后台/平台后台业务页面、小程序兼容验证和端到端测试 |
| 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商用收费和运营能力

View File

@@ -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` |
当前平台后台页面以只读联调为主。下一批继续补创建/编辑租户、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑、平台审计报表和高风险操作二次确认。

View File

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

View File

@@ -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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -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/*` 作为小程序登录适配层。
## 租户后台前端建议