diff --git a/package-lock.json b/package-lock.json index a0bf487..5ae3994 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/src/app/(main)/cps/page.tsx b/src/app/(main)/cps/page.tsx new file mode 100644 index 0000000..4683ac3 --- /dev/null +++ b/src/app/(main)/cps/page.tsx @@ -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 = { + '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 = { + '1': 'H5 长链', + '2': '短链(微信可发)', + '3': 'deeplink(唤起 App)', +}; + +const fmtRate = (r: string | null) => (r ? `${Number(r) / 100}%` : '-'); + +export default function CpsPage() { + return ( +
+

CPS 优惠券分发与对账

+ + 当前仅接美团联盟。每群一个 sid:在「群管理」给群生成带 sid 的券链接发群,「对账统计」按 sid + 拉回订单与佣金归群。点击数需独立跳转服务(第二期)。 + + }, + { key: 'groups', label: '群管理', children: }, + { key: 'activities', label: '活动管理', children: }, + { key: 'orders', label: '订单明细', children: }, + ]} + /> +
+ ); +} + +// ───────────── 对账统计 ───────────── +function StatsTab() { + const [days, setDays] = useState(30); + const [data, setData] = useState(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('/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('/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 = [ + { title: '群', dataIndex: 'name', width: 160, ellipsis: true }, + { + title: 'sid', + dataIndex: 'sid', + width: 130, + render: (v: string | null) => (v ? {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), + }, + { + 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) => {yuan(v)}, + }, + { + title: '已结算佣金', + dataIndex: 'settled_commission_cents', + width: 110, + render: (v: number) => {yuan(v)}, + }, + ]; + + return ( +
+ + 统计区间 + setKeyword(e.target.value)} + onPressEnter={() => setApplied({ keyword: keyword || undefined })} + allowClear + style={{ width: 180 }} + /> + + {canManage && ( + + )} + + + `共 ${t} 个群`, + onChange, + }} + scroll={{ x: 1050 }} + /> + + setCreateOpen(false)} + onDone={reload} + /> + setEditing(null)} + onDone={reload} + /> + setLinkGroup(null)} /> + + ); +} + +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 ( + +
+ + + + {!isEdit && ( + + + + )} + + + + {isEdit && ( + + ({ + value: a.id, + label: `${a.name}${a.act_id ? ` (actId=${a.act_id})` : ''}`, + }))} + notFoundContent="无活动,请先到「活动管理」新建" + /> + + + + {result && ( +
+ 👇 发群就用这条链接(已带 sid={result.sid},点击会被统计): +
+ + {result.redirect_url} + +
+ {!result.redirect_url.startsWith('http') && ( + + ⚠️ 后端未配 CPS_REDIRECT_BASE,这是相对路径;请在 .env 配跳转域名后重启 + + )} + + 美团原始链接(参考/备用,直接发不带点击统计): + + {Object.entries(result.link_map).map(([type, url]) => ( +
+ {LINK_TYPE_NAME[type] ?? type} + + {url.length > 56 ? `${url.slice(0, 56)}…` : url} + +
+ ))} +
+ )} +
+ ); +} + +// ───────────── 活动管理 ───────────── +function ActivitiesTab() { + const { items, total, page, pageSize, loading, onChange, reload } = usePagedList( + '/admin/api/cps/activities', + {}, + ); + const canManage = canDo(['operator']); + const [createOpen, setCreateOpen] = useState(false); + + const columns: ColumnsType = [ + { title: 'ID', dataIndex: 'id', width: 70 }, + { title: '平台', dataIndex: 'platform', width: 90, render: (v: string) => {v} }, + { 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) => {s}, + }, + { title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) }, + ]; + + return ( +
+ {canManage && ( + + + + )} +
`共 ${t} 个活动`, + onChange, + }} + scroll={{ x: 1000 }} + /> + setCreateOpen(false)} onDone={reload} /> + + ); +} + +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 ( + +
+ + + + + + + + + + + + + +
+ ); +} + +// ───────────── 订单明细 ───────────── +function OrdersTab() { + const [sid, setSid] = useState(''); + const [status, setStatus] = useState(); + const [applied, setApplied] = useState>({}); + const { items, total, page, pageSize, loading, onChange } = usePagedList( + '/admin/api/cps/orders', + applied, + ); + + const columns: ColumnsType = [ + { + title: '订单号', + dataIndex: 'order_id', + width: 200, + ellipsis: true, + render: (v: string) => {v}, + }, + { 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 ? {m.text} : s || '-'; + }, + }, + { title: '支付时间', dataIndex: 'pay_time', width: 150, render: (v) => formatUtcTime(v) }, + ]; + + const search = () => setApplied({ sid: sid || undefined, mt_status: status }); + + return ( +
+ + setSid(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 150 }} + /> +
`共 ${t} 单`, + onChange, + }} + scroll={{ x: 1030 }} + /> + + ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index efb8ba3..f831cca 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -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: , label: '上报审核' }, { key: '/feedbacks', icon: , label: '反馈工单' }, { key: '/ad-revenue', icon: , label: '广告数据' }, + { key: '/cps', icon: , label: 'CPS 分发' }, { key: '/config', icon: , label: '系统配置' }, { key: '/admins', icon: , label: '管理员', superOnly: true }, { key: '/audit-logs', icon: , label: '审计日志' }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 63e6bdd..d3e5ea3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; // 美团原始 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; +}