feat(cps): 分发后台从仅美团扩展到美团/淘宝/京东多平台

- 群/活动支持平台多选,sid 仅美团群保留
- 活动按平台切换录入(美团 actId/pvs、淘宝淘口令、京东链接)
- 生成发群链接改批量勾选多活动(/cps/referral-links)
- 统计表加平台/复制列,淘宝/京东无对账字段显示 "-"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 19:15:27 +08:00
parent a393b5c0e3
commit 654aca4c40
2 changed files with 228 additions and 167 deletions
+187 -135
View File
@@ -1,12 +1,13 @@
'use client';
// CPS 优惠券分发与对账(当前仅美团)。单页 Tabs:对账统计 / 群管理 / 活动管理 / 订单明细
// 群管理生成带 sid 的券链接 → 发群 → 对账按 sid 拉回订单/佣金归群统计
// CPS 优惠券分发与对账。平台:美团(actId+sid,完整对账) / 淘宝(淘口令) / 京东(链接)
// 淘宝/京东无 API → 只统计点击,对账字段显示 "-"。单页 Tabs:对账统计/群管理/活动管理/订单明细
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
Button,
Card,
Checkbox,
Form,
Input,
InputNumber,
@@ -30,7 +31,7 @@ import type {
CpsGroupStat,
CpsOrder,
CpsReconcileResult,
CpsReferralLink,
CpsReferralLinks,
CpsStats,
} from '@/lib/types';
@@ -42,21 +43,30 @@ const MT_STATUS: Record<string, { text: string; color: string }> = {
'6': { text: '已结算', color: 'green' },
};
const LINK_TYPE_NAME: Record<string, string> = {
'1': 'H5 长链',
'2': '短链(微信可发)',
'3': 'deeplink(唤起 App)',
};
const PLATFORM_LABEL: Record<string, string> = { meituan: '美团', taobao: '淘宝', jd: '京东' };
const PLATFORM_COLOR: Record<string, string> = { meituan: 'gold', taobao: 'volcano', jd: 'red' };
const PLATFORM_OPTIONS = [
{ label: '美团', value: 'meituan' },
{ label: '淘宝', value: 'taobao' },
{ label: '京东', value: 'jd' },
];
const fmtRate = (r: string | null) => (r ? `${Number(r) / 100}%` : '-');
const DASH = <span style={{ color: '#bbb' }}>-</span>;
const platformTags = (ps: string[] | undefined) =>
(ps || []).map((p) => (
<Tag key={p} color={PLATFORM_COLOR[p]} style={{ marginInlineEnd: 4 }}>
{PLATFORM_LABEL[p] || p}
</Tag>
));
export default function CpsPage() {
return (
<div>
<h2>CPS </h2>
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
sid: sid , sid
()
(actId + sid,/) / () / () API,
<b></b>,- /c/xxx
</Typography.Paragraph>
<Tabs
defaultActiveKey="stats"
@@ -113,43 +123,54 @@ function StatsTab() {
};
const columns: ColumnsType<CpsGroupStat> = [
{ title: '群', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '群', dataIndex: 'name', width: 140, ellipsis: true, fixed: 'left' },
{ title: '平台', key: 'platforms', width: 130, render: (_, r) => platformTags(r.platforms) },
{ title: '人数', dataIndex: 'member_count', width: 64, render: (v) => v ?? '-' },
{ title: '点击', dataIndex: 'click_pv', width: 64 },
{ title: '独立', dataIndex: 'click_uv', width: 64 },
{
title: 'sid',
dataIndex: 'sid',
width: 130,
render: (v: string | null) => (v ? <Typography.Text copyable>{v}</Typography.Text> : '-'),
title: '复制',
dataIndex: 'copy_pv',
width: 64,
render: (v: number) => (v ? v : DASH),
},
{
title: '有效单',
dataIndex: 'order_count',
width: 70,
render: (v: number | null) => (v == null ? DASH : v),
},
{ title: '人数', dataIndex: 'member_count', width: 70, render: (v) => v ?? '-' },
{ title: '点击', dataIndex: 'click_pv', width: 70 },
{ title: '独立访客', dataIndex: 'click_uv', width: 80 },
{ title: '有效单', dataIndex: 'order_count', width: 70 },
{
title: '取消/风控',
dataIndex: 'canceled_count',
width: 85,
render: (v: number) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : 0),
width: 80,
render: (v: number | null) => (v == null ? DASH : v ? <span style={{ color: '#cf1322' }}>{v}</span> : 0),
},
{
title: '点击→下单',
key: 'rate',
width: 90,
width: 88,
render: (_, r) =>
r.click_uv ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` : '-',
r.order_count == null ? DASH : r.click_uv ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` : '-',
},
{
title: '成交额',
dataIndex: 'gmv_cents',
width: 96,
render: (v: number | null) => (v == null ? DASH : yuan(v)),
},
{ title: '成交额', dataIndex: 'gmv_cents', width: 110, render: (v: number) => yuan(v) },
{
title: '预估佣金',
dataIndex: 'est_commission_cents',
width: 110,
sorter: (a, b) => a.est_commission_cents - b.est_commission_cents,
render: (v: number) => <b>{yuan(v)}</b>,
width: 100,
sorter: (a, b) => (a.est_commission_cents || 0) - (b.est_commission_cents || 0),
render: (v: number | null) => (v == null ? DASH : <b>{yuan(v)}</b>),
},
{
title: '已结算佣金',
dataIndex: 'settled_commission_cents',
width: 110,
render: (v: number) => <span style={{ color: '#3f8600' }}>{yuan(v)}</span>,
width: 100,
render: (v: number | null) => (v == null ? DASH : <span style={{ color: '#3f8600' }}>{yuan(v)}</span>),
},
];
@@ -172,14 +193,12 @@ function StatsTab() {
()
</Button>
)}
<Typography.Text type="secondary">
,
</Typography.Text>
<Typography.Text type="secondary">/,-;</Typography.Text>
</Space>
<Space size="large" style={{ marginBottom: 16 }} wrap>
<Card size="small">
<Statistic title="有效订单数" value={data?.total_order_count ?? 0} />
<Statistic title="有效订单数(美团)" value={data?.total_order_count ?? 0} />
</Card>
<Card size="small">
<Statistic
@@ -206,7 +225,7 @@ function StatsTab() {
dataSource={data?.groups ?? []}
loading={loading}
pagination={false}
scroll={{ x: 1120 }}
scroll={{ x: 1200 }}
/>
</div>
);
@@ -222,33 +241,33 @@ function GroupsTab() {
);
const canManage = canDo(['operator']);
const [editing, setEditing] = useState<CpsGroup | null>(null); // null=不开,{} 新建用单独标志
const [editing, setEditing] = useState<CpsGroup | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [linkGroup, setLinkGroup] = useState<CpsGroup | null>(null);
const columns: ColumnsType<CpsGroup> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '群名', dataIndex: 'name', width: 180, ellipsis: true },
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '群名', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '平台', key: 'platforms', width: 140, render: (_, g) => platformTags(g.platforms) },
{
title: 'sid',
dataIndex: 'sid',
width: 150,
render: (v: string) => <Typography.Text copyable>{v}</Typography.Text>,
width: 130,
render: (v: string | null) => (v ? <Typography.Text copyable>{v}</Typography.Text> : DASH),
},
{ title: '人数', dataIndex: 'member_count', width: 80, render: (v) => v ?? '-' },
{ title: '人数', dataIndex: 'member_count', width: 70, render: (v) => v ?? '-' },
{
title: '状态',
dataIndex: 'status',
width: 90,
width: 80,
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s}</Tag>,
},
{ title: '备注', dataIndex: 'remark', width: 160, ellipsis: true, render: (v) => v || '-' },
{ title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
{
title: '操作',
key: 'op',
fixed: 'right',
width: 170,
width: 150,
render: (_, g) => (
<Space onClick={(e) => e.stopPropagation()}>
{canManage && <a onClick={() => setLinkGroup(g)}></a>}
@@ -272,9 +291,7 @@ function GroupsTab() {
<Button type="primary" onClick={() => setApplied({ keyword: keyword || undefined })}>
</Button>
{canManage && (
<Button onClick={() => setCreateOpen(true)}></Button>
)}
{canManage && <Button onClick={() => setCreateOpen(true)}></Button>}
</Space>
<Table
@@ -290,21 +307,11 @@ function GroupsTab() {
showTotal: (t) => `${t} 个群`,
onChange,
}}
scroll={{ x: 1050 }}
scroll={{ x: 1040 }}
/>
<GroupFormModal
open={createOpen}
group={null}
onClose={() => setCreateOpen(false)}
onDone={reload}
/>
<GroupFormModal
open={!!editing}
group={editing}
onClose={() => setEditing(null)}
onDone={reload}
/>
<GroupFormModal open={createOpen} group={null} onClose={() => setCreateOpen(false)} onDone={reload} />
<GroupFormModal open={!!editing} group={editing} onClose={() => setEditing(null)} onDone={reload} />
<ReferralLinkModal group={linkGroup} onClose={() => setLinkGroup(null)} />
</div>
);
@@ -323,18 +330,22 @@ function GroupFormModal({
}) {
const [form] = Form.useForm();
const isEdit = !!group;
const watchPlatforms = (Form.useWatch('platforms', form) as string[] | undefined) || [];
const hasMeituan = watchPlatforms.includes('meituan');
useEffect(() => {
if (open) {
if (group) {
form.setFieldsValue({
name: group.name,
platforms: group.platforms,
member_count: group.member_count,
remark: group.remark,
status: group.status,
});
} else {
form.resetFields();
form.setFieldsValue({ platforms: ['meituan'] });
}
}
}, [open, group, form]);
@@ -357,26 +368,32 @@ function GroupFormModal({
};
return (
<Modal
title={isEdit ? '编辑群' : '新建群'}
open={open}
onOk={submit}
onCancel={onClose}
destroyOnHidden
>
<Modal title={isEdit ? '编辑群' : '新建群'} open={open} onOk={submit} onCancel={onClose} destroyOnHidden>
<Form form={form} layout="vertical">
<Form.Item name="name" label="群名" rules={[{ required: true, message: '请输入群名' }]}>
<Input placeholder="如:宝妈优惠群1" />
</Form.Item>
{!isEdit && (
<Form.Item
name="platforms"
label="这个群发哪些平台的券(可多选)"
rules={[{ required: true, message: '至少选一个平台' }]}
>
<Checkbox.Group options={PLATFORM_OPTIONS} />
</Form.Item>
{hasMeituan && !isEdit && (
<Form.Item
name="sid"
label="sid(渠道标识,留空自动生成 g+ID)"
label="美团 sid(渠道标识,留空自动生成 g+ID)"
rules={[{ pattern: /^[A-Za-z0-9]+$/, message: '仅字母和数字' }]}
>
<Input placeholder="留空自动生成;或自定义如 baoma1" maxLength={64} />
</Form.Item>
)}
{hasMeituan && isEdit && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
sid:{group?.sid || '(无,保存后如缺会在生成美团链接时报错)'}
</Typography.Text>
)}
<Form.Item name="member_count" label="群人数(用于估算转化率)">
<InputNumber style={{ width: '100%' }} min={0} placeholder="如 200" />
</Form.Item>
@@ -400,13 +417,13 @@ function GroupFormModal({
function ReferralLinkModal({ group, onClose }: { group: CpsGroup | null; onClose: () => void }) {
const [activities, setActivities] = useState<CpsActivity[]>([]);
const [activityId, setActivityId] = useState<number | undefined>();
const [result, setResult] = useState<CpsReferralLink | null>(null);
const [activityIds, setActivityIds] = useState<number[]>([]);
const [result, setResult] = useState<CpsReferralLinks | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!group) {
setActivityId(undefined);
setActivityIds([]);
setResult(null);
return;
}
@@ -416,16 +433,19 @@ function ReferralLinkModal({ group, onClose }: { group: CpsGroup | null; onClose
.catch((e) => message.error(errMsg(e)));
}, [group]);
// 只列群平台范围内的活动
const groupActivities = activities.filter((a) => (group?.platforms || []).includes(a.platform));
const generate = async () => {
if (!group || !activityId) {
message.warning('请选择活动');
if (!group || !activityIds.length) {
message.warning('请勾选要生成的活动');
return;
}
setLoading(true);
try {
const resp = await api.post<CpsReferralLink>('/admin/api/cps/referral-link', {
const resp = await api.post<CpsReferralLinks>('/admin/api/cps/referral-links', {
group_id: group.id,
activity_id: activityId,
activity_ids: activityIds,
});
setResult(resp.data);
} catch (e) {
@@ -437,68 +457,68 @@ function ReferralLinkModal({ group, onClose }: { group: CpsGroup | null; onClose
return (
<Modal
title={`生成链接 - ${group?.name ?? ''}(sid=${group?.sid ?? ''})`}
title={`生成发群链接 - ${group?.name ?? ''}`}
open={!!group}
onCancel={onClose}
onOk={generate}
okText="生成"
confirmLoading={loading}
destroyOnHidden
width={640}
width={660}
>
<Form layout="vertical">
<Form.Item label="选择活动" required>
<Select
placeholder="选要推广的活动"
value={activityId}
onChange={setActivityId}
options={activities.map((a) => ({
value: a.id,
label: `${a.name}${a.act_id ? ` (actId=${a.act_id})` : ''}`,
}))}
notFoundContent="无活动,请先到「活动管理」新建"
/>
</Form.Item>
</Form>
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
,;
</Typography.Paragraph>
{groupActivities.length === 0 ? (
<Typography.Text type="warning">
({(group?.platforms || []).map((p) => PLATFORM_LABEL[p]).join('/')}),
</Typography.Text>
) : (
<Checkbox.Group
value={activityIds}
onChange={(v) => setActivityIds(v as number[])}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{groupActivities.map((a) => (
<Checkbox key={a.id} value={a.id}>
<Tag color={PLATFORM_COLOR[a.platform]}>{PLATFORM_LABEL[a.platform]}</Tag>
{a.name}
{a.act_id ? <span style={{ color: '#999' }}> (actId={a.act_id})</span> : null}
</Checkbox>
))}
</Space>
</Checkbox.Group>
)}
{result && (
<div style={{ marginTop: 8 }}>
<Typography.Text strong>👇 ( sid={result.sid},):</Typography.Text>
<div style={{ marginTop: 16 }}>
<Typography.Text strong>👇 ():</Typography.Text>
{result.results.map((item) => (
<div
key={item.code}
style={{
marginTop: 8,
padding: 12,
padding: 10,
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 6,
}}
>
<Typography.Text
copyable={{ text: result.redirect_url }}
style={{ fontSize: 15, wordBreak: 'break-all' }}
>
{result.redirect_url}
</Typography.Text>
<div style={{ marginBottom: 4 }}>
<Tag color={PLATFORM_COLOR[item.platform]}>{PLATFORM_LABEL[item.platform]}</Tag>
<span style={{ fontSize: 13 }}>{item.activity_name}</span>
</div>
{!result.redirect_url.startsWith('http') && (
<Typography.Text type="warning" style={{ fontSize: 12 }}>
CPS_REDIRECT_BASE,; .env
</Typography.Text>
)}
<Typography.Paragraph type="secondary" style={{ marginTop: 14, marginBottom: 4, fontSize: 12 }}>
(/,):
</Typography.Paragraph>
{Object.entries(result.link_map).map(([type, url]) => (
<div key={type} style={{ marginBottom: 4 }}>
<Tag>{LINK_TYPE_NAME[type] ?? type}</Tag>
<Typography.Text
copyable={{ text: url }}
style={{ fontSize: 12, color: '#999', wordBreak: 'break-all' }}
>
{url.length > 56 ? `${url.slice(0, 56)}` : url}
<Typography.Text copyable={{ text: item.redirect_url }} style={{ fontSize: 14, wordBreak: 'break-all' }}>
{item.redirect_url}
</Typography.Text>
</div>
))}
{result.results.some((i) => !i.redirect_url.startsWith('http')) && (
<Typography.Text type="warning" style={{ fontSize: 12 }}>
部分为相对路径:后端未配 CPS_REDIRECT_BASE,
</Typography.Text>
)}
</div>
)}
</Modal>
@@ -515,26 +535,31 @@ function ActivitiesTab() {
const [createOpen, setCreateOpen] = useState(false);
const columns: ColumnsType<CpsActivity> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '平台', dataIndex: 'platform', width: 90, render: (v: string) => <Tag>{v}</Tag> },
{ title: '活动名', dataIndex: 'name', width: 220, ellipsis: true },
{ title: 'ID', dataIndex: 'id', width: 60 },
{
title: 'actId',
title: '平台',
dataIndex: 'platform',
width: 80,
render: (v: string) => <Tag color={PLATFORM_COLOR[v]}>{PLATFORM_LABEL[v] || v}</Tag>,
},
{ title: '活动名', dataIndex: 'name', width: 180, ellipsis: true },
{
title: 'actId(美团)',
dataIndex: 'act_id',
width: 120,
width: 110,
render: (v: string | null) => v || '-',
},
{
title: 'productViewSign',
dataIndex: 'product_view_sign',
width: 160,
title: '淘口令/链接',
dataIndex: 'payload',
width: 240,
ellipsis: true,
render: (v: string | null) => v || '-',
},
{
title: '状态',
dataIndex: 'status',
width: 90,
width: 80,
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s}</Tag>,
},
{ title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
@@ -579,15 +604,24 @@ function ActivityFormModal({
onDone: () => void;
}) {
const [form] = Form.useForm();
const platform = (Form.useWatch('platform', form) as string | undefined) || 'meituan';
useEffect(() => {
if (open) form.resetFields();
if (open) {
form.resetFields();
form.setFieldsValue({ platform: 'meituan' });
}
}, [open, form]);
const submit = async () => {
const v = await form.validateFields();
if (v.platform === 'meituan') {
if (!v.act_id && !v.product_view_sign) {
message.warning('actId 与 productViewSign 至少填一个');
message.warning('美团活动:actId 与 productViewSign 至少填一个');
return;
}
} else if (!v.payload) {
message.warning(v.platform === 'taobao' ? '请填淘口令' : '请填京东链接');
return;
}
try {
@@ -606,21 +640,39 @@ function ActivityFormModal({
<Form.Item name="name" label="活动名" rules={[{ required: true }]}>
<Input placeholder="如:点我领取大额红包" />
</Form.Item>
<Form.Item name="platform" label="平台">
<Select
options={[
{ value: 'meituan', label: '美团(已支持)' },
{ value: 'taobao', label: '淘宝(待接入)', disabled: true },
{ value: 'jd', label: '京东(待接入)', disabled: true },
]}
/>
<Form.Item name="platform" label="平台" rules={[{ required: true }]}>
<Select options={PLATFORM_OPTIONS} />
</Form.Item>
{platform === 'meituan' && (
<>
<Form.Item name="act_id" label="actId(美团活动物料 ID,推荐)">
<Input placeholder="美团联盟「我要推广-活动推广」第一列 ID,如 792" maxLength={64} />
</Form.Item>
<Form.Item name="product_view_sign" label="productViewSign(商品券,与 actId 二选一)">
<Input maxLength={128} />
</Form.Item>
</>
)}
{platform === 'taobao' && (
<Form.Item
name="payload"
label="淘口令(整段,运营从淘宝联盟复制,落地页原样复制给用户)"
rules={[{ required: true, message: '请填淘口令' }]}
>
<Input.TextArea rows={4} placeholder="如:8¥abcXYZ¥ https://m.tb.cn/h.xxx 长按复制本条..." maxLength={4096} />
</Form.Item>
)}
{platform === 'jd' && (
<Form.Item
name="payload"
label="京东推广链接(落地页 302 跳到它)"
rules={[{ required: true, message: '请填京东链接' }]}
>
<Input placeholder="如:https://u.jd.com/xxxxxx" maxLength={1024} />
</Form.Item>
)}
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={256} />
</Form.Item>
+26 -17
View File
@@ -307,8 +307,9 @@ export interface PriceReportSummary {
// ===== CPS 分发与对账 =====
export interface CpsGroup {
id: number;
sid: string; // 美团二级渠道追踪位,每群唯一
sid: string | null; // 美团群有(渠道追踪位)
name: string;
platforms: string[]; // meituan/taobao/jd 多选
member_count: number | null;
status: string; // active / archived
remark: string | null;
@@ -321,6 +322,7 @@ export interface CpsActivity {
name: string;
act_id: string | null; // 美团活动物料 ID
product_view_sign: string | null;
payload: string | null; // 淘宝整段淘口令 / 京东链接
status: string;
remark: string | null;
created_at: string;
@@ -340,14 +342,17 @@ export interface CpsOrder {
pay_time: string | null;
}
export interface CpsReferralLink {
redirect_url: string; // ★ 我们的群发短链(/c/{code}),发群用这个,点击经我们统计
code: string;
sid: string;
group_name: string;
export interface CpsReferralLinkItem {
activity_id: number;
activity_name: string;
target_url: string; // 实际 302 跳转的美团短链
link_map: Record<string, string>; // 美团原始 1长链/2短链/3deeplink(参考)
platform: string;
redirect_url: string; // 我们的落地页 /c/{code}(发群用)
code: string;
}
export interface CpsReferralLinks {
group_name: string;
results: CpsReferralLinkItem[];
}
export interface CpsReconcileResult {
@@ -358,18 +363,22 @@ export interface CpsReconcileResult {
}
export interface CpsGroupStat {
group_id: number | null; // null = 未归群的 sid
group_id: number | null;
sid: string | null;
name: string;
platforms: string[];
member_count: number | null;
click_pv: number; // 点击总次数
click_uv: number; // 独立点击(ip+ua 近似去重)
order_count: number; // 有效单(不含取消/风控)
settled_count: number;
canceled_count: number;
gmv_cents: number;
est_commission_cents: number;
settled_commission_cents: number;
click_pv: number;
click_uv: number;
copy_pv: number; // 淘宝"复制口令"次数
copy_uv: number;
// 对账类:淘宝/京东无法对账 → null(前端显示 "-")
order_count: number | null;
settled_count: number | null;
canceled_count: number | null;
gmv_cents: number | null;
est_commission_cents: number | null;
settled_commission_cents: number | null;
}
export interface CpsStats {