feat(cps): 群对账详情页折线图(点击/独立/复制趋势,3/7/14/30天+小时)
- 对账统计页群名可点进群详情页 /cps/groups/[id] - @ant-design/plots 折线图,4 指标多线,Segmented 切时间范围,y 轴 0 基线 - 数据稀疏靠后端补零保证折线连续/x 轴铺满 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
'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 { Button, Card, Col, Empty, Row, Segmented, Space, Spin, Statistic, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
|
||||
// @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[];
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
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 } },
|
||||
point: { sizeField: 3, shapeField: 'circle' },
|
||||
// y 轴恒从 0 起、上限自适应取整;CPS 数据量小,固定 0 基线对比更直观。
|
||||
scale: { y: { domainMin: 0, nice: true } },
|
||||
axis: { x: { title: range === 'h' ? '小时' : '日期' }, y: { title: '次数' } },
|
||||
interaction: { tooltip: { shared: true } },
|
||||
};
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// CPS 优惠券分发与对账。平台:美团(actId+sid,完整对账) / 淘宝(淘口令) / 京东(链接)。
|
||||
// 淘宝/京东无 API → 只统计点击,对账字段显示 "-"。单页 Tabs:对账统计/群管理/活动管理/订单明细。
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
Button,
|
||||
@@ -125,7 +126,15 @@ function StatsTab() {
|
||||
};
|
||||
|
||||
const columns: ColumnsType<CpsGroupStat> = [
|
||||
{ title: '群', dataIndex: 'name', width: 140, ellipsis: true, fixed: 'left' },
|
||||
{
|
||||
title: '群',
|
||||
dataIndex: 'name',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
fixed: 'left',
|
||||
render: (v: string, r) =>
|
||||
r.group_id ? <Link href={`/cps/groups/${r.group_id}`}>{v}</Link> : v,
|
||||
},
|
||||
{ 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 },
|
||||
|
||||
Reference in New Issue
Block a user