From c83c1421f5c4e0c90e2bd57a98c8db45445692b9 Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Fri, 26 Jun 2026 19:08:51 +0800 Subject: [PATCH 1/2] =?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?= 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 --- 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 70bf4cf..aaa6245 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, LogoutOutlined, @@ -37,6 +38,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: '审计日志' }, ]; -- 2.52.0 From 342fd0cf5dc6689f2fc5c9c0f2a74a687b52fb3d Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Fri, 26 Jun 2026 23:17:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(ad):=20=E5=90=8E=E5=8F=B0=E5=B9=BF?= =?UTF-8?q?=E5=91=8A=E9=85=8D=E7=BD=AE=E4=B8=8E=E6=94=B6=E7=9B=8A=E6=8A=A5?= =?UTF-8?q?=E8=A1=A8=E6=94=AF=E6=8C=81=20Draw=20=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E6=B5=81=E5=B9=B6=E5=8C=BA=E5=88=86=E6=AF=94=E4=BB=B7/?= =?UTF-8?q?=E9=A2=86=E5=88=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运营后台:广告配置页信息流代码位改为 Draw 代码位(比价Draw信息流/领券Draw信息流,字段 compare_draw_code_id/coupon_draw_code_id);收益报表新增"场景"列与筛选,按比价/领券拆分 Draw 收益并显示各自金币小计。 Co-Authored-By: Claude Opus 4.8 --- 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 cacc526..3514c37 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -348,6 +348,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) -- 2.52.0