diff --git a/src/app/(main)/ad-revenue-report/page.tsx b/src/app/(main)/ad-revenue-report/page.tsx index 0a67d60..d76c5cc 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, @@ -32,6 +33,7 @@ import { api, errMsg } from '@/lib/api'; import { formatUtcTime } from '@/lib/format'; import type { AdRevenueDaily, + AdRevenueHourly, AdRevenueRecord, AdRevenueReport, AdRevenueRow, @@ -146,8 +148,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 +265,11 @@ 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 [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 +280,40 @@ 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) => { + 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, + }, + }); + 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], + ); useEffect(() => { load(); @@ -361,13 +371,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 +378,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 +434,13 @@ export default function AdRevenueReportPage() { - ), }, + { + title: '广告位ID', + dataIndex: 'our_code_id', + width: 110, + render: (v: string | null) => + v ? {v} : -, + }, { title: '底层 ADN', dataIndex: 'adn', @@ -447,15 +468,8 @@ 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; + // 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。 + const items = data?.items ?? []; return (
@@ -534,7 +548,6 @@ export default function AdRevenueReportPage() { onChange={setScene} allowClear style={{ width: 130 }} - title="仅前端过滤下方明细,不影响趋势图/汇总" options={[ { value: 'comparison', label: '比价' }, { value: 'coupon', label: '领券' }, @@ -557,15 +570,18 @@ export default function AdRevenueReportPage() { /> - 条数 + 每页