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

@@ -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() {