forked from wangziqi/gongxue-base
feat: add taro tenant 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 初始化、安全环境变量边界、学生端第一批可联调页面,以及租户后台第一批运营页面。
|
||||
|
||||
## 三个 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');
|
||||
}
|
||||
Reference in New Issue
Block a user