feat: connect tenant coupon marketing ui

This commit is contained in:
Codex
2026-06-29 23:13:45 +08:00
parent 62c45a8fed
commit 620f3ac5ed
5 changed files with 410 additions and 13 deletions

View File

@@ -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 提交。
更完整的进度看这些文档:

View File

@@ -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<CouponItem[]>([]);
const [couponReport, setCouponReport] = useState<CouponReport | null>(null);
const [couponRedemptions, setCouponRedemptions] = useState<CouponRedemptionItem[]>([]);
const [batches, setBatches] = useState<CodeBatchItem[]>([]);
const [codes, setCodes] = useState<Record<string, unknown>[]>([]);
const [crmConfig, setCrmConfig] = useState<CrmConfigItem | null>(null);
@@ -90,6 +157,15 @@ export default function TenantMarketingPage() {
assignmentMode: 'none' as 'none' | 'direct' | 'round_robin' | 'referrer',
assignmentPoolText: '',
});
const [couponForm, setCouponForm] = useState<CouponFormState>(() => 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<typeof couponFilter> = {}) {
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() {
<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-form-grid'>
<Input className='admin-input' placeholder='优惠券码,例如 SUMMER80' value={couponForm.code} onInput={event => setCouponForm(prev => ({ ...prev, code: String(event.detail.value || '').toUpperCase() }))} />
<Input className='admin-input' placeholder='活动分组,例如 暑期活动' value={couponForm.campaignName} onInput={event => setCouponForm(prev => ({ ...prev, campaignName: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='默认套餐 ID可留空' value={couponForm.planId} onInput={event => setCouponForm(prev => ({ ...prev, planId: String(event.detail.value || '') }))} />
<Input className='admin-input' type='digit' placeholder={couponForm.discountType === 'fixed' ? '优惠金额,单位元' : '优惠百分比,例如 80 表示 80%'} value={couponForm.discountValue} onInput={event => setCouponForm(prev => ({ ...prev, discountValue: String(event.detail.value || '') }))} />
<Input className='admin-input' type='digit' placeholder='最低订单金额,单位元' value={couponForm.minOrderYuan} onInput={event => setCouponForm(prev => ({ ...prev, minOrderYuan: String(event.detail.value || '') }))} />
<Input className='admin-input' type='digit' placeholder='最高优惠封顶,单位元,留空不限' value={couponForm.maxDiscountYuan} onInput={event => setCouponForm(prev => ({ ...prev, maxDiscountYuan: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='单用户可用次数' value={couponForm.perUserLimit} onInput={event => setCouponForm(prev => ({ ...prev, perUserLimit: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='总可用次数,留空不限' value={couponForm.maxUses} onInput={event => setCouponForm(prev => ({ ...prev, maxUses: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='适用套餐 ID多个用逗号分隔' value={couponForm.allowedPlanIdsText} onInput={event => setCouponForm(prev => ({ ...prev, allowedPlanIdsText: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='适用地区 ID多个用逗号分隔' value={couponForm.allowedRegionIdsText} onInput={event => setCouponForm(prev => ({ ...prev, allowedRegionIdsText: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='开始日期 YYYY-MM-DD可留空' value={couponForm.validFrom} onInput={event => setCouponForm(prev => ({ ...prev, validFrom: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='结束日期 YYYY-MM-DD可留空' value={couponForm.validTo} onInput={event => setCouponForm(prev => ({ ...prev, validTo: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='内部备注' value={couponForm.remark} onInput={event => setCouponForm(prev => ({ ...prev, remark: String(event.detail.value || '') }))} />
</View>
<View className='admin-actions compact'>
{(['fixed', 'percent'] as const).map(type => (
<Button key={type} className={`admin-button ${couponForm.discountType === type ? 'active' : ''}`} onClick={() => setCouponForm(prev => ({ ...prev, discountType: type, discountValue: '' }))}>{type === 'fixed' ? '固定金额' : '百分比'}</Button>
))}
{(['active', 'disabled', 'archived'] as const).map(status => (
<Button key={status} className={`admin-button ${couponForm.status === status ? 'active' : ''}`} onClick={() => setCouponForm(prev => ({ ...prev, status }))}>{status === 'active' ? '启用' : status === 'disabled' ? '停用' : '归档'}</Button>
))}
<Button className={`admin-button ${couponForm.firstOrderOnly ? 'active' : ''}`} onClick={() => setCouponForm(prev => ({ ...prev, firstOrderOnly: !prev.firstOrderOnly }))}>{couponForm.firstOrderOnly ? '仅首单' : '不限首单'}</Button>
<Button className='admin-button primary' loading={busy === 'coupon-save'} onClick={saveCoupon}></Button>
<Button className='admin-button' onClick={() => setCouponForm(defaultCouponForm())}></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={couponFilter.startDate} onInput={event => setCouponFilter(prev => ({ ...prev, startDate: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='报表结束日期 YYYY-MM-DD' value={couponFilter.endDate} onInput={event => setCouponFilter(prev => ({ ...prev, endDate: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='活动分组筛选' value={couponFilter.campaignName} onInput={event => setCouponFilter(prev => ({ ...prev, campaignName: String(event.detail.value || '') }))} />
</View>
<View className='admin-actions compact'>
<Button className={`admin-button ${couponFilter.status ? '' : 'active'}`} onClick={() => setCouponFilter(prev => ({ ...prev, status: '' }))}></Button>
{['active', 'disabled', 'archived'].map(status => (
<Button key={status} className={`admin-button ${couponFilter.status === status ? 'active' : ''}`} onClick={() => setCouponFilter(prev => ({ ...prev, status }))}>{status}</Button>
))}
{(['claimed', 'pending', 'used', 'cancelled', 'expired'] as const).map(status => (
<Button key={status} className={`admin-button ${couponFilter.redemptionStatus === status ? 'active' : ''}`} onClick={() => setCouponFilter(prev => ({ ...prev, redemptionStatus: status }))}>{status}</Button>
))}
<Button className='admin-button primary' loading={busy === 'coupons'} onClick={() => reloadCoupons()}></Button>
</View>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(couponReport?.claimCount || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(couponReport?.usedCount || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{money(couponReport?.discountCents)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{percentLabel(couponReport?.conversionRate)}</Text></View>
</View>
<View className='admin-list compact'>
{(couponReport?.byCampaign || []).slice(0, 5).map((item, index) => (
<View className='admin-row' key={`${String(item.campaignName || 'campaign')}:${index}`}>
<Text className='admin-row-main'>{String(item.campaignName || '未分组')}</Text>
<Text className='admin-row-meta'> {String(item.claimCount || 0)} · {String(item.usedCount || 0)} · {money(item.discountCents)}</Text>
</View>
))}
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'>CRM </Text>
<View className='admin-form-grid'>
@@ -592,13 +857,48 @@ export default function TenantMarketingPage() {
{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>
<Text className='admin-row-meta'>
{item.status || 'active'} · {item.campaignName || '未分组'} · {item.discountType === 'fixed' ? money(item.discountValue) : `${String(item.discountValue ?? 0)}%`}
</Text>
<Text className='admin-row-meta'>
{item.usedCount || 0}/{item.maxUses || '不限'} · {money(item.minOrderAmountCents)} · {item.maxDiscountCents === null || item.maxDiscountCents === undefined ? '不限' : money(item.maxDiscountCents)} · {item.perUserLimit || 1}
</Text>
<Text className='admin-row-meta break-line'>
{item.allowedPlanIds?.length ? item.allowedPlanIds.join(', ') : item.planId || '不限'} · {item.allowedRegionIds?.length ? item.allowedRegionIds.join(', ') : '不限'} {item.firstOrderOnly ? '· 仅首单' : ''}
</Text>
<View className='admin-row-actions'>
<Button className='admin-mini-button primary' onClick={() => editCoupon(item)}></Button>
<Button
className={`admin-mini-button ${couponFilter.selectedCouponId === item.id ? 'primary' : ''}`}
onClick={() => {
const override = { selectedCouponId: item.id, campaignName: item.campaignName || couponFilter.campaignName };
setCouponFilter(prev => ({ ...prev, ...override }));
void reloadCoupons(override);
}}
>
</Button>
</View>
</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'>
{couponRedemptions.map(item => (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.couponCode || '优惠券'} · {item.status || 'claimed'} · {money(item.discountAppliedCents)}</Text>
<Text className='admin-row-meta'>{item.userName || item.userPhone || item.userId || '学生'} · {item.planName || item.planId || '套餐'} · {item.regionName || item.regionId || '未指定地区'}</Text>
<Text className='admin-row-meta'>{item.orderNo || '未绑定订单'} · {item.claimedAt || item.createdAt || '-'} · {item.usedAt || '-'}</Text>
</View>
))}
</View>
{!couponRedemptions.length ? <View className='admin-empty'> `coupons:redemptions:read` </View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<Record<string, unknown>>;
byCampaign?: Array<Record<string, unknown>>;
daily?: Array<Record<string, unknown>>;
}
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() {

View File

@@ -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` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。

View File

@@ -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=<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 分配策略由后端执行,前端只提交配置: