forked from wangziqi/gongxue-base
feat: add taro tenant admin pages
This commit is contained in:
15
README.md
15
README.md
@@ -33,7 +33,7 @@
|
||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||
- 题库导出目前完成服务端结构化 payload;PDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。
|
||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心,租户后台/平台后台完整页面和端到端联调仍需继续补。
|
||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置;平台后台完整页面和端到端联调仍需继续补。
|
||||
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
||||
|
||||
更完整的进度看这些文档:
|
||||
@@ -131,6 +131,17 @@ apps/taro/src/pages/student/assets
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -313,7 +324,7 @@ git diff --check
|
||||
优先继续补:
|
||||
|
||||
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
||||
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台和平台后台完整业务页面,小程序兼容验证。
|
||||
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI,平台后台完整业务页面,小程序兼容验证。
|
||||
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
|
||||
4. 题库导出 PDF/Word worker、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||
5. 真实 OAuth/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Taro 前端工程
|
||||
|
||||
这是 SaaS 题库的新跨端前端地基。当前阶段已经提供 H5 多入口、租户解析、统一 API client、Supabase Auth client 初始化、安全环境变量边界,以及学生端第一批可联调页面。
|
||||
这是 SaaS 题库的新跨端前端地基。当前阶段已经提供 H5 多入口、租户解析、统一 API client、Supabase Auth client 初始化、安全环境变量边界、学生端第一批可联调页面,以及租户后台第一批运营页面。
|
||||
|
||||
## 三个 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 为准,前端菜单隐藏只做体验优化。
|
||||
|
||||
## 前端环境变量
|
||||
|
||||
只允许使用:
|
||||
|
||||
@@ -11,6 +11,11 @@ export default defineAppConfig({
|
||||
'pages/student/assets/index',
|
||||
'pages/student/profile/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',
|
||||
],
|
||||
window: {
|
||||
|
||||
175
apps/taro/src/pages/tenant-admin/admin.css
Normal file
175
apps/taro/src/pages/tenant-admin/admin.css
Normal 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;
|
||||
}
|
||||
3
apps/taro/src/pages/tenant-admin/content/index.config.ts
Normal file
3
apps/taro/src/pages/tenant-admin/content/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '题库内容',
|
||||
});
|
||||
75
apps/taro/src/pages/tenant-admin/content/index.tsx
Normal file
75
apps/taro/src/pages/tenant-admin/content/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '租户数据看板',
|
||||
});
|
||||
76
apps/taro/src/pages/tenant-admin/dashboard/index.tsx
Normal file
76
apps/taro/src/pages/tenant-admin/dashboard/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '营销中心',
|
||||
});
|
||||
100
apps/taro/src/pages/tenant-admin/marketing/index.tsx
Normal file
100
apps/taro/src/pages/tenant-admin/marketing/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '租户设置',
|
||||
});
|
||||
109
apps/taro/src/pages/tenant-admin/settings/index.tsx
Normal file
109
apps/taro/src/pages/tenant-admin/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '学生运营',
|
||||
});
|
||||
84
apps/taro/src/pages/tenant-admin/students/index.tsx
Normal file
84
apps/taro/src/pages/tenant-admin/students/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,75 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text, View } from '@tarojs/components';
|
||||
import { apiRequest, getTenantContext } from '@/services/api';
|
||||
import './index.css';
|
||||
|
||||
interface Dashboard {
|
||||
cards?: Record<string, unknown>;
|
||||
trends?: unknown[];
|
||||
recentActivities?: unknown[];
|
||||
}
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { getTenantContext } from '@/services/api';
|
||||
import { loadTenantDashboard, loadTenantOverview, type TenantDashboard, type TenantOverview } from '@/services/tenantAdmin';
|
||||
import '../admin.css';
|
||||
|
||||
export default function TenantWorkbenchPage() {
|
||||
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(() => {
|
||||
apiRequest<{ item?: Dashboard }>('/api/tenant-admin/dashboard', { query: { timeRange: '30d' } })
|
||||
.then(payload => setDashboard(payload.item || null))
|
||||
.catch(() => setDashboard(null));
|
||||
loadTenantDashboard('30d').then(payload => setDashboard(payload.item || null)).catch(() => setDashboard(null));
|
||||
loadTenantOverview().then(payload => setOverview(payload.item || null)).catch(() => setOverview(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 (
|
||||
<View className='admin-page'>
|
||||
<View className='admin-header'>
|
||||
<Text className='admin-title'>{tenant?.branding.brandName || '租户运营后台'}</Text>
|
||||
<Text className='admin-subtitle'>品牌、题库、学生、订单、营销和数据看板的统一工作台</Text>
|
||||
</View>
|
||||
<View className='metric-row'>
|
||||
{['收益', '注册', '答题', '激活码'].map(label => (
|
||||
<View className='metric' key={label}>
|
||||
<Text className='metric-label'>{label}</Text>
|
||||
<Text className='metric-value'>{dashboard ? '已接入' : '待登录'}</Text>
|
||||
<View className='admin-shell'>
|
||||
<View className='admin-header'>
|
||||
<Text className='admin-kicker'>{overview?.slug || 'Tenant Admin'}</Text>
|
||||
<Text className='admin-title'>{overview?.brandName || tenant?.branding.brandName || '租户运营后台'}</Text>
|
||||
<Text className='admin-subtitle'>品牌、题库、学生、订单、营销和数据看板的统一工作台。</Text>
|
||||
</View>
|
||||
|
||||
<View className='admin-grid'>
|
||||
<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='module-list'>
|
||||
{['内容管理', '导入复检', '学生运营', '销售分佣', 'CRM 队列', '品牌域名'].map(name => (
|
||||
<View className='module-row' key={name}>
|
||||
<Text>{name}</Text>
|
||||
<Text className='module-status'>按权限显示</Text>
|
||||
<View className='admin-metric'>
|
||||
<Text className='admin-metric-label'>学生数</Text>
|
||||
<Text className='admin-metric-value'>{String(cards.students?.total || 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'>{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>
|
||||
);
|
||||
|
||||
183
apps/taro/src/services/tenantAdmin.ts
Normal file
183
apps/taro/src/services/tenantAdmin.ts
Normal 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');
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
## 当前可进入的前端工作
|
||||
|
||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、题库、练习、背单词、知识手册、分数线、资料、个人中心。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:选地区、刷题细节、视频解析、题目反馈、模考报告、错题复习、商城收银台、订单详情、勋章展示。
|
||||
- 可以按新后端主模型接入内容导航:
|
||||
- `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` |
|
||||
|
||||
当前页面主要用于打通接口和路由,后续 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 和权限驱动菜单。
|
||||
|
||||
@@ -1374,6 +1374,7 @@ src/services/catalog.ts 目录、题库、单词、手册、分数线、资
|
||||
src/services/learning.ts 练习 session、答题、收藏、单词复习
|
||||
src/services/commerce.ts 套餐、订单、权益、激活码
|
||||
src/services/profile.ts 个人中心、签到、勋章、倒计时
|
||||
src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设置
|
||||
```
|
||||
|
||||
验证命令:
|
||||
@@ -1381,6 +1382,7 @@ src/services/profile.ts 个人中心、签到、勋章、倒计时
|
||||
```bash
|
||||
npm run check:taro
|
||||
npm run build:taro:h5:student
|
||||
npm run build:taro:h5:tenant
|
||||
```
|
||||
|
||||
已通过。构建仍有 Taro H5 入口体积 warning,属于当前 Taro 工程既有警告,不阻断联调。
|
||||
@@ -1388,7 +1390,7 @@ npm run build:taro:h5:student
|
||||
下一批前端开发重点:
|
||||
|
||||
- 学生端:选地区、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、订单收银台和订单详情。
|
||||
- 租户后台:数据看板、学生/班级/教师、角色模板、品牌域名、题库导入、公共题库采纳、营销、CRM、分佣。
|
||||
- 租户后台:写入表单、字段映射 UI、导入 preview/import/issues 操作台、公共题库采纳/同步、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作。
|
||||
- 平台后台:租户、SaaS 套餐、订阅账单、公共题库授权、平台审计。
|
||||
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user