forked from wangziqi/gongxue-base
feat: connect tenant coupon marketing ui
This commit is contained in:
@@ -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'>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user