From 620f3ac5ed9d229bd72712ecb37a81582188b901 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 23:13:45 +0800 Subject: [PATCH] feat: connect tenant coupon marketing ui --- README.md | 4 +- .../pages/tenant-admin/marketing/index.tsx | 306 +++++++++++++++++- apps/taro/src/services/tenantAdmin.ts | 101 +++++- docs/refactor/next-development-todo.md | 4 +- docs/refactor/taro-frontend-integration.md | 8 +- 5 files changed, 410 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2229b39a..ae8eddfe 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、动态水印上下文、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成。生产还要配置真实扫描服务 endpoint/token,并继续补转码/CDN 级水印、CDN 刷新和对象生命周期策略。 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 -- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;前端营销操作台仍需补更完整活动 UI。 +- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 - 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 -- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 +- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 更完整的进度看这些文档: diff --git a/apps/taro/src/pages/tenant-admin/marketing/index.tsx b/apps/taro/src/pages/tenant-admin/marketing/index.tsx index 33869af3..a8da4423 100644 --- a/apps/taro/src/pages/tenant-admin/marketing/index.tsx +++ b/apps/taro/src/pages/tenant-admin/marketing/index.tsx @@ -12,6 +12,8 @@ import { loadCommissionSettings, loadCommissionSettlements, loadCommissionSummary, + loadCouponRedemptions, + loadCouponReport, loadCoupons, loadCrmConfig, loadCrmQueue, @@ -20,6 +22,7 @@ import { updateCommissionSettlementProofStatus, updateCommissionSettlementStatus, updateMemberCommissionRate, + upsertCoupon, upsertCrmConfig, type CodeBatchItem, type CommissionOrderItem, @@ -28,6 +31,8 @@ import { type CommissionSettingsItem, type CommissionSummaryItem, type CouponItem, + type CouponRedemptionItem, + type CouponReport, type CrmConfigItem, type CrmQueueItem, type TenantMemberItem, @@ -38,6 +43,24 @@ function money(cents: unknown) { return `¥${(Number(cents || 0) / 100).toFixed(2)}`; } +function yuanInput(cents: unknown) { + if (cents === undefined || cents === null || cents === '') return ''; + return String((Number(cents || 0) / 100).toFixed(2)).replace(/\.00$/, ''); +} + +function yuanToCents(value: string) { + const parsed = Number(value || 0); + return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed * 100)) : 0; +} + +function csvValues(value: string) { + return value.split(/[\n,,]/).map(item => item.trim()).filter(Boolean); +} + +function percentLabel(value: unknown) { + return `${(Number(value || 0) * 100).toFixed(1).replace(/\.0$/, '')}%`; +} + function today() { return new Date().toISOString().slice(0, 10); } @@ -67,8 +90,52 @@ function downloadBase64File(filename: string, contentBase64: string, mimeType: s return true; } +interface CouponFormState { + id: string; + code: string; + status: 'active' | 'disabled' | 'archived'; + campaignName: string; + planId: string; + discountType: 'fixed' | 'percent'; + discountValue: string; + minOrderYuan: string; + maxDiscountYuan: string; + perUserLimit: string; + maxUses: string; + firstOrderOnly: boolean; + allowedPlanIdsText: string; + allowedRegionIdsText: string; + validFrom: string; + validTo: string; + remark: string; +} + +function defaultCouponForm(): CouponFormState { + return { + id: '', + code: '', + status: 'active', + campaignName: '', + planId: '', + discountType: 'fixed', + discountValue: '', + minOrderYuan: '0', + maxDiscountYuan: '', + perUserLimit: '1', + maxUses: '', + firstOrderOnly: false, + allowedPlanIdsText: '', + allowedRegionIdsText: '', + validFrom: '', + validTo: '', + remark: '', + }; +} + export default function TenantMarketingPage() { const [coupons, setCoupons] = useState([]); + const [couponReport, setCouponReport] = useState(null); + const [couponRedemptions, setCouponRedemptions] = useState([]); const [batches, setBatches] = useState([]); const [codes, setCodes] = useState[]>([]); const [crmConfig, setCrmConfig] = useState(null); @@ -90,6 +157,15 @@ export default function TenantMarketingPage() { assignmentMode: 'none' as 'none' | 'direct' | 'round_robin' | 'referrer', assignmentPoolText: '', }); + const [couponForm, setCouponForm] = useState(() => defaultCouponForm()); + const [couponFilter, setCouponFilter] = useState({ + status: '', + campaignName: '', + selectedCouponId: '', + redemptionStatus: 'used' as 'claimed' | 'pending' | 'used' | 'cancelled' | 'expired', + startDate: monthStart(), + endDate: today(), + }); 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: '' }); @@ -107,8 +183,32 @@ export default function TenantMarketingPage() { async function reloadAll() { setError(''); try { - const [couponPayload, batchPayload, codePayload, crmConfigPayload, crmPayload, commissionSettingsPayload, commissionPayload, orderPayload, settlementPayload, memberPayload] = await Promise.all([ - loadCoupons().catch(() => ({ items: [] })), + const [ + couponPayload, + couponReportPayload, + couponRedemptionPayload, + batchPayload, + codePayload, + crmConfigPayload, + crmPayload, + commissionSettingsPayload, + commissionPayload, + orderPayload, + settlementPayload, + memberPayload, + ] = await Promise.all([ + loadCoupons({ status: couponFilter.status || undefined, campaignName: couponFilter.campaignName || undefined }).catch(() => ({ items: [] })), + loadCouponReport({ + startDate: couponFilter.startDate, + endDate: couponFilter.endDate, + couponId: couponFilter.selectedCouponId || undefined, + campaignName: couponFilter.campaignName || undefined, + }).catch(() => ({ item: null })), + loadCouponRedemptions({ + couponId: couponFilter.selectedCouponId || undefined, + status: couponFilter.redemptionStatus, + limit: 20, + }).catch(() => ({ items: [] })), loadCodeBatches().catch(() => ({ items: [] })), loadActivationCodes(20).catch(() => ({ items: [] })), loadCrmConfig().catch(() => ({ item: null })), @@ -122,6 +222,8 @@ export default function TenantMarketingPage() { const nextCrm = crmConfigPayload.item || null; const nextSettings = commissionSettingsPayload.item || null; setCoupons(couponPayload.items || []); + setCouponReport(couponReportPayload.item || null); + setCouponRedemptions(couponRedemptionPayload.items || []); setBatches(batchPayload.items || []); setCodes(codePayload.items || []); setCrmConfig(nextCrm); @@ -160,6 +262,106 @@ export default function TenantMarketingPage() { void reloadAll(); }, []); + async function reloadCoupons(override: Partial = {}) { + const nextFilter = { ...couponFilter, ...override }; + setBusy('coupons'); + setError(''); + try { + const [couponPayload, reportPayload, redemptionPayload] = await Promise.all([ + loadCoupons({ status: nextFilter.status || undefined, campaignName: nextFilter.campaignName || undefined }), + loadCouponReport({ + startDate: nextFilter.startDate, + endDate: nextFilter.endDate, + couponId: nextFilter.selectedCouponId || undefined, + campaignName: nextFilter.campaignName || undefined, + }).catch(() => ({ item: null })), + loadCouponRedemptions({ + couponId: nextFilter.selectedCouponId || undefined, + status: nextFilter.redemptionStatus, + limit: 30, + }).catch(() => ({ items: [] })), + ]); + setCoupons(couponPayload.items || []); + setCouponReport(reportPayload.item || null); + setCouponRedemptions(redemptionPayload.items || []); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '优惠券数据加载失败'); + } finally { + setBusy(''); + } + } + + function editCoupon(item: CouponItem) { + setCouponForm({ + id: item.id, + code: item.code, + status: item.status === 'disabled' || item.status === 'archived' ? item.status : 'active', + campaignName: item.campaignName || '', + planId: item.planId || '', + discountType: item.discountType === 'percent' ? 'percent' : 'fixed', + discountValue: item.discountType === 'percent' ? String(item.discountValue || '') : yuanInput(item.discountValue), + minOrderYuan: yuanInput(item.minOrderAmountCents), + maxDiscountYuan: yuanInput(item.maxDiscountCents), + perUserLimit: String(item.perUserLimit || 1), + maxUses: item.maxUses === null || item.maxUses === undefined ? '' : String(item.maxUses), + firstOrderOnly: item.firstOrderOnly === true, + allowedPlanIdsText: (item.allowedPlanIds || []).join(','), + allowedRegionIdsText: (item.allowedRegionIds || []).join(','), + validFrom: item.validFrom ? item.validFrom.slice(0, 10) : '', + validTo: item.validTo ? item.validTo.slice(0, 10) : '', + remark: item.remark || '', + }); + setCouponFilter(prev => ({ ...prev, selectedCouponId: item.id, campaignName: item.campaignName || prev.campaignName })); + } + + async function saveCoupon() { + if (!couponForm.code.trim()) { + Taro.showToast({ title: '请填写优惠券码', icon: 'none' }); + return; + } + setBusy('coupon-save'); + setError(''); + try { + const discountValue = couponForm.discountType === 'fixed' + ? yuanToCents(couponForm.discountValue) + : Math.max(0, Number(couponForm.discountValue || 0)); + const result = await upsertCoupon({ + id: couponForm.id || undefined, + code: couponForm.code.trim().toUpperCase(), + status: couponForm.status, + campaignName: couponForm.campaignName.trim() || null, + planId: couponForm.planId.trim() || null, + discountType: couponForm.discountType, + discountValue, + minOrderAmountCents: yuanToCents(couponForm.minOrderYuan), + maxDiscountCents: couponForm.maxDiscountYuan.trim() ? yuanToCents(couponForm.maxDiscountYuan) : null, + perUserLimit: Math.max(1, Math.min(100, Math.trunc(Number(couponForm.perUserLimit || 1)))), + maxUses: couponForm.maxUses.trim() ? Math.max(0, Math.trunc(Number(couponForm.maxUses || 0))) : null, + firstOrderOnly: couponForm.firstOrderOnly, + allowedPlanIds: csvValues(couponForm.allowedPlanIdsText), + allowedRegionIds: csvValues(couponForm.allowedRegionIdsText), + validFrom: couponForm.validFrom || null, + validTo: couponForm.validTo || null, + remark: couponForm.remark.trim() || null, + source: 'taro-tenant-admin', + metadata: { source: 'taro-tenant-admin' }, + }); + if (result.item?.id) { + setCouponFilter(prev => ({ + ...prev, + selectedCouponId: result.item?.id || prev.selectedCouponId, + campaignName: result.item?.campaignName || prev.campaignName, + })); + } + Taro.showToast({ title: '优惠券已保存', icon: 'success' }); + await reloadCoupons(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '优惠券保存失败'); + } finally { + setBusy(''); + } + } + async function saveCrmConfig() { setBusy('crm'); setError(''); @@ -402,6 +604,69 @@ export default function TenantMarketingPage() { 预计佣金{money(commission?.commissionAmountCents)} + + 优惠券规则 + + setCouponForm(prev => ({ ...prev, code: String(event.detail.value || '').toUpperCase() }))} /> + setCouponForm(prev => ({ ...prev, campaignName: String(event.detail.value || '') }))} /> + setCouponForm(prev => ({ ...prev, planId: String(event.detail.value || '') }))} /> + setCouponForm(prev => ({ ...prev, discountValue: String(event.detail.value || '') }))} /> + setCouponForm(prev => ({ ...prev, minOrderYuan: String(event.detail.value || '') }))} /> + setCouponForm(prev => ({ ...prev, maxDiscountYuan: String(event.detail.value || '') }))} /> + setCouponForm(prev => ({ ...prev, perUserLimit: String(event.detail.value || '') }))} /> + setCouponForm(prev => ({ ...prev, maxUses: String(event.detail.value || '') }))} /> + setCouponForm(prev => ({ ...prev, allowedPlanIdsText: String(event.detail.value || '') }))} /> + setCouponForm(prev => ({ ...prev, allowedRegionIdsText: String(event.detail.value || '') }))} /> + setCouponForm(prev => ({ ...prev, validFrom: String(event.detail.value || '') }))} /> + setCouponForm(prev => ({ ...prev, validTo: String(event.detail.value || '') }))} /> + setCouponForm(prev => ({ ...prev, remark: String(event.detail.value || '') }))} /> + + + {(['fixed', 'percent'] as const).map(type => ( + + ))} + {(['active', 'disabled', 'archived'] as const).map(status => ( + + ))} + + + + + + + + 优惠券核销报表 + + setCouponFilter(prev => ({ ...prev, startDate: String(event.detail.value || '') }))} /> + setCouponFilter(prev => ({ ...prev, endDate: String(event.detail.value || '') }))} /> + setCouponFilter(prev => ({ ...prev, campaignName: String(event.detail.value || '') }))} /> + + + + {['active', 'disabled', 'archived'].map(status => ( + + ))} + {(['claimed', 'pending', 'used', 'cancelled', 'expired'] as const).map(status => ( + + ))} + + + + 领取数{String(couponReport?.claimCount || 0)} + 核销数{String(couponReport?.usedCount || 0)} + 抵扣金额{money(couponReport?.discountCents)} + 转化率{percentLabel(couponReport?.conversionRate)} + + + {(couponReport?.byCampaign || []).slice(0, 5).map((item, index) => ( + + {String(item.campaignName || '未分组')} + 领取 {String(item.claimCount || 0)} · 核销 {String(item.usedCount || 0)} · 抵扣 {money(item.discountCents)} + + ))} + + + CRM 配置 @@ -592,13 +857,48 @@ export default function TenantMarketingPage() { {coupons.map(item => ( {item.code} - {item.status || 'active'} · {item.discountType || 'discount'} {String(item.discountValue ?? '')} · 已用 {item.usedCount || 0}/{item.maxUses || '不限'} + + {item.status || 'active'} · {item.campaignName || '未分组'} · {item.discountType === 'fixed' ? money(item.discountValue) : `${String(item.discountValue ?? 0)}%`} + + + 已用 {item.usedCount || 0}/{item.maxUses || '不限'} · 满 {money(item.minOrderAmountCents)} 可用 · 封顶 {item.maxDiscountCents === null || item.maxDiscountCents === undefined ? '不限' : money(item.maxDiscountCents)} · 每人 {item.perUserLimit || 1} 次 + + + 套餐 {item.allowedPlanIds?.length ? item.allowedPlanIds.join(', ') : item.planId || '不限'} · 地区 {item.allowedRegionIds?.length ? item.allowedRegionIds.join(', ') : '不限'} {item.firstOrderOnly ? '· 仅首单' : ''} + + + + + ))} {!coupons.length ? 暂无优惠券。 : null} + + 核销明细 + + {couponRedemptions.map(item => ( + + {item.couponCode || '优惠券'} · {item.status || 'claimed'} · {money(item.discountAppliedCents)} + {item.userName || item.userPhone || item.userId || '学生'} · {item.planName || item.planId || '套餐'} · {item.regionName || item.regionId || '未指定地区'} + {item.orderNo || '未绑定订单'} · 领取 {item.claimedAt || item.createdAt || '-'} · 核销 {item.usedAt || '-'} + + ))} + + {!couponRedemptions.length ? 当前筛选下暂无核销明细,或当前角色没有 `coupons:redemptions:read` 权限。 : null} + + 激活码批次 diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index 1c2354f2..13192835 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -397,10 +397,81 @@ export interface CouponItem { id: string; code: string; status?: string; + campaignName?: string | null; + planId?: string | null; discountType?: string | null; discountValue?: number | string | null; + validFrom?: string | null; + validTo?: string | null; usedCount?: number; maxUses?: number | null; + minOrderAmountCents?: number; + maxDiscountCents?: number | null; + perUserLimit?: number; + firstOrderOnly?: boolean; + allowedPlanIds?: string[]; + allowedRegionIds?: string[]; + metadata?: Record; + source?: string | null; + remark?: string | null; +} + +export interface CouponInput { + id?: string; + code: string; + planId?: string | null; + discountType?: 'fixed' | 'percent' | null; + discountValue?: number | string | null; + validFrom?: string | null; + validTo?: string | null; + maxUses?: number | null; + status?: 'active' | 'disabled' | 'archived'; + campaignName?: string | null; + minOrderAmountCents?: number; + maxDiscountCents?: number | null; + perUserLimit?: number; + firstOrderOnly?: boolean; + allowedPlanIds?: string[]; + allowedRegionIds?: string[]; + metadata?: Record; + source?: string | null; + remark?: string | null; +} + +export interface CouponRedemptionItem { + id: string; + couponId?: string | null; + couponCode?: string | null; + campaignName?: string | null; + userId?: string | null; + userName?: string | null; + userPhone?: string | null; + planId?: string | null; + planName?: string | null; + orderId?: string | null; + orderNo?: string | null; + status?: string; + discountAppliedCents?: number; + regionId?: string | null; + regionName?: string | null; + source?: string | null; + remark?: string | null; + claimedAt?: string | null; + usedAt?: string | null; + createdAt?: string; +} + +export interface CouponReport { + startDate?: string; + endDate?: string; + claimCount?: number; + usedCount?: number; + discountCents?: number; + paidAmountCents?: number; + conversionRate?: number; + byCoupon?: Array>; + byCampaign?: Array>; + daily?: Array>; } export interface CodeBatchItem { @@ -982,8 +1053,34 @@ export async function executeContentImport(importType: ImportType, body: ImportR }); } -export async function loadCoupons() { - return apiRequest<{ items?: CouponItem[] }>('/api/tenant-admin/coupons'); +export async function loadCoupons(query: { status?: string; campaignName?: string; limit?: number } = {}) { + return apiRequest<{ items?: CouponItem[] }>('/api/tenant-admin/coupons', { query }); +} + +export async function upsertCoupon(input: CouponInput) { + return apiRequest<{ item?: CouponItem }>('/api/tenant-admin/coupons', { + method: 'PUT', + body: input, + }); +} + +export async function loadCouponRedemptions(query: { + couponId?: string; + status?: 'claimed' | 'pending' | 'used' | 'cancelled' | 'expired'; + limit?: number; +} = {}) { + return apiRequest<{ items?: CouponRedemptionItem[] }>('/api/tenant-admin/coupons/redemptions', { + query: { ...query, limit: query.limit || 50 }, + }); +} + +export async function loadCouponReport(query: { + startDate?: string; + endDate?: string; + couponId?: string; + campaignName?: string; +} = {}) { + return apiRequest<{ item?: CouponReport }>('/api/tenant-admin/coupons/report', { query }); } export async function loadCodeBatches() { diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index ab2e0c1f..9d0e4d7e 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -12,7 +12,7 @@ - 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。 - 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 -- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接第一版操作台。 +- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 - 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。 @@ -24,7 +24,7 @@ - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 - 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;后续补自动发放规则和活动联动。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 -- 优惠券商用规则已补齐后端第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。 +- 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。 - 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。 - 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。 - 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index a3007da7..67afdecc 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -2306,7 +2306,7 @@ src/services/commerce.ts 套餐、优惠券、下单、支付参数、订单 src/services/profile.ts 个人中心、地区目标、签到、反馈、勋章、倒计时 src/services/video.ts 题目视频列表、播放签名 src/services/ai.ts AI 择校推荐生成、报告列表、报告详情 -src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算 +src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表 src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权 ``` @@ -2324,7 +2324,7 @@ npm run build:taro:h5:platform 下一批前端开发重点: - 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。 -- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、CRM 分配策略、真实打款/导出/凭证、更细数据范围 UI 和主题素材库。 +- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细和核销报表第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、CRM 分配策略、真实打款/导出/凭证、更细数据范围 UI 和主题素材库。 - 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。 - 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。 @@ -2424,11 +2424,11 @@ GET /api/ai/school-recommendations/detail?reportId= - 内容入口/分类树/题目集合/练习蓝图 - 题目/单词/知识手册/分数线/视频维护 - 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入 preview/import/issues、字段映射、模板下载和导入后复检 -- Banner/FAQ/公告/激活码/优惠券,优惠券后台应接 `GET/PUT /api/tenant-admin/coupons`、`GET /api/tenant-admin/coupons/redemptions`、`GET /api/tenant-admin/coupons/report`,用于配置复杂规则、查看核销明细和活动效果。 +- Banner/FAQ/公告/激活码/优惠券,当前 Taro 租户营销中心已接 `GET/PUT /api/tenant-admin/coupons`、`GET /api/tenant-admin/coupons/redemptions`、`GET /api/tenant-admin/coupons/report`,用于配置复杂规则、查看核销明细和活动效果。 - 勋章:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` - 考试日期:`GET/PUT /api/tenant-admin/exam-dates` - 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` -- 销售/代理/CRM 队列、CRM 配置、CRM 跟进分配策略、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记;当前 Taro 租户营销中心已接第一版,真实打款 provider、结算导出、凭证和财务复核后续增强。 +- 销售/代理/CRM 队列、CRM 配置、CRM 跟进分配策略、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记;当前 Taro 租户营销中心已接 CRM、分佣、优惠券规则和核销报表第一版,真实打款 provider、结算导出、凭证和财务复核后续增强。 CRM 分配策略由后端执行,前端只提交配置: