feat: add taro tenant admin pages

This commit is contained in:
Codex
2026-06-29 12:15:56 +08:00
parent b79c4259c0
commit 9bd608b27e
18 changed files with 928 additions and 34 deletions

View File

@@ -33,7 +33,7 @@
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
- 题库导出目前完成服务端结构化 payloadPDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。 - 题库导出目前完成服务端结构化 payloadPDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 - 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、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 提交。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
更完整的进度看这些文档: 更完整的进度看这些文档:
@@ -131,6 +131,17 @@ apps/taro/src/pages/student/assets
apps/taro/src/pages/student/profile apps/taro/src/pages/student/profile
``` ```
租户后台当前页面:
```text
apps/taro/src/pages/tenant-admin/workbench
apps/taro/src/pages/tenant-admin/dashboard
apps/taro/src/pages/tenant-admin/students
apps/taro/src/pages/tenant-admin/content
apps/taro/src/pages/tenant-admin/marketing
apps/taro/src/pages/tenant-admin/settings
```
单次运行 CRM worker 单次运行 CRM worker
```bash ```bash
@@ -313,7 +324,7 @@ git diff --check
优先继续补: 优先继续补:
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台平台后台完整业务页面,小程序兼容验证。 2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI平台后台完整业务页面,小程序兼容验证。
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。 3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
4. 题库导出 PDF/Word worker、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 4. 题库导出 PDF/Word worker、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
5. 真实 OAuth/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。 5. 真实 OAuth/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。

View File

@@ -1,6 +1,6 @@
# Taro 前端工程 # Taro 前端工程
这是 SaaS 题库的新跨端前端地基。当前阶段已经提供 H5 多入口、租户解析、统一 API client、Supabase Auth client 初始化、安全环境变量边界,以及学生端第一批可联调页面。 这是 SaaS 题库的新跨端前端地基。当前阶段已经提供 H5 多入口、租户解析、统一 API client、Supabase Auth client 初始化、安全环境变量边界学生端第一批可联调页面,以及租户后台第一批运营页面
## 三个 H5 入口 ## 三个 H5 入口
@@ -39,6 +39,21 @@ pages/student/profile/index 个人中心、会员、订单、签到、激
这些页面是联调骨架,不是最终视觉稿。后续应继续参照旧题库样式完善刷题交互、视频解析、题目反馈、模考报告、错题复习、订单收银台和小程序兼容。 这些页面是联调骨架,不是最终视觉稿。后续应继续参照旧题库样式完善刷题交互、视频解析、题目反馈、模考报告、错题复习、订单收银台和小程序兼容。
## 当前租户后台页面
已接入第一批真实后端 API
```text
pages/tenant-admin/workbench/index 租户后台工作台
pages/tenant-admin/dashboard/index 数据看板
pages/tenant-admin/students/index 学生与班级
pages/tenant-admin/content/index 内容入口、导入任务、公共题库
pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板
```
当前后台页面以只读联调和运营扫描为主,少量写入动作后续会按权限、表单校验、审计结果继续补。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
## 前端环境变量 ## 前端环境变量
只允许使用: 只允许使用:

View File

@@ -11,6 +11,11 @@ export default defineAppConfig({
'pages/student/assets/index', 'pages/student/assets/index',
'pages/student/profile/index', 'pages/student/profile/index',
'pages/tenant-admin/workbench/index', 'pages/tenant-admin/workbench/index',
'pages/tenant-admin/dashboard/index',
'pages/tenant-admin/students/index',
'pages/tenant-admin/content/index',
'pages/tenant-admin/marketing/index',
'pages/tenant-admin/settings/index',
'pages/platform-admin/workbench/index', 'pages/platform-admin/workbench/index',
], ],
window: { window: {

View File

@@ -0,0 +1,175 @@
.admin-page {
min-height: 100vh;
padding: 28px;
background: #f8fafc;
color: #172033;
}
.admin-shell {
display: flex;
flex-direction: column;
gap: 26px;
}
.admin-header {
padding-bottom: 24px;
border-bottom: 1px solid #dbe3ef;
}
.admin-kicker {
display: block;
color: #64748b;
font-size: 22px;
font-weight: 700;
}
.admin-title {
display: block;
margin-top: 8px;
color: #111827;
font-size: 38px;
font-weight: 800;
line-height: 1.2;
}
.admin-subtitle {
display: block;
margin-top: 10px;
color: #64748b;
font-size: 24px;
line-height: 1.45;
}
.admin-tabs,
.admin-actions {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 6px;
}
.admin-button {
min-width: 132px;
height: 62px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #1e3a8a;
font-size: 23px;
font-weight: 700;
line-height: 62px;
}
.admin-button.primary {
border-color: #2563eb;
background: #2563eb;
color: #fff;
}
.admin-button.active {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
}
.admin-section {
margin-top: 4px;
}
.admin-section-title {
display: block;
margin-bottom: 14px;
color: #0f172a;
font-size: 29px;
font-weight: 800;
}
.admin-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.admin-grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-metric {
min-height: 112px;
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.admin-metric-label {
display: block;
color: #64748b;
font-size: 21px;
}
.admin-metric-value {
display: block;
margin-top: 12px;
color: #0f172a;
font-size: 31px;
font-weight: 800;
line-height: 1.2;
}
.admin-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-row {
padding: 20px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.admin-row-main {
display: block;
color: #111827;
font-size: 26px;
font-weight: 760;
line-height: 1.35;
}
.admin-row-meta {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 21px;
line-height: 1.45;
}
.admin-empty {
padding: 32px 22px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
background: #fff;
color: #64748b;
font-size: 23px;
line-height: 1.45;
}
.admin-input {
width: 100%;
height: 66px;
padding: 0 18px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #111827;
font-size: 24px;
}
.admin-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,75 @@
import { useEffect, useState } from 'react';
import { Text, View } from '@tarojs/components';
import { loadContentEntriesAdmin, loadImportJobs, loadPublicQuestionBanks, type ContentEntryAdminItem, type ImportJobItem, type PublicQuestionBankItem } from '@/services/tenantAdmin';
import '../admin.css';
export default function TenantContentPage() {
const [entries, setEntries] = useState<ContentEntryAdminItem[]>([]);
const [jobs, setJobs] = useState<ImportJobItem[]>([]);
const [publicBanks, setPublicBanks] = useState<PublicQuestionBankItem[]>([]);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([
loadContentEntriesAdmin().catch(() => ({ items: [] })),
loadImportJobs(20).catch(() => ({ items: [] })),
loadPublicQuestionBanks().catch(() => ({ items: [] })),
]).then(([entryPayload, jobPayload, bankPayload]) => {
setEntries(entryPayload.items || []);
setJobs(jobPayload.items || []);
setPublicBanks(bankPayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '内容数据加载失败'));
}, []);
return (
<View className='admin-page'>
<View className='admin-shell'>
<View className='admin-header'>
<Text className='admin-kicker'>Content</Text>
<Text className='admin-title'></Text>
<Text className='admin-subtitle'></Text>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{entries.map(item => (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.name}</Text>
<Text className='admin-row-meta'>{item.entryType || 'entry'} · {item.visibility || 'visible'} · {item.isActive === false ? '停用' : '启用'}</Text>
</View>
))}
</View>
{!entries.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{jobs.map(item => (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.importType || 'import'} · {item.status || 'unknown'}</Text>
<Text className='admin-row-meta'> {item.totalCount || 0} · {item.successCount || 0} · {item.errorCount || 0} · {item.warningCount || 0}</Text>
</View>
))}
</View>
{!jobs.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{publicBanks.map((item, index) => (
<View className='admin-row' key={item.grantId || item.sourceQuestionBankId || index}>
<Text className='admin-row-main'>{item.name || '公共题库'}</Text>
<Text className='admin-row-meta'>{item.regionName || '通用'} · {item.grantScope || '授权'} · {item.adoption ? '已采纳' : '可采纳'}</Text>
</View>
))}
</View>
{!publicBanks.length ? <View className='admin-empty'></View> : null}
</View>
{error ? <Text className='admin-error'>{error}</Text> : null}
</View>
</View>
);
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '租户数据看板',
});

View File

@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import { Button, Text, View } from '@tarojs/components';
import { loadTenantDashboard, type TenantDashboard } from '@/services/tenantAdmin';
import '../admin.css';
function money(cents: unknown) {
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
}
export default function TenantDashboardPage() {
const [range, setRange] = useState('30d');
const [dashboard, setDashboard] = useState<TenantDashboard | null>(null);
const [error, setError] = useState('');
useEffect(() => {
loadTenantDashboard(range)
.then(payload => setDashboard(payload.item || null))
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '看板加载失败'));
}, [range]);
const cards = dashboard?.cards || {};
const payment = dashboard?.paymentStats || {};
return (
<View className='admin-page'>
<View className='admin-shell'>
<View className='admin-header'>
<Text className='admin-kicker'>Dashboard</Text>
<Text className='admin-title'></Text>
<Text className='admin-subtitle'>使</Text>
</View>
<View className='admin-tabs'>
{['7d', '30d', '90d'].map(item => (
<Button key={item} className={`admin-button ${item === range ? 'active' : ''}`} onClick={() => setRange(item)}>{item}</Button>
))}
</View>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{money(payment.revenueCentsInRange)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(payment.paidOrdersInRange || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(cards.students?.newInRange || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(cards.learning?.activeStudents || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(cards.learning?.answers || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{`${Math.round(Number(cards.learning?.accuracy || 0) * 100)}%`}</Text></View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{(dashboard?.planSales || []).slice(0, 8).map(item => (
<View className='admin-row' key={String(item.planId || item.name)}>
<Text className='admin-row-main'>{String(item.name || '未命名套餐')}</Text>
<Text className='admin-row-meta'>{String(item.paidOrders || 0)} · {money(item.revenueCents)}</Text>
</View>
))}
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{(dashboard?.recentActivities || []).slice(0, 8).map((item, index) => (
<View className='admin-row' key={`${String(item.activityType)}-${index}`}>
<Text className='admin-row-main'>{String(item.title || item.activityType)}</Text>
<Text className='admin-row-meta'>{String(item.occurredAt || '')}</Text>
</View>
))}
</View>
{!dashboard?.recentActivities?.length ? <View className='admin-empty'></View> : null}
</View>
{error ? <Text className='admin-error'>{error}</Text> : null}
</View>
</View>
);
}

View File

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

View File

@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import { Text, View } from '@tarojs/components';
import {
loadActivationCodes,
loadCodeBatches,
loadCommissionSummary,
loadCoupons,
loadCrmQueue,
type CodeBatchItem,
type CouponItem,
} from '@/services/tenantAdmin';
import '../admin.css';
function money(cents: unknown) {
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
}
export default function TenantMarketingPage() {
const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [batches, setBatches] = useState<CodeBatchItem[]>([]);
const [codes, setCodes] = useState<Record<string, unknown>[]>([]);
const [crmQueue, setCrmQueue] = useState<Record<string, unknown>[]>([]);
const [commission, setCommission] = useState<Record<string, unknown> | null>(null);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([
loadCoupons().catch(() => ({ items: [] })),
loadCodeBatches().catch(() => ({ items: [] })),
loadActivationCodes(20).catch(() => ({ items: [] })),
loadCrmQueue(20).catch(() => ({ items: [] })),
loadCommissionSummary().catch(() => ({ item: null })),
]).then(([couponPayload, batchPayload, codePayload, crmPayload, commissionPayload]) => {
setCoupons(couponPayload.items || []);
setBatches(batchPayload.items || []);
setCodes(codePayload.items || []);
setCrmQueue(crmPayload.items || []);
setCommission(commissionPayload.item || null);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '营销数据加载失败'));
}, []);
return (
<View className='admin-page'>
<View className='admin-shell'>
<View className='admin-header'>
<Text className='admin-kicker'>Marketing</Text>
<Text className='admin-title'></Text>
<Text className='admin-subtitle'>CRM /</Text>
</View>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(coupons.length)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(batches.length)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'>CRM </Text><Text className='admin-metric-value'>{String(crmQueue.length)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{money(commission?.commissionAmountCents)}</Text></View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{coupons.map(item => (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.code}</Text>
<Text className='admin-row-meta'>{item.status || 'active'} · {item.discountType || 'discount'} {String(item.discountValue ?? '')} · {item.usedCount || 0}/{item.maxUses || '不限'}</Text>
</View>
))}
</View>
{!coupons.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{batches.map(item => (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.name || item.batchNo || item.id}</Text>
<Text className='admin-row-meta'>{item.status || 'active'} · {item.usedCount || 0}/{item.totalCount || 0} </Text>
</View>
))}
</View>
{!batches.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{codes.slice(0, 8).map((item, index) => (
<View className='admin-row' key={String(item.id || index)}>
<Text className='admin-row-main'>{String(item.code || '激活码')}</Text>
<Text className='admin-row-meta'>{String(item.isUsed ? '已使用' : '未使用')} · {String(item.days || 0)} </Text>
</View>
))}
</View>
</View>
{error ? <Text className='admin-error'>{error}</Text> : null}
</View>
</View>
);
}

View File

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

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { Text, View } from '@tarojs/components';
import {
loadAuthProviders,
loadPaymentAccounts,
loadRoleTemplates,
loadTenantDomains,
loadTenantOverview,
type TenantOverview,
} from '@/services/tenantAdmin';
import '../admin.css';
export default function TenantSettingsPage() {
const [overview, setOverview] = useState<TenantOverview | null>(null);
const [domains, setDomains] = useState<Record<string, unknown>[]>([]);
const [payments, setPayments] = useState<Record<string, unknown>[]>([]);
const [authProviders, setAuthProviders] = useState<Record<string, unknown>[]>([]);
const [roles, setRoles] = useState<Record<string, unknown>[]>([]);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([
loadTenantOverview().catch(() => ({ item: null })),
loadTenantDomains().catch(() => ({ items: [] })),
loadPaymentAccounts().catch(() => ({ items: [] })),
loadAuthProviders().catch(() => ({ items: [] })),
loadRoleTemplates().catch(() => ({ items: [] })),
]).then(([overviewPayload, domainPayload, paymentPayload, authPayload, rolePayload]) => {
setOverview(overviewPayload.item || null);
setDomains(domainPayload.items || []);
setPayments(paymentPayload.items || []);
setAuthProviders(authPayload.items || []);
setRoles(rolePayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '设置加载失败'));
}, []);
return (
<View className='admin-page'>
<View className='admin-shell'>
<View className='admin-header'>
<Text className='admin-kicker'>Settings</Text>
<Text className='admin-title'></Text>
<Text className='admin-subtitle'> provider </Text>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-row'>
<Text className='admin-row-main'>{overview?.brandName || overview?.name || '未配置品牌名'}</Text>
<Text className='admin-row-meta'>{overview?.slogan || overview?.shortName || '暂无标语'}</Text>
<Text className='admin-row-meta'> {overview?.status || '-'} · slug {overview?.slug || '-'}</Text>
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{domains.map((item, index) => (
<View className='admin-row' key={String(item.id || index)}>
<Text className='admin-row-main'>{String(item.host || '未命名域名')}</Text>
<Text className='admin-row-meta'>{String(item.domainType || 'custom')} · {String(item.status || 'pending')} · {item.isPrimary ? '主域名' : '备用'}</Text>
</View>
))}
</View>
{!domains.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-grid'>
{payments.map((item, index) => (
<View className='admin-metric' key={String(item.id || index)}>
<Text className='admin-metric-label'>{String(item.provider || 'provider')}</Text>
<Text className='admin-metric-value'>{String(item.status || 'disabled')}</Text>
<Text className='admin-row-meta'>{String(item.mode || 'platform_collect')}</Text>
</View>
))}
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{authProviders.map((item, index) => (
<View className='admin-row' key={String(item.id || index)}>
<Text className='admin-row-main'>{String(item.provider || 'provider')}</Text>
<Text className='admin-row-meta'>{String(item.status || 'disabled')}</Text>
</View>
))}
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{roles.map((item, index) => (
<View className='admin-row' key={String(item.id || index)}>
<Text className='admin-row-main'>{String(item.name || item.roleKey || '角色模板')}</Text>
<Text className='admin-row-meta'>{String(item.status || 'active')} · </Text>
</View>
))}
</View>
{!roles.length ? <View className='admin-empty'></View> : null}
</View>
{error ? <Text className='admin-error'>{error}</Text> : null}
</View>
</View>
);
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '学生运营',
});

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react';
import { Button, Input, Text, View } from '@tarojs/components';
import { loadTenantClasses, loadTenantStudents, type TenantClassItem, type TenantStudentItem } from '@/services/tenantAdmin';
import '../admin.css';
export default function TenantStudentsPage() {
const [keyword, setKeyword] = useState('');
const [classes, setClasses] = useState<TenantClassItem[]>([]);
const [students, setStudents] = useState<TenantStudentItem[]>([]);
const [selectedClassId, setSelectedClassId] = useState('');
const [error, setError] = useState('');
function reload(nextClassId = selectedClassId, nextKeyword = keyword) {
Promise.all([
loadTenantClasses(80).catch(() => ({ items: [] })),
loadTenantStudents({ classId: nextClassId || undefined, keyword: nextKeyword || undefined, limit: 80 }),
]).then(([classPayload, studentPayload]) => {
setClasses(classPayload.items || []);
setStudents(studentPayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '学生数据加载失败'));
}
useEffect(() => {
reload('', '');
}, []);
function chooseClass(classId: string) {
setSelectedClassId(classId);
reload(classId, keyword);
}
return (
<View className='admin-page'>
<View className='admin-shell'>
<View className='admin-header'>
<Text className='admin-kicker'>Students</Text>
<Text className='admin-title'></Text>
<Text className='admin-subtitle'></Text>
</View>
<View className='admin-actions'>
<Input className='admin-input' placeholder='手机号、姓名、邮箱' value={keyword} onInput={event => setKeyword(String(event.detail.value || ''))} />
<Button className='admin-button primary' onClick={() => reload(selectedClassId, keyword)}></Button>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-tabs'>
<Button className={`admin-button ${selectedClassId ? '' : 'active'}`} onClick={() => chooseClass('')}></Button>
{classes.map(item => (
<Button key={item.id} className={`admin-button ${selectedClassId === item.id ? 'active' : ''}`} onClick={() => chooseClass(item.id)}>
{item.name}
</Button>
))}
</View>
<View className='admin-grid'>
{classes.slice(0, 6).map(item => (
<View className='admin-metric' key={item.id}>
<Text className='admin-metric-label'>{item.code || item.status || '班级'}</Text>
<Text className='admin-metric-value'>{item.name}</Text>
<Text className='admin-row-meta'>{item.studentCount || 0} · {item.teacherCount || 0} </Text>
</View>
))}
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{students.map(item => (
<View className='admin-row' key={item.userId}>
<Text className='admin-row-main'>{item.name || item.phone || item.email || item.userId}</Text>
<Text className='admin-row-meta'>{item.regionName || '未选地区'}{item.selectedSchoolName ? ` · ${item.selectedSchoolName}` : ''}{item.selectedMajorName ? ` · ${item.selectedMajorName}` : ''}</Text>
<Text className='admin-row-meta'> {item.questionsAnsweredToday || 0} · {item.masteredWordsCount || 0} · {(item.classes || []).length}</Text>
</View>
))}
</View>
{!students.length ? <View className='admin-empty'></View> : null}
</View>
{error ? <Text className='admin-error'>{error}</Text> : null}
</View>
</View>
);
}

View File

@@ -1,45 +1,75 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Text, View } from '@tarojs/components'; import Taro from '@tarojs/taro';
import { apiRequest, getTenantContext } from '@/services/api'; import { Button, Text, View } from '@tarojs/components';
import './index.css'; import { getTenantContext } from '@/services/api';
import { loadTenantDashboard, loadTenantOverview, type TenantDashboard, type TenantOverview } from '@/services/tenantAdmin';
interface Dashboard { import '../admin.css';
cards?: Record<string, unknown>;
trends?: unknown[];
recentActivities?: unknown[];
}
export default function TenantWorkbenchPage() { export default function TenantWorkbenchPage() {
const tenant = getTenantContext(); const tenant = getTenantContext();
const [dashboard, setDashboard] = useState<Dashboard | null>(null); const [dashboard, setDashboard] = useState<TenantDashboard | null>(null);
const [overview, setOverview] = useState<TenantOverview | null>(null);
useEffect(() => { useEffect(() => {
apiRequest<{ item?: Dashboard }>('/api/tenant-admin/dashboard', { query: { timeRange: '30d' } }) loadTenantDashboard('30d').then(payload => setDashboard(payload.item || null)).catch(() => setDashboard(null));
.then(payload => setDashboard(payload.item || null)) loadTenantOverview().then(payload => setOverview(payload.item || null)).catch(() => setOverview(null));
.catch(() => setDashboard(null));
}, []); }, []);
const cards = dashboard?.cards || {};
const payment = dashboard?.paymentStats || {};
const modules = [
{ name: '数据看板', path: '/pages/tenant-admin/dashboard/index', meta: '收益、注册、学习、内容' },
{ name: '学生运营', path: '/pages/tenant-admin/students/index', meta: '学生、班级、教师范围' },
{ name: '题库内容', path: '/pages/tenant-admin/content/index', meta: '入口、导入、公共题库' },
{ name: '营销中心', path: '/pages/tenant-admin/marketing/index', meta: '优惠券、激活码、分佣' },
{ name: '租户设置', path: '/pages/tenant-admin/settings/index', meta: '品牌、域名、支付、登录' },
];
return ( return (
<View className='admin-page'> <View className='admin-page'>
<View className='admin-header'> <View className='admin-shell'>
<Text className='admin-title'>{tenant?.branding.brandName || '租户运营后台'}</Text> <View className='admin-header'>
<Text className='admin-subtitle'></Text> <Text className='admin-kicker'>{overview?.slug || 'Tenant Admin'}</Text>
</View> <Text className='admin-title'>{overview?.brandName || tenant?.branding.brandName || '租户运营后台'}</Text>
<View className='metric-row'> <Text className='admin-subtitle'></Text>
{['收益', '注册', '答题', '激活码'].map(label => ( </View>
<View className='metric' key={label}>
<Text className='metric-label'>{label}</Text> <View className='admin-grid'>
<Text className='metric-value'>{dashboard ? '已接入' : '待登录'}</Text> <View className='admin-metric'>
<Text className='admin-metric-label'>30 </Text>
<Text className='admin-metric-value'>¥{((Number(payment.revenueCentsInRange || 0)) / 100).toFixed(2)}</Text>
</View> </View>
))} <View className='admin-metric'>
</View> <Text className='admin-metric-label'></Text>
<View className='module-list'> <Text className='admin-metric-value'>{String(cards.students?.total || 0)}</Text>
{['内容管理', '导入复检', '学生运营', '销售分佣', 'CRM 队列', '品牌域名'].map(name => (
<View className='module-row' key={name}>
<Text>{name}</Text>
<Text className='module-status'></Text>
</View> </View>
))} <View className='admin-metric'>
<Text className='admin-metric-label'></Text>
<Text className='admin-metric-value'>{String(cards.learning?.answers || 0)}</Text>
</View>
<View className='admin-metric'>
<Text className='admin-metric-label'></Text>
<Text className='admin-metric-value'>{String(cards.content?.questions || 0)}</Text>
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>
{modules.map(item => (
<View className='admin-row' key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
<Text className='admin-row-main'>{item.name}</Text>
<Text className='admin-row-meta'>{item.meta}</Text>
</View>
))}
</View>
</View>
<View className='admin-actions'>
<Button className='admin-button primary' onClick={() => Taro.navigateTo({ url: '/pages/tenant-admin/content/index' })}></Button>
<Button className='admin-button' onClick={() => Taro.navigateTo({ url: '/pages/tenant-admin/marketing/index' })}></Button>
<Button className='admin-button' onClick={() => Taro.navigateTo({ url: '/pages/tenant-admin/settings/index' })}></Button>
</View>
</View> </View>
</View> </View>
); );

View File

@@ -0,0 +1,183 @@
import { apiRequest } from './api';
export interface TenantDashboard {
scope?: Record<string, unknown>;
cards?: {
students?: Record<string, number>;
learning?: Record<string, number>;
content?: Record<string, number>;
activationCodes?: Record<string, number>;
feedback?: Record<string, number>;
};
paymentStats?: Record<string, number>;
trends?: Record<string, unknown>[];
activeHours?: Record<string, unknown>[];
questionDistribution?: Record<string, unknown>[];
subjectTop?: Record<string, unknown>[];
regionStats?: Record<string, unknown>[];
planSales?: Record<string, unknown>[];
recentActivities?: Record<string, unknown>[];
}
export interface TenantClassItem {
id: string;
name: string;
code?: string | null;
status?: string;
regionName?: string | null;
studentCount?: number;
teacherCount?: number;
}
export interface TenantStudentItem {
userId: string;
name?: string | null;
phone?: string | null;
email?: string | null;
status?: string;
regionName?: string | null;
selectedSchoolName?: string | null;
selectedMajorName?: string | null;
questionsAnsweredToday?: number;
masteredWordsCount?: number;
classes?: Record<string, unknown>[];
}
export interface ImportJobItem {
id: string;
importType?: string;
status?: string;
sourceName?: string | null;
totalCount?: number;
successCount?: number;
errorCount?: number;
warningCount?: number;
createdAt?: string;
updatedAt?: string;
}
export interface ContentEntryAdminItem {
id: string;
name: string;
entryType?: string;
visibility?: string;
isActive?: boolean;
regionName?: string | null;
}
export interface PublicQuestionBankItem {
grantId?: string;
sourceQuestionBankId?: string;
name?: string;
regionName?: string | null;
grantScope?: string;
adoption?: Record<string, unknown> | null;
}
export interface CouponItem {
id: string;
code: string;
status?: string;
discountType?: string | null;
discountValue?: number | string | null;
usedCount?: number;
maxUses?: number | null;
}
export interface CodeBatchItem {
id: string;
name?: string | null;
batchNo?: string | null;
totalCount?: number;
usedCount?: number;
status?: string;
}
export interface TenantOverview {
id?: string;
name?: string;
slug?: string;
status?: string;
brandName?: string;
shortName?: string;
slogan?: string | null;
logoUrl?: string | null;
theme?: Record<string, unknown>;
featureFlags?: Record<string, unknown>;
adminFeatureFlags?: Record<string, unknown>;
publicConfig?: Record<string, unknown>;
}
export async function loadTenantDashboard(timeRange = '30d') {
return apiRequest<{ item?: TenantDashboard }>('/api/tenant-admin/dashboard', { query: { timeRange } });
}
export async function loadTenantOverview() {
return apiRequest<{ item?: TenantOverview }>('/api/tenant-admin/overview');
}
export async function loadTenantPermissions() {
return apiRequest<{ items?: Record<string, unknown>[]; modules?: Record<string, unknown>[] }>('/api/tenant-admin/permissions');
}
export async function loadTenantClasses(limit = 50) {
return apiRequest<{ items?: TenantClassItem[]; scoped?: boolean }>('/api/tenant-admin/classes', { query: { limit } });
}
export async function loadTenantStudents(query: { keyword?: string; classId?: string; limit?: number } = {}) {
return apiRequest<{ items?: TenantStudentItem[]; scoped?: boolean }>('/api/tenant-admin/students', {
query: { ...query, limit: query.limit || 50 },
});
}
export async function loadContentEntriesAdmin() {
return apiRequest<{ items?: ContentEntryAdminItem[] }>('/api/tenant-content/content-entries');
}
export async function loadImportJobs(limit = 20) {
return apiRequest<{ items?: ImportJobItem[] }>('/api/tenant-content/imports', { query: { limit } });
}
export async function loadImportIssues(jobId: string) {
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/tenant-content/imports/issues', { query: { jobId } });
}
export async function loadPublicQuestionBanks() {
return apiRequest<{ items?: PublicQuestionBankItem[] }>('/api/tenant-content/public-question-banks');
}
export async function loadCoupons() {
return apiRequest<{ items?: CouponItem[] }>('/api/tenant-admin/coupons');
}
export async function loadCodeBatches() {
return apiRequest<{ items?: CodeBatchItem[] }>('/api/tenant-admin/code-batches');
}
export async function loadActivationCodes(limit = 20) {
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/tenant-admin/activation-codes', { query: { limit } });
}
export async function loadTenantDomains() {
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/tenant-admin/domains');
}
export async function loadPaymentAccounts() {
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/tenant-admin/payment-accounts');
}
export async function loadAuthProviders() {
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/tenant-admin/auth-providers');
}
export async function loadRoleTemplates() {
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/tenant-admin/role-templates');
}
export async function loadCrmQueue(limit = 20) {
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/crm/queue', { query: { limit } });
}
export async function loadCommissionSummary() {
return apiRequest<{ item?: Record<string, unknown> }>('/api/commission/summary');
}

View File

@@ -26,6 +26,7 @@
## 当前可进入的前端工作 ## 当前可进入的前端工作
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、题库、练习、背单词、知识手册、分数线、资料、个人中心。 - `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、题库、练习、背单词、知识手册、分数线、资料、个人中心。
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置。
- 可以继续复刻旧题库学生端主要视觉和交互:选地区、刷题细节、视频解析、题目反馈、模考报告、错题复习、商城收银台、订单详情、勋章展示。 - 可以继续复刻旧题库学生端主要视觉和交互:选地区、刷题细节、视频解析、题目反馈、模考报告、错题复习、商城收银台、订单详情、勋章展示。
- 可以按新后端主模型接入内容导航: - 可以按新后端主模型接入内容导航:
- `content_entries` - `content_entries`
@@ -71,3 +72,16 @@
| 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me``check-in``badges``exam-countdowns``svip-plans``orders``entitlements``activation-codes``leaderboard` | | 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me``check-in``badges``exam-countdowns``svip-plans``orders``entitlements``activation-codes``leaderboard` |
当前页面主要用于打通接口和路由,后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化。 当前页面主要用于打通接口和路由,后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化。
## 已落地的 Taro 租户后台页面
| 页面 | 文件 | 已接接口 |
| --- | --- | --- |
| 工作台 | `apps/taro/src/pages/tenant-admin/workbench/index.tsx` | `tenant-admin/overview``tenant-admin/dashboard` |
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes``tenant-admin/students` |
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries``tenant-content/imports``tenant-content/public-question-banks` |
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons``code-batches``activation-codes``crm/queue``commission/summary` |
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview``domains``payment-accounts``auth-providers``role-templates` |
当前页面以运营看板和只读列表为主。下一批需要继续补后台写入表单、导入 preview/import/issue 操作台、公共题库采纳/同步按钮、学生批量导入、角色模板配置 UI 和权限驱动菜单。

View File

@@ -1374,6 +1374,7 @@ src/services/catalog.ts 目录、题库、单词、手册、分数线、资
src/services/learning.ts 练习 session、答题、收藏、单词复习 src/services/learning.ts 练习 session、答题、收藏、单词复习
src/services/commerce.ts 套餐、订单、权益、激活码 src/services/commerce.ts 套餐、订单、权益、激活码
src/services/profile.ts 个人中心、签到、勋章、倒计时 src/services/profile.ts 个人中心、签到、勋章、倒计时
src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设置
``` ```
验证命令: 验证命令:
@@ -1381,6 +1382,7 @@ src/services/profile.ts 个人中心、签到、勋章、倒计时
```bash ```bash
npm run check:taro npm run check:taro
npm run build:taro:h5:student npm run build:taro:h5:student
npm run build:taro:h5:tenant
``` ```
已通过。构建仍有 Taro H5 入口体积 warning属于当前 Taro 工程既有警告,不阻断联调。 已通过。构建仍有 Taro H5 入口体积 warning属于当前 Taro 工程既有警告,不阻断联调。
@@ -1388,7 +1390,7 @@ npm run build:taro:h5:student
下一批前端开发重点: 下一批前端开发重点:
- 学生端:选地区、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、订单收银台和订单详情。 - 学生端:选地区、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、订单收银台和订单详情。
- 租户后台:数据看板、学生/班级/教师、角色模板、品牌域名、题库导入、公共题库采纳、营销、CRM、分佣 - 租户后台:写入表单、字段映射 UI、导入 preview/import/issues 操作台、公共题库采纳/同步、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作
- 平台后台租户、SaaS 套餐、订阅账单、公共题库授权、平台审计。 - 平台后台租户、SaaS 套餐、订阅账单、公共题库授权、平台审计。
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。 - 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。