forked from wangziqi/gongxue-base
feat: add tenant crm commission console
This commit is contained in:
@@ -60,11 +60,11 @@ pages/tenant-admin/workbench/index 租户后台工作台、权限驱动模块
|
||||
pages/tenant-admin/dashboard/index 数据看板
|
||||
pages/tenant-admin/students/index 学生与班级
|
||||
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、异步轮询、复检、公共题库采纳/同步/单条和批量冲突处理
|
||||
pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要
|
||||
pages/tenant-admin/marketing/index 优惠券、激活码、CRM 配置/队列、分佣结算
|
||||
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板和成员绑定
|
||||
```
|
||||
|
||||
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览/下载、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览/下载、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销中心已接入 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、生成结算单、审核通过/驳回和标记线下打款第一版。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||
|
||||
租户工作台会读取 `/api/tenant-admin/permissions`,按菜单权限和有效权限隐藏不可见模块。租户设置页已接入角色模板与成员绑定写操作第一版:可新建、编辑、停用非系统模板,配置权限点、菜单可见、模块可见、字段可见和基础数据范围,也可以搜索/新建成员、绑定角色模板、设置成员状态和额外权限覆盖。前端只负责操作体验,`tenant_owner`、`tenant_admin`、通配权限、系统模板保护和最后一名 owner 保护仍以后端校验与审计为准。
|
||||
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text, View } from '@tarojs/components';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import {
|
||||
generateCommissionSettlement,
|
||||
loadActivationCodes,
|
||||
loadCodeBatches,
|
||||
loadCommissionOrders,
|
||||
loadCommissionSettings,
|
||||
loadCommissionSettlements,
|
||||
loadCommissionSummary,
|
||||
loadCoupons,
|
||||
loadCrmConfig,
|
||||
loadCrmQueue,
|
||||
loadTenantMembers,
|
||||
updateCommissionSettings,
|
||||
updateCommissionSettlementStatus,
|
||||
updateMemberCommissionRate,
|
||||
upsertCrmConfig,
|
||||
type CodeBatchItem,
|
||||
type CommissionOrderItem,
|
||||
type CommissionSettlementItem,
|
||||
type CommissionSettingsItem,
|
||||
type CommissionSummaryItem,
|
||||
type CouponItem,
|
||||
type CrmConfigItem,
|
||||
type CrmQueueItem,
|
||||
type TenantMemberItem,
|
||||
} from '@/services/tenantAdmin';
|
||||
import '../admin.css';
|
||||
|
||||
@@ -15,30 +33,251 @@ function money(cents: unknown) {
|
||||
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function monthStart() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
}
|
||||
|
||||
function percentToRate(value: string) {
|
||||
const numberValue = Number(value || 0);
|
||||
return Number.isFinite(numberValue) ? numberValue / 100 : 0;
|
||||
}
|
||||
|
||||
function rateToPercent(value: unknown) {
|
||||
return String((Number(value || 0) * 100).toFixed(2)).replace(/\.00$/, '');
|
||||
}
|
||||
|
||||
export default function TenantMarketingPage() {
|
||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||
const [batches, setBatches] = useState<CodeBatchItem[]>([]);
|
||||
const [codes, setCodes] = useState<Record<string, unknown>[]>([]);
|
||||
const [crmQueue, setCrmQueue] = useState<Record<string, unknown>[]>([]);
|
||||
const [commission, setCommission] = useState<Record<string, unknown> | null>(null);
|
||||
const [crmConfig, setCrmConfig] = useState<CrmConfigItem | null>(null);
|
||||
const [crmQueue, setCrmQueue] = useState<CrmQueueItem[]>([]);
|
||||
const [commissionSettings, setCommissionSettings] = useState<CommissionSettingsItem | null>(null);
|
||||
const [commission, setCommission] = useState<CommissionSummaryItem | null>(null);
|
||||
const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]);
|
||||
const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]);
|
||||
const [members, setMembers] = useState<TenantMemberItem[]>([]);
|
||||
const [crmForm, setCrmForm] = useState({ enabled: false, url: '', secretRef: '', formName: '刷题题库', examType: '专升本', timeoutSec: '10', delaySec: '60' });
|
||||
const [commissionForm, setCommissionForm] = useState({ defaultRatePercent: '20', minSettlementYuan: '0', settlementCycle: 'monthly' });
|
||||
const [period, setPeriod] = useState({ startDate: monthStart(), endDate: today(), referrerUserId: '' });
|
||||
const [memberRate, setMemberRate] = useState({ userId: '', ratePercent: '' });
|
||||
const [settlementPay, setSettlementPay] = useState({ paymentMethod: 'offline_bank', paymentAccount: '' });
|
||||
const [crmStatus, setCrmStatus] = useState('');
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
loadCoupons().catch(() => ({ items: [] })),
|
||||
loadCodeBatches().catch(() => ({ items: [] })),
|
||||
loadActivationCodes(20).catch(() => ({ items: [] })),
|
||||
loadCrmQueue(20).catch(() => ({ items: [] })),
|
||||
loadCommissionSummary().catch(() => ({ item: null })),
|
||||
]).then(([couponPayload, batchPayload, codePayload, crmPayload, commissionPayload]) => {
|
||||
const salesMembers = useMemo(
|
||||
() => members.filter(item => ['sales', 'agent', 'tenant_operator', 'teacher', 'tenant_admin', 'tenant_owner'].includes(item.role) && item.status === 'active'),
|
||||
[members],
|
||||
);
|
||||
|
||||
async function reloadAll() {
|
||||
setError('');
|
||||
try {
|
||||
const [couponPayload, batchPayload, codePayload, crmConfigPayload, crmPayload, commissionSettingsPayload, commissionPayload, orderPayload, settlementPayload, memberPayload] = await Promise.all([
|
||||
loadCoupons().catch(() => ({ items: [] })),
|
||||
loadCodeBatches().catch(() => ({ items: [] })),
|
||||
loadActivationCodes(20).catch(() => ({ items: [] })),
|
||||
loadCrmConfig().catch(() => ({ item: null })),
|
||||
loadCrmQueue(20, crmStatus || undefined).catch(() => ({ items: [] })),
|
||||
loadCommissionSettings().catch(() => ({ item: null })),
|
||||
loadCommissionSummary(period).catch(() => ({ item: null })),
|
||||
loadCommissionOrders({ ...period, limit: 20 }).catch(() => ({ items: [] })),
|
||||
loadCommissionSettlements({ limit: 20 }).catch(() => ({ items: [] })),
|
||||
loadTenantMembers({ limit: 100 }).catch(() => ({ items: [] })),
|
||||
]);
|
||||
const nextCrm = crmConfigPayload.item || null;
|
||||
const nextSettings = commissionSettingsPayload.item || null;
|
||||
setCoupons(couponPayload.items || []);
|
||||
setBatches(batchPayload.items || []);
|
||||
setCodes(codePayload.items || []);
|
||||
setCrmConfig(nextCrm);
|
||||
setCrmQueue(crmPayload.items || []);
|
||||
setCommissionSettings(nextSettings);
|
||||
setCommission(commissionPayload.item || null);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '营销数据加载失败'));
|
||||
setCommissionOrders(orderPayload.items || []);
|
||||
setSettlements(settlementPayload.items || []);
|
||||
setMembers(memberPayload.items || []);
|
||||
if (nextCrm) {
|
||||
setCrmForm({
|
||||
enabled: nextCrm.enabled === true,
|
||||
url: nextCrm.url || '',
|
||||
secretRef: nextCrm.secretRef || '',
|
||||
formName: nextCrm.formName || '刷题题库',
|
||||
examType: nextCrm.examType || '专升本',
|
||||
timeoutSec: String(nextCrm.timeoutSec || 10),
|
||||
delaySec: String(nextCrm.delaySec || 60),
|
||||
});
|
||||
}
|
||||
if (nextSettings) {
|
||||
setCommissionForm({
|
||||
defaultRatePercent: rateToPercent(nextSettings.defaultRate),
|
||||
minSettlementYuan: String((Number(nextSettings.minSettlementCents || 0) / 100).toFixed(2)).replace(/\.00$/, ''),
|
||||
settlementCycle: nextSettings.settlementCycle || 'monthly',
|
||||
});
|
||||
}
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '营销数据加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reloadAll();
|
||||
}, []);
|
||||
|
||||
async function saveCrmConfig() {
|
||||
setBusy('crm');
|
||||
setError('');
|
||||
try {
|
||||
const result = await upsertCrmConfig({
|
||||
enabled: crmForm.enabled,
|
||||
url: crmForm.url.trim() || null,
|
||||
secretRef: crmForm.secretRef.trim() || null,
|
||||
formName: crmForm.formName.trim() || '刷题题库',
|
||||
examType: crmForm.examType.trim() || '专升本',
|
||||
timeoutSec: Math.max(1, Math.trunc(Number(crmForm.timeoutSec || 10))),
|
||||
delaySec: Math.max(0, Math.trunc(Number(crmForm.delaySec || 60))),
|
||||
});
|
||||
setCrmConfig(result.item || null);
|
||||
Taro.showToast({ title: 'CRM 已保存', icon: 'success' });
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'CRM 保存失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCrmQueue(status = crmStatus) {
|
||||
setBusy('crm-queue');
|
||||
setError('');
|
||||
try {
|
||||
const payload = await loadCrmQueue(30, status || undefined);
|
||||
setCrmQueue(payload.items || []);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'CRM 队列加载失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCommissionSettings() {
|
||||
setBusy('commission-settings');
|
||||
setError('');
|
||||
try {
|
||||
const result = await updateCommissionSettings({
|
||||
defaultRate: percentToRate(commissionForm.defaultRatePercent),
|
||||
minSettlementCents: Math.max(0, Math.round(Number(commissionForm.minSettlementYuan || 0) * 100)),
|
||||
settlementCycle: commissionForm.settlementCycle,
|
||||
config: { source: 'taro-tenant-admin' },
|
||||
});
|
||||
setCommissionSettings(result.item || null);
|
||||
Taro.showToast({ title: '分佣规则已保存', icon: 'success' });
|
||||
await reloadCommission();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '分佣规则保存失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMemberRate() {
|
||||
if (!memberRate.userId) {
|
||||
Taro.showToast({ title: '请选择成员', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
setBusy('member-rate');
|
||||
setError('');
|
||||
try {
|
||||
await updateMemberCommissionRate({
|
||||
userId: memberRate.userId,
|
||||
commissionRate: memberRate.ratePercent.trim() ? percentToRate(memberRate.ratePercent) : null,
|
||||
commissionConfig: { source: 'taro-tenant-admin' },
|
||||
});
|
||||
Taro.showToast({ title: '成员比例已保存', icon: 'success' });
|
||||
await reloadCommission();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '成员比例保存失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadCommission() {
|
||||
setBusy('commission');
|
||||
setError('');
|
||||
try {
|
||||
const [summaryPayload, orderPayload, settlementPayload] = await Promise.all([
|
||||
loadCommissionSummary(period),
|
||||
loadCommissionOrders({ ...period, limit: 30 }),
|
||||
loadCommissionSettlements({ limit: 30 }),
|
||||
]);
|
||||
setCommission(summaryPayload.item || null);
|
||||
setCommissionOrders(orderPayload.items || []);
|
||||
setSettlements(settlementPayload.items || []);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '分佣数据加载失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSettlement() {
|
||||
if (!period.referrerUserId) {
|
||||
Taro.showToast({ title: '请选择销售/代理成员', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
setBusy('settlement-generate');
|
||||
setError('');
|
||||
try {
|
||||
await generateCommissionSettlement({
|
||||
referrerUserId: period.referrerUserId,
|
||||
startDate: period.startDate,
|
||||
endDate: period.endDate,
|
||||
status: 'pending_review',
|
||||
remark: 'Taro 管理台生成',
|
||||
});
|
||||
Taro.showToast({ title: '结算单已生成', icon: 'success' });
|
||||
await reloadCommission();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '结算单生成失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSettlement(item: CommissionSettlementItem, status: string) {
|
||||
const confirmed = await Taro.showModal({
|
||||
title: status === 'paid' ? '确认已打款' : '更新结算状态',
|
||||
content: `${item.referrerName || item.referrerPhone || item.referrerUserId || '成员'} · ${money(item.commissionAmountCents)}`,
|
||||
confirmText: status === 'paid' ? '已打款' : '确认',
|
||||
cancelText: '取消',
|
||||
});
|
||||
if (!confirmed.confirm) return;
|
||||
setBusy(`settlement:${item.id}:${status}`);
|
||||
setError('');
|
||||
try {
|
||||
await updateCommissionSettlementStatus({
|
||||
settlementId: item.id,
|
||||
status,
|
||||
paymentMethod: status === 'paid' ? settlementPay.paymentMethod : undefined,
|
||||
paymentAccount: status === 'paid' ? settlementPay.paymentAccount || null : undefined,
|
||||
remark: `Taro 管理台标记 ${status}`,
|
||||
metadata: { source: 'taro-tenant-admin' },
|
||||
});
|
||||
Taro.showToast({ title: '状态已更新', icon: 'success' });
|
||||
await reloadCommission();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '状态更新失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='admin-page'>
|
||||
<View className='admin-shell'>
|
||||
@@ -55,6 +294,150 @@ export default function TenantMarketingPage() {
|
||||
<View className='admin-metric'><Text className='admin-metric-label'>预计佣金</Text><Text className='admin-metric-value'>{money(commission?.commissionAmountCents)}</Text></View>
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>CRM 配置</Text>
|
||||
<View className='admin-form-grid'>
|
||||
<Button className={`admin-button ${crmForm.enabled ? 'active' : ''}`} onClick={() => setCrmForm(prev => ({ ...prev, enabled: !prev.enabled }))}>{crmForm.enabled ? '已启用' : '已停用'}</Button>
|
||||
<Input className='admin-input' placeholder='Webhook URL' value={crmForm.url} onInput={event => setCrmForm(prev => ({ ...prev, url: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='secretRef,例如 app_private.tenant_secrets:crm:webhook' value={crmForm.secretRef} onInput={event => setCrmForm(prev => ({ ...prev, secretRef: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='表单名称' value={crmForm.formName} onInput={event => setCrmForm(prev => ({ ...prev, formName: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='考试类型' value={crmForm.examType} onInput={event => setCrmForm(prev => ({ ...prev, examType: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' type='number' placeholder='超时秒数' value={crmForm.timeoutSec} onInput={event => setCrmForm(prev => ({ ...prev, timeoutSec: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' type='number' placeholder='延迟推送秒数' value={crmForm.delaySec} onInput={event => setCrmForm(prev => ({ ...prev, delaySec: String(event.detail.value || '') }))} />
|
||||
</View>
|
||||
<View className='admin-actions compact'>
|
||||
<Button className='admin-button primary' loading={busy === 'crm'} onClick={saveCrmConfig}>保存 CRM</Button>
|
||||
<Button className='admin-button' onClick={() => void reloadAll()}>刷新</Button>
|
||||
</View>
|
||||
<View className='admin-row'>
|
||||
<Text className='admin-row-main'>{crmConfig?.enabled ? 'CRM 已启用' : 'CRM 未启用'}</Text>
|
||||
<Text className='admin-row-meta'>{crmConfig?.url || '未配置 URL'} · {crmConfig?.secretRef || '未配置 secretRef'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>CRM 队列</Text>
|
||||
<View className='admin-actions compact'>
|
||||
{['', 'pending', 'sent', 'failed', 'discarded'].map(status => (
|
||||
<Button
|
||||
key={status || 'all'}
|
||||
className={`admin-button ${crmStatus === status ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setCrmStatus(status);
|
||||
void refreshCrmQueue(status);
|
||||
}}
|
||||
>
|
||||
{status || '全部'}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
<View className='admin-list'>
|
||||
{crmQueue.slice(0, 10).map(item => (
|
||||
<View className='admin-row' key={item.id}>
|
||||
<Text className='admin-row-main'>{item.status || 'pending'} · {item.source || 'lead'}</Text>
|
||||
<Text className='admin-row-meta'>尝试 {item.attempts || 0} 次 · HTTP {item.lastHttpCode || '-'} · {item.sentAt || item.nextAttemptAt || item.createdAt || '-'}</Text>
|
||||
{item.lastError ? <Text className='admin-row-meta break-line'>{item.lastError}</Text> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!crmQueue.length ? <View className='admin-empty'>暂无 CRM 队列。</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>分佣规则</Text>
|
||||
<View className='admin-form-grid'>
|
||||
<Input className='admin-input' type='digit' placeholder='默认分佣比例,例如 20 表示 20%' value={commissionForm.defaultRatePercent} onInput={event => setCommissionForm(prev => ({ ...prev, defaultRatePercent: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' type='digit' placeholder='最低结算金额,单位元' value={commissionForm.minSettlementYuan} onInput={event => setCommissionForm(prev => ({ ...prev, minSettlementYuan: String(event.detail.value || '') }))} />
|
||||
</View>
|
||||
<View className='admin-actions compact'>
|
||||
{['manual', 'weekly', 'monthly'].map(item => (
|
||||
<Button key={item} className={`admin-button ${commissionForm.settlementCycle === item ? 'active' : ''}`} onClick={() => setCommissionForm(prev => ({ ...prev, settlementCycle: item }))}>{item}</Button>
|
||||
))}
|
||||
<Button className='admin-button primary' loading={busy === 'commission-settings'} onClick={saveCommissionSettings}>保存规则</Button>
|
||||
</View>
|
||||
<View className='admin-row'>
|
||||
<Text className='admin-row-main'>默认 {rateToPercent(commissionSettings?.defaultRate)}%</Text>
|
||||
<Text className='admin-row-meta'>最低结算 {money(commissionSettings?.minSettlementCents)} · {commissionSettings?.settlementCycle || 'monthly'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>成员分佣比例</Text>
|
||||
<View className='admin-actions compact'>
|
||||
{salesMembers.map(item => (
|
||||
<Button
|
||||
key={item.userId}
|
||||
className={`admin-button ${memberRate.userId === item.userId ? 'active' : ''}`}
|
||||
onClick={() => setMemberRate(prev => ({ ...prev, userId: item.userId }))}
|
||||
>
|
||||
{item.name || item.phone || item.username || item.role}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
<View className='admin-form-grid'>
|
||||
<Input className='admin-input' type='digit' placeholder='成员专属比例,例如 35 表示 35%,留空清除' value={memberRate.ratePercent} onInput={event => setMemberRate(prev => ({ ...prev, ratePercent: String(event.detail.value || '') }))} />
|
||||
<Button className='admin-button primary' loading={busy === 'member-rate'} onClick={saveMemberRate}>保存成员比例</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>分佣统计</Text>
|
||||
<View className='admin-form-grid'>
|
||||
<Input className='admin-input' placeholder='开始日期 YYYY-MM-DD' value={period.startDate} onInput={event => setPeriod(prev => ({ ...prev, startDate: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='结束日期 YYYY-MM-DD' value={period.endDate} onInput={event => setPeriod(prev => ({ ...prev, endDate: String(event.detail.value || '') }))} />
|
||||
</View>
|
||||
<View className='admin-actions compact'>
|
||||
<Button className={`admin-button ${period.referrerUserId ? '' : 'active'}`} onClick={() => setPeriod(prev => ({ ...prev, referrerUserId: '' }))}>全部成员</Button>
|
||||
{salesMembers.map(item => (
|
||||
<Button key={item.userId} className={`admin-button ${period.referrerUserId === item.userId ? 'active' : ''}`} onClick={() => setPeriod(prev => ({ ...prev, referrerUserId: item.userId }))}>{item.name || item.phone || item.role}</Button>
|
||||
))}
|
||||
<Button className='admin-button primary' loading={busy === 'commission'} onClick={reloadCommission}>刷新统计</Button>
|
||||
<Button className='admin-button primary' loading={busy === 'settlement-generate'} onClick={generateSettlement}>生成结算单</Button>
|
||||
</View>
|
||||
<View className='admin-grid'>
|
||||
<View className='admin-metric'><Text className='admin-metric-label'>来源数</Text><Text className='admin-metric-value'>{String(commission?.sourceCount || 0)}</Text></View>
|
||||
<View className='admin-metric'><Text className='admin-metric-label'>成交金额</Text><Text className='admin-metric-value'>{money(commission?.grossAmountCents)}</Text></View>
|
||||
<View className='admin-metric'><Text className='admin-metric-label'>应结佣金</Text><Text className='admin-metric-value'>{money(commission?.commissionAmountCents)}</Text></View>
|
||||
<View className='admin-metric'><Text className='admin-metric-label'>成交用户</Text><Text className='admin-metric-value'>{String(commission?.paidUserCount || 0)}</Text></View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>分佣订单明细</Text>
|
||||
<View className='admin-list'>
|
||||
{commissionOrders.slice(0, 10).map((item, index) => (
|
||||
<View className='admin-row' key={`${item.sourceType || 'source'}:${item.sourceId || index}`}>
|
||||
<Text className='admin-row-main'>{item.referrerName || item.referrerPhone || item.referrerUserId || '成员'} · {money(item.commissionAmountCents)}</Text>
|
||||
<Text className='admin-row-meta'>{item.sourceType || '-'} · 成交 {money(item.grossAmountCents)} · 比例 {rateToPercent(item.commissionRate)}% · {item.rateSource || '-'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!commissionOrders.length ? <View className='admin-empty'>当前筛选范围暂无可结算订单。</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>结算单</Text>
|
||||
<View className='admin-form-grid'>
|
||||
<Input className='admin-input' placeholder='打款方式,例如 offline_bank' value={settlementPay.paymentMethod} onInput={event => setSettlementPay(prev => ({ ...prev, paymentMethod: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='打款账户备注,可填脱敏账号' value={settlementPay.paymentAccount} onInput={event => setSettlementPay(prev => ({ ...prev, paymentAccount: String(event.detail.value || '') }))} />
|
||||
</View>
|
||||
<View className='admin-list'>
|
||||
{settlements.map(item => (
|
||||
<View className='admin-row' key={item.id}>
|
||||
<Text className='admin-row-main'>{item.settlementNo || item.id}</Text>
|
||||
<Text className='admin-row-meta'>{item.referrerName || item.referrerPhone || item.referrerUserId} · {item.status || 'draft'} · {money(item.commissionAmountCents)}</Text>
|
||||
<Text className='admin-row-meta'>{item.periodStart || '-'} 至 {item.periodEnd || '-'} · 来源 {item.sourceCount || 0}</Text>
|
||||
<View className='admin-row-actions'>
|
||||
{item.status === 'pending_review' ? <Button className='admin-mini-button primary' loading={busy === `settlement:${item.id}:approved`} onClick={() => updateSettlement(item, 'approved')}>审核通过</Button> : null}
|
||||
{item.status === 'pending_review' ? <Button className='admin-mini-button' loading={busy === `settlement:${item.id}:rejected`} onClick={() => updateSettlement(item, 'rejected')}>驳回</Button> : null}
|
||||
{item.status === 'approved' ? <Button className='admin-mini-button primary' loading={busy === `settlement:${item.id}:paid`} onClick={() => updateSettlement(item, 'paid')}>标记打款</Button> : null}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!settlements.length ? <View className='admin-empty'>暂无结算单。</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>优惠券</Text>
|
||||
<View className='admin-list'>
|
||||
|
||||
@@ -234,6 +234,101 @@ export interface CodeBatchItem {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CrmConfigItem {
|
||||
id?: string;
|
||||
enabled?: boolean;
|
||||
url?: string | null;
|
||||
secretRef?: string | null;
|
||||
formName?: string | null;
|
||||
examType?: string | null;
|
||||
timeoutSec?: number;
|
||||
delaySec?: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CrmQueueItem {
|
||||
id: string;
|
||||
recordId?: string | null;
|
||||
status?: string;
|
||||
scheduledAt?: string | null;
|
||||
attempts?: number;
|
||||
nextAttemptAt?: string | null;
|
||||
lastError?: string | null;
|
||||
lastHttpCode?: number | null;
|
||||
leadId?: string | null;
|
||||
sentAt?: string | null;
|
||||
source?: string | null;
|
||||
targetUrl?: string | null;
|
||||
payload?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CommissionSettingsItem {
|
||||
defaultRate?: number | string;
|
||||
minSettlementCents?: number;
|
||||
settlementCycle?: string;
|
||||
config?: Record<string, unknown>;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CommissionSummaryItem {
|
||||
tenantId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
referrerUserId?: string | null;
|
||||
defaultRate?: number | string;
|
||||
minSettlementCents?: number;
|
||||
sourceCount?: number;
|
||||
paidUserCount?: number;
|
||||
grossAmountCents?: number;
|
||||
commissionAmountCents?: number;
|
||||
byReferrer?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface CommissionOrderItem {
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
sourceNo?: string | null;
|
||||
referrerUserId?: string;
|
||||
referrerName?: string | null;
|
||||
referrerPhone?: string | null;
|
||||
studentUserId?: string | null;
|
||||
studentName?: string | null;
|
||||
grossAmountCents?: number;
|
||||
commissionRate?: number | string;
|
||||
commissionAmountCents?: number;
|
||||
rateSource?: string;
|
||||
settlementId?: string | null;
|
||||
sourcePaidAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CommissionSettlementItem {
|
||||
id: string;
|
||||
settlementNo?: string;
|
||||
referrerUserId?: string;
|
||||
referrerName?: string | null;
|
||||
referrerPhone?: string | null;
|
||||
status?: string;
|
||||
periodStart?: string;
|
||||
periodEnd?: string;
|
||||
sourceCount?: number;
|
||||
paidUserCount?: number;
|
||||
grossAmountCents?: number;
|
||||
commissionAmountCents?: number;
|
||||
defaultRate?: number | string;
|
||||
effectiveRate?: number | string;
|
||||
reviewedBy?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
paidBy?: string | null;
|
||||
paidAt?: string | null;
|
||||
paymentMethod?: string | null;
|
||||
paymentAccount?: string | null;
|
||||
remark?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TenantOverview {
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -518,10 +613,95 @@ export async function disableTenantMember(membershipId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCrmQueue(limit = 20) {
|
||||
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/crm/queue', { query: { limit } });
|
||||
export async function loadCrmConfig() {
|
||||
return apiRequest<{ item?: CrmConfigItem | null }>('/api/crm/config');
|
||||
}
|
||||
|
||||
export async function loadCommissionSummary() {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/commission/summary');
|
||||
export async function upsertCrmConfig(input: {
|
||||
enabled?: boolean;
|
||||
url?: string | null;
|
||||
secretRef?: string | null;
|
||||
formName?: string | null;
|
||||
examType?: string | null;
|
||||
timeoutSec?: number;
|
||||
delaySec?: number;
|
||||
}) {
|
||||
return apiRequest<{ item?: CrmConfigItem }>('/api/crm/config', {
|
||||
method: 'PUT',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCrmQueue(limit = 20, status?: string) {
|
||||
return apiRequest<{ items?: CrmQueueItem[] }>('/api/crm/queue', { query: { limit, status } });
|
||||
}
|
||||
|
||||
export async function loadCommissionSettings() {
|
||||
return apiRequest<{ item?: CommissionSettingsItem }>('/api/commission/settings');
|
||||
}
|
||||
|
||||
export async function updateCommissionSettings(input: {
|
||||
defaultRate: number;
|
||||
minSettlementCents?: number;
|
||||
settlementCycle?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}) {
|
||||
return apiRequest<{ item?: CommissionSettingsItem }>('/api/commission/settings', {
|
||||
method: 'PUT',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMemberCommissionRate(input: {
|
||||
userId: string;
|
||||
commissionRate?: number | null;
|
||||
commissionConfig?: Record<string, unknown>;
|
||||
}) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/commission/member-rate', {
|
||||
method: 'PUT',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCommissionSummary(query: { startDate?: string; endDate?: string; referrerUserId?: string } = {}) {
|
||||
return apiRequest<{ item?: CommissionSummaryItem }>('/api/commission/summary', { query });
|
||||
}
|
||||
|
||||
export async function loadCommissionOrders(query: { startDate?: string; endDate?: string; referrerUserId?: string; limit?: number } = {}) {
|
||||
return apiRequest<{ items?: CommissionOrderItem[]; scope?: Record<string, unknown> }>('/api/commission/orders', {
|
||||
query: { ...query, limit: query.limit || 50 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCommissionSettlements(query: { status?: string; referrerUserId?: string; limit?: number } = {}) {
|
||||
return apiRequest<{ items?: CommissionSettlementItem[] }>('/api/commission/settlements', {
|
||||
query: { ...query, limit: query.limit || 50 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateCommissionSettlement(input: {
|
||||
referrerUserId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status?: string;
|
||||
remark?: string | null;
|
||||
}) {
|
||||
return apiRequest<{ item?: CommissionSettlementItem & { items?: CommissionOrderItem[] } }>('/api/commission/settlements/generate', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCommissionSettlementStatus(input: {
|
||||
settlementId: string;
|
||||
status: string;
|
||||
paymentMethod?: string | null;
|
||||
paymentAccount?: string | null;
|
||||
remark?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
return apiRequest<{ item?: CommissionSettlementItem }>('/api/commission/settlements/status', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user