forked from wangziqi/gongxue-base
feat: add taro platform admin pages
This commit is contained in:
@@ -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 平台公共题库、授权规则
|
||||
```
|
||||
|
||||
当前平台后台页面以只读联调和风险扫描为主。创建租户、修改状态、生成账单、确认收款、维护公共题库授权等写操作,后续应在表单校验、二次确认、审计日志和平台权限点完善后继续接入。
|
||||
|
||||
## 前端环境变量
|
||||
|
||||
只允许使用:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '账务中心',
|
||||
});
|
||||
120
apps/taro/src/pages/platform-admin/billing/index.tsx
Normal file
120
apps/taro/src/pages/platform-admin/billing/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
apps/taro/src/pages/platform-admin/platform.css
Normal file
175
apps/taro/src/pages/platform-admin/platform.css
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '公共题库',
|
||||
});
|
||||
101
apps/taro/src/pages/platform-admin/question-banks/index.tsx
Normal file
101
apps/taro/src/pages/platform-admin/question-banks/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '租户管理',
|
||||
});
|
||||
80
apps/taro/src/pages/platform-admin/tenants/index.tsx
Normal file
80
apps/taro/src/pages/platform-admin/tenants/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
160
apps/taro/src/services/platformAdmin.ts
Normal file
160
apps/taro/src/services/platformAdmin.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user