大盘领券和比价广告收益卡改读服务端全量场景小计,激励视频卡改读类型小计 (#37)

Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #37
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
This commit was merged in pull request #37.
This commit is contained in:
chenshirui
2026-07-06 00:24:58 +08:00
committed by marco
parent dd84d771f9
commit c905e15ec9
3 changed files with 108 additions and 15 deletions
+73 -6
View File
@@ -118,9 +118,10 @@ function previousPeriodRange(range: ReturnType<typeof periodRange>) {
}
function retentionTitle(period: PeriodKey) {
if (period === '7d') return '近 7 日新增留存';
if (period === '30d') return '近 30 日新增留存';
return '昨日新增留存';
// 次日留存口径(2026-07-05):每天取前一日新增用户,统计其当日活跃比例;单日窗口即「前日新增的昨日留存」。
if (period === '7d') return '近 7 日新增次日留存';
if (period === '30d') return '近 30 日新增次日留存';
return '前日用户新增留存';
}
function avgEcpm(report: AdRevenueReport | null) {
@@ -131,19 +132,23 @@ function avgEcpm(report: AdRevenueReport | null) {
function adTypeSummary(report: AdRevenueReport | null, adType: string) {
if (!report) return null;
if (report.by_ad_type?.[adType]) return report.by_ad_type[adType];
// 优先用后端全量小计 type_stats(items 受 limit 分页截断,>1000 行的区间按 items 现算会漏旧数据)
const stat = report.type_stats?.[adType];
if (stat) return { impressions: stat.impressions, revenue_yuan: stat.revenue_yuan };
const matched = report.items.filter((it) => it.ad_type === adType);
if (matched.length === 0) return null;
return {
ad_type: adType,
impressions: matched.reduce((sum, it) => sum + it.impressions, 0),
revenue_yuan: matched.reduce((sum, it) => sum + it.revenue_yuan, 0),
expected_coin: matched.reduce((sum, it) => sum + it.expected_coin, 0),
actual_coin: matched.reduce((sum, it) => sum + it.actual_coin, 0),
};
}
function adSceneSummary(report: AdRevenueReport | null, feedScene: 'coupon' | 'comparison') {
if (!report) return null;
// 优先用后端全量小计 scene_stats:2026-07-02 起 items 不再含信息流逐条展示行(唯一带收益+场景的行),
// 按 items 现算恒为 0;scene_stats 在全量 events 上聚合且不受分页截断。旧后端无此字段时回退 items 现算。
const stat = report.scene_stats?.[feedScene];
if (stat) return { impressions: stat.impressions, revenue_yuan: stat.revenue_yuan };
const matched = report.items.filter((it) => it.feed_scene === feedScene);
if (matched.length === 0) return null;
return {
@@ -521,6 +526,23 @@ export default function DashboardPage() {
periodData?.comparison.average_duration_ms,
previousPeriodData?.comparison.average_duration_ms,
);
// 领券核心数据(period.coupon;旧后端无此字段时整节显示 '--')
const couponPeriod = periodData?.coupon ?? null;
const previousCouponPeriod = previousPeriodData?.coupon ?? null;
const couponStartedDelta = percentDelta(couponPeriod?.started, previousCouponPeriod?.started);
const couponSuccessRateValue =
couponPeriod?.success_rate == null ? '--' : (couponPeriod.success_rate * 100).toFixed(1);
const couponSuccessRateDelta = pointDelta(couponPeriod?.success_rate, previousCouponPeriod?.success_rate);
const couponPointRateValue =
couponPeriod?.point_success_rate == null ? '--' : (couponPeriod.point_success_rate * 100).toFixed(1);
const couponPointRateDelta = pointDelta(
couponPeriod?.point_success_rate,
previousCouponPeriod?.point_success_rate,
);
const couponMedianDelta = durationDelta(
couponPeriod?.median_elapsed_ms,
previousCouponPeriod?.median_elapsed_ms,
);
const averageSavedDelta = moneyDeltaCents(
periodData?.comparison.average_saved_cents,
previousPeriodData?.comparison.average_saved_cents,
@@ -761,6 +783,51 @@ export default function DashboardPage() {
</div>
</section>
<section className="section">
<div className="section-head">
<span className="bar" />
<h3></h3>
<span></span>
</div>
<div className="grid g4">
<StatCard
title="领券发起数"
value={fmtInt(couponPeriod?.started)}
unit="次"
delta={couponStartedDelta.value}
deltaTone={couponStartedDelta.tone}
/>
<StatCard
title="领券成功率"
value={couponSuccessRateValue}
unit="%"
delta={couponSuccessRateDelta.value}
deltaTone={couponSuccessRateDelta.tone}
hint={`全部点位领成功的次数 / 领券发起数;本期 ${fmtInt(couponPeriod?.all_success)} / ${fmtInt(
couponPeriod?.started,
)} 次。点位成功口径含「今日已领过」。`}
/>
<StatCard
title="点位成功率"
value={couponPointRateValue}
unit="%"
delta={couponPointRateDelta.value}
deltaTone={couponPointRateDelta.tone}
hint={`成功点位数 / (领券发起数 × 每次应领点位数);中途退出未跑到的点位计入分母视为失败。本期成功点位 ${fmtInt(
couponPeriod?.point_success,
)} 个,每次应领 ${fmtInt(couponPeriod?.points_per_session)} 个(按本期完成场自动校准)。`}
/>
<StatCard
title="耗时中位数"
value={fmtMsAsSeconds(couponPeriod?.median_elapsed_ms)}
unit="秒"
delta={couponMedianDelta.value}
deltaTone={couponMedianDelta.tone}
hint="仅统计「完成」的领券(同领券数据页口径),中位数比均值更抗中途暂停等待的长尾。"
/>
</div>
</section>
<section className="section">
<div className="section-head">
<span className="bar" />
+16 -9
View File
@@ -5,7 +5,7 @@ import type { Key } from 'react';
import { useRouter } from 'next/navigation';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import { App, Button, DatePicker, Input, Select, Space, Table, Tag } from 'antd';
import { App, Button, DatePicker, Input, Select, Space, Table, Tag, Tooltip } from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
@@ -18,7 +18,7 @@ const { RangePicker } = DatePicker;
const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' };
type SortField = 'id' | 'created_at' | 'last_login_at';
type SortField = 'id' | 'created_at' | 'last_active_at';
export default function UsersPage() {
const router = useRouter();
@@ -61,8 +61,8 @@ export default function UsersPage() {
register_channel: channel || undefined,
created_from: regRange?.[0] ? regRange[0].startOf('day').toISOString() : undefined,
created_to: regRange?.[1] ? regRange[1].endOf('day').toISOString() : undefined,
last_login_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined,
last_login_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined,
last_active_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined,
last_active_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined,
});
const resetFilters = () => {
@@ -235,12 +235,19 @@ export default function UsersPage() {
render: (v: string) => formatUtcTime(v),
},
{
title: '最近登录',
dataIndex: 'last_login_at',
// 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券),与大盘 DAU/留存活跃口径一致(2026-07-05)
title: (
<Tooltip title="进入 App(登录)/ 发起比价 / 发起领券,取最近一次">
</Tooltip>
),
dataIndex: 'last_active_at',
width: 160,
sorter: true,
sortOrder: sortOrderOf('last_login_at'),
render: (v: string) => formatUtcTime(v),
// 关掉 antd 默认的「点击升序/降序」表头提示,否则与上面自定义口径 Tooltip 同时弹出互相遮挡
showSorterTooltip: false,
sortOrder: sortOrderOf('last_active_at'),
render: (v: string | null, u: UserListItem) => formatUtcTime(v ?? u.last_login_at),
},
{
title: '操作',
@@ -324,7 +331,7 @@ export default function UsersPage() {
allowClear
/>
<RangePicker
placeholder={['最近登录起', '最近登录止']}
placeholder={['最近活跃起', '最近活跃止']}
value={loginRange}
onChange={(v) => setLoginRange(v as [Dayjs, Dayjs] | null)}
allowClear