9b7a1aef8a
增加微信用户每日领券明细 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #19
456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
'use client';
|
||
|
||
// CPS 群对账详情:该群点击量/独立点击/复制/独立复制的天级 or 小时级变化折线图。
|
||
// 时间范围: 近 3/7/14/30 天(天级) 或 今日(小时级)。数据由后端补零成连续桶,折线不断裂。
|
||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { useParams, useRouter } from 'next/navigation';
|
||
import dynamic from 'next/dynamic';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import { Button, Card, Col, Empty, Modal, Row, Segmented, Space, Spin, Statistic, Table, Tooltip, message } from 'antd';
|
||
import { api, errMsg } from '@/lib/api';
|
||
import { formatUtcTime, yuan } from '@/lib/format';
|
||
|
||
// @ant-design/plots 依赖 DOM,关掉 SSR 仅在客户端渲染(否则 Next 预渲染报 document undefined)。
|
||
const Line = dynamic(() => import('@ant-design/plots').then((m) => m.Line), { ssr: false });
|
||
|
||
interface TsPoint {
|
||
label: string;
|
||
click_pv: number;
|
||
click_uv: number;
|
||
copy_pv: number;
|
||
copy_uv: number;
|
||
}
|
||
interface TsResp {
|
||
group_id: number;
|
||
group_name: string;
|
||
granularity: string;
|
||
points: TsPoint[];
|
||
}
|
||
|
||
// 按天大表格一行(订单侧字段对淘宝/京东群为 null → 显示 "-")
|
||
interface DailyRow {
|
||
date: string;
|
||
click_pv: number;
|
||
click_uv: number;
|
||
copy_pv: number;
|
||
order_count: number | null;
|
||
canceled_count: number | null;
|
||
gmv_cents: number | null;
|
||
est_commission_cents: number | null;
|
||
refund_profit_cents: number | null;
|
||
settled_commission_cents: number | null;
|
||
}
|
||
interface DailyResp {
|
||
group_name: string;
|
||
is_meituan: boolean;
|
||
days: number;
|
||
rows: DailyRow[];
|
||
}
|
||
|
||
// 群内微信用户(落地页授权拿到;nickname/headimgurl 仅 userinfo 授权后有)
|
||
interface WxUser {
|
||
openid: string;
|
||
nickname: string | null;
|
||
headimgurl: string | null;
|
||
first_seen: string;
|
||
visit_count: number;
|
||
copy_count: number;
|
||
}
|
||
|
||
// 某天该群按用户领券下钻(/day-users)
|
||
interface DayCoupon {
|
||
name: string;
|
||
count: number;
|
||
}
|
||
interface DayUser {
|
||
openid: string;
|
||
nickname: string | null;
|
||
headimgurl: string | null;
|
||
copy_count: number;
|
||
visit_count: number;
|
||
coupons: DayCoupon[];
|
||
}
|
||
|
||
const RANGES = [
|
||
{ label: '近 3 天', value: 'd3' },
|
||
{ label: '近 7 天', value: 'd7' },
|
||
{ label: '近 14 天', value: 'd14' },
|
||
{ label: '近 30 天', value: 'd30' },
|
||
{ label: '今日(按小时)', value: 'h' },
|
||
];
|
||
|
||
const METRICS: { key: keyof Omit<TsPoint, 'label'>; name: string }[] = [
|
||
{ key: 'click_pv', name: '点击量' },
|
||
{ key: 'click_uv', name: '独立点击' },
|
||
{ key: 'copy_pv', name: '复制次数' },
|
||
{ key: 'copy_uv', name: '独立复制' },
|
||
];
|
||
|
||
function rangeToQuery(r: string): string {
|
||
if (r === 'h') return 'granularity=hour';
|
||
const days: Record<string, number> = { d3: 3, d7: 7, d14: 14, d30: 30 };
|
||
return `granularity=day&days=${days[r] ?? 7}`;
|
||
}
|
||
|
||
// 大表格只按天:天级范围 → 天数;今日小时(h) → null(此时隐藏表格,小时维度看上面折线)
|
||
function rangeToDays(r: string): number | null {
|
||
return ({ d3: 3, d7: 7, d14: 14, d30: 30 } as Record<string, number>)[r] ?? null;
|
||
}
|
||
|
||
export default function GroupDetailPage() {
|
||
const params = useParams();
|
||
const router = useRouter();
|
||
const id = params.id as string;
|
||
const [range, setRange] = useState('d7');
|
||
const [data, setData] = useState<TsResp | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const r = await api.get<TsResp>(
|
||
`/admin/api/cps/groups/${id}/timeseries?${rangeToQuery(range)}`,
|
||
);
|
||
setData(r.data);
|
||
} catch (e) {
|
||
message.error(errMsg(e, '加载失败'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [id, range]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, [load]);
|
||
|
||
// 按天大表格(独立于折线;今日小时模式 tableDays=null 时不取、不显示)
|
||
const [daily, setDaily] = useState<DailyResp | null>(null);
|
||
const tableDays = rangeToDays(range);
|
||
const loadDaily = useCallback(async () => {
|
||
if (tableDays == null) return;
|
||
try {
|
||
const r = await api.get<DailyResp>(`/admin/api/cps/groups/${id}/daily?days=${tableDays}`);
|
||
setDaily(r.data);
|
||
} catch (e) {
|
||
message.error(errMsg(e, '明细加载失败'));
|
||
}
|
||
}, [id, tableDays]);
|
||
useEffect(() => {
|
||
loadDaily();
|
||
}, [loadDaily]);
|
||
|
||
// 群内微信用户(独立于时间范围,累计画像)
|
||
const [wxUsers, setWxUsers] = useState<WxUser[]>([]);
|
||
const loadWxUsers = useCallback(async () => {
|
||
try {
|
||
const r = await api.get<{ users: WxUser[] }>(`/admin/api/cps/groups/${id}/wx-users`);
|
||
setWxUsers(r.data.users || []);
|
||
} catch (e) {
|
||
message.error(errMsg(e, '用户列表加载失败'));
|
||
}
|
||
}, [id]);
|
||
useEffect(() => {
|
||
loadWxUsers();
|
||
}, [loadWxUsers]);
|
||
|
||
// 某天领券下钻弹窗
|
||
const [dayDetail, setDayDetail] = useState<{
|
||
open: boolean;
|
||
date: string;
|
||
loading: boolean;
|
||
users: DayUser[];
|
||
}>({ open: false, date: '', loading: false, users: [] });
|
||
|
||
const openDayDetail = useCallback(
|
||
async (date: string) => {
|
||
setDayDetail({ open: true, date, loading: true, users: [] });
|
||
try {
|
||
const r = await api.get<{ users: DayUser[] }>(
|
||
`/admin/api/cps/groups/${id}/day-users?date=${date}`,
|
||
);
|
||
setDayDetail((s) => ({ ...s, loading: false, users: r.data.users || [] }));
|
||
} catch (e) {
|
||
message.error(errMsg(e, '明细加载失败'));
|
||
setDayDetail((s) => ({ ...s, loading: false }));
|
||
}
|
||
},
|
||
[id],
|
||
);
|
||
|
||
const points = useMemo(() => data?.points ?? [], [data]);
|
||
// 展平成「长表」给折线图:每个 (时间桶 × 指标) 一行,colorField 按指标分 4 条线。
|
||
const chartData = useMemo(
|
||
() =>
|
||
points.flatMap((p) =>
|
||
METRICS.map((m) => ({ label: p.label, 指标: m.name, value: Number(p[m.key]) })),
|
||
),
|
||
[points],
|
||
);
|
||
const totals = useMemo(
|
||
() => METRICS.map((m) => ({ name: m.name, sum: points.reduce((s, p) => s + Number(p[m.key]), 0) })),
|
||
[points],
|
||
);
|
||
const hasData = points.some((p) => p.click_pv || p.click_uv || p.copy_pv || p.copy_uv);
|
||
|
||
const config = {
|
||
data: chartData,
|
||
xField: 'label',
|
||
yField: 'value',
|
||
colorField: '指标',
|
||
height: 420,
|
||
legend: { color: { position: 'top' as const } },
|
||
// 折线带数据点(每桶一个);大小用 style.r 固定 —— sizeField/shapeField 是数据字段映射,
|
||
// 给常量会被 G2 当字段名找而失效。
|
||
point: { style: { r: 3 } },
|
||
// y 轴恒从 0 起、上限自适应取整;CPS 数据量小,固定 0 基线对比更直观。
|
||
scale: { y: { domainMin: 0, nice: true } },
|
||
axis: { x: { title: range === 'h' ? '小时' : '日期' }, y: { title: '次数' } },
|
||
interaction: { tooltip: { shared: true } },
|
||
};
|
||
|
||
const dailyColumns: ColumnsType<DailyRow> = [
|
||
{ title: '日期', dataIndex: 'date', width: 96, fixed: 'left' },
|
||
{ title: '点击', dataIndex: 'click_pv', width: 56 },
|
||
{ title: '独立', dataIndex: 'click_uv', width: 56 },
|
||
{ title: '复制', dataIndex: 'copy_pv', width: 56, render: (v: number) => v || '-' },
|
||
{
|
||
title: '点击→下单',
|
||
key: 'rate',
|
||
width: 86,
|
||
render: (_, r) =>
|
||
r.order_count == null
|
||
? '-'
|
||
: r.click_uv
|
||
? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%`
|
||
: '-',
|
||
},
|
||
{ title: '有效单', dataIndex: 'order_count', width: 64, render: (v: number | null) => (v == null ? '-' : v) },
|
||
{
|
||
title: '取消/风控',
|
||
dataIndex: 'canceled_count',
|
||
width: 78,
|
||
render: (v: number | null) => (v == null ? '-' : v ? <span style={{ color: '#cf1322' }}>{v}</span> : 0),
|
||
},
|
||
{ title: '成交额', dataIndex: 'gmv_cents', width: 84, render: (v: number | null) => (v == null ? '-' : yuan(v)) },
|
||
{
|
||
title: '预估佣金',
|
||
dataIndex: 'est_commission_cents',
|
||
width: 84,
|
||
render: (v: number | null) => (v == null ? '-' : yuan(v)),
|
||
},
|
||
{
|
||
title: '退款佣金',
|
||
dataIndex: 'refund_profit_cents',
|
||
width: 84,
|
||
render: (v: number | null) =>
|
||
v == null ? '-' : v ? <span style={{ color: '#cf1322' }}>{yuan(v)}</span> : yuan(0),
|
||
},
|
||
{
|
||
title: '净佣金',
|
||
key: 'net',
|
||
width: 84,
|
||
render: (_, r) =>
|
||
r.est_commission_cents == null ? (
|
||
'-'
|
||
) : (
|
||
<b>{yuan(r.est_commission_cents - (r.refund_profit_cents || 0))}</b>
|
||
),
|
||
},
|
||
{
|
||
title: '结算佣金',
|
||
dataIndex: 'settled_commission_cents',
|
||
width: 84,
|
||
render: (v: number | null) => (v == null ? '-' : <span style={{ color: '#3f8600' }}>{yuan(v)}</span>),
|
||
},
|
||
{
|
||
title: '明细',
|
||
key: 'drill',
|
||
width: 64,
|
||
fixed: 'right',
|
||
render: (_, r) => (
|
||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => openDayDetail(r.date)}>
|
||
明细
|
||
</Button>
|
||
),
|
||
},
|
||
];
|
||
|
||
const wxColumns: ColumnsType<WxUser> = [
|
||
{
|
||
title: '用户',
|
||
key: 'user',
|
||
render: (_, u) => (
|
||
<Space>
|
||
{u.headimgurl ? (
|
||
<img
|
||
src={u.headimgurl}
|
||
alt=""
|
||
style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover' }}
|
||
/>
|
||
) : (
|
||
<span
|
||
style={{ width: 32, height: 32, borderRadius: '50%', background: '#f0f0f0', display: 'inline-block' }}
|
||
/>
|
||
)}
|
||
<span>{u.nickname || '(未授权昵称)'}</span>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '领券次数',
|
||
dataIndex: 'copy_count',
|
||
width: 90,
|
||
sorter: (a, b) => a.copy_count - b.copy_count,
|
||
render: (v: number) => (v ? <b style={{ color: '#ff4d4f' }}>{v}</b> : 0),
|
||
},
|
||
{ title: '点击次数', dataIndex: 'visit_count', width: 90 },
|
||
{ title: '首次进入', dataIndex: 'first_seen', width: 160, render: (v: string) => formatUtcTime(v) },
|
||
];
|
||
|
||
const dayUserColumns: ColumnsType<DayUser> = [
|
||
{
|
||
title: '用户',
|
||
key: 'user',
|
||
render: (_, u) => (
|
||
<Space>
|
||
{u.headimgurl ? (
|
||
<img
|
||
src={u.headimgurl}
|
||
alt=""
|
||
style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }}
|
||
/>
|
||
) : (
|
||
<span
|
||
style={{ width: 28, height: 28, borderRadius: '50%', background: '#f0f0f0', display: 'inline-block' }}
|
||
/>
|
||
)}
|
||
<span>{u.nickname || '(未授权昵称)'}</span>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '领券次数',
|
||
dataIndex: 'copy_count',
|
||
width: 100,
|
||
sorter: (a, b) => a.copy_count - b.copy_count,
|
||
render: (v: number) => (v ? <b style={{ color: '#ff4d4f' }}>{v}</b> : 0),
|
||
},
|
||
{
|
||
title: '点击次数',
|
||
dataIndex: 'visit_count',
|
||
width: 100,
|
||
render: (v: number, u) =>
|
||
u.coupons.length ? (
|
||
<Tooltip
|
||
title={
|
||
<div>
|
||
{u.coupons.map((c) => (
|
||
<div key={c.name}>
|
||
{c.name} ×{c.count}
|
||
</div>
|
||
))}
|
||
</div>
|
||
}
|
||
>
|
||
<span style={{ cursor: 'help', textDecoration: 'underline dotted' }}>{v}</span>
|
||
</Tooltip>
|
||
) : (
|
||
v
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<Space style={{ marginBottom: 16 }} align="center" wrap>
|
||
<Button onClick={() => router.push('/cps')}>← 返回</Button>
|
||
<span style={{ fontSize: 18, fontWeight: 600 }}>
|
||
群「{data?.group_name ?? ''}」点击趋势
|
||
</span>
|
||
</Space>
|
||
|
||
<Card style={{ marginBottom: 16 }}>
|
||
<Segmented options={RANGES} value={range} onChange={(v) => setRange(v as string)} />
|
||
</Card>
|
||
|
||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||
{totals.map((t) => (
|
||
<Col key={t.name} xs={12} sm={6}>
|
||
<Card size="small">
|
||
<Statistic title={`${t.name}(合计)`} value={t.sum} />
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
|
||
<Card>
|
||
<Spin spinning={loading}>
|
||
{hasData ? (
|
||
<Line {...config} />
|
||
) : (
|
||
<Empty
|
||
style={{ padding: '80px 0' }}
|
||
description={loading ? '加载中…' : '该时间范围内暂无点击数据'}
|
||
/>
|
||
)}
|
||
</Spin>
|
||
</Card>
|
||
|
||
{tableDays != null && (
|
||
<Card title={`每日明细(近 ${tableDays} 天)`} style={{ marginTop: 16 }}>
|
||
{daily && !daily.is_meituan && (
|
||
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
|
||
该群非美团渠道,订单/佣金类无对账数据,显示「-」
|
||
</div>
|
||
)}
|
||
<Table<DailyRow>
|
||
rowKey="date"
|
||
size="small"
|
||
columns={dailyColumns}
|
||
dataSource={daily?.rows ?? []}
|
||
pagination={false}
|
||
scroll={{ x: 1000 }}
|
||
/>
|
||
</Card>
|
||
)}
|
||
|
||
<Card title={`群内微信用户(${wxUsers.length})`} style={{ marginTop: 16 }}>
|
||
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
|
||
在微信里打开本群落地页并授权的用户。「领券次数」= 点了几次「复制口令」;昵称/头像需用户点领券时授权补充。
|
||
</div>
|
||
<Table<WxUser>
|
||
rowKey="openid"
|
||
size="small"
|
||
columns={wxColumns}
|
||
dataSource={wxUsers}
|
||
pagination={false}
|
||
scroll={{ x: 560 }}
|
||
locale={{ emptyText: '暂无授权用户' }}
|
||
/>
|
||
</Card>
|
||
|
||
<Modal
|
||
open={dayDetail.open}
|
||
title={`群「${data?.group_name ?? ''}」${dayDetail.date} · 用户领券明细`}
|
||
footer={null}
|
||
width={640}
|
||
height={600}
|
||
onCancel={() => setDayDetail((s) => ({ ...s, open: false }))}
|
||
>
|
||
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
|
||
仅统计微信授权(openid)用户;美团/京东多为匿名跳转,可能为空。悬停「点击次数」看该用户当天点过的券。
|
||
</div>
|
||
<Table<DayUser>
|
||
rowKey="openid"
|
||
size="small"
|
||
loading={dayDetail.loading}
|
||
columns={dayUserColumns}
|
||
dataSource={dayDetail.users}
|
||
pagination={false}
|
||
locale={{ emptyText: '当天无授权用户领券/点击' }}
|
||
/>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|