Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32fde71c96 |
@@ -1,101 +0,0 @@
|
|||||||
# 点位成功率改为「按券成功率的算术平均」设计
|
|
||||||
|
|
||||||
- **日期**:2026-07-11
|
|
||||||
- **范围**:纯前端 `shaguabijia-admin-web`,**后端零改动、零 schema 改动、零迁移**
|
|
||||||
- **涉及文件**:`src/app/(main)/coupon-data/page.tsx`(单文件)
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
「领券数据」看板汇总卡里的 **分平台点位成功率**(美团/淘宝/京东)与 **点位成功率(合计)**,改为
|
|
||||||
**该平台(/全部)几张券成功率的算术平均**,让平台数字与下方「按券明细」自洽,便于快速揪出坏券。
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
现状:分平台 / 合计点位成功率来自后端 `_success_rates`(**会话口径**,数据源 `coupon_session.platform_success`),
|
|
||||||
与页面下方「按券成功率」(数据源 `coupon_claim_record`)口径不同,导致同一平台的平台数(如淘宝 71.4%)
|
|
||||||
与其券明细(100%/80%/80%)对不上,甚至出现「合计低于每个平台」的反直觉现象。
|
|
||||||
|
|
||||||
关键点:**本页已经把全量按券数据拉到前端**——`couponSlots`(来自 `/admin/api/coupon-data/coupons`,
|
|
||||||
后端 `coupon_slot_report` 不分页、返回全量),每行含 `platform` 与 `success_rate`。因此直接在前端
|
|
||||||
对其求算术平均即可,**无需后端改动**。
|
|
||||||
|
|
||||||
## 口径(改动后)
|
|
||||||
|
|
||||||
- **分平台点位成功率** = 该平台所有券 `success_rate` 的算术平均(每张券等权;`success_rate` 为空的券跳过;无券 → 显示 `-`)。
|
|
||||||
- **点位成功率(合计)** = `couponSlots` 全部券 `success_rate` 的算术平均。
|
|
||||||
- **每张券成功率**(按券明细,不变):成功(success + already_claimed) ÷ 尝试(+ failed),skipped 排除(后端 `coupon_slot_report`)。
|
|
||||||
|
|
||||||
例:淘宝三张券 100% / 80% / 80% → 淘宝点位 = (100+80+80)/3 = **86.7%**。
|
|
||||||
|
|
||||||
## 不改(其他保持不变)
|
|
||||||
|
|
||||||
- **后端**:`coupon_slot_report` / `_success_rates` / `CouponDataSummary` schema 全不动。
|
|
||||||
`summary.per_platform`、`summary.point_success_rate` 仍会返回(会话口径),只是前端这两处不再引用它们(留着不删,接口稳定)。
|
|
||||||
- **整单成功率**:会话口径不变。
|
|
||||||
- **按券明细表**:排序、列等不动。
|
|
||||||
- **筛选联动**:整单 / 发起 / 完成 / 耗时仍随「用户 / 状态 / 日期 / 环境」全部筛选;
|
|
||||||
点位随 `couponSlots` 走(= 日期 + 环境,不随「用户 / 状态」搜索变——与按券表现状一致)。
|
|
||||||
|
|
||||||
## 实现(单文件 `page.tsx`)
|
|
||||||
|
|
||||||
### 1. 加一个求均值 helper(放 `fmtPct` 附近)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// 一组按券行的 success_rate 算术平均;无有效券 → null(显示 -)
|
|
||||||
const slotRateMean = (rows: CouponSlotRow[]): number | null => {
|
|
||||||
const rates = rows
|
|
||||||
.map((r) => r.success_rate)
|
|
||||||
.filter((v): v is number => v != null);
|
|
||||||
return rates.length ? rates.reduce((a, b) => a + b, 0) / rates.length : null;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 合计:改 value
|
|
||||||
|
|
||||||
把
|
|
||||||
```tsx
|
|
||||||
value={fmtPct(summary.point_success_rate)}
|
|
||||||
```
|
|
||||||
改成
|
|
||||||
```tsx
|
|
||||||
value={fmtPct(slotRateMean(couponSlots))}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 分平台卡:改 rate 来源
|
|
||||||
|
|
||||||
把每个平台卡里的
|
|
||||||
```tsx
|
|
||||||
const rate = summary.per_platform?.[pid];
|
|
||||||
```
|
|
||||||
改成
|
|
||||||
```tsx
|
|
||||||
const rate = slotRateMean(couponSlots.filter((r) => r.platform === pid));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 更新口径 tooltip 文案
|
|
||||||
|
|
||||||
`POINT_RATE_HINT` 改成(去掉旧的会话口径描述):
|
|
||||||
```ts
|
|
||||||
const POINT_RATE_HINT =
|
|
||||||
'点位成功率(合计):当前区间内全部券成功率的算术平均(每张券等权);' +
|
|
||||||
'单券成功率=成功(含已领)÷尝试(不含跳过),源自领券每券记录。';
|
|
||||||
```
|
|
||||||
分平台卡标题(`${SLOT_PLATFORM[pid]}点位成功率`)如需补口径,注明「该平台各券成功率的算术平均」。
|
|
||||||
|
|
||||||
## 边角 / 注意
|
|
||||||
|
|
||||||
- **精度**:`success_rate` 后端已 round 到 4 位,前端再平均,展示只到 1 位小数(`fmtPct`),误差可忽略。
|
|
||||||
- **依赖按券请求**:点位现在依赖 `couponSlots`(独立请求,失败时 `setCouponSlots([])`)。若该请求失败,点位卡显示 `-`(优雅降级)。此前它来自主 `summary` 请求。
|
|
||||||
- **platform=null 的券**:coupon_id 前缀无法识别的券(实践中 pricebot 都是 `mt_`/`tb_`/`ele_`/`elm_`/`jd_`,基本不出现)会计入 **合计**、但不在任何分平台卡里。可接受。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- **手动**:选一天,展开某平台「查看明细」,核对该平台卡数字 = 明细表各券成功率的算术平均;合计 = 全部券的算术平均。
|
|
||||||
- `npx tsc --noEmit` 通过;`npm run lint` 无新增 error。
|
|
||||||
|
|
||||||
## 不做(YAGNI)
|
|
||||||
|
|
||||||
- 最小尝试数阈值 / 低量券滤噪 / 坏券标红。
|
|
||||||
- 按券表默认升序(坏券置顶)。
|
|
||||||
- 「数据大盘」overview 的领券点位成功率(另一页、另一口径)。
|
|
||||||
- 删除后端已不再被前端使用的 `per_platform` / `point_success_rate` 字段。
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// 埋点 / 上报成功率大盘:度量客户端埋点数据可信度,两段漏斗两个大率。
|
|
||||||
// 数据源 analytics_selfstat 快照差分(与埋点日志表无关);三个只读端点在 admin 服务(8771)。
|
|
||||||
// 时区:端点吃 UTC 时刻(见 toUtcRange);trend.day 已是北京日,直接展示不再换算。
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import {
|
|
||||||
App,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
DatePicker,
|
|
||||||
Empty,
|
|
||||||
Row,
|
|
||||||
Segmented,
|
|
||||||
Space,
|
|
||||||
Statistic,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from 'antd';
|
|
||||||
import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
|
||||||
import utc from 'dayjs/plugin/utc';
|
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
|
||||||
import { api, errMsg } from '@/lib/api';
|
|
||||||
import type { HealthBreakdownRow, HealthMetrics, HealthTrendPoint } from '@/lib/types';
|
|
||||||
|
|
||||||
dayjs.extend(utc);
|
|
||||||
dayjs.extend(timezone);
|
|
||||||
const CN = 'Asia/Shanghai';
|
|
||||||
|
|
||||||
// @ant-design/plots 依赖 DOM,关掉 SSR 仅客户端渲染(否则 Next 预渲染报 document undefined)。
|
|
||||||
// 用仓内 v2 写法(colorField/scale/axis/interaction),非指南里的 v1(seriesField/yAxis)。
|
|
||||||
const Line = dynamic(() => import('@ant-design/plots').then((m) => m.Line), { ssr: false });
|
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
|
||||||
type Dim = 'event' | 'app_ver' | 'oem';
|
|
||||||
const DIM_LABEL: Record<Dim, string> = { event: '事件', app_ver: '版本', oem: 'ROM' };
|
|
||||||
const LOW = 0.9; // 成功率低于此阈值标红(定位掉数据最狠的点位/版本/ROM)
|
|
||||||
|
|
||||||
// 北京日范围 → UTC ISO 瞬时(右开区间;date_to = 结束日 +1 天的北京零点)。
|
|
||||||
// 端点按 UTC 时刻比较 created_at,直接传 YYYY-MM-DD 会被当 UTC 零点,与北京日差 8 小时。
|
|
||||||
function toUtcRange(start: Dayjs, end: Dayjs) {
|
|
||||||
return {
|
|
||||||
date_from: dayjs.tz(start.format('YYYY-MM-DD'), CN).utc().toISOString(),
|
|
||||||
date_to: dayjs.tz(end.add(1, 'day').format('YYYY-MM-DD'), CN).utc().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 率:null → "-"(分母为 0 ≠ 0%);否则百分比一位小数。
|
|
||||||
const pct = (v: number | null | undefined) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`);
|
|
||||||
const int = (v: number | null | undefined) =>
|
|
||||||
v == null ? '-' : new Intl.NumberFormat('zh-CN').format(v);
|
|
||||||
|
|
||||||
// 四原子计数说明(总览卡标题 + 下钻表头 tooltip 共用,一处改两处同步)。
|
|
||||||
const METRIC_TIP = {
|
|
||||||
attempted: '端上 track() 被调用的次数,是埋点段分母。埋点成功率 =(attempted − drop_capture)/ attempted。',
|
|
||||||
drop_capture: '采集环节因异常(队列满 / 序列化失败等)被丢弃、未能落地的事件数,计入埋点失败。',
|
|
||||||
delivered: '成功送达并入库后端的事件数。上报成功率 = delivered /(delivered + drop_undelivered)。',
|
|
||||||
drop_undelivered:
|
|
||||||
'已落盘但久未送达、被队列淘汰的事件数(弱网 / 被 ROM 杀后台等);在途未送达的不计入。',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// 标题/表头后跟一个说明图标(hover 出 tooltip)。
|
|
||||||
const withTip = (label: string, tip: string) => (
|
|
||||||
<Space size={4}>
|
|
||||||
{label}
|
|
||||||
<Tooltip title={tip}>
|
|
||||||
<InfoCircleOutlined style={{ color: '#bfbfbf', cursor: 'help' }} />
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 大率卡:率偏低标红 + 「偏低」标。
|
|
||||||
function RateStat({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
value: number | null | undefined;
|
|
||||||
loading: boolean;
|
|
||||||
}) {
|
|
||||||
const low = value != null && value < LOW;
|
|
||||||
return (
|
|
||||||
<Card loading={loading}>
|
|
||||||
<Statistic
|
|
||||||
title={
|
|
||||||
<Space size={6}>
|
|
||||||
{title}
|
|
||||||
{low && <Tag color="error">偏低</Tag>}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
value={value == null ? '-' : (value * 100).toFixed(1)}
|
|
||||||
suffix={value == null ? undefined : '%'}
|
|
||||||
valueStyle={low ? { color: '#cf1322' } : undefined}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 原子量卡(attempted / drop_capture / delivered / drop_undelivered)。
|
|
||||||
function CountStat({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
loading,
|
|
||||||
warn,
|
|
||||||
tip,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
value: number | null | undefined;
|
|
||||||
loading: boolean;
|
|
||||||
warn?: boolean; // drop_* > 0 时橙色提示
|
|
||||||
tip?: string;
|
|
||||||
}) {
|
|
||||||
const bad = warn && value != null && value > 0;
|
|
||||||
return (
|
|
||||||
<Card loading={loading}>
|
|
||||||
<Statistic
|
|
||||||
title={tip ? withTip(title, tip) : title}
|
|
||||||
value={value == null ? '-' : value}
|
|
||||||
valueStyle={bad ? { color: '#d46b08' } : undefined}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AnalyticsHealthPage() {
|
|
||||||
const { message } = App.useApp();
|
|
||||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([
|
|
||||||
dayjs().tz(CN).subtract(6, 'day'),
|
|
||||||
dayjs().tz(CN),
|
|
||||||
]);
|
|
||||||
const [dim, setDim] = useState<Dim>('event');
|
|
||||||
const [overview, setOverview] = useState<HealthMetrics | null>(null);
|
|
||||||
const [trend, setTrend] = useState<HealthTrendPoint[]>([]);
|
|
||||||
const [rows, setRows] = useState<HealthBreakdownRow[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false); // 总览 + 趋势
|
|
||||||
const [rowsLoading, setRowsLoading] = useState(false); // 下钻表
|
|
||||||
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 总览 + 趋势:只随区间变。
|
|
||||||
const loadMain = useCallback(async () => {
|
|
||||||
const params = toUtcRange(range[0], range[1]);
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [ov, tr] = await Promise.all([
|
|
||||||
api.get<HealthMetrics>('/admin/api/analytics-health/overview', { params }),
|
|
||||||
api.get<HealthTrendPoint[]>('/admin/api/analytics-health/trend', { params }),
|
|
||||||
]);
|
|
||||||
setOverview(ov.data);
|
|
||||||
setTrend(tr.data);
|
|
||||||
setUpdatedAt(dayjs().tz(CN).format('YYYY-MM-DD HH:mm'));
|
|
||||||
} catch (e) {
|
|
||||||
message.error(errMsg(e, '加载成功率数据失败'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [range, message]);
|
|
||||||
|
|
||||||
// 下钻表:随区间或维度变(切 dim 只重拉表,不动总览/趋势)。
|
|
||||||
const loadRows = useCallback(async () => {
|
|
||||||
const params = toUtcRange(range[0], range[1]);
|
|
||||||
setRowsLoading(true);
|
|
||||||
try {
|
|
||||||
const bd = await api.get<HealthBreakdownRow[]>('/admin/api/analytics-health/breakdown', {
|
|
||||||
params: { ...params, dim },
|
|
||||||
});
|
|
||||||
setRows(bd.data);
|
|
||||||
} catch (e) {
|
|
||||||
message.error(errMsg(e, '加载下钻数据失败'));
|
|
||||||
} finally {
|
|
||||||
setRowsLoading(false);
|
|
||||||
}
|
|
||||||
}, [range, dim, message]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadMain();
|
|
||||||
}, [loadMain]);
|
|
||||||
useEffect(() => {
|
|
||||||
void loadRows();
|
|
||||||
}, [loadRows]);
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
void loadMain();
|
|
||||||
void loadRows();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 折线长表:每天 ×{埋点率, 上报率}。保留 rate=null 的点(不 filter)→ G2 在断点处断线,
|
|
||||||
// 真正实现「null 点跳过不连线」;若 filter 掉 null 会把两侧点连成一条误导直线。
|
|
||||||
const trendLong = useMemo(
|
|
||||||
() =>
|
|
||||||
trend.flatMap((p) => [
|
|
||||||
{ day: p.day, metric: '埋点成功率', rate: p.track_success_rate },
|
|
||||||
{ day: p.day, metric: '上报成功率', rate: p.report_success_rate },
|
|
||||||
]),
|
|
||||||
[trend],
|
|
||||||
);
|
|
||||||
|
|
||||||
const chartConfig = {
|
|
||||||
data: trendLong,
|
|
||||||
xField: 'day',
|
|
||||||
yField: 'rate',
|
|
||||||
colorField: 'metric',
|
|
||||||
height: 300,
|
|
||||||
scale: {
|
|
||||||
y: { domainMin: 0, domainMax: 1, nice: false },
|
|
||||||
color: { range: ['#1677ff', '#fa8c16'] }, // 埋点率蓝 / 上报率橙(按长表首次出现序)
|
|
||||||
},
|
|
||||||
axis: { y: { labelFormatter: (v: number) => `${(v * 100).toFixed(0)}%` } },
|
|
||||||
point: { style: { r: 3 } },
|
|
||||||
legend: { color: { position: 'top' as const } },
|
|
||||||
tooltip: { items: [{ channel: 'y' as const, valueFormatter: (v: number) => pct(v) }] },
|
|
||||||
interaction: { tooltip: { shared: true } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnsType<HealthBreakdownRow> = [
|
|
||||||
{ title: DIM_LABEL[dim], dataIndex: 'key', key: 'key', render: (v: string) => v || '(unknown)' },
|
|
||||||
{
|
|
||||||
title: '埋点成功率',
|
|
||||||
key: 'track',
|
|
||||||
width: 110,
|
|
||||||
align: 'right',
|
|
||||||
render: (_: unknown, r: HealthBreakdownRow) => (
|
|
||||||
<span style={r.track_success_rate != null && r.track_success_rate < LOW ? { color: '#cf1322', fontWeight: 600 } : undefined}>
|
|
||||||
{pct(r.track_success_rate)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '上报成功率',
|
|
||||||
key: 'report',
|
|
||||||
width: 110,
|
|
||||||
align: 'right',
|
|
||||||
render: (_: unknown, r: HealthBreakdownRow) => (
|
|
||||||
<span style={r.report_success_rate != null && r.report_success_rate < LOW ? { color: '#cf1322', fontWeight: 600 } : undefined}>
|
|
||||||
{pct(r.report_success_rate)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: withTip('尝试总数', METRIC_TIP.attempted), dataIndex: 'attempted', key: 'attempted', width: 120, align: 'right', render: (v: number) => int(v) },
|
|
||||||
{ title: withTip('采集丢弃', METRIC_TIP.drop_capture), dataIndex: 'drop_capture', key: 'drop_capture', width: 128, align: 'right', render: (v: number) => int(v) },
|
|
||||||
{ title: withTip('送达', METRIC_TIP.delivered), dataIndex: 'delivered', key: 'delivered', width: 108, align: 'right', render: (v: number) => int(v) },
|
|
||||||
{ title: withTip('淘汰未送达', METRIC_TIP.drop_undelivered), dataIndex: 'drop_undelivered', key: 'drop_undelivered', width: 150, align: 'right', render: (v: number) => int(v) },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
|
|
||||||
<Space align="center">
|
|
||||||
<h2 style={{ margin: 0 }}>埋点成功率</h2>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
端上埋点/上报可信度;数据分钟级延迟,非实时
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
<Space align="center">
|
|
||||||
{updatedAt && (
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
数据更新于 {updatedAt}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={refresh} loading={loading || rowsLoading}>
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
|
||||||
<Space size={6} align="center">
|
|
||||||
<Typography.Text type="secondary">日期(北京)</Typography.Text>
|
|
||||||
<RangePicker
|
|
||||||
value={range}
|
|
||||||
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
|
|
||||||
allowClear={false}
|
|
||||||
presets={[
|
|
||||||
{ label: '今天', value: [dayjs().tz(CN), dayjs().tz(CN)] },
|
|
||||||
{ label: '近 7 天', value: [dayjs().tz(CN).subtract(6, 'day'), dayjs().tz(CN)] },
|
|
||||||
{ label: '近 30 天', value: [dayjs().tz(CN).subtract(29, 'day'), dayjs().tz(CN)] },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 总览:两大率居前,四原子量次之 */}
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
|
||||||
<Col xs={24} sm={12}>
|
|
||||||
<RateStat title="埋点成功率" value={overview?.track_success_rate} loading={loading} />
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={12}>
|
|
||||||
<RateStat title="上报成功率" value={overview?.report_success_rate} loading={loading} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
|
||||||
<Col xs={12} sm={6}>
|
|
||||||
<CountStat title="尝试总数 attempted" value={overview?.attempted} loading={loading} tip={METRIC_TIP.attempted} />
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={6}>
|
|
||||||
<CountStat title="采集丢弃 drop_capture" value={overview?.drop_capture} loading={loading} warn tip={METRIC_TIP.drop_capture} />
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={6}>
|
|
||||||
<CountStat title="送达 delivered" value={overview?.delivered} loading={loading} tip={METRIC_TIP.delivered} />
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={6}>
|
|
||||||
<CountStat title="淘汰未送达 drop_undelivered" value={overview?.drop_undelivered} loading={loading} warn tip={METRIC_TIP.drop_undelivered} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{/* 趋势:两条率折线,Y 轴锁 [0,1] 显示百分比,null 断线 */}
|
|
||||||
<Card title="按天趋势(北京时间)" size="small" style={{ marginBottom: 16 }}>
|
|
||||||
{trendLong.length > 0 ? (
|
|
||||||
<Line {...chartConfig} />
|
|
||||||
) : (
|
|
||||||
<Empty style={{ padding: '60px 0' }} description={loading ? '加载中…' : '该区间暂无埋点数据'} />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 下钻:后端已按上报率升序(最差在前),率 < 90% 标红 */}
|
|
||||||
<Card
|
|
||||||
title="维度下钻(按上报成功率升序,最差在前)"
|
|
||||||
size="small"
|
|
||||||
extra={
|
|
||||||
<Segmented
|
|
||||||
value={dim}
|
|
||||||
onChange={(v) => setDim(v as Dim)}
|
|
||||||
options={[
|
|
||||||
{ label: '按事件', value: 'event' },
|
|
||||||
{ label: '按版本', value: 'app_ver' },
|
|
||||||
{ label: '按 ROM', value: 'oem' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Table<HealthBreakdownRow>
|
|
||||||
rowKey="key"
|
|
||||||
dataSource={rows}
|
|
||||||
columns={columns}
|
|
||||||
loading={rowsLoading}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: 820 }}
|
|
||||||
locale={{ emptyText: rowsLoading ? '加载中…' : '该区间暂无数据' }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -184,18 +184,6 @@ export default function ComparisonRecordsPage() {
|
|||||||
<b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b>
|
<b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '广告收益',
|
|
||||||
key: 'ad_revenue',
|
|
||||||
width: 96,
|
|
||||||
align: 'right',
|
|
||||||
render: (_, r) =>
|
|
||||||
r.ad_revenue_yuan > 0 ? (
|
|
||||||
<span style={{ color: '#3f8600' }}>¥{r.ad_revenue_yuan.toFixed(4)}</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#ccc' }}>-</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '耗时', dataIndex: 'total_ms', width: 64, render: fmtMs },
|
{ title: '耗时', dataIndex: 'total_ms', width: 64, render: fmtMs },
|
||||||
{ title: '步', dataIndex: 'step_count', width: 48, render: (v) => v ?? '-' },
|
{ title: '步', dataIndex: 'step_count', width: 48, render: (v) => v ?? '-' },
|
||||||
{ title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' },
|
{ title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' },
|
||||||
@@ -309,7 +297,7 @@ export default function ComparisonRecordsPage() {
|
|||||||
showTotal: (t) => `共 ${t} 条`,
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
onChange,
|
onChange,
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1920 }}
|
scroll={{ x: 1820 }}
|
||||||
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
|
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ import {
|
|||||||
Statistic,
|
Statistic,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { DownOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
import { formatUtcTime } from '@/lib/format';
|
import { formatUtcTime } from '@/lib/format';
|
||||||
@@ -36,13 +34,6 @@ interface CouponDataSummary {
|
|||||||
p50_ms: number | null;
|
p50_ms: number | null;
|
||||||
p95_ms: number | null;
|
p95_ms: number | null;
|
||||||
p99_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 {
|
interface CouponDataDaily {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -73,7 +64,6 @@ interface CouponDataRow {
|
|||||||
started_at: string;
|
started_at: string;
|
||||||
claimed_count: number | null;
|
claimed_count: number | null;
|
||||||
trace_url: string | null;
|
trace_url: string | null;
|
||||||
ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元)
|
|
||||||
}
|
}
|
||||||
interface CouponDataReport {
|
interface CouponDataReport {
|
||||||
date_from: string;
|
date_from: string;
|
||||||
@@ -84,20 +74,6 @@ interface CouponDataReport {
|
|||||||
total: number;
|
total: number;
|
||||||
items: CouponDataRow[];
|
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 包名→对应平台。
|
// 发起来源 App 包名 → 中文(「发起平台」列):空=傻瓜比价首页发起,外卖 App 包名→对应平台。
|
||||||
function originLabel(pkg: string | null): string {
|
function originLabel(pkg: string | null): string {
|
||||||
@@ -120,45 +96,6 @@ const STATUS_TAG: Record<string, { color: string; label: string }> = {
|
|||||||
const fmtSec = (ms: number | null | undefined): string =>
|
const fmtSec = (ms: number | null | undefined): string =>
|
||||||
ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`;
|
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)}`;
|
|
||||||
|
|
||||||
// 一组按券行的 success_rate 算术平均(每张券等权;空值券跳过);无有效券 → null(显示 -)。
|
|
||||||
// 汇总卡「分平台/合计点位成功率」据此由按券明细上卷,口径与下方按券表一致。
|
|
||||||
const slotRateMean = (rows: CouponSlotRow[]): number | null => {
|
|
||||||
const rates = rows
|
|
||||||
.map((r) => r.success_rate)
|
|
||||||
.filter((v): v is number => v != null);
|
|
||||||
return rates.length ? rates.reduce((a, b) => a + b, 0) / rates.length : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 汇总卡成功率口径 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> = [
|
const RECORD_COLUMNS: ColumnsType<CouponDataRow> = [
|
||||||
{ title: '时间', dataIndex: 'started_at', width: 160, render: (v: string) => formatUtcTime(v) },
|
{ title: '时间', dataIndex: 'started_at', width: 160, render: (v: string) => formatUtcTime(v) },
|
||||||
@@ -349,8 +286,6 @@ export default function CouponDataPage() {
|
|||||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
|
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
|
||||||
const [user, setUser] = useState<string>('');
|
const [user, setUser] = useState<string>('');
|
||||||
const [appEnv, setAppEnv] = useState<string>('prod');
|
const [appEnv, setAppEnv] = useState<string>('prod');
|
||||||
// 领券状态多选(方案 A:整视图联动),默认全选四态(= 现状,不改口径)。
|
|
||||||
const [statuses, setStatuses] = useState<string[]>(['started', 'completed', 'failed', 'abandoned']);
|
|
||||||
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
||||||
const [sortBy, setSortBy] = useState<'time' | 'elapsed'>('time');
|
const [sortBy, setSortBy] = useState<'time' | 'elapsed'>('time');
|
||||||
const [limit, setLimit] = useState<number>(100);
|
const [limit, setLimit] = useState<number>(100);
|
||||||
@@ -359,9 +294,6 @@ export default function CouponDataPage() {
|
|||||||
const [queriedMultiDay, setQueriedMultiDay] = useState(false);
|
const [queriedMultiDay, setQueriedMultiDay] = useState(false);
|
||||||
const [queriedLimit, setQueriedLimit] = useState<number>(100);
|
const [queriedLimit, setQueriedLimit] = useState<number>(100);
|
||||||
const [data, setData] = useState<CouponDataReport | null>(null);
|
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 [loading, setLoading] = useState(false);
|
||||||
// 点手机号:抽屉看该用户全部领券(总次数 + 记录)
|
// 点手机号:抽屉看该用户全部领券(总次数 + 记录)
|
||||||
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
|
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
|
||||||
@@ -386,23 +318,13 @@ export default function CouponDataPage() {
|
|||||||
date_to: to,
|
date_to: to,
|
||||||
user: user.trim() || undefined,
|
user: user.trim() || undefined,
|
||||||
app_env: appEnv,
|
app_env: appEnv,
|
||||||
status: statuses,
|
|
||||||
granularity: gran,
|
granularity: gran,
|
||||||
limit: targetLimit,
|
limit: targetLimit,
|
||||||
offset: (targetPage - 1) * targetLimit,
|
offset: (targetPage - 1) * targetLimit,
|
||||||
sort: targetSort,
|
sort: targetSort,
|
||||||
},
|
},
|
||||||
// status 是数组:repeat 无方括号序列化(status=a&status=b),对齐 FastAPI list[str] Query
|
|
||||||
paramsSerializer: { indexes: null },
|
|
||||||
});
|
});
|
||||||
setData(res.data);
|
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);
|
setPage(targetPage);
|
||||||
setQueriedLimit(targetLimit);
|
setQueriedLimit(targetLimit);
|
||||||
setQueriedGranularity(gran);
|
setQueriedGranularity(gran);
|
||||||
@@ -413,7 +335,7 @@ export default function CouponDataPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[range, user, appEnv, statuses, granularity, limit, sortBy, message],
|
[range, user, appEnv, granularity, limit, sortBy, message],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -471,12 +393,6 @@ export default function CouponDataPage() {
|
|||||||
width: 110,
|
width: 110,
|
||||||
render: (pkg: string | null) => originLabel(pkg),
|
render: (pkg: string | null) => originLabel(pkg),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '时间',
|
|
||||||
dataIndex: 'started_at',
|
|
||||||
width: 165,
|
|
||||||
render: (v: string) => formatUtcTime(v),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '耗时',
|
title: '耗时',
|
||||||
dataIndex: 'elapsed_ms',
|
dataIndex: 'elapsed_ms',
|
||||||
@@ -484,13 +400,6 @@ export default function CouponDataPage() {
|
|||||||
align: 'right',
|
align: 'right',
|
||||||
render: (v: number | null) => fmtSec(v),
|
render: (v: number | null) => fmtSec(v),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '广告收益',
|
|
||||||
dataIndex: 'ad_revenue_yuan',
|
|
||||||
width: 100,
|
|
||||||
align: 'right',
|
|
||||||
render: (v: number) => fmtYuan(v),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '美团耗时',
|
title: '美团耗时',
|
||||||
key: 'mt_elapsed',
|
key: 'mt_elapsed',
|
||||||
@@ -521,6 +430,12 @@ export default function CouponDataPage() {
|
|||||||
return parts.length ? parts.join(' / ') : <Typography.Text type="secondary">-</Typography.Text>;
|
return parts.length ? parts.join(' / ') : <Typography.Text type="secondary">-</Typography.Text>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'started_at',
|
||||||
|
width: 165,
|
||||||
|
render: (v: string) => formatUtcTime(v),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '领券 trace',
|
title: '领券 trace',
|
||||||
dataIndex: 'trace_id',
|
dataIndex: 'trace_id',
|
||||||
@@ -539,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 summary = data?.summary;
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
@@ -615,18 +505,6 @@ export default function CouponDataPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</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}>
|
<Space size={6}>
|
||||||
<Typography.Text type="secondary">粒度</Typography.Text>
|
<Typography.Text type="secondary">粒度</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
@@ -702,114 +580,6 @@ export default function CouponDataPage() {
|
|||||||
<Statistic title="耗时 99 分位" value={fmtSec(summary.p99_ms)} />
|
<Statistic title="耗时 99 分位" value={fmtSec(summary.p99_ms)} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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(slotRateMean(couponSlots))}
|
|
||||||
/>
|
|
||||||
</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;
|
|
||||||
// 点位成功率 = 该平台各券成功率的算术平均(源自按券明细 couponSlots,与下方展开的按券表一致)
|
|
||||||
const rate = slotRateMean(couponSlots.filter((r) => r.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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -886,7 +656,7 @@ export default function CouponDataPage() {
|
|||||||
onChange: (p) => load(p, queriedLimit),
|
onChange: (p) => load(p, queriedLimit),
|
||||||
}}
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
scroll={{ x: 1560 }}
|
scroll={{ x: 1450 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UserRecordsDrawer<CouponDataRow>
|
<UserRecordsDrawer<CouponDataRow>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
FlagOutlined,
|
FlagOutlined,
|
||||||
GiftOutlined,
|
GiftOutlined,
|
||||||
HeartOutlined,
|
HeartOutlined,
|
||||||
LineChartOutlined,
|
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
MoneyCollectOutlined,
|
MoneyCollectOutlined,
|
||||||
@@ -58,7 +57,6 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
||||||
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' },
|
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' },
|
||||||
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
|
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
|
||||||
{ key: '/analytics-health', icon: <LineChartOutlined />, label: '埋点成功率' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -359,7 +359,6 @@ export interface ComparisonRecordListItem {
|
|||||||
rom_name: string | null;
|
rom_name: string | null;
|
||||||
android_version: string | null;
|
android_version: string | null;
|
||||||
app_version: string | null;
|
app_version: string | null;
|
||||||
ad_revenue_yuan: number; // 本次比价看的信息流广告预估收益(元)
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,27 +727,3 @@ export interface CpsStats {
|
|||||||
total_est_commission_cents: number;
|
total_est_commission_cents: number;
|
||||||
total_settled_commission_cents: number;
|
total_settled_commission_cents: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 埋点 / 上报成功率大盘(analytics_selfstat 快照差分聚合)=====
|
|
||||||
// 后端 feat/analytics-success-rate 分支的三个只读端点(/admin/api/analytics-health/*)。
|
|
||||||
// rate 分母为 0 时后端给 null(前端显示 "-",不是 0%)。
|
|
||||||
|
|
||||||
/** 一组区间聚合指标:两大率 + 四原子量。 */
|
|
||||||
export interface HealthMetrics {
|
|
||||||
attempted: number; // track 被调用次数(埋点段分母)
|
|
||||||
drop_capture: number; // 采集时异常丢弃(队列/序列化失败)
|
|
||||||
delivered: number; // 成功送达后端
|
|
||||||
drop_undelivered: number; // 已落盘但久未送达被队列淘汰
|
|
||||||
track_success_rate: number | null; // 埋点成功率 ∈ [0,1]:(attempted − drop_capture) / attempted
|
|
||||||
report_success_rate: number | null; // 上报成功率 ∈ [0,1]:delivered / (delivered + drop_undelivered)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 趋势点:某北京天的指标(trend 端点,按北京天升序)。 */
|
|
||||||
export interface HealthTrendPoint extends HealthMetrics {
|
|
||||||
day: string; // 北京日期 "YYYY-MM-DD"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下钻行:某维度值的指标(breakdown 端点,已按上报率升序,最差在前)。 */
|
|
||||||
export interface HealthBreakdownRow extends HealthMetrics {
|
|
||||||
key: string; // 维度值(event 名 / app_ver / oem);缺失为 "(unknown)"
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user