forked from wangziqi/gongxue-base
feat: add tenant crm commission console
This commit is contained in:
@@ -60,11 +60,11 @@ pages/tenant-admin/workbench/index 租户后台工作台、权限驱动模块
|
|||||||
pages/tenant-admin/dashboard/index 数据看板
|
pages/tenant-admin/dashboard/index 数据看板
|
||||||
pages/tenant-admin/students/index 学生与班级
|
pages/tenant-admin/students/index 学生与班级
|
||||||
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、异步轮询、复检、公共题库采纳/同步/单条和批量冲突处理
|
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、异步轮询、复检、公共题库采纳/同步/单条和批量冲突处理
|
||||||
pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要
|
pages/tenant-admin/marketing/index 优惠券、激活码、CRM 配置/队列、分佣结算
|
||||||
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板和成员绑定
|
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板和成员绑定
|
||||||
```
|
```
|
||||||
|
|
||||||
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览/下载、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览/下载、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销中心已接入 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、生成结算单、审核通过/驳回和标记线下打款第一版。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||||
|
|
||||||
租户工作台会读取 `/api/tenant-admin/permissions`,按菜单权限和有效权限隐藏不可见模块。租户设置页已接入角色模板与成员绑定写操作第一版:可新建、编辑、停用非系统模板,配置权限点、菜单可见、模块可见、字段可见和基础数据范围,也可以搜索/新建成员、绑定角色模板、设置成员状态和额外权限覆盖。前端只负责操作体验,`tenant_owner`、`tenant_admin`、通配权限、系统模板保护和最后一名 owner 保护仍以后端校验与审计为准。
|
租户工作台会读取 `/api/tenant-admin/permissions`,按菜单权限和有效权限隐藏不可见模块。租户设置页已接入角色模板与成员绑定写操作第一版:可新建、编辑、停用非系统模板,配置权限点、菜单可见、模块可见、字段可见和基础数据范围,也可以搜索/新建成员、绑定角色模板、设置成员状态和额外权限覆盖。前端只负责操作体验,`tenant_owner`、`tenant_admin`、通配权限、系统模板保护和最后一名 owner 保护仍以后端校验与审计为准。
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Text, View } from '@tarojs/components';
|
import Taro from '@tarojs/taro';
|
||||||
|
import { Button, Input, Text, View } from '@tarojs/components';
|
||||||
import {
|
import {
|
||||||
|
generateCommissionSettlement,
|
||||||
loadActivationCodes,
|
loadActivationCodes,
|
||||||
loadCodeBatches,
|
loadCodeBatches,
|
||||||
|
loadCommissionOrders,
|
||||||
|
loadCommissionSettings,
|
||||||
|
loadCommissionSettlements,
|
||||||
loadCommissionSummary,
|
loadCommissionSummary,
|
||||||
loadCoupons,
|
loadCoupons,
|
||||||
|
loadCrmConfig,
|
||||||
loadCrmQueue,
|
loadCrmQueue,
|
||||||
|
loadTenantMembers,
|
||||||
|
updateCommissionSettings,
|
||||||
|
updateCommissionSettlementStatus,
|
||||||
|
updateMemberCommissionRate,
|
||||||
|
upsertCrmConfig,
|
||||||
type CodeBatchItem,
|
type CodeBatchItem,
|
||||||
|
type CommissionOrderItem,
|
||||||
|
type CommissionSettlementItem,
|
||||||
|
type CommissionSettingsItem,
|
||||||
|
type CommissionSummaryItem,
|
||||||
type CouponItem,
|
type CouponItem,
|
||||||
|
type CrmConfigItem,
|
||||||
|
type CrmQueueItem,
|
||||||
|
type TenantMemberItem,
|
||||||
} from '@/services/tenantAdmin';
|
} from '@/services/tenantAdmin';
|
||||||
import '../admin.css';
|
import '../admin.css';
|
||||||
|
|
||||||
@@ -15,30 +33,251 @@ function money(cents: unknown) {
|
|||||||
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
|
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function today() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthStart() {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentToRate(value: string) {
|
||||||
|
const numberValue = Number(value || 0);
|
||||||
|
return Number.isFinite(numberValue) ? numberValue / 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateToPercent(value: unknown) {
|
||||||
|
return String((Number(value || 0) * 100).toFixed(2)).replace(/\.00$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
export default function TenantMarketingPage() {
|
export default function TenantMarketingPage() {
|
||||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||||
const [batches, setBatches] = useState<CodeBatchItem[]>([]);
|
const [batches, setBatches] = useState<CodeBatchItem[]>([]);
|
||||||
const [codes, setCodes] = useState<Record<string, unknown>[]>([]);
|
const [codes, setCodes] = useState<Record<string, unknown>[]>([]);
|
||||||
const [crmQueue, setCrmQueue] = useState<Record<string, unknown>[]>([]);
|
const [crmConfig, setCrmConfig] = useState<CrmConfigItem | null>(null);
|
||||||
const [commission, setCommission] = useState<Record<string, unknown> | null>(null);
|
const [crmQueue, setCrmQueue] = useState<CrmQueueItem[]>([]);
|
||||||
|
const [commissionSettings, setCommissionSettings] = useState<CommissionSettingsItem | null>(null);
|
||||||
|
const [commission, setCommission] = useState<CommissionSummaryItem | null>(null);
|
||||||
|
const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]);
|
||||||
|
const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]);
|
||||||
|
const [members, setMembers] = useState<TenantMemberItem[]>([]);
|
||||||
|
const [crmForm, setCrmForm] = useState({ enabled: false, url: '', secretRef: '', formName: '刷题题库', examType: '专升本', timeoutSec: '10', delaySec: '60' });
|
||||||
|
const [commissionForm, setCommissionForm] = useState({ defaultRatePercent: '20', minSettlementYuan: '0', settlementCycle: 'monthly' });
|
||||||
|
const [period, setPeriod] = useState({ startDate: monthStart(), endDate: today(), referrerUserId: '' });
|
||||||
|
const [memberRate, setMemberRate] = useState({ userId: '', ratePercent: '' });
|
||||||
|
const [settlementPay, setSettlementPay] = useState({ paymentMethod: 'offline_bank', paymentAccount: '' });
|
||||||
|
const [crmStatus, setCrmStatus] = useState('');
|
||||||
|
const [busy, setBusy] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
const salesMembers = useMemo(
|
||||||
Promise.all([
|
() => members.filter(item => ['sales', 'agent', 'tenant_operator', 'teacher', 'tenant_admin', 'tenant_owner'].includes(item.role) && item.status === 'active'),
|
||||||
loadCoupons().catch(() => ({ items: [] })),
|
[members],
|
||||||
loadCodeBatches().catch(() => ({ items: [] })),
|
);
|
||||||
loadActivationCodes(20).catch(() => ({ items: [] })),
|
|
||||||
loadCrmQueue(20).catch(() => ({ items: [] })),
|
async function reloadAll() {
|
||||||
loadCommissionSummary().catch(() => ({ item: null })),
|
setError('');
|
||||||
]).then(([couponPayload, batchPayload, codePayload, crmPayload, commissionPayload]) => {
|
try {
|
||||||
|
const [couponPayload, batchPayload, codePayload, crmConfigPayload, crmPayload, commissionSettingsPayload, commissionPayload, orderPayload, settlementPayload, memberPayload] = await Promise.all([
|
||||||
|
loadCoupons().catch(() => ({ items: [] })),
|
||||||
|
loadCodeBatches().catch(() => ({ items: [] })),
|
||||||
|
loadActivationCodes(20).catch(() => ({ items: [] })),
|
||||||
|
loadCrmConfig().catch(() => ({ item: null })),
|
||||||
|
loadCrmQueue(20, crmStatus || undefined).catch(() => ({ items: [] })),
|
||||||
|
loadCommissionSettings().catch(() => ({ item: null })),
|
||||||
|
loadCommissionSummary(period).catch(() => ({ item: null })),
|
||||||
|
loadCommissionOrders({ ...period, limit: 20 }).catch(() => ({ items: [] })),
|
||||||
|
loadCommissionSettlements({ limit: 20 }).catch(() => ({ items: [] })),
|
||||||
|
loadTenantMembers({ limit: 100 }).catch(() => ({ items: [] })),
|
||||||
|
]);
|
||||||
|
const nextCrm = crmConfigPayload.item || null;
|
||||||
|
const nextSettings = commissionSettingsPayload.item || null;
|
||||||
setCoupons(couponPayload.items || []);
|
setCoupons(couponPayload.items || []);
|
||||||
setBatches(batchPayload.items || []);
|
setBatches(batchPayload.items || []);
|
||||||
setCodes(codePayload.items || []);
|
setCodes(codePayload.items || []);
|
||||||
|
setCrmConfig(nextCrm);
|
||||||
setCrmQueue(crmPayload.items || []);
|
setCrmQueue(crmPayload.items || []);
|
||||||
|
setCommissionSettings(nextSettings);
|
||||||
setCommission(commissionPayload.item || null);
|
setCommission(commissionPayload.item || null);
|
||||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '营销数据加载失败'));
|
setCommissionOrders(orderPayload.items || []);
|
||||||
|
setSettlements(settlementPayload.items || []);
|
||||||
|
setMembers(memberPayload.items || []);
|
||||||
|
if (nextCrm) {
|
||||||
|
setCrmForm({
|
||||||
|
enabled: nextCrm.enabled === true,
|
||||||
|
url: nextCrm.url || '',
|
||||||
|
secretRef: nextCrm.secretRef || '',
|
||||||
|
formName: nextCrm.formName || '刷题题库',
|
||||||
|
examType: nextCrm.examType || '专升本',
|
||||||
|
timeoutSec: String(nextCrm.timeoutSec || 10),
|
||||||
|
delaySec: String(nextCrm.delaySec || 60),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (nextSettings) {
|
||||||
|
setCommissionForm({
|
||||||
|
defaultRatePercent: rateToPercent(nextSettings.defaultRate),
|
||||||
|
minSettlementYuan: String((Number(nextSettings.minSettlementCents || 0) / 100).toFixed(2)).replace(/\.00$/, ''),
|
||||||
|
settlementCycle: nextSettings.settlementCycle || 'monthly',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : '营销数据加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reloadAll();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
async function saveCrmConfig() {
|
||||||
|
setBusy('crm');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await upsertCrmConfig({
|
||||||
|
enabled: crmForm.enabled,
|
||||||
|
url: crmForm.url.trim() || null,
|
||||||
|
secretRef: crmForm.secretRef.trim() || null,
|
||||||
|
formName: crmForm.formName.trim() || '刷题题库',
|
||||||
|
examType: crmForm.examType.trim() || '专升本',
|
||||||
|
timeoutSec: Math.max(1, Math.trunc(Number(crmForm.timeoutSec || 10))),
|
||||||
|
delaySec: Math.max(0, Math.trunc(Number(crmForm.delaySec || 60))),
|
||||||
|
});
|
||||||
|
setCrmConfig(result.item || null);
|
||||||
|
Taro.showToast({ title: 'CRM 已保存', icon: 'success' });
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : 'CRM 保存失败');
|
||||||
|
} finally {
|
||||||
|
setBusy('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCrmQueue(status = crmStatus) {
|
||||||
|
setBusy('crm-queue');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const payload = await loadCrmQueue(30, status || undefined);
|
||||||
|
setCrmQueue(payload.items || []);
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : 'CRM 队列加载失败');
|
||||||
|
} finally {
|
||||||
|
setBusy('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCommissionSettings() {
|
||||||
|
setBusy('commission-settings');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await updateCommissionSettings({
|
||||||
|
defaultRate: percentToRate(commissionForm.defaultRatePercent),
|
||||||
|
minSettlementCents: Math.max(0, Math.round(Number(commissionForm.minSettlementYuan || 0) * 100)),
|
||||||
|
settlementCycle: commissionForm.settlementCycle,
|
||||||
|
config: { source: 'taro-tenant-admin' },
|
||||||
|
});
|
||||||
|
setCommissionSettings(result.item || null);
|
||||||
|
Taro.showToast({ title: '分佣规则已保存', icon: 'success' });
|
||||||
|
await reloadCommission();
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : '分佣规则保存失败');
|
||||||
|
} finally {
|
||||||
|
setBusy('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMemberRate() {
|
||||||
|
if (!memberRate.userId) {
|
||||||
|
Taro.showToast({ title: '请选择成员', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy('member-rate');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await updateMemberCommissionRate({
|
||||||
|
userId: memberRate.userId,
|
||||||
|
commissionRate: memberRate.ratePercent.trim() ? percentToRate(memberRate.ratePercent) : null,
|
||||||
|
commissionConfig: { source: 'taro-tenant-admin' },
|
||||||
|
});
|
||||||
|
Taro.showToast({ title: '成员比例已保存', icon: 'success' });
|
||||||
|
await reloadCommission();
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : '成员比例保存失败');
|
||||||
|
} finally {
|
||||||
|
setBusy('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadCommission() {
|
||||||
|
setBusy('commission');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const [summaryPayload, orderPayload, settlementPayload] = await Promise.all([
|
||||||
|
loadCommissionSummary(period),
|
||||||
|
loadCommissionOrders({ ...period, limit: 30 }),
|
||||||
|
loadCommissionSettlements({ limit: 30 }),
|
||||||
|
]);
|
||||||
|
setCommission(summaryPayload.item || null);
|
||||||
|
setCommissionOrders(orderPayload.items || []);
|
||||||
|
setSettlements(settlementPayload.items || []);
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : '分佣数据加载失败');
|
||||||
|
} finally {
|
||||||
|
setBusy('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSettlement() {
|
||||||
|
if (!period.referrerUserId) {
|
||||||
|
Taro.showToast({ title: '请选择销售/代理成员', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy('settlement-generate');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await generateCommissionSettlement({
|
||||||
|
referrerUserId: period.referrerUserId,
|
||||||
|
startDate: period.startDate,
|
||||||
|
endDate: period.endDate,
|
||||||
|
status: 'pending_review',
|
||||||
|
remark: 'Taro 管理台生成',
|
||||||
|
});
|
||||||
|
Taro.showToast({ title: '结算单已生成', icon: 'success' });
|
||||||
|
await reloadCommission();
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : '结算单生成失败');
|
||||||
|
} finally {
|
||||||
|
setBusy('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSettlement(item: CommissionSettlementItem, status: string) {
|
||||||
|
const confirmed = await Taro.showModal({
|
||||||
|
title: status === 'paid' ? '确认已打款' : '更新结算状态',
|
||||||
|
content: `${item.referrerName || item.referrerPhone || item.referrerUserId || '成员'} · ${money(item.commissionAmountCents)}`,
|
||||||
|
confirmText: status === 'paid' ? '已打款' : '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
});
|
||||||
|
if (!confirmed.confirm) return;
|
||||||
|
setBusy(`settlement:${item.id}:${status}`);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await updateCommissionSettlementStatus({
|
||||||
|
settlementId: item.id,
|
||||||
|
status,
|
||||||
|
paymentMethod: status === 'paid' ? settlementPay.paymentMethod : undefined,
|
||||||
|
paymentAccount: status === 'paid' ? settlementPay.paymentAccount || null : undefined,
|
||||||
|
remark: `Taro 管理台标记 ${status}`,
|
||||||
|
metadata: { source: 'taro-tenant-admin' },
|
||||||
|
});
|
||||||
|
Taro.showToast({ title: '状态已更新', icon: 'success' });
|
||||||
|
await reloadCommission();
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : '状态更新失败');
|
||||||
|
} finally {
|
||||||
|
setBusy('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='admin-page'>
|
<View className='admin-page'>
|
||||||
<View className='admin-shell'>
|
<View className='admin-shell'>
|
||||||
@@ -55,6 +294,150 @@ export default function TenantMarketingPage() {
|
|||||||
<View className='admin-metric'><Text className='admin-metric-label'>预计佣金</Text><Text className='admin-metric-value'>{money(commission?.commissionAmountCents)}</Text></View>
|
<View className='admin-metric'><Text className='admin-metric-label'>预计佣金</Text><Text className='admin-metric-value'>{money(commission?.commissionAmountCents)}</Text></View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='admin-section'>
|
||||||
|
<Text className='admin-section-title'>CRM 配置</Text>
|
||||||
|
<View className='admin-form-grid'>
|
||||||
|
<Button className={`admin-button ${crmForm.enabled ? 'active' : ''}`} onClick={() => setCrmForm(prev => ({ ...prev, enabled: !prev.enabled }))}>{crmForm.enabled ? '已启用' : '已停用'}</Button>
|
||||||
|
<Input className='admin-input' placeholder='Webhook URL' value={crmForm.url} onInput={event => setCrmForm(prev => ({ ...prev, url: String(event.detail.value || '') }))} />
|
||||||
|
<Input className='admin-input' placeholder='secretRef,例如 app_private.tenant_secrets:crm:webhook' value={crmForm.secretRef} onInput={event => setCrmForm(prev => ({ ...prev, secretRef: String(event.detail.value || '') }))} />
|
||||||
|
<Input className='admin-input' placeholder='表单名称' value={crmForm.formName} onInput={event => setCrmForm(prev => ({ ...prev, formName: String(event.detail.value || '') }))} />
|
||||||
|
<Input className='admin-input' placeholder='考试类型' value={crmForm.examType} onInput={event => setCrmForm(prev => ({ ...prev, examType: String(event.detail.value || '') }))} />
|
||||||
|
<Input className='admin-input' type='number' placeholder='超时秒数' value={crmForm.timeoutSec} onInput={event => setCrmForm(prev => ({ ...prev, timeoutSec: String(event.detail.value || '') }))} />
|
||||||
|
<Input className='admin-input' type='number' placeholder='延迟推送秒数' value={crmForm.delaySec} onInput={event => setCrmForm(prev => ({ ...prev, delaySec: String(event.detail.value || '') }))} />
|
||||||
|
</View>
|
||||||
|
<View className='admin-actions compact'>
|
||||||
|
<Button className='admin-button primary' loading={busy === 'crm'} onClick={saveCrmConfig}>保存 CRM</Button>
|
||||||
|
<Button className='admin-button' onClick={() => void reloadAll()}>刷新</Button>
|
||||||
|
</View>
|
||||||
|
<View className='admin-row'>
|
||||||
|
<Text className='admin-row-main'>{crmConfig?.enabled ? 'CRM 已启用' : 'CRM 未启用'}</Text>
|
||||||
|
<Text className='admin-row-meta'>{crmConfig?.url || '未配置 URL'} · {crmConfig?.secretRef || '未配置 secretRef'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='admin-section'>
|
||||||
|
<Text className='admin-section-title'>CRM 队列</Text>
|
||||||
|
<View className='admin-actions compact'>
|
||||||
|
{['', 'pending', 'sent', 'failed', 'discarded'].map(status => (
|
||||||
|
<Button
|
||||||
|
key={status || 'all'}
|
||||||
|
className={`admin-button ${crmStatus === status ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCrmStatus(status);
|
||||||
|
void refreshCrmQueue(status);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status || '全部'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View className='admin-list'>
|
||||||
|
{crmQueue.slice(0, 10).map(item => (
|
||||||
|
<View className='admin-row' key={item.id}>
|
||||||
|
<Text className='admin-row-main'>{item.status || 'pending'} · {item.source || 'lead'}</Text>
|
||||||
|
<Text className='admin-row-meta'>尝试 {item.attempts || 0} 次 · HTTP {item.lastHttpCode || '-'} · {item.sentAt || item.nextAttemptAt || item.createdAt || '-'}</Text>
|
||||||
|
{item.lastError ? <Text className='admin-row-meta break-line'>{item.lastError}</Text> : null}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{!crmQueue.length ? <View className='admin-empty'>暂无 CRM 队列。</View> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='admin-section'>
|
||||||
|
<Text className='admin-section-title'>分佣规则</Text>
|
||||||
|
<View className='admin-form-grid'>
|
||||||
|
<Input className='admin-input' type='digit' placeholder='默认分佣比例,例如 20 表示 20%' value={commissionForm.defaultRatePercent} onInput={event => setCommissionForm(prev => ({ ...prev, defaultRatePercent: String(event.detail.value || '') }))} />
|
||||||
|
<Input className='admin-input' type='digit' placeholder='最低结算金额,单位元' value={commissionForm.minSettlementYuan} onInput={event => setCommissionForm(prev => ({ ...prev, minSettlementYuan: String(event.detail.value || '') }))} />
|
||||||
|
</View>
|
||||||
|
<View className='admin-actions compact'>
|
||||||
|
{['manual', 'weekly', 'monthly'].map(item => (
|
||||||
|
<Button key={item} className={`admin-button ${commissionForm.settlementCycle === item ? 'active' : ''}`} onClick={() => setCommissionForm(prev => ({ ...prev, settlementCycle: item }))}>{item}</Button>
|
||||||
|
))}
|
||||||
|
<Button className='admin-button primary' loading={busy === 'commission-settings'} onClick={saveCommissionSettings}>保存规则</Button>
|
||||||
|
</View>
|
||||||
|
<View className='admin-row'>
|
||||||
|
<Text className='admin-row-main'>默认 {rateToPercent(commissionSettings?.defaultRate)}%</Text>
|
||||||
|
<Text className='admin-row-meta'>最低结算 {money(commissionSettings?.minSettlementCents)} · {commissionSettings?.settlementCycle || 'monthly'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='admin-section'>
|
||||||
|
<Text className='admin-section-title'>成员分佣比例</Text>
|
||||||
|
<View className='admin-actions compact'>
|
||||||
|
{salesMembers.map(item => (
|
||||||
|
<Button
|
||||||
|
key={item.userId}
|
||||||
|
className={`admin-button ${memberRate.userId === item.userId ? 'active' : ''}`}
|
||||||
|
onClick={() => setMemberRate(prev => ({ ...prev, userId: item.userId }))}
|
||||||
|
>
|
||||||
|
{item.name || item.phone || item.username || item.role}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View className='admin-form-grid'>
|
||||||
|
<Input className='admin-input' type='digit' placeholder='成员专属比例,例如 35 表示 35%,留空清除' value={memberRate.ratePercent} onInput={event => setMemberRate(prev => ({ ...prev, ratePercent: String(event.detail.value || '') }))} />
|
||||||
|
<Button className='admin-button primary' loading={busy === 'member-rate'} onClick={saveMemberRate}>保存成员比例</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='admin-section'>
|
||||||
|
<Text className='admin-section-title'>分佣统计</Text>
|
||||||
|
<View className='admin-form-grid'>
|
||||||
|
<Input className='admin-input' placeholder='开始日期 YYYY-MM-DD' value={period.startDate} onInput={event => setPeriod(prev => ({ ...prev, startDate: String(event.detail.value || '') }))} />
|
||||||
|
<Input className='admin-input' placeholder='结束日期 YYYY-MM-DD' value={period.endDate} onInput={event => setPeriod(prev => ({ ...prev, endDate: String(event.detail.value || '') }))} />
|
||||||
|
</View>
|
||||||
|
<View className='admin-actions compact'>
|
||||||
|
<Button className={`admin-button ${period.referrerUserId ? '' : 'active'}`} onClick={() => setPeriod(prev => ({ ...prev, referrerUserId: '' }))}>全部成员</Button>
|
||||||
|
{salesMembers.map(item => (
|
||||||
|
<Button key={item.userId} className={`admin-button ${period.referrerUserId === item.userId ? 'active' : ''}`} onClick={() => setPeriod(prev => ({ ...prev, referrerUserId: item.userId }))}>{item.name || item.phone || item.role}</Button>
|
||||||
|
))}
|
||||||
|
<Button className='admin-button primary' loading={busy === 'commission'} onClick={reloadCommission}>刷新统计</Button>
|
||||||
|
<Button className='admin-button primary' loading={busy === 'settlement-generate'} onClick={generateSettlement}>生成结算单</Button>
|
||||||
|
</View>
|
||||||
|
<View className='admin-grid'>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>来源数</Text><Text className='admin-metric-value'>{String(commission?.sourceCount || 0)}</Text></View>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>成交金额</Text><Text className='admin-metric-value'>{money(commission?.grossAmountCents)}</Text></View>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>应结佣金</Text><Text className='admin-metric-value'>{money(commission?.commissionAmountCents)}</Text></View>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>成交用户</Text><Text className='admin-metric-value'>{String(commission?.paidUserCount || 0)}</Text></View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='admin-section'>
|
||||||
|
<Text className='admin-section-title'>分佣订单明细</Text>
|
||||||
|
<View className='admin-list'>
|
||||||
|
{commissionOrders.slice(0, 10).map((item, index) => (
|
||||||
|
<View className='admin-row' key={`${item.sourceType || 'source'}:${item.sourceId || index}`}>
|
||||||
|
<Text className='admin-row-main'>{item.referrerName || item.referrerPhone || item.referrerUserId || '成员'} · {money(item.commissionAmountCents)}</Text>
|
||||||
|
<Text className='admin-row-meta'>{item.sourceType || '-'} · 成交 {money(item.grossAmountCents)} · 比例 {rateToPercent(item.commissionRate)}% · {item.rateSource || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{!commissionOrders.length ? <View className='admin-empty'>当前筛选范围暂无可结算订单。</View> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='admin-section'>
|
||||||
|
<Text className='admin-section-title'>结算单</Text>
|
||||||
|
<View className='admin-form-grid'>
|
||||||
|
<Input className='admin-input' placeholder='打款方式,例如 offline_bank' value={settlementPay.paymentMethod} onInput={event => setSettlementPay(prev => ({ ...prev, paymentMethod: String(event.detail.value || '') }))} />
|
||||||
|
<Input className='admin-input' placeholder='打款账户备注,可填脱敏账号' value={settlementPay.paymentAccount} onInput={event => setSettlementPay(prev => ({ ...prev, paymentAccount: String(event.detail.value || '') }))} />
|
||||||
|
</View>
|
||||||
|
<View className='admin-list'>
|
||||||
|
{settlements.map(item => (
|
||||||
|
<View className='admin-row' key={item.id}>
|
||||||
|
<Text className='admin-row-main'>{item.settlementNo || item.id}</Text>
|
||||||
|
<Text className='admin-row-meta'>{item.referrerName || item.referrerPhone || item.referrerUserId} · {item.status || 'draft'} · {money(item.commissionAmountCents)}</Text>
|
||||||
|
<Text className='admin-row-meta'>{item.periodStart || '-'} 至 {item.periodEnd || '-'} · 来源 {item.sourceCount || 0}</Text>
|
||||||
|
<View className='admin-row-actions'>
|
||||||
|
{item.status === 'pending_review' ? <Button className='admin-mini-button primary' loading={busy === `settlement:${item.id}:approved`} onClick={() => updateSettlement(item, 'approved')}>审核通过</Button> : null}
|
||||||
|
{item.status === 'pending_review' ? <Button className='admin-mini-button' loading={busy === `settlement:${item.id}:rejected`} onClick={() => updateSettlement(item, 'rejected')}>驳回</Button> : null}
|
||||||
|
{item.status === 'approved' ? <Button className='admin-mini-button primary' loading={busy === `settlement:${item.id}:paid`} onClick={() => updateSettlement(item, 'paid')}>标记打款</Button> : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{!settlements.length ? <View className='admin-empty'>暂无结算单。</View> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='admin-section'>
|
<View className='admin-section'>
|
||||||
<Text className='admin-section-title'>优惠券</Text>
|
<Text className='admin-section-title'>优惠券</Text>
|
||||||
<View className='admin-list'>
|
<View className='admin-list'>
|
||||||
|
|||||||
@@ -234,6 +234,101 @@ export interface CodeBatchItem {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CrmConfigItem {
|
||||||
|
id?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
url?: string | null;
|
||||||
|
secretRef?: string | null;
|
||||||
|
formName?: string | null;
|
||||||
|
examType?: string | null;
|
||||||
|
timeoutSec?: number;
|
||||||
|
delaySec?: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmQueueItem {
|
||||||
|
id: string;
|
||||||
|
recordId?: string | null;
|
||||||
|
status?: string;
|
||||||
|
scheduledAt?: string | null;
|
||||||
|
attempts?: number;
|
||||||
|
nextAttemptAt?: string | null;
|
||||||
|
lastError?: string | null;
|
||||||
|
lastHttpCode?: number | null;
|
||||||
|
leadId?: string | null;
|
||||||
|
sentAt?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
targetUrl?: string | null;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommissionSettingsItem {
|
||||||
|
defaultRate?: number | string;
|
||||||
|
minSettlementCents?: number;
|
||||||
|
settlementCycle?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommissionSummaryItem {
|
||||||
|
tenantId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
referrerUserId?: string | null;
|
||||||
|
defaultRate?: number | string;
|
||||||
|
minSettlementCents?: number;
|
||||||
|
sourceCount?: number;
|
||||||
|
paidUserCount?: number;
|
||||||
|
grossAmountCents?: number;
|
||||||
|
commissionAmountCents?: number;
|
||||||
|
byReferrer?: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommissionOrderItem {
|
||||||
|
sourceType?: string;
|
||||||
|
sourceId?: string;
|
||||||
|
sourceNo?: string | null;
|
||||||
|
referrerUserId?: string;
|
||||||
|
referrerName?: string | null;
|
||||||
|
referrerPhone?: string | null;
|
||||||
|
studentUserId?: string | null;
|
||||||
|
studentName?: string | null;
|
||||||
|
grossAmountCents?: number;
|
||||||
|
commissionRate?: number | string;
|
||||||
|
commissionAmountCents?: number;
|
||||||
|
rateSource?: string;
|
||||||
|
settlementId?: string | null;
|
||||||
|
sourcePaidAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommissionSettlementItem {
|
||||||
|
id: string;
|
||||||
|
settlementNo?: string;
|
||||||
|
referrerUserId?: string;
|
||||||
|
referrerName?: string | null;
|
||||||
|
referrerPhone?: string | null;
|
||||||
|
status?: string;
|
||||||
|
periodStart?: string;
|
||||||
|
periodEnd?: string;
|
||||||
|
sourceCount?: number;
|
||||||
|
paidUserCount?: number;
|
||||||
|
grossAmountCents?: number;
|
||||||
|
commissionAmountCents?: number;
|
||||||
|
defaultRate?: number | string;
|
||||||
|
effectiveRate?: number | string;
|
||||||
|
reviewedBy?: string | null;
|
||||||
|
reviewedAt?: string | null;
|
||||||
|
paidBy?: string | null;
|
||||||
|
paidAt?: string | null;
|
||||||
|
paymentMethod?: string | null;
|
||||||
|
paymentAccount?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TenantOverview {
|
export interface TenantOverview {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -518,10 +613,95 @@ export async function disableTenantMember(membershipId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadCrmQueue(limit = 20) {
|
export async function loadCrmConfig() {
|
||||||
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/crm/queue', { query: { limit } });
|
return apiRequest<{ item?: CrmConfigItem | null }>('/api/crm/config');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadCommissionSummary() {
|
export async function upsertCrmConfig(input: {
|
||||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/commission/summary');
|
enabled?: boolean;
|
||||||
|
url?: string | null;
|
||||||
|
secretRef?: string | null;
|
||||||
|
formName?: string | null;
|
||||||
|
examType?: string | null;
|
||||||
|
timeoutSec?: number;
|
||||||
|
delaySec?: number;
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ item?: CrmConfigItem }>('/api/crm/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCrmQueue(limit = 20, status?: string) {
|
||||||
|
return apiRequest<{ items?: CrmQueueItem[] }>('/api/crm/queue', { query: { limit, status } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCommissionSettings() {
|
||||||
|
return apiRequest<{ item?: CommissionSettingsItem }>('/api/commission/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCommissionSettings(input: {
|
||||||
|
defaultRate: number;
|
||||||
|
minSettlementCents?: number;
|
||||||
|
settlementCycle?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ item?: CommissionSettingsItem }>('/api/commission/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMemberCommissionRate(input: {
|
||||||
|
userId: string;
|
||||||
|
commissionRate?: number | null;
|
||||||
|
commissionConfig?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ item?: Record<string, unknown> }>('/api/commission/member-rate', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCommissionSummary(query: { startDate?: string; endDate?: string; referrerUserId?: string } = {}) {
|
||||||
|
return apiRequest<{ item?: CommissionSummaryItem }>('/api/commission/summary', { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCommissionOrders(query: { startDate?: string; endDate?: string; referrerUserId?: string; limit?: number } = {}) {
|
||||||
|
return apiRequest<{ items?: CommissionOrderItem[]; scope?: Record<string, unknown> }>('/api/commission/orders', {
|
||||||
|
query: { ...query, limit: query.limit || 50 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCommissionSettlements(query: { status?: string; referrerUserId?: string; limit?: number } = {}) {
|
||||||
|
return apiRequest<{ items?: CommissionSettlementItem[] }>('/api/commission/settlements', {
|
||||||
|
query: { ...query, limit: query.limit || 50 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCommissionSettlement(input: {
|
||||||
|
referrerUserId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
status?: string;
|
||||||
|
remark?: string | null;
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ item?: CommissionSettlementItem & { items?: CommissionOrderItem[] } }>('/api/commission/settlements/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCommissionSettlementStatus(input: {
|
||||||
|
settlementId: string;
|
||||||
|
status: string;
|
||||||
|
paymentMethod?: string | null;
|
||||||
|
paymentAccount?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ item?: CommissionSettlementItem }>('/api/commission/settlements/status', {
|
||||||
|
method: 'POST',
|
||||||
|
body: input,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
- 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。
|
- 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。
|
||||||
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
||||||
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API。
|
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API。
|
||||||
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。
|
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。
|
||||||
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
|
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
|
||||||
|
|
||||||
因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信/OAuth 生产账号、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载、字段映射 API 和导入任务详情已可联调,Taro 租户内容页已接入上传/粘贴预览、字段别名覆盖、同步/异步执行、异步轮询和复检详情第一版。
|
因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信/OAuth 生产账号、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载、字段映射 API 和导入任务详情已可联调,Taro 租户内容页已接入上传/粘贴预览、字段别名覆盖、同步/异步执行、异步轮询和复检详情第一版,租户营销中心已接入 CRM 配置/队列和分佣结算操作台第一版。
|
||||||
|
|
||||||
## 后端模块进度
|
## 后端模块进度
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API | 自动计费、平台审计、公共题库版本通知和运营消息 |
|
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API | 自动计费、平台审计、公共题库版本通知和运营消息 |
|
||||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,租户设置页已接角色模板和成员绑定操作台第一版 | 更细的数据范围组合、成员批量运营和完整权限菜单 |
|
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库版本通知、排行榜防刷/预聚合 |
|
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库版本通知、排行榜防刷/预聚合 |
|
||||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||||
@@ -31,11 +31,11 @@
|
|||||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架 | PDF 渲染、CDN 防盗链、杀毒扫描、视频水印 |
|
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架 | PDF 渲染、CDN 防盗链、杀毒扫描、视频水印 |
|
||||||
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 |
|
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 |
|
||||||
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
||||||
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
|
| 销售/代理/CRM | 可联调 | 邀请码、首绑保护、团队关系、销售统计、CRM 配置/队列、钉钉/飞书/企微 worker、分佣规则、成员分佣比例、订单/激活码归因、结算生成、审核和打款状态;Taro 营销中心已接第一版操作台 | 小程序码真实生成、CRM 分配策略、结算导出、真实打款、凭证、财务复核和销售转化看板 |
|
||||||
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
|
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
|
||||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||||
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
|
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
|
||||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户工作台已接权限驱动模块入口,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版 | 刷题细节 UI、更细数据范围 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户工作台已接权限驱动模块入口,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 刷题细节 UI、更细数据范围 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||||
|
|
||||||
## 前端接入建议
|
## 前端接入建议
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
|
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
|
||||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版;下一步补刷题细节 UI、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列和分佣结算操作台第一版;下一步补刷题细节 UI、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
||||||
|
|
||||||
### P1:商用收费和运营能力
|
### P1:商用收费和运营能力
|
||||||
|
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
|
|
||||||
- 租户自定义角色模板基础 API、Taro 权限配置 UI、成员绑定模板和工作台权限驱动入口第一版已完成;继续补班级/教师/学生组合范围 UI、成员批量运营和平台级审计报表。
|
- 租户自定义角色模板基础 API、Taro 权限配置 UI、成员绑定模板和工作台权限驱动入口第一版已完成;继续补班级/教师/学生组合范围 UI、成员批量运营和平台级审计报表。
|
||||||
- 三套默认主题、租户主题预览、Logo/图标/分享图配置。
|
- 三套默认主题、租户主题预览、Logo/图标/分享图配置。
|
||||||
- CRM worker:钉钉、飞书、企微机器人发送、签名、失败重试已落地;继续补轮询/定向分配、富卡片、失败告警和死信运营台。
|
- CRM worker:钉钉、飞书、企微机器人发送、签名、失败重试已落地;Taro 营销中心已能配置 CRM 和查看队列;继续补轮询/定向分配、富卡片、失败告警和死信运营台。
|
||||||
- 销售/代理分佣结算、销售团队看板、客资跟进效果。
|
- 销售/代理分佣基础闭环已接 Taro 第一版;继续补销售团队看板、客资跟进效果、结算导出、真实打款、凭证和财务复核。
|
||||||
- AI 择校推荐:地区考试数据、学生输入 schema、AI JSON 输出、PDF 报告生成。
|
- AI 择校推荐:地区考试数据、学生输入 schema、AI JSON 输出、PDF 报告生成。
|
||||||
- 性能压测、慢 SQL 审查、备份恢复演练、灰度发布和回滚预案。
|
- 性能压测、慢 SQL 审查、备份恢复演练、灰度发布和回滚预案。
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
## 当前可进入的前端工作
|
## 当前可进入的前端工作
|
||||||
|
|
||||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;题库内容页已具备公共题库采纳/同步、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;租户设置页已具备角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;题库内容页已具备公共题库采纳/同步、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款第一版;租户设置页已具备角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
||||||
- 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
- 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||||
- 可以按新后端主模型接入内容导航:
|
- 可以按新后端主模型接入内容导航:
|
||||||
@@ -88,10 +88,10 @@
|
|||||||
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `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/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`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch` |
|
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch` |
|
||||||
| 营销中心 | `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/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/config`、`crm/queue`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status` |
|
||||||
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
||||||
|
|
||||||
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;题库内容页支持公共题库采纳/同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情;租户设置页支持角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补完整后台写入表单、学生批量导入、更细数据范围 UI 和成员批量运营。
|
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;题库内容页支持公共题库采纳/同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款;租户设置页支持角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补完整后台写入表单、学生批量导入、更细数据范围 UI、真实打款 provider、结算导出和凭证。
|
||||||
|
|
||||||
## 已落地的 Taro 平台后台页面
|
## 已落地的 Taro 平台后台页面
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章。
|
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章。
|
||||||
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
||||||
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
|
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核和打款状态;Taro 租户营销中心已接第一版操作台。
|
||||||
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
||||||
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
||||||
- 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。
|
- 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
- H5 和小程序共用同一套业务 API client。
|
- H5 和小程序共用同一套业务 API client。
|
||||||
- 租户通过域名、小程序配置或启动参数解析。
|
- 租户通过域名、小程序配置或启动参数解析。
|
||||||
- 页面主题、品牌、功能开关都从后端租户配置读取。
|
- 页面主题、品牌、功能开关都从后端租户配置读取。
|
||||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、角色模板和成员绑定第一版;下一步补租户后台 CRM/分佣深度写操作、刷题细节 UI、状态管理和小程序兼容验证。
|
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补刷题细节 UI、状态管理、更细数据范围 UI、学生批量运营和小程序兼容验证。
|
||||||
|
|
||||||
### 第一批页面
|
### 第一批页面
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
|
|
||||||
## 推荐下一步顺序
|
## 推荐下一步顺序
|
||||||
|
|
||||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、角色模板配置和成员绑定模板已接第一版;继续补 CRM/分佣深度写操作、成员批量运营和更细数据范围 UI。
|
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、角色模板配置、成员绑定模板、CRM 配置/队列、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、学生批量导入、更细数据范围 UI、真实打款/导出/凭证。
|
||||||
2. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补刷题细节 UI、小程序支付容器、分享场景和状态管理。
|
2. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补刷题细节 UI、小程序支付容器、分享场景和状态管理。
|
||||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
|
|||||||
@@ -1408,7 +1408,7 @@ src/services/learning.ts 练习 session、答题、收藏、错题/收藏复
|
|||||||
src/services/commerce.ts 套餐、优惠券、下单、支付参数、订单详情、权益、激活码
|
src/services/commerce.ts 套餐、优惠券、下单、支付参数、订单详情、权益、激活码
|
||||||
src/services/profile.ts 个人中心、地区目标、签到、反馈、勋章、倒计时
|
src/services/profile.ts 个人中心、地区目标、签到、反馈、勋章、倒计时
|
||||||
src/services/video.ts 题目视频列表、播放签名
|
src/services/video.ts 题目视频列表、播放签名
|
||||||
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检
|
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算
|
||||||
src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权
|
src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1426,7 +1426,7 @@ npm run build:taro:h5:platform
|
|||||||
下一批前端开发重点:
|
下一批前端开发重点:
|
||||||
|
|
||||||
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。
|
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。
|
||||||
- 租户后台:工作台已接权限驱动模块入口;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;租户设置页已接角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补完整写入表单、学生批量导入、CRM 分配和分佣结算操作。
|
- 租户后台:工作台已接权限驱动模块入口;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款第一版;租户设置页已接角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补完整写入表单、学生批量导入、CRM 分配策略、真实打款/导出/凭证和更细数据范围 UI。
|
||||||
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。
|
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。
|
||||||
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
||||||
|
|
||||||
@@ -1448,7 +1448,7 @@ npm run build:taro:h5:platform
|
|||||||
- 勋章:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`
|
- 勋章:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`
|
||||||
- 考试日期:`GET/PUT /api/tenant-admin/exam-dates`
|
- 考试日期:`GET/PUT /api/tenant-admin/exam-dates`
|
||||||
- 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events`
|
- 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events`
|
||||||
- 销售/代理/CRM 队列
|
- 销售/代理/CRM 队列、CRM 配置、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记;当前 Taro 租户营销中心已接第一版,真实打款 provider、结算导出、凭证和财务复核后续增强。
|
||||||
|
|
||||||
租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。班级/学生范围权限由后端根据角色、模板 `dataScope.classIds` 和 `tenant_class_members` 计算,教师默认只能看到自己负责班级。
|
租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。班级/学生范围权限由后端根据角色、模板 `dataScope.classIds` 和 `tenant_class_members` 计算,教师默认只能看到自己负责班级。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user