feat(cps): CPS 分发与对账后台页

群/活动管理 + 生成带 sid 短链(/c/{code}) + 对账统计(点击 PV/UV + 下单/佣金)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 10:01:08 +08:00
parent f82f511b84
commit 9134ea9255
4 changed files with 797 additions and 1 deletions
+1 -1
View File
@@ -2438,7 +2438,7 @@
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.19",
"resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
"integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==",
"cpu": [
"x64"
+719
View File
@@ -0,0 +1,719 @@
'use client';
// CPS 优惠券分发与对账(当前仅美团)。单页 Tabs:对账统计 / 群管理 / 活动管理 / 订单明细。
// 群管理生成带 sid 的券链接 → 发群 → 对账按 sid 拉回订单/佣金归群统计。
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime, yuan } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type {
CpsActivity,
CpsGroup,
CpsGroupStat,
CpsOrder,
CpsReconcileResult,
CpsReferralLink,
CpsStats,
} from '@/lib/types';
const MT_STATUS: Record<string, { text: string; color: string }> = {
'2': { text: '已付款', color: 'blue' },
'3': { text: '已完成', color: 'cyan' },
'4': { text: '已取消', color: 'red' },
'5': { text: '风控', color: 'orange' },
'6': { text: '已结算', color: 'green' },
};
const LINK_TYPE_NAME: Record<string, string> = {
'1': 'H5 长链',
'2': '短链(微信可发)',
'3': 'deeplink(唤起 App)',
};
const fmtRate = (r: string | null) => (r ? `${Number(r) / 100}%` : '-');
export default function CpsPage() {
return (
<div>
<h2>CPS </h2>
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
sid: sid , sid
()
</Typography.Paragraph>
<Tabs
defaultActiveKey="stats"
destroyInactiveTabPane
items={[
{ key: 'stats', label: '对账统计', children: <StatsTab /> },
{ key: 'groups', label: '群管理', children: <GroupsTab /> },
{ key: 'activities', label: '活动管理', children: <ActivitiesTab /> },
{ key: 'orders', label: '订单明细', children: <OrdersTab /> },
]}
/>
</div>
);
}
// ───────────── 对账统计 ─────────────
function StatsTab() {
const [days, setDays] = useState(30);
const [data, setData] = useState<CpsStats | null>(null);
const [loading, setLoading] = useState(false);
const [reconciling, setReconciling] = useState(false);
const canReconcile = canDo(['finance']);
const load = useCallback(async (d: number) => {
setLoading(true);
try {
const resp = await api.get<CpsStats>('/admin/api/cps/stats', { params: { days: d } });
setData(resp.data);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(days);
}, [days, load]);
const reconcile = async () => {
setReconciling(true);
try {
const resp = await api.post<CpsReconcileResult>('/admin/api/cps/orders/reconcile', null, {
params: { days },
});
const r = resp.data;
message.success(`对账完成:拉取 ${r.fetched} 单(新增 ${r.inserted}、更新 ${r.updated})`);
load(days);
} catch (e) {
message.error(errMsg(e));
} finally {
setReconciling(false);
}
};
const columns: ColumnsType<CpsGroupStat> = [
{ title: '群', dataIndex: 'name', width: 160, ellipsis: true },
{
title: 'sid',
dataIndex: 'sid',
width: 130,
render: (v: string | null) => (v ? <Typography.Text copyable>{v}</Typography.Text> : '-'),
},
{ 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),
},
{
title: '点击→下单',
key: 'rate',
width: 90,
render: (_, r) =>
r.click_uv ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` : '-',
},
{ 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>,
},
{
title: '已结算佣金',
dataIndex: 'settled_commission_cents',
width: 110,
render: (v: number) => <span style={{ color: '#3f8600' }}>{yuan(v)}</span>,
},
];
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<span></span>
<Select
value={days}
onChange={setDays}
style={{ width: 120 }}
options={[
{ value: 7, label: '近 7 天' },
{ value: 30, label: '近 30 天' },
{ value: 90, label: '近 90 天' },
]}
/>
{canReconcile && (
<Button type="primary" loading={reconciling} onClick={reconcile}>
()
</Button>
)}
<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} />
</Card>
<Card size="small">
<Statistic
title="预估佣金"
value={(data?.total_est_commission_cents ?? 0) / 100}
precision={2}
prefix="¥"
/>
</Card>
<Card size="small">
<Statistic
title="已结算佣金"
value={(data?.total_settled_commission_cents ?? 0) / 100}
precision={2}
prefix="¥"
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Space>
<Table
rowKey={(r) => r.sid ?? `gid-${r.group_id ?? 'none'}`}
columns={columns}
dataSource={data?.groups ?? []}
loading={loading}
pagination={false}
scroll={{ x: 1120 }}
/>
</div>
);
}
// ───────────── 群管理 ─────────────
function GroupsTab() {
const [keyword, setKeyword] = useState('');
const [applied, setApplied] = useState<Record<string, unknown>>({});
const { items, total, page, pageSize, loading, onChange, reload } = usePagedList<CpsGroup>(
'/admin/api/cps/groups',
applied,
);
const canManage = canDo(['operator']);
const [editing, setEditing] = useState<CpsGroup | null>(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: 'sid',
dataIndex: 'sid',
width: 150,
render: (v: string) => <Typography.Text copyable>{v}</Typography.Text>,
},
{ title: '人数', dataIndex: 'member_count', width: 80, render: (v) => v ?? '-' },
{
title: '状态',
dataIndex: 'status',
width: 90,
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,
render: (_, g) => (
<Space onClick={(e) => e.stopPropagation()}>
{canManage && <a onClick={() => setLinkGroup(g)}></a>}
{canManage && <a onClick={() => setEditing(g)}></a>}
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="群名/sid"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => setApplied({ keyword: keyword || undefined })}
allowClear
style={{ width: 180 }}
/>
<Button type="primary" onClick={() => setApplied({ keyword: keyword || undefined })}>
</Button>
{canManage && (
<Button onClick={() => setCreateOpen(true)}></Button>
)}
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 个群`,
onChange,
}}
scroll={{ x: 1050 }}
/>
<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>
);
}
function GroupFormModal({
open,
group,
onClose,
onDone,
}: {
open: boolean;
group: CpsGroup | null;
onClose: () => void;
onDone: () => void;
}) {
const [form] = Form.useForm();
const isEdit = !!group;
useEffect(() => {
if (open) {
if (group) {
form.setFieldsValue({
name: group.name,
member_count: group.member_count,
remark: group.remark,
status: group.status,
});
} else {
form.resetFields();
}
}
}, [open, group, form]);
const submit = async () => {
const v = await form.validateFields();
try {
if (isEdit && group) {
await api.patch(`/admin/api/cps/groups/${group.id}`, v);
message.success('已更新群');
} else {
await api.post('/admin/api/cps/groups', v);
message.success('已新建群');
}
onClose();
onDone();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<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="sid"
label="sid(渠道标识,留空自动生成 g+ID)"
rules={[{ pattern: /^[A-Za-z0-9]+$/, message: '仅字母和数字' }]}
>
<Input placeholder="留空自动生成;或自定义如 baoma1" maxLength={64} />
</Form.Item>
)}
<Form.Item name="member_count" label="群人数(用于估算转化率)">
<InputNumber style={{ width: '100%' }} min={0} placeholder="如 200" />
</Form.Item>
{isEdit && (
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'active', label: 'active(启用)' },
{ value: 'archived', label: 'archived(归档)' },
]}
/>
</Form.Item>
)}
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={256} />
</Form.Item>
</Form>
</Modal>
);
}
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 [loading, setLoading] = useState(false);
useEffect(() => {
if (!group) {
setActivityId(undefined);
setResult(null);
return;
}
api
.get('/admin/api/cps/activities', { params: { status: 'active', limit: 100 } })
.then((r) => setActivities(r.data.items as CpsActivity[]))
.catch((e) => message.error(errMsg(e)));
}, [group]);
const generate = async () => {
if (!group || !activityId) {
message.warning('请选择活动');
return;
}
setLoading(true);
try {
const resp = await api.post<CpsReferralLink>('/admin/api/cps/referral-link', {
group_id: group.id,
activity_id: activityId,
});
setResult(resp.data);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
};
return (
<Modal
title={`生成券链接 - ${group?.name ?? ''}(sid=${group?.sid ?? ''})`}
open={!!group}
onCancel={onClose}
onOk={generate}
okText="生成"
confirmLoading={loading}
destroyOnHidden
width={640}
>
<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>
{result && (
<div style={{ marginTop: 8 }}>
<Typography.Text strong>👇 ( sid={result.sid},):</Typography.Text>
<div
style={{
marginTop: 8,
padding: 12,
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>
{!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>
</div>
))}
</div>
)}
</Modal>
);
}
// ───────────── 活动管理 ─────────────
function ActivitiesTab() {
const { items, total, page, pageSize, loading, onChange, reload } = usePagedList<CpsActivity>(
'/admin/api/cps/activities',
{},
);
const canManage = canDo(['operator']);
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: 'actId',
dataIndex: 'act_id',
width: 120,
render: (v: string | null) => v || '-',
},
{
title: 'productViewSign',
dataIndex: 'product_view_sign',
width: 160,
ellipsis: true,
render: (v: string | null) => v || '-',
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s}</Tag>,
},
{ title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
];
return (
<div>
{canManage && (
<Space style={{ marginBottom: 16 }}>
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</Space>
)}
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 个活动`,
onChange,
}}
scroll={{ x: 1000 }}
/>
<ActivityFormModal open={createOpen} onClose={() => setCreateOpen(false)} onDone={reload} />
</div>
);
}
function ActivityFormModal({
open,
onClose,
onDone,
}: {
open: boolean;
onClose: () => void;
onDone: () => void;
}) {
const [form] = Form.useForm();
useEffect(() => {
if (open) form.resetFields();
}, [open, form]);
const submit = async () => {
const v = await form.validateFields();
if (!v.act_id && !v.product_view_sign) {
message.warning('actId 与 productViewSign 至少填一个');
return;
}
try {
await api.post('/admin/api/cps/activities', v);
message.success('已新建活动');
onClose();
onDone();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<Modal title="新建活动" open={open} onOk={submit} onCancel={onClose} destroyOnHidden>
<Form form={form} layout="vertical" initialValues={{ platform: 'meituan' }}>
<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>
<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 name="remark" label="备注">
<Input.TextArea rows={2} maxLength={256} />
</Form.Item>
</Form>
</Modal>
);
}
// ───────────── 订单明细 ─────────────
function OrdersTab() {
const [sid, setSid] = useState('');
const [status, setStatus] = useState<string | undefined>();
const [applied, setApplied] = useState<Record<string, unknown>>({});
const { items, total, page, pageSize, loading, onChange } = usePagedList<CpsOrder>(
'/admin/api/cps/orders',
applied,
);
const columns: ColumnsType<CpsOrder> = [
{
title: '订单号',
dataIndex: 'order_id',
width: 200,
ellipsis: true,
render: (v: string) => <Typography.Text copyable>{v}</Typography.Text>,
},
{ title: 'sid', dataIndex: 'sid', width: 120, render: (v) => v || '-' },
{ title: '商品', dataIndex: 'product_name', width: 200, ellipsis: true, render: (v) => v || '-' },
{
title: '成交额',
dataIndex: 'pay_price_cents',
width: 100,
render: (v: number | null) => (v != null ? yuan(v) : '-'),
},
{
title: '佣金',
dataIndex: 'commission_cents',
width: 90,
render: (v: number | null) => (v != null ? yuan(v) : '-'),
},
{ title: '佣金率', dataIndex: 'commission_rate', width: 80, render: fmtRate },
{
title: '状态',
dataIndex: 'mt_status',
width: 90,
render: (s: string | null) => {
const m = s ? MT_STATUS[s] : null;
return m ? <Tag color={m.color}>{m.text}</Tag> : s || '-';
},
},
{ title: '支付时间', dataIndex: 'pay_time', width: 150, render: (v) => formatUtcTime(v) },
];
const search = () => setApplied({ sid: sid || undefined, mt_status: status });
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="sid"
value={sid}
onChange={(e) => setSid(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Select
placeholder="状态"
value={status}
onChange={setStatus}
allowClear
style={{ width: 120 }}
options={Object.entries(MT_STATUS).map(([v, m]) => ({ value: v, label: m.text }))}
/>
<Button type="primary" onClick={search}>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange,
}}
scroll={{ x: 1030 }}
/>
</div>
);
}
+2
View File
@@ -12,6 +12,7 @@ import {
MobileOutlined,
MoneyCollectOutlined,
SettingOutlined,
ShareAltOutlined,
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
@@ -29,6 +30,7 @@ const MENU = [
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
{ key: '/ad-revenue', icon: <BarChartOutlined />, label: '广告数据' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
+75
View File
@@ -303,3 +303,78 @@ export interface PriceReportSummary {
rejected: number;
total: number;
}
// ===== CPS 分发与对账 =====
export interface CpsGroup {
id: number;
sid: string; // 美团二级渠道追踪位,每群唯一
name: string;
member_count: number | null;
status: string; // active / archived
remark: string | null;
created_at: string;
}
export interface CpsActivity {
id: number;
platform: string; // meituan / taobao / jd
name: string;
act_id: string | null; // 美团活动物料 ID
product_view_sign: string | null;
status: string;
remark: string | null;
created_at: string;
}
export interface CpsOrder {
id: number;
order_id: string;
sid: string | null;
act_id: string | null;
pay_price_cents: number | null;
commission_cents: number | null;
commission_rate: string | null; // "300"=3% "10"=0.1%
mt_status: string | null; // 2付款 3完成 4取消 5风控 6结算
invalid_reason: string | null;
product_name: string | null;
pay_time: string | null;
}
export interface CpsReferralLink {
redirect_url: string; // ★ 我们的群发短链(/c/{code}),发群用这个,点击经我们统计
code: string;
sid: string;
group_name: string;
activity_name: string;
target_url: string; // 实际 302 跳转的美团短链
link_map: Record<string, string>; // 美团原始 1长链/2短链/3deeplink(参考)
}
export interface CpsReconcileResult {
fetched: number;
inserted: number;
updated: number;
pages: number;
}
export interface CpsGroupStat {
group_id: number | null; // null = 未归群的 sid
sid: string | null;
name: 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;
}
export interface CpsStats {
groups: CpsGroupStat[];
total_order_count: number;
total_est_commission_cents: number;
total_settled_commission_cents: number;
}