diff --git a/src/app/(main)/cps/page.tsx b/src/app/(main)/cps/page.tsx index 4683ac3..fa3901e 100644 --- a/src/app/(main)/cps/page.tsx +++ b/src/app/(main)/cps/page.tsx @@ -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 = { '6': { text: '已结算', color: 'green' }, }; -const LINK_TYPE_NAME: Record = { - '1': 'H5 长链', - '2': '短链(微信可发)', - '3': 'deeplink(唤起 App)', -}; +const PLATFORM_LABEL: Record = { meituan: '美团', taobao: '淘宝', jd: '京东' }; +const PLATFORM_COLOR: Record = { 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 = -; +const platformTags = (ps: string[] | undefined) => + (ps || []).map((p) => ( + + {PLATFORM_LABEL[p] || p} + + )); export default function CpsPage() { return (

CPS 优惠券分发与对账

- 当前仅接美团联盟。每群一个 sid:在「群管理」给群生成带 sid 的券链接发群,「对账统计」按 sid - 拉回订单与佣金归群。点击数需独立跳转服务(第二期)。 + 美团(actId + sid,可完整对账下单/佣金) / 淘宝(淘口令) / 京东(链接)。淘宝、京东无开放 API, + 只统计点击,对账字段显示「-」。发群链接都是咱的落地页 /c/xxx。 = [ - { 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 ? {v} : '-'), + 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 ? {v} : 0), + width: 80, + render: (v: number | null) => (v == null ? DASH : v ? {v} : 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) => {yuan(v)}, + width: 100, + sorter: (a, b) => (a.est_commission_cents || 0) - (b.est_commission_cents || 0), + render: (v: number | null) => (v == null ? DASH : {yuan(v)}), }, { title: '已结算佣金', dataIndex: 'settled_commission_cents', - width: 110, - render: (v: number) => {yuan(v)}, + width: 100, + render: (v: number | null) => (v == null ? DASH : {yuan(v)}), }, ]; @@ -172,14 +193,12 @@ function StatsTab() { 刷新对账(拉美团订单) )} - - 佣金为美团预估口径,实际以「已结算」为准 - + 淘宝/京东无对账,「-」即无法对账;佣金以「已结算」为准 - +
); @@ -222,33 +241,33 @@ function GroupsTab() { ); const canManage = canDo(['operator']); - const [editing, setEditing] = useState(null); // null=不开,{} 新建用单独标志 + const [editing, setEditing] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [linkGroup, setLinkGroup] = useState(null); const columns: ColumnsType = [ - { 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) => {v}, + width: 130, + render: (v: string | null) => (v ? {v} : 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) => {s}, }, - { 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) => ( e.stopPropagation()}> {canManage && setLinkGroup(g)}>生成链接} @@ -272,9 +291,7 @@ function GroupsTab() { - {canManage && ( - - )} + {canManage && } `共 ${t} 个群`, onChange, }} - scroll={{ x: 1050 }} + scroll={{ x: 1040 }} /> - setCreateOpen(false)} - onDone={reload} - /> - setEditing(null)} - onDone={reload} - /> + setCreateOpen(false)} onDone={reload} /> + setEditing(null)} onDone={reload} /> setLinkGroup(null)} /> ); @@ -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 ( - +
- {!isEdit && ( + + + + {hasMeituan && !isEdit && ( )} + {hasMeituan && isEdit && ( + + 当前 sid:{group?.sid || '(无,保存后如缺会在生成美团链接时报错)'} + + )} @@ -400,13 +417,13 @@ function GroupFormModal({ function ReferralLinkModal({ group, onClose }: { group: CpsGroup | null; onClose: () => void }) { const [activities, setActivities] = useState([]); - const [activityId, setActivityId] = useState(); - const [result, setResult] = useState(null); + const [activityIds, setActivityIds] = useState([]); + const [result, setResult] = useState(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('/admin/api/cps/referral-link', { + const resp = await api.post('/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 ( - - - - - - - - + + + + + + + + )} + {platform === 'taobao' && ( + + + + )} + {platform === 'jd' && ( + + + + )} + diff --git a/src/lib/types.ts b/src/lib/types.ts index d3e5ea3..3d2f4ab 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; // 美团原始 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 {