|
|
|
@@ -16,10 +16,8 @@ import {
|
|
|
|
|
Statistic,
|
|
|
|
|
Table,
|
|
|
|
|
Tag,
|
|
|
|
|
Tooltip,
|
|
|
|
|
Typography,
|
|
|
|
|
} from 'antd';
|
|
|
|
|
import { DownOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
|
|
|
|
import dayjs, { type Dayjs } from 'dayjs';
|
|
|
|
|
import { api, errMsg } from '@/lib/api';
|
|
|
|
|
import { formatUtcTime } from '@/lib/format';
|
|
|
|
@@ -36,13 +34,6 @@ interface CouponDataSummary {
|
|
|
|
|
p50_ms: number | null;
|
|
|
|
|
p95_ms: number | null;
|
|
|
|
|
p99_ms: number | null;
|
|
|
|
|
// 平台粒度成功率(见 shaguabijia-app-server docs/guides/领券成功率指标-设计与埋点.md §3/§12)
|
|
|
|
|
full_success_count: number;
|
|
|
|
|
full_success_rate: number | null;
|
|
|
|
|
point_success_count: number;
|
|
|
|
|
point_total_count: number;
|
|
|
|
|
point_success_rate: number | null;
|
|
|
|
|
per_platform: Record<string, number | null>; // {平台id: rate|null};恒含美团/淘宝/京东三档
|
|
|
|
|
}
|
|
|
|
|
interface CouponDataDaily {
|
|
|
|
|
date: string;
|
|
|
|
@@ -73,7 +64,6 @@ interface CouponDataRow {
|
|
|
|
|
started_at: string;
|
|
|
|
|
claimed_count: number | null;
|
|
|
|
|
trace_url: string | null;
|
|
|
|
|
ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元)
|
|
|
|
|
}
|
|
|
|
|
interface CouponDataReport {
|
|
|
|
|
date_from: string;
|
|
|
|
@@ -84,20 +74,6 @@ interface CouponDataReport {
|
|
|
|
|
total: number;
|
|
|
|
|
items: CouponDataRow[];
|
|
|
|
|
}
|
|
|
|
|
// 按券成功率(§13):coupon_id 粒度,数据源 coupon_claim_record;成功率=成功/(成功+失败),skipped 排除。
|
|
|
|
|
interface CouponSlotRow {
|
|
|
|
|
coupon_id: string;
|
|
|
|
|
coupon_name: string | null;
|
|
|
|
|
platform: string | null;
|
|
|
|
|
tried: number;
|
|
|
|
|
succeeded: number;
|
|
|
|
|
success_rate: number | null;
|
|
|
|
|
}
|
|
|
|
|
interface CouponSlotsOut {
|
|
|
|
|
date_from: string;
|
|
|
|
|
date_to: string;
|
|
|
|
|
items: CouponSlotRow[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 发起来源 App 包名 → 中文(「发起平台」列):空=傻瓜比价首页发起,外卖 App 包名→对应平台。
|
|
|
|
|
function originLabel(pkg: string | null): string {
|
|
|
|
@@ -120,35 +96,6 @@ const STATUS_TAG: Record<string, { color: string; label: string }> = {
|
|
|
|
|
const fmtSec = (ms: number | null | undefined): string =>
|
|
|
|
|
ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`;
|
|
|
|
|
|
|
|
|
|
// rate(0..1)→ "60.0%"(空值显示 -)
|
|
|
|
|
const fmtPct = (v: number | null | undefined): string =>
|
|
|
|
|
v == null ? '-' : `${(v * 100).toFixed(1)}%`;
|
|
|
|
|
|
|
|
|
|
// 元(小数)→ "¥0.0050"(空/≤0 显示 -)。单次广告收益很小,保留 4 位。
|
|
|
|
|
const fmtYuan = (v: number | null | undefined): string =>
|
|
|
|
|
v == null || v <= 0 ? '-' : `¥${v.toFixed(4)}`;
|
|
|
|
|
|
|
|
|
|
// 汇总卡成功率口径 tooltip 文案
|
|
|
|
|
const FULL_RATE_HINT =
|
|
|
|
|
'整单成功率:一次领券勾选的平台全部至少领到一张的场次 ÷ 领券发起数;基数含未完成/失败/中途退出。';
|
|
|
|
|
const POINT_RATE_HINT =
|
|
|
|
|
'点位成功率(合计):平台粒度——各平台(美团/淘宝闪购/京东)成功点位数之和 ÷ 勾选平台点位数之和;空勾选按全领三档计。注意与「数据大盘」券粒度的点位成功率口径不同。';
|
|
|
|
|
|
|
|
|
|
// 按券表:coupon_id 平台 → 中文
|
|
|
|
|
const SLOT_PLATFORM: Record<string, string> = {
|
|
|
|
|
'meituan-waimai': '美团',
|
|
|
|
|
'taobao-shanguang': '淘宝',
|
|
|
|
|
'jd-waimai': '京东',
|
|
|
|
|
};
|
|
|
|
|
const slotPlatformLabel = (p: string | null): string => (p ? SLOT_PLATFORM[p] ?? p : '其他');
|
|
|
|
|
|
|
|
|
|
// 三平台品牌色(平台名前小圆点用):美团黄 / 淘宝闪购橙 / 京东红。
|
|
|
|
|
const SLOT_PLATFORM_COLOR: Record<string, string> = {
|
|
|
|
|
'meituan-waimai': '#FFB300',
|
|
|
|
|
'taobao-shanguang': '#FF6A00',
|
|
|
|
|
'jd-waimai': '#E1251B',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 点手机号弹出的「该用户全部领券」抽屉列(精简版:单用户,不含用户/手机号列)。
|
|
|
|
|
const RECORD_COLUMNS: ColumnsType<CouponDataRow> = [
|
|
|
|
|
{ title: '时间', dataIndex: 'started_at', width: 160, render: (v: string) => formatUtcTime(v) },
|
|
|
|
@@ -339,8 +286,6 @@ export default function CouponDataPage() {
|
|
|
|
|
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
|
|
|
|
|
const [user, setUser] = useState<string>('');
|
|
|
|
|
const [appEnv, setAppEnv] = useState<string>('prod');
|
|
|
|
|
// 领券状态多选(方案 A:整视图联动),默认全选四态(= 现状,不改口径)。
|
|
|
|
|
const [statuses, setStatuses] = useState<string[]>(['started', 'completed', 'failed', 'abandoned']);
|
|
|
|
|
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
|
|
|
|
const [sortBy, setSortBy] = useState<'time' | 'elapsed'>('time');
|
|
|
|
|
const [limit, setLimit] = useState<number>(100);
|
|
|
|
@@ -349,9 +294,6 @@ export default function CouponDataPage() {
|
|
|
|
|
const [queriedMultiDay, setQueriedMultiDay] = useState(false);
|
|
|
|
|
const [queriedLimit, setQueriedLimit] = useState<number>(100);
|
|
|
|
|
const [data, setData] = useState<CouponDataReport | null>(null);
|
|
|
|
|
const [couponSlots, setCouponSlots] = useState<CouponSlotRow[]>([]);
|
|
|
|
|
// 按券表:默认隐藏,点某平台点位成功率卡 → 展开该平台的券表(null=隐藏)。
|
|
|
|
|
const [selectedSlotPlatform, setSelectedSlotPlatform] = useState<string | null>(null);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
// 点手机号:抽屉看该用户全部领券(总次数 + 记录)
|
|
|
|
|
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
|
|
|
|
@@ -376,23 +318,13 @@ export default function CouponDataPage() {
|
|
|
|
|
date_to: to,
|
|
|
|
|
user: user.trim() || undefined,
|
|
|
|
|
app_env: appEnv,
|
|
|
|
|
status: statuses,
|
|
|
|
|
granularity: gran,
|
|
|
|
|
limit: targetLimit,
|
|
|
|
|
offset: (targetPage - 1) * targetLimit,
|
|
|
|
|
sort: targetSort,
|
|
|
|
|
},
|
|
|
|
|
// status 是数组:repeat 无方括号序列化(status=a&status=b),对齐 FastAPI list[str] Query
|
|
|
|
|
paramsSerializer: { indexes: null },
|
|
|
|
|
});
|
|
|
|
|
setData(res.data);
|
|
|
|
|
// 并行拉「按券成功率」(独立端点,失败不影响主报表)
|
|
|
|
|
api
|
|
|
|
|
.get<CouponSlotsOut>('/admin/api/coupon-data/coupons', {
|
|
|
|
|
params: { date_from: from, date_to: to, app_env: appEnv },
|
|
|
|
|
})
|
|
|
|
|
.then((r) => setCouponSlots(r.data.items))
|
|
|
|
|
.catch(() => setCouponSlots([]));
|
|
|
|
|
setPage(targetPage);
|
|
|
|
|
setQueriedLimit(targetLimit);
|
|
|
|
|
setQueriedGranularity(gran);
|
|
|
|
@@ -403,7 +335,7 @@ export default function CouponDataPage() {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[range, user, appEnv, statuses, granularity, limit, sortBy, message],
|
|
|
|
|
[range, user, appEnv, granularity, limit, sortBy, message],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
@@ -461,12 +393,6 @@ export default function CouponDataPage() {
|
|
|
|
|
width: 110,
|
|
|
|
|
render: (pkg: string | null) => originLabel(pkg),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: '时间',
|
|
|
|
|
dataIndex: 'started_at',
|
|
|
|
|
width: 165,
|
|
|
|
|
render: (v: string) => formatUtcTime(v),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: '耗时',
|
|
|
|
|
dataIndex: 'elapsed_ms',
|
|
|
|
@@ -474,13 +400,6 @@ export default function CouponDataPage() {
|
|
|
|
|
align: 'right',
|
|
|
|
|
render: (v: number | null) => fmtSec(v),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: '广告收益',
|
|
|
|
|
dataIndex: 'ad_revenue_yuan',
|
|
|
|
|
width: 100,
|
|
|
|
|
align: 'right',
|
|
|
|
|
render: (v: number) => fmtYuan(v),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: '美团耗时',
|
|
|
|
|
key: 'mt_elapsed',
|
|
|
|
@@ -511,6 +430,12 @@ export default function CouponDataPage() {
|
|
|
|
|
return parts.length ? parts.join(' / ') : <Typography.Text type="secondary">-</Typography.Text>;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: '时间',
|
|
|
|
|
dataIndex: 'started_at',
|
|
|
|
|
width: 165,
|
|
|
|
|
render: (v: string) => formatUtcTime(v),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: '领券 trace',
|
|
|
|
|
dataIndex: 'trace_id',
|
|
|
|
@@ -529,31 +454,6 @@ export default function CouponDataPage() {
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const slotColumns: ColumnsType<CouponSlotRow> = [
|
|
|
|
|
{
|
|
|
|
|
title: '券名',
|
|
|
|
|
dataIndex: 'coupon_name',
|
|
|
|
|
render: (v: string | null, r: CouponSlotRow) => v || r.coupon_id,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: '尝试',
|
|
|
|
|
dataIndex: 'tried',
|
|
|
|
|
width: 90,
|
|
|
|
|
align: 'right',
|
|
|
|
|
sorter: (a: CouponSlotRow, b: CouponSlotRow) => a.tried - b.tried,
|
|
|
|
|
},
|
|
|
|
|
{ title: '成功', dataIndex: 'succeeded', width: 90, align: 'right' },
|
|
|
|
|
{
|
|
|
|
|
title: '成功率',
|
|
|
|
|
dataIndex: 'success_rate',
|
|
|
|
|
width: 100,
|
|
|
|
|
align: 'right',
|
|
|
|
|
defaultSortOrder: 'descend',
|
|
|
|
|
render: (v: number | null) => fmtPct(v),
|
|
|
|
|
sorter: (a: CouponSlotRow, b: CouponSlotRow) => (a.success_rate ?? 0) - (b.success_rate ?? 0),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const summary = data?.summary;
|
|
|
|
|
const items = data?.items ?? [];
|
|
|
|
|
|
|
|
|
@@ -605,18 +505,6 @@ export default function CouponDataPage() {
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
</Space>
|
|
|
|
|
<Space size={6}>
|
|
|
|
|
<Typography.Text type="secondary">状态</Typography.Text>
|
|
|
|
|
<Select
|
|
|
|
|
mode="multiple"
|
|
|
|
|
value={statuses}
|
|
|
|
|
onChange={setStatuses}
|
|
|
|
|
style={{ minWidth: 240 }}
|
|
|
|
|
maxTagCount="responsive"
|
|
|
|
|
placeholder="全部状态"
|
|
|
|
|
options={Object.entries(STATUS_TAG).map(([value, t]) => ({ value, label: t.label }))}
|
|
|
|
|
/>
|
|
|
|
|
</Space>
|
|
|
|
|
<Space size={6}>
|
|
|
|
|
<Typography.Text type="secondary">粒度</Typography.Text>
|
|
|
|
|
<Select
|
|
|
|
@@ -692,113 +580,6 @@ export default function CouponDataPage() {
|
|
|
|
|
<Statistic title="耗时 99 分位" value={fmtSec(summary.p99_ms)} />
|
|
|
|
|
</Col>
|
|
|
|
|
</Row>
|
|
|
|
|
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
|
|
|
|
|
<Row gutter={[16, 12]}>
|
|
|
|
|
<Col flex="1 1 0">
|
|
|
|
|
<Statistic
|
|
|
|
|
title={
|
|
|
|
|
<span>
|
|
|
|
|
整单成功率{' '}
|
|
|
|
|
<Tooltip title={FULL_RATE_HINT}>
|
|
|
|
|
<InfoCircleOutlined style={{ color: '#999' }} />
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</span>
|
|
|
|
|
}
|
|
|
|
|
value={fmtPct(summary.full_success_rate)}
|
|
|
|
|
/>
|
|
|
|
|
</Col>
|
|
|
|
|
<Col flex="1 1 0">
|
|
|
|
|
<Statistic
|
|
|
|
|
title={
|
|
|
|
|
<span>
|
|
|
|
|
点位成功率(合计){' '}
|
|
|
|
|
<Tooltip title={POINT_RATE_HINT}>
|
|
|
|
|
<InfoCircleOutlined style={{ color: '#999' }} />
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</span>
|
|
|
|
|
}
|
|
|
|
|
value={fmtPct(summary.point_success_rate)}
|
|
|
|
|
/>
|
|
|
|
|
</Col>
|
|
|
|
|
</Row>
|
|
|
|
|
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
|
|
|
|
|
<Row gutter={[16, 12]}>
|
|
|
|
|
{(['meituan-waimai', 'taobao-shanguang', 'jd-waimai'] as const).map((pid) => {
|
|
|
|
|
const active = selectedSlotPlatform === pid;
|
|
|
|
|
const rate = summary.per_platform?.[pid];
|
|
|
|
|
// 平台名(前置品牌色圆点) | 大数字成功率 | 「查看明细」按钮;仅按钮可展开表格。
|
|
|
|
|
return (
|
|
|
|
|
<Col flex="1 1 0" key={pid}>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
border: `1px solid ${active ? '#91caff' : '#f0f0f0'}`,
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
background: active ? '#f0f7ff' : '#fff',
|
|
|
|
|
transition: 'all .2s',
|
|
|
|
|
padding: '10px 14px',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{/* 平台名前放小品牌色圆点(取代原左侧竖条),克制地标记平台身份,不打破页面灰度。 */}
|
|
|
|
|
<div style={{ fontSize: 13, color: '#666', display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
width: 8,
|
|
|
|
|
height: 8,
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
flex: '0 0 auto',
|
|
|
|
|
background: SLOT_PLATFORM_COLOR[pid],
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
{`${SLOT_PLATFORM[pid]}点位成功率`}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontSize: 26, fontWeight: 600, lineHeight: 1.1, margin: '4px 0 10px' }}>
|
|
|
|
|
{fmtPct(rate)}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
size="small"
|
|
|
|
|
type={active ? 'primary' : 'default'}
|
|
|
|
|
onClick={() => setSelectedSlotPlatform(active ? null : pid)}
|
|
|
|
|
>
|
|
|
|
|
查看明细{' '}
|
|
|
|
|
<DownOutlined
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
transform: active ? 'rotate(180deg)' : 'none',
|
|
|
|
|
transition: 'transform .2s',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</Col>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</Row>
|
|
|
|
|
{selectedSlotPlatform && (
|
|
|
|
|
// 表格区域:浅灰底 + 内边距,和上方指标卡主体分层区分。
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
marginTop: 12,
|
|
|
|
|
background: '#fafafa',
|
|
|
|
|
border: '1px solid #f0f0f0',
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
padding: 12,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}
|
|
|
|
|
>
|
|
|
|
|
<span style={{ fontWeight: 500 }}>{`${slotPlatformLabel(selectedSlotPlatform)} · 按券成功率`}</span>
|
|
|
|
|
<a onClick={() => setSelectedSlotPlatform(null)}>收起</a>
|
|
|
|
|
</div>
|
|
|
|
|
<Table<CouponSlotRow>
|
|
|
|
|
rowKey="coupon_id"
|
|
|
|
|
size="small"
|
|
|
|
|
pagination={false}
|
|
|
|
|
dataSource={couponSlots.filter((r) => r.platform === selectedSlotPlatform)}
|
|
|
|
|
columns={slotColumns}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
@@ -875,7 +656,7 @@ export default function CouponDataPage() {
|
|
|
|
|
onChange: (p) => load(p, queriedLimit),
|
|
|
|
|
}}
|
|
|
|
|
size="small"
|
|
|
|
|
scroll={{ x: 1560 }}
|
|
|
|
|
scroll={{ x: 1450 }}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<UserRecordsDrawer<CouponDataRow>
|
|
|
|
|