@@ -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 < string , string > | 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 < Record < string , unknown > > ( { } ) ;
// 排序(服务端)
const [ sortBy , setSortBy ] = useState < SortField > ( 'id' ) ;
const [ sortOrder , setSortOrder ] = useState < 'asc' | 'desc' > ( 'desc' ) ;
// 近实时:自动刷新开关
const [ autoRefresh , setAutoRefresh ] = useState ( false ) ;
const filters : Record < string , unknown > = { . . . applied , sort_by : sortBy , sort_order : sortOrder } ;
const { items , total , page , pageSize , loading , onChange : onPageChange , reload } =
usePagedList < EventLog > ( '/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 < EventLog > | SorterResult < EventLog > [ ] ,
) = > {
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 < EventLog > = [
{
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 ) = > < Tag color = "blue" > { v } < / Tag > ,
} ,
{
title : '页面' ,
dataIndex : 'page' ,
width : 100 ,
render : ( v : string | null ) = > v || < Text type = "secondary" > - < / Text > ,
} ,
{
title : '用户' ,
dataIndex : 'user_id' ,
width : 80 ,
render : ( v : number | null ) = > v ? ? < Text type = "secondary" > 未 登 录 < / Text > ,
} ,
{
title : '设备' ,
dataIndex : 'device_id' ,
width : 150 ,
ellipsis : true ,
render : ( v : string ) = > (
< Text style = { { fontSize : 12 } } copyable = { { text : v } } >
{ v }
< / Text >
) ,
} ,
{ title : '网络' , dataIndex : 'network' , width : 70 , render : ( v : string | null ) = > v || '-' } ,
{
title : '机型' ,
key : 'oem' ,
width : 130 ,
render : ( _ : unknown , r : EventLog ) = > (
< Text style = { { fontSize : 12 } } > { [ r . oem , r . os ] . filter ( Boolean ) . join ( ' / ' ) || '-' } < / Text >
) ,
} ,
{ title : '版本' , dataIndex : 'app_ver' , width : 100 , render : ( v : string | null ) = > v || '-' } ,
] ;
return (
< div >
< h2 > 埋 点 日 志 < / h2 >
< Space style = { { marginBottom : 16 } } wrap >
< Input
placeholder = "事件名 (如 video_play)"
value = { event }
onChange = { ( e ) = > setEvent ( e . target . value ) }
onPressEnter = { search }
allowClear
style = { { width : 180 } }
/ >
< Input
placeholder = "设备ID(前缀)"
value = { deviceId }
onChange = { ( e ) = > setDeviceId ( e . target . value ) }
onPressEnter = { search }
allowClear
style = { { width : 160 } }
/ >
< Input
placeholder = "用户ID"
value = { userId }
onChange = { ( e ) = > setUserId ( e . target . value . replace ( /\D/g , '' ) ) }
onPressEnter = { search }
allowClear
style = { { width : 100 } }
/ >
< Input
placeholder = "会话ID"
value = { sessionId }
onChange = { ( e ) = > setSessionId ( e . target . value ) }
onPressEnter = { search }
allowClear
style = { { width : 160 } }
/ >
< RangePicker
placeholder = { [ '接收起' , '接收止' ] }
value = { createdRange }
onChange = { ( v ) = > setCreatedRange ( v as [ Dayjs , Dayjs ] | null ) }
allowClear
/ >
< Button type = "primary" onClick = { search } >
查 询
< / Button >
< Button onClick = { resetFilters } > 重 置 < / Button >
< Button onClick = { reload } > 刷 新 < / Button >
< Space size = { 4 } >
< Switch checked = { autoRefresh } onChange = { setAutoRefresh } / >
< Text type = "secondary" > 自 动 刷 新 ( 5 s ) < / Text >
< / Space >
< / Space >
< Table
rowKey = "id"
size = "small"
columns = { columns }
dataSource = { items }
loading = { loading }
expandable = { {
expandedRowRender : ( r ) = > (
< Space direction = "vertical" size = { 2 } style = { { fontSize : 12 } } >
< Text type = "secondary" >
会 话 : { r . session_id || '-' } | 设 备 型 号 : { r . model || '-' } | 渠 道 : { r . channel || '-' } | IP : { ' ' }
{ r . client_ip || '-' }
< / Text >
< Text type = "secondary" >
端 事 件 时 间 : { fmtMs ( r . client_ts ) } | 端 上 报 时 间 : { fmtMs ( r . sent_at ) }
< / Text >
< Text type = "secondary" >
属 性 : { r . props && Object . keys ( r . props ) . length ? JSON . stringify ( r . props ) : '无' }
< / Text >
< / Space >
) ,
} }
pagination = { {
current : page ,
pageSize ,
total ,
showSizeChanger : true ,
showTotal : ( t ) = > ` 共 ${ t } 条 ` ,
onChange : onPageChange ,
} }
onChange = { onTableChange }
/ >
< / div >
) ;
}