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