From d3efb3c487034abd37d3ce025bf09afbf780a1db Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Fri, 26 Jun 2026 23:33:35 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(analytics):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E3=80=8C=E5=9F=8B=E7=82=B9=E6=97=A5=E5=BF=97=E3=80=8D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/app/(main)/event-logs/page.tsx:埋点日志列表,五维列 + 展开行看 props 扩展字段, 按 事件/设备/用户/会话/接收时间 筛选,自动刷新(5s),接收时间列置首 - layout 左侧菜单加「埋点日志」入口 - 对接 admin 后端 GET /admin/api/event-logs(见 app-server 同批 PR) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/21 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- src/app/(main)/event-logs/page.tsx | 245 +++++++++++++++++++++++++++++ src/app/(main)/layout.tsx | 2 + 2 files changed, 247 insertions(+) create mode 100644 src/app/(main)/event-logs/page.tsx diff --git a/src/app/(main)/event-logs/page.tsx b/src/app/(main)/event-logs/page.tsx new file mode 100644 index 0000000..c8e1f2b --- /dev/null +++ b/src/app/(main)/event-logs/page.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import type { ColumnsType } from 'antd/es/table'; +import type { SorterResult } from 'antd/es/table/interface'; +import { Button, DatePicker, Input, Space, Switch, Table, Tag, Typography } from 'antd'; +import type { Dayjs } from 'dayjs'; +import dayjs from 'dayjs'; +import { formatUtcTime } from '@/lib/format'; +import { usePagedList } from '@/lib/usePagedList'; + +const { Text } = Typography; +const { RangePicker } = DatePicker; + +/** 一条埋点日志(对应后端 analytics_event,五维)。 */ +type EventLog = { + id: number; + event: string; + device_id: string; + user_id: number | null; + session_id: string | null; + client_ts: number; + sent_at: number | null; + created_at: string; + page: string | null; + client_ip: string | null; + oem: string | null; + os: string | null; + model: string | null; + app_ver: string | null; + network: string | null; + channel: string | null; + props: Record | null; +}; + +type SortField = 'id' | 'created_at'; + +// 端时间是 epoch ms;格式化成「月-日 时:分:秒.毫秒」便于核对节流/顺序 +const fmtMs = (ms: number | null) => (ms ? dayjs(ms).format('MM-DD HH:mm:ss.SSS') : '-'); + +export default function EventLogsPage() { + // 筛选草稿:点「查询」才应用 + const [event, setEvent] = useState(''); + const [deviceId, setDeviceId] = useState(''); + const [userId, setUserId] = useState(''); + const [sessionId, setSessionId] = useState(''); + const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null); + const [applied, setApplied] = useState>({}); + // 排序(服务端) + const [sortBy, setSortBy] = useState('id'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + // 近实时:自动刷新开关 + const [autoRefresh, setAutoRefresh] = useState(false); + + const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; + const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = + usePagedList('/admin/api/event-logs', filters); + + // 开自动刷新后每 5s 拉当前页;reload 闭包随渲染更新,用 ref 取最新,避免 interval captured 过期闭包。 + const reloadRef = useRef(reload); + reloadRef.current = reload; + useEffect(() => { + if (!autoRefresh) return; + const t = setInterval(() => reloadRef.current(), 5000); + return () => clearInterval(t); + }, [autoRefresh]); + + const search = () => + setApplied({ + event: event.trim() || undefined, + device_id: deviceId.trim() || undefined, + user_id: userId.trim() ? Number(userId.trim()) : undefined, + session_id: sessionId.trim() || undefined, + created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined, + created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined, + }); + + const resetFilters = () => { + setEvent(''); + setDeviceId(''); + setUserId(''); + setSessionId(''); + setCreatedRange(null); + setSortBy('id'); + setSortOrder('desc'); + setApplied({}); + }; + + const onTableChange = ( + _p: unknown, + _f: unknown, + sorter: SorterResult | SorterResult[], + ) => { + const s = Array.isArray(sorter) ? sorter[0] : sorter; + if (s && s.order && s.field) { + setSortBy(s.field as SortField); + setSortOrder(s.order === 'ascend' ? 'asc' : 'desc'); + } else { + setSortBy('id'); + setSortOrder('desc'); + } + }; + + const sortOrderOf = (field: SortField) => + sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; + + const columns: ColumnsType = [ + { + title: '接收时间', + dataIndex: 'created_at', + width: 170, + sorter: true, + sortOrder: sortOrderOf('created_at'), + render: (v: string) => formatUtcTime(v), + }, + { title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') }, + { + title: '事件', + dataIndex: 'event', + width: 170, + render: (v: string) => {v}, + }, + { + title: '页面', + dataIndex: 'page', + width: 100, + render: (v: string | null) => v || -, + }, + { + title: '用户', + dataIndex: 'user_id', + width: 80, + render: (v: number | null) => v ?? 未登录, + }, + { + title: '设备', + dataIndex: 'device_id', + width: 150, + ellipsis: true, + render: (v: string) => ( + + {v} + + ), + }, + { title: '网络', dataIndex: 'network', width: 70, render: (v: string | null) => v || '-' }, + { + title: '机型', + key: 'oem', + width: 130, + render: (_: unknown, r: EventLog) => ( + {[r.oem, r.os].filter(Boolean).join(' / ') || '-'} + ), + }, + { title: '版本', dataIndex: 'app_ver', width: 100, render: (v: string | null) => v || '-' }, + ]; + + return ( +
+

埋点日志

+ + setEvent(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 180 }} + /> + setDeviceId(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 160 }} + /> + setUserId(e.target.value.replace(/\D/g, ''))} + onPressEnter={search} + allowClear + style={{ width: 100 }} + /> + setSessionId(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 160 }} + /> + setCreatedRange(v as [Dayjs, Dayjs] | null)} + allowClear + /> + + + + + + 自动刷新(5s) + + + + ( + + + 会话: {r.session_id || '-'} | 设备型号: {r.model || '-'} | 渠道: {r.channel || '-'} | IP:{' '} + {r.client_ip || '-'} + + + 端事件时间: {fmtMs(r.client_ts)} | 端上报时间: {fmtMs(r.sent_at)} + + + 属性: {r.props && Object.keys(r.props).length ? JSON.stringify(r.props) : '无'} + + + ), + }} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条`, + onChange: onPageChange, + }} + onChange={onTableChange} + /> + + ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 5ed4edd..6aac954 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { BarChartOutlined, DashboardOutlined, + DatabaseOutlined, FileSearchOutlined, FlagOutlined, HeartOutlined, @@ -39,6 +40,7 @@ const MENU = [ { key: '/cps', icon: , label: 'CPS 分发' }, { key: '/config', icon: , label: '系统配置' }, { key: '/admins', icon: , label: '管理员', superOnly: true }, + { key: '/event-logs', icon: , label: '埋点日志' }, { key: '/audit-logs', icon: , label: '审计日志' }, ]; From 6ece274af64dbb1330e776c87e21d66bf31144ca Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Fri, 26 Jun 2026 23:56:25 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(analytics):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E3=80=8C=E5=9F=8B=E7=82=B9=E6=97=A5=E5=BF=97=E3=80=8D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/22 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- src/app/(main)/ad-revenue-report/page.tsx | 75 ++++++++++++++++++++++- src/app/(main)/ad-revenue/page.tsx | 12 ++-- src/lib/types.ts | 1 + 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/app/(main)/ad-revenue-report/page.tsx b/src/app/(main)/ad-revenue-report/page.tsx index 553dc8d..0a67d60 100644 --- a/src/app/(main)/ad-revenue-report/page.tsx +++ b/src/app/(main)/ad-revenue-report/page.tsx @@ -47,6 +47,13 @@ const TYPE_TAG: Record = { withdrawal_video: { color: 'gold', label: '提现激励视频' }, }; +// Draw 信息流投放场景标签(后端 AdRevenueRow.feed_scene:comparison/coupon/welfare) +const SCENE_TAG: Record = { + comparison: { text: '比价', color: 'blue' }, + coupon: { text: '领券', color: 'gold' }, + welfare: { text: '福利', color: 'green' }, +}; + // 我们的应用环境标签 const APP_TAG: Record = { prod: { color: 'green', label: '傻瓜比价(正式)' }, @@ -256,6 +263,8 @@ export default function AdRevenueReportPage() { const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]); const [userId, setUserId] = useState(null); const [adType, setAdType] = useState(); + // 「场景」为纯前端过滤(后端 query 暂不支持 feed_scene),只作用于下方明细表,不重新请求、不影响趋势/日汇总。 + const [scene, setScene] = useState(); const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); const [limit, setLimit] = useState(500); const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列 @@ -332,6 +341,16 @@ export default function AdRevenueReportPage() { return {t.label}; }, }, + { + title: '场景', + dataIndex: 'feed_scene', + width: 80, + render: (v: string | null | undefined) => { + if (!v) return -; + const t = SCENE_TAG[v]; + return t ? {t.text} : {v}; + }, + }, { title: '来源应用', dataIndex: 'app_env', @@ -428,6 +447,16 @@ export default function AdRevenueReportPage() { } : null; + // 「场景」纯前端过滤:只对已加载的明细 items 生效(后端 query 暂不支持 feed_scene);趋势图/日汇总仍用全量 daily/total_*。 + const allItems = data?.items ?? []; + const filteredItems = scene ? allItems.filter((r) => r.feed_scene === scene) : allItems; + + // 选中比价/领券且类型=draw 时,给出该场景在已加载明细内的实发金币小计(仅明细口径,可能受 limit 截断)。 + const sceneCoinSubtotal = + adType === 'draw' && (scene === 'comparison' || scene === 'coupon') + ? filteredItems.reduce((s, r) => s + (r.has_reward ? r.actual_coin : 0), 0) + : null; + return (
@@ -497,6 +526,22 @@ export default function AdRevenueReportPage() { ]} /> + + 场景 + )} + {sceneCoinSubtotal != null && ( + + 当前已加载明细中, + + {SCENE_TAG[scene!].text} + + Draw 信息流 实发金币小计 {sceneCoinSubtotal}(共 {filteredItems.length} 条; + 仅明细口径,若已截断可能偏少;上方汇总/趋势仍为全量)。 + + } + /> + )} )} @@ -640,7 +701,7 @@ export default function AdRevenueReportPage() { } > - {/* 按小时图由 items 聚合,会被 limit 截断;按天图用 daily(全量)不受影响,故只在按小时时警告 */} + {/* 趋势图与日汇总恒为全量:按天图用 daily(全量),按小时图用全量 items;「场景」前端过滤只作用于下方明细表,不影响此图。 */} {!queriedMultiDay && data.truncated && ( )} + {scene && ( + + )} {queriedMultiDay ? ( ) : ( @@ -660,11 +729,11 @@ export default function AdRevenueReportPage() {
(r.has_reward && !r.matched ? 'row-mismatch' : '')} expandable={{ // 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。 diff --git a/src/app/(main)/ad-revenue/page.tsx b/src/app/(main)/ad-revenue/page.tsx index 1bc8e27..8e9e609 100644 --- a/src/app/(main)/ad-revenue/page.tsx +++ b/src/app/(main)/ad-revenue/page.tsx @@ -10,8 +10,8 @@ import { api, errMsg } from '@/lib/api'; interface AdCfg { app_id: string; reward_code_id: string; - compare_feed_code_id: string; - coupon_feed_code_id: string; + compare_draw_code_id: string; + coupon_draw_code_id: string; reward_mkey: string; reward_enabled: boolean; compare_ad_enabled: boolean; @@ -74,11 +74,11 @@ export default function AdConfigPage() { > - - + + - - + + diff --git a/src/lib/types.ts b/src/lib/types.ts index 1ac18af..127a7de 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -381,6 +381,7 @@ export interface AdRevenueRow { user_id: number; user_phone: string | null; // 用户手机号(admin 展示;查不到为空) ad_type: string; // reward_video / feed / draw + feed_scene?: string | null; // Draw 信息流投放场景 comparison(比价) / coupon(领券) / welfare(福利);非信息流或旧数据为 null app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空 our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空 hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null) From 03ef222b71def5bb89bc3886c6b9e6fc3c1a3425 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Sat, 27 Jun 2026 22:42:28 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(ad-revenue):=20=E6=94=B6=E7=9B=8A?= =?UTF-8?q?=E6=8A=A5=E8=A1=A8=E5=88=86=E9=A1=B5=E3=80=81eCPM=20=E5=85=83?= =?UTF-8?q?=E5=88=97=E3=80=81=E5=9C=BA=E6=99=AF=E7=AD=9B=E9=80=89=E4=B8=8E?= =?UTF-8?q?=E9=80=90=E8=A1=8C=E5=B1=95=E5=BC=80+=20=E5=A4=A7=E7=9B=98?= =?UTF-8?q?=E6=94=B9=E7=89=88=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 列:eCPM 新增「元」列;广告位ID 移到「一致」列后。 - 分页:「每页条数」接后端 offset 真分页,可翻页看当前筛选下全部数据(原仅展示截断的前 N 条)。 - 场景:「场景」下推后端作为全局筛选(随查询生效,同时影响明细/合计/趋势),移除前端仅过滤当前数据的旧逻辑与过时提示。 - 趋势:按小时趋势改用后端全量 hourly,不受分页影响。 - 展开:明细表每行左侧都有展开号——发奖行看金币复算因子,纯展示行看展示明细(eCPM/收益/ADN/底层rit/代码位/来源)。 - 文案:修正金币规则弹窗「信息流每满10秒折1条」→「一条广告=1条」。 - types 新增 AdRevenueHourly + hourly 字段。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/23 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- src/app/(main)/ad-revenue-report/page.tsx | 333 +++++++++++++--------- src/lib/types.ts | 24 +- 2 files changed, 217 insertions(+), 140 deletions(-) diff --git a/src/app/(main)/ad-revenue-report/page.tsx b/src/app/(main)/ad-revenue-report/page.tsx index 0a67d60..e43afaf 100644 --- a/src/app/(main)/ad-revenue-report/page.tsx +++ b/src/app/(main)/ad-revenue-report/page.tsx @@ -9,6 +9,7 @@ import { Card, Col, DatePicker, + Descriptions, Divider, InputNumber, Modal, @@ -19,6 +20,7 @@ import { Statistic, Table, Tag, + Tooltip, Typography, } from 'antd'; import { @@ -32,9 +34,11 @@ import { api, errMsg } from '@/lib/api'; import { formatUtcTime } from '@/lib/format'; import type { AdRevenueDaily, + AdRevenueHourly, AdRevenueRecord, AdRevenueReport, AdRevenueRow, + AdRevenueTypeStat, } from '@/lib/types'; const { RangePicker } = DatePicker; @@ -146,8 +150,8 @@ interface TrendPoint { revenue: number; } -// 按小时聚合(0–23),数据源是 items(被 limit 截断时会偏少,调用处给警告) -function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] { +// 按小时聚合(0–23),数据源是 hourly(后端全量聚合,不受分页影响) +function aggregateHourly(rows: AdRevenueHourly[]): TrendPoint[] { const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 })); for (const r of rows) { if (r.hour == null || r.hour < 0 || r.hour > 23) continue; @@ -263,10 +267,12 @@ export default function AdRevenueReportPage() { const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]); const [userId, setUserId] = useState(null); const [adType, setAdType] = useState(); - // 「场景」为纯前端过滤(后端 query 暂不支持 feed_scene),只作用于下方明细表,不重新请求、不影响趋势/日汇总。 + // 「场景」作为后端全局筛选(feed_scene):同时影响明细 / 合计 / 趋势,与「用户 / 类型」一致,点「查询」生效。 const [scene, setScene] = useState(); const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); - const [limit, setLimit] = useState(500); + const [sortBy, setSortBy] = useState<'time' | 'ecpm'>('time'); // 明细排序:time=时间倒序 / ecpm=eCPM 倒序 + const [limit, setLimit] = useState(500); // 每页条数(分页大小) + const [page, setPage] = useState(1); // 当前页码(后端分页;1 起) const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列 const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图 const [data, setData] = useState(null); @@ -277,33 +283,41 @@ export default function AdRevenueReportPage() { // 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天 const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD'); - const load = useCallback(async () => { - const from = range[0].format('YYYY-MM-DD'); - const to = range[1].format('YYYY-MM-DD'); - const multiDay = from !== to; - const gran = multiDay ? 'day' : granularity; // 跨多天强制按天 - setLoading(true); - try { - const res = await api.get('/admin/api/ad-revenue-report', { - params: { - date_from: from, - date_to: to, - user_id: userId ?? undefined, - ad_type: adType ?? undefined, - granularity: gran, - limit, - }, - }); - setData(res.data); - setQueriedLimit(limit); - setQueriedGranularity(gran); - setQueriedMultiDay(multiDay); - } catch (e) { - message.error(errMsg(e)); - } finally { - setLoading(false); - } - }, [range, userId, adType, granularity, limit]); + // load(目标页, 每页条数):分页与筛选都经它。offset 由 页码×每页 算;场景(feed_scene)随筛选下推后端。 + const load = useCallback( + async (targetPage = 1, targetLimit = limit, targetSort = sortBy) => { + const from = range[0].format('YYYY-MM-DD'); + const to = range[1].format('YYYY-MM-DD'); + const multiDay = from !== to; + const gran = multiDay ? 'day' : granularity; // 跨多天强制按天 + setLoading(true); + try { + const res = await api.get('/admin/api/ad-revenue-report', { + params: { + date_from: from, + date_to: to, + user_id: userId ?? undefined, + ad_type: adType ?? undefined, + feed_scene: scene ?? undefined, + granularity: gran, + limit: targetLimit, + offset: (targetPage - 1) * targetLimit, + sort: targetSort, + }, + }); + setData(res.data); + setPage(targetPage); + setQueriedLimit(targetLimit); + setQueriedGranularity(gran); + setQueriedMultiDay(multiDay); + } catch (e) { + message.error(errMsg(e)); + } finally { + setLoading(false); + } + }, + [range, userId, adType, scene, granularity, limit, sortBy], + ); useEffect(() => { load(); @@ -361,13 +375,6 @@ export default function AdRevenueReportPage() { return {t.label}; }, }, - { - title: '广告位ID', - dataIndex: 'our_code_id', - width: 110, - render: (v: string | null) => - v ? {v} : -, - }, { title: 'eCPM(分)', dataIndex: 'ecpm', @@ -375,6 +382,17 @@ export default function AdRevenueReportPage() { align: 'right', render: (v: string | null) => v ?? '-', }, + { + title: 'eCPM(元)', + key: 'ecpm_yuan', + width: 100, + align: 'right', + render: (_: unknown, r: AdRevenueRow) => { + // eCPM 原始值单位「分/千次」,÷100 转「元/千次」(块);非数字 / 空显示 -。 + const cents = r.ecpm == null ? null : Number(r.ecpm); + return cents == null || Number.isNaN(cents) ? '-' : (cents / 100).toFixed(2); + }, + }, { title: '预估收益(元)', dataIndex: 'revenue_yuan', @@ -420,6 +438,13 @@ export default function AdRevenueReportPage() { - ), }, + { + title: '广告位ID', + dataIndex: 'our_code_id', + width: 110, + render: (v: string | null) => + v ? {v} : -, + }, { title: '底层 ADN', dataIndex: 'adn', @@ -429,14 +454,11 @@ export default function AdRevenueReportPage() { }, ]; - // 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准): - // 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本; - // 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。 + // 派生指标(全部基于全量 total_* 字段,不受分页影响,准): + // 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益; + // 应发实发差额(金币)= 应发−实发(正=少发/负=多发);ARPU = 预估收益÷今日DAU(仅今日)。 const derived = data ? { - avgEcpm: data.total_impressions - ? (data.total_revenue_yuan / data.total_impressions) * 1000 - : 0, payoutYuan: data.total_actual_coin / COIN_PER_YUAN, grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN, payoutRatioPct: @@ -444,18 +466,19 @@ export default function AdRevenueReportPage() { ? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100 : null, coinGap: data.total_expected_coin - data.total_actual_coin, + // ARPU(今日)= 预估广告收益 ÷ 今日 DAU;dau 为 null(历史/多天)或 0 时不可算 → null,前端显示「-」。 + arpu: data.dau && data.dau > 0 ? data.total_revenue_yuan / data.dau : null, } : null; - // 「场景」纯前端过滤:只对已加载的明细 items 生效(后端 query 暂不支持 feed_scene);趋势图/日汇总仍用全量 daily/total_*。 - const allItems = data?.items ?? []; - const filteredItems = scene ? allItems.filter((r) => r.feed_scene === scene) : allItems; + // 第二行大盘「分广告类型」:draw / 激励视频 各自 收益 / eCPM / 展示条数;eCPM = 收益÷展示×1000。 + const drawStat = data?.type_stats?.draw; + const rvStat = data?.type_stats?.reward_video; + const ecpmOf = (s?: AdRevenueTypeStat) => + s && s.impressions > 0 ? (s.revenue_yuan / s.impressions) * 1000 : 0; - // 选中比价/领券且类型=draw 时,给出该场景在已加载明细内的实发金币小计(仅明细口径,可能受 limit 截断)。 - const sceneCoinSubtotal = - adType === 'draw' && (scene === 'comparison' || scene === 'coupon') - ? filteredItems.reduce((s, r) => s + (r.has_reward ? r.actual_coin : 0), 0) - : null; + // 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。 + const items = data?.items ?? []; return (
@@ -534,7 +557,6 @@ export default function AdRevenueReportPage() { onChange={setScene} allowClear style={{ width: 130 }} - title="仅前端过滤下方明细,不影响趋势图/汇总" options={[ { value: 'comparison', label: '比价' }, { value: 'coupon', label: '领券' }, @@ -557,15 +579,33 @@ export default function AdRevenueReportPage() { /> - 条数 + 排序 { + setLimit(v); + load(1, v); // 改每页大小:回到第 1 页并重查 + }} + style={{ width: 120 }} + options={[20, 50, 100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} 条/页` }))} + /> + + @@ -575,41 +615,46 @@ export default function AdRevenueReportPage() { - 广告展示与收益 + 核心指标 -
- - - - - - + - - - - - - - 发奖与对账 - - - - - - - - - - - - - + + + ARPU(今日) + + + + } + value={derived.arpu ?? '-'} + precision={4} + /> + + + + + 今日活跃 DAU + + + + } + value={data.dau ?? '-'} + /> + + + + + + = 0 ? : } @@ -621,6 +666,31 @@ export default function AdRevenueReportPage() { + + + 分广告类型 + + + + + + + + + + + + + + + + + + + + + + )} @@ -637,36 +707,13 @@ export default function AdRevenueReportPage() { ) : ( )} - {data.truncated && ( - - )} - {sceneCoinSubtotal != null && ( - - 当前已加载明细中, - - {SCENE_TAG[scene!].text} - - Draw 信息流 实发金币小计 {sceneCoinSubtotal}(共 {filteredItems.length} 条; - 仅明细口径,若已截断可能偏少;上方汇总/趋势仍为全量)。 - - } - /> - )} )} {data && (queriedMultiDay ? (data.daily?.length ?? 0) > 0 - : queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && ( + : queriedGranularity === 'hour' && (data.hourly?.length ?? 0) > 0) && ( } > - {/* 趋势图与日汇总恒为全量:按天图用 daily(全量),按小时图用全量 items;「场景」前端过滤只作用于下方明细表,不影响此图。 */} - {!queriedMultiDay && data.truncated && ( - - )} - {scene && ( - - )} + {/* 趋势图恒为全量(不受分页影响):按天图用 daily,按小时图用 hourly;「场景」已下推后端,趋势随场景筛选一并变化。 */} {queriedMultiDay ? ( ) : ( - + )} )} @@ -729,15 +760,22 @@ export default function AdRevenueReportPage() {
`共 ${t} 条`, + onChange: (p) => load(p, queriedLimit), + }} size="small" - scroll={{ x: 1280 }} + scroll={{ x: 1380 }} rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')} expandable={{ - // 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。 - rowExpandable: (r) => r.reward_detail != null, + // 每行都可展开(左侧恒有 + 号):有发奖看「金币复算因子」;纯展示看「展示明细」。 + rowExpandable: () => true, expandedRowRender: (r) => r.reward_detail ? (
@@ -756,7 +794,28 @@ export default function AdRevenueReportPage() { rowClassName={(d) => (d.matched ? '' : 'row-mismatch')} />
- ) : null, + ) : ( +
+ 展示明细{' '} + (纯展示事件,未单独发奖,无金币复算) + + {formatUtcTime(r.created_at)} + {r.ecpm ?? '-'} + + {r.ecpm != null && !Number.isNaN(Number(r.ecpm)) + ? (Number(r.ecpm) / 100).toFixed(2) + : '-'} + + + {r.has_impression ? r.revenue_yuan.toFixed(4) : '-'} + + {r.adn ?? '-'} + {r.slot_id ?? '-'} + {r.our_code_id ?? '-'} + {r.app_env ?? '-'} + +
+ ), }} /> · eCPM 取自穿山甲 SDK getEcpm() 原值,单位「分/千次展示」,计算时 ÷100 转元;单次收益按「每千次 ÷1000」折到每次。
· 金币汇率:1 元 = {COIN_PER_YUAN.toLocaleString()} 金币,最终四舍五入取整。 -
· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);激励视频每次 1 条,信息流每满 10 秒折 1 条。 +
· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);一条广告 = 1 条(激励视频每次 1 条;信息流看满 10 秒即发该条满额,不按时长折多份)。
· 本表与发奖后端同源,本页「应发金币」即按此复算并与「实发金币」对账。 diff --git a/src/lib/types.ts b/src/lib/types.ts index 127a7de..5cd9ad7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -365,7 +365,7 @@ export interface AdRevenueRecord { matched: boolean; } -// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响) +// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受分页影响) export interface AdRevenueDaily { date: string; // 北京时间 YYYY-MM-DD impressions: number; @@ -374,6 +374,21 @@ export interface AdRevenueDaily { actual_coin: number; } +// 广告收益报表:按北京小时(0–23)汇总的一小时(按小时趋势图用;全量,不受分页影响;按天查询时为空) +export interface AdRevenueHourly { + hour: number; // 北京时间小时 0–23 + impressions: number; + revenue_yuan: number; + expected_coin: number; + actual_coin: number; +} + +// 广告收益报表:按广告类型(ad_type)的小计(展示条数 + 预估收益;eCPM 前端用 收益÷展示×1000 算) +export interface AdRevenueTypeStat { + impressions: number; // 该类型展示条数合计 + revenue_yuan: number; // 该类型预估收益合计(元) +} + // 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。 export interface AdRevenueRow { event_key: string; // 事件稳定唯一键(前端 rowKey) @@ -406,8 +421,11 @@ export interface AdRevenueReport { date_from: string; // 起始日 北京时间 YYYY-MM-DD date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同) daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图) - total: number; // 广告事件总数(全量,不受 limit 影响) - truncated: boolean; + hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空) + type_stats: Record; // 按广告类型小计;前端取 draw / reward_video + dau: number | null; // 今日活跃(复用大盘 last_login_at);仅查询=今日时有值,否则 null + total: number; // 当前筛选下的分页总条数(全量,不受分页影响) + truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警) total_impressions: number; total_revenue_yuan: number; total_expected_coin: number;